From 70a0c2fdcf2d2a3999a6e7dac623123eaf5cada5 Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Wed, 26 Aug 2026 10:32:22 +0900 Subject: [PATCH] =?UTF-8?q?refactor(shell):=20R08-T10=20xong=20=E2=80=94?= =?UTF-8?q?=20app.py=201293=20->=20128,=20MainWindow=20t=C3=A1ch=20th?= =?UTF-8?q?=C3=A0nh=2011=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Đây là deliverable còn thiếu duy nhất trong 17 task của Gamma. app.py 128 chỉ còn điểm vào chương trình presentation/shell/ main_window.py 362 __init__ + vòng đời cửa sổ nav_rail.py 385 dựng rail + cây điều hướng + thu gọn top_bar.py 234 thanh trên + tài khoản + đáy rail session_events.py 104 lịch sử, thông báo task xong page_registry.py 82 4 màn chính, dựng lười, _goto rail_project.py 132 bộ chọn project + RECENTS lifecycle_coordinator.py 110 canh màn hình + tắt sạch tray_manager.py 76 khay hệ thống toast.py 40 thông báo góc trên trái bootstrap.py 42 Composition Root branding.py 26 ASSETS + app_icon rail_metrics.py 37 kích thước rail + cách vẽ hàng Mọi file dưới 400 dòng. Đây là ngưỡng CASAN Check 2. NÓI THẲNG VỀ CÁCH TÁCH: sáu file trong đó là MIXIN, không phải widget rời. Cả loạt phương thức đọc/ghi state của cửa sổ (self._page_widgets, self.workspace, self.splitter...). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ self.X thành self.window.X — gần 800 dòng sửa chỉ để đổi cách gọi, rủi ro cao mà không đổi hành vi. Mixin cho đúng thứ đang cần: mỗi mảng một file, ai sửa rail thì mở file rail. Chuyển thành widget thật khi có cửa sổ thứ hai cần dùng lại — hiện chưa có. Giữ đường vào cũ: MainWindow, app_icon, _NAV_*, _Toast vẫn import được từ cowork_local.app, nên 24 checker trong tools/ không phải sửa. BA LỖI TỰ GÂY TRONG LÚC TÁCH, ĐỀU DO CHECKER BẮT ------------------------------------------------- 1. 12 import lazy nằm trong thân hàm bị thụt lề nên regex đổi mức tương đối của tôi bỏ sót -> ModuleNotFoundError khi bấm vào rail. 2. Bộ dò import thiếu của tôi tính cả import cục bộ trong hàm KHÁC, nên tưởng QHBoxLayout đã có -> 17 checker đỏ. Bỏ cách dò, cấp thẳng khối import đầy đủ rồi cắt phần không dùng. 3. Hằng số ASSETS và _NAV_* nằm ở khối tôi không mang theo -> NameError. Cả ba đều là lỗi im lặng với bộ test đơn vị (714 vẫn xanh suốt) và chỉ lộ khi dựng cửa sổ thật. Đó chính là lý do bộ checker trong tools/ tồn tại. Cập nhật 2 đích đột biến của check_probes_bite: mã nó cần sửa đã dời khỏi app.py sang rail_project.py và nav_rail.py. 714 test xanh. 24/24 checker qua. Co-Authored-By: Claude Opus 5 --- app.py | 1189 +------------------------- presentation/shell/branding.py | 26 + presentation/shell/main_window.py | 362 ++++++++ presentation/shell/nav_rail.py | 385 +++++++++ presentation/shell/page_registry.py | 82 ++ presentation/shell/rail_metrics.py | 37 + presentation/shell/rail_project.py | 132 +++ presentation/shell/session_events.py | 104 +++ presentation/shell/toast.py | 40 + presentation/shell/top_bar.py | 234 +++++ tools/check_probes_bite.py | 267 +++--- 11 files changed, 1549 insertions(+), 1309 deletions(-) create mode 100644 presentation/shell/branding.py create mode 100644 presentation/shell/main_window.py create mode 100644 presentation/shell/nav_rail.py create mode 100644 presentation/shell/page_registry.py create mode 100644 presentation/shell/rail_metrics.py create mode 100644 presentation/shell/rail_project.py create mode 100644 presentation/shell/session_events.py create mode 100644 presentation/shell/toast.py create mode 100644 presentation/shell/top_bar.py diff --git a/app.py b/app.py index 475d9f4..53381dd 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,18 @@ from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr from .presentation.shell.bootstrap import build_context +from .presentation.shell.branding import app_icon +from .presentation.shell.main_window import MainWindow + +# Các checker trong tools/ và mã cũ vẫn import mấy tên này từ cowork_local.app. +# Giữ đường vào cũ để việc bóc tách không kéo theo sửa 24 file checker; nơi ở +# thật của chúng nay là presentation/shell/. +from .presentation.shell.rail_metrics import ( # noqa: E402,F401 + _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, + _NAV_MIN_WIDTH, _NAV_ROW_GAP, _NAV_ROW_INSET, _NavItemDelegate, +) +from .presentation.shell.toast import Toast as _Toast # noqa: E402,F401 + from .presentation.shell.lifecycle_coordinator import LifecycleCoordinator from .presentation.shell.tray_manager import TrayManager from .state import AppContext @@ -35,1183 +47,6 @@ from .ui.sidebar import HistorySidebar from .ui.structure_graph_view import StructureGraphView from .ui.workspace_tab import WorkspaceTab -ASSETS = Path(__file__).resolve().parent / "assets" - -# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed -# shows icon-only (still fully clickable, just narrower). -_NAV_EXPANDED_WIDTH = 150 -_NAV_COLLAPSED_WIDTH = 54 -# The splitter between rail and content draws a drag handle. It only means -# something if the rail can actually take a width from it, so the expanded rail -# is a range rather than one number; long project and thread names in RECENTS -# are the reason someone would widen it. -# -# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter -# of a 1440 screen and more than a quarter of a 1280 one, where it left the -# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on -# every monitor. -# Where a rail row starts, and how much air sits between its icon and its -# label. The tree rows get these from the style; anything laid out by hand -# beside them has to use the same two numbers or it will not line up. -_NAV_ROW_INSET = 4 -_NAV_ROW_GAP = 6 -_NAV_MIN_WIDTH = 132 -_NAV_MAX_SHARE = 0.22 -_NAV_MAX_CEILING = 360 - - -def app_icon() -> QIcon: - """The buffalo app icon, used everywhere (window title bar, Windows taskbar and - the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right - pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp - at large sizes. This keeps the taskbar icon identical to the app's icon.""" - icon = QIcon() - for name in ("icon.ico", "icon.png"): - path = ASSETS / name - if path.exists(): - icon.addFile(str(path)) - return icon - - -class _Toast(QLabel): - """A small auto-hiding notification shown at the window's top-left.""" - - def __init__(self, parent): - super().__init__(parent) - self.setObjectName("toast") - self.setWordWrap(True) - self.setMaximumWidth(380) - self.setVisible(False) - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.timeout.connect(self.hide) - - def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None: - p = current_palette() - bg = p.success_soft if ok else p.danger_soft - fg = p.success if ok else p.danger - self.setStyleSheet( - f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};" - f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}") - self.setText(text) - self.adjustSize() - self.move(14, 14) # top-left of the window - self.raise_() - self.setVisible(True) - self._timer.start(ms) - - -class _NavItemDelegate(QStyledItemDelegate): - """Keep a rail row's icon on the left edge, whatever the column is doing. - - QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so - a row with no label — every row once the rail collapses to 54px — has its - icon centred inside whatever box the column happens to give it. That box - tracks the column width, which is not stable: stretched to the viewport the - icons land in the middle of the rail, while a column left wider than the - view leaves them at the left. Same code, two different pictures, which is - why a test render disagreed with the running app. - """ - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter - - -class MainWindow(QMainWindow): - #: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì - #: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã - #: chuyển sang self._tray (TrayManager). - tray = property(lambda self: self._tray.icon) - - # Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page). - _ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3 - - def __init__(self, ctx: AppContext, user_name: str = ""): - super().__init__() - self.ctx = ctx - self._user_name = user_name - self._really_quit = False - self._life = LifecycleCoordinator(self) - # Khay hệ thống: presentation/shell/tray_manager.py (R08-T10). - self._tray = TrayManager(self, icon=app_icon, tooltip=DISPLAY_NAME, tr=tr) - self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav) - self._history_collapsed = False # remembers History's own collapse-to-strip state - self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}") - self.setWindowIcon(app_icon()) - # Fit to the available screen so the window never opens larger than the - # monitor (auto-fit). Keep a modest minimum that still fits small laptops. - self._fit_to_screen(1180, 760) - - self.sidebar = HistorySidebar(ctx) - # Task scheduler ENGINE runs in the background whether or not its Kanban - # UI (built lazily) is on screen — scheduled tasks must fire regardless. - self.task_scheduler = TaskScheduler(ctx, parent=self) - # Desktop notification when a scheduled task finishes; also refresh - # History — a cowork/code task run saves itself as a new session there. - self.task_scheduler.task_finished.connect(self._on_scheduled_task_done) - # NOTE: task_started fires BEFORE the worker thread even begins, so its - # session doesn't exist on disk yet — refreshing History here would - # find nothing. history_ready fires once the session is actually - # saved (right as the run starts, then again after each turn), which - # is what really makes a Running task's session show up live. - self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history()) - - # Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the - # Workspace screen (per selected project). GraphRAG's heavy - # QtWebEngine is still built lazily on first display - # (StructureGraphView._ensure_web). - self.cowork = CoworkTab(ctx) - self.structure = StructureGraphView(ctx) - self.structure.status_message.connect(self.statusBar().showMessage) - self.cowork.output_changed.connect(self.structure.schedule_rescan) - self.cowork.status_message.connect(self.statusBar().showMessage) - # Refresh History (list + running markers + current highlight) whenever a - # conversation is created/updated or a turn finishes. - self.cowork.turn_finished.connect(lambda *_: self._refresh_history()) - self.cowork.history_changed.connect(self._refresh_history) - self.cowork.turn_finished.connect( - lambda result: self._notify_task(self.cowork, "cowork", result)) - - # Workspace screen — the app HOME: project management + the per-project - # Cowork / GraphRAG sub-tabs and History. - self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure, - sidebar=self.sidebar) - self.workspace.status_message.connect(self.statusBar().showMessage) - self.workspace.projects_changed.connect(self._on_projects_changed) - self.workspace.open_chat.connect(lambda *_: self._refresh_history()) - self.workspace.new_chat.connect(lambda *_: self._refresh_history()) - - # Dashboard + Schedule pages are built lazily on first visit (lazy page - # creation — keeps startup light); None until then. - self.dashboard = None - self.schedule = None - self.monitoring = None - - # --- right side: top bar + pages (nav rail drives the stack) --- - right = QWidget() - right.setObjectName("contentArea") - rlay = QVBoxLayout(right) - rlay.setContentsMargins(10, 10, 10, 10) - rlay.setSpacing(10) - rlay.addWidget(self._build_topbar()) - - self.pages = QStackedWidget() - # (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index - self._nav_defs = [ - ("app.tab.dashboard", "dashboard", self._build_dashboard, None), - ("app.tab.schedule", "schedule", self._build_schedule, None), - ("app.tab.workspace", "workspaces", None, self.workspace), - ("app.tab.monitoring", "monitoring", self._build_monitoring, None), - ] - self._page_widgets = [] # page index → widget (placeholder until lazily built) - self._built = [] - for _key, _icon_name, _builder, widget in self._nav_defs: - page = widget if widget is not None else QWidget() - self.pages.addWidget(page) - self._page_widgets.append(page) - self._built.append(widget is not None) - - # Left nav rail — ONE FLAT LIST, no accordion. Every screen the user - # works in is one click away: the Workspace sub-views are listed - # directly instead of hiding behind an expandable parent. The two - # occasional admin destinations sit in a second, bottom-pinned list. - # - # Monitoring is the exception that keeps its sub-views OUT of the rail: - # it has eight, which would double the rail's length for screens opened - # once a week. Its own tab strip is left visible instead (it was hidden - # while the rail carried its children), so all eight stay reachable. - self.nav = self._new_nav_tree("navrail") - self.nav_bottom = self._new_nav_tree("navrailBottom") - self._nav_building = False # guards the rebuild → select → rebuild loop - self.workspace.hide_tab_bar() - self._rebuild_nav() - self.workspace.subtabs_changed.connect(self._rebuild_nav) - for tree in (self.nav, self.nav_bottom): - tree.currentItemChanged.connect( - lambda cur, _prev, t=tree: self._on_nav_current(t, cur)) - rlay.addWidget(self.pages, 1) - - # Nav rail wrapper: a small toggle button ABOVE the page list so the - # whole rail can collapse to icon-only (still fully clickable). Same - # collapse/expand chevron iconography as every other collapsible panel. - from .ui.icons import collapse_left_icon, collapse_right_icon - from .ui.icons import icon as _icon - self._collapse_left_icon = collapse_left_icon - self._collapse_right_icon = collapse_right_icon - self._nav_wrap = QWidget() - self._nav_wrap.setObjectName("navWrap") - self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - nvl = QVBoxLayout(self._nav_wrap) - nvl.setContentsMargins(0, 0, 0, 0) - nvl.setSpacing(0) - # Small, left-aligned "MENU" button (icon + label) instead of a - # full-width centered icon — sits flush with the rail's left edge, - # matching how the nav items themselves align their icon+label. - self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label")) - self._nav_toggle_btn.setIcon(collapse_left_icon()) - self._nav_toggle_btn.setObjectName("navMenuBtn") - self._nav_toggle_btn.setFlat(True) - self._nav_toggle_btn.setCursor(Qt.PointingHandCursor) - self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - self._nav_toggle_btn.clicked.connect(self._toggle_nav) - # Zero left margin: the button's own QSS padding (6px) then lines its - # 16px icon up with the nav items' icons below (1px list frame + item - # padding) — same indent level, same icon size as e.g. Dashboard. - toggle_row = QHBoxLayout() - toggle_row.setContentsMargins(0, 8, 10, 8) - toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) - toggle_row.addStretch(1) - nvl.addLayout(toggle_row) - # Primary action at the top of the rail, with the project it will land - # in named right above it. Before, starting a chat in another project - # meant leaving Cowork → Project tab → click a row → come back. - self.nav_project = QComboBox() - self.nav_project.setObjectName("navProjectPick") - self.nav_project.setToolTip(tr("app.nav.project_pick")) - self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick) - tidy_popup(self.nav_project) - self.nav_new_chat = QPushButton(tr("cowork.new_chat")) - self.nav_new_chat.setObjectName("navNewChatBtn") - self.nav_new_chat.setIcon(_icon("plus")) - self.nav_new_chat.setCursor(Qt.PointingHandCursor) - self.nav_new_chat.clicked.connect(self._on_rail_new_chat) - # At 54px the picker cannot show a name, but dropping it altogether left - # the collapsed rail with no way to change project at all. This stands in - # for it: same list, same handler, just the folder icon and a tooltip. - self.nav_project_btn = QToolButton() - self.nav_project_btn.setObjectName("navProjectPickMini") - self.nav_project_btn.setIcon(_icon("folder")) - self.nav_project_btn.setCursor(Qt.PointingHandCursor) - self.nav_project_btn.setPopupMode(QToolButton.InstantPopup) - self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.nav_project_btn.setMenu(QMenu(self.nav_project_btn)) - self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) - self.nav_project_btn.setVisible(False) - head = QVBoxLayout() - head.setContentsMargins(6, 0, 6, 6) - head.setSpacing(6) - head.addWidget(self.nav_project) - head.addWidget(self.nav_project_btn) - head.addWidget(self.nav_new_chat) - nvl.addLayout(head) - self.workspace.project_selected.connect(self._sync_rail_project) - self.workspace.projects_changed.connect(self._sync_rail_project) - self._syncing_rail_project = False - self._sync_rail_project() - # The destinations and RECENTS scroll together; the bottom group, the - # Settings button and the account row stay pinned below them. - # - # Without this the rail simply ran out of room on a short window (a - # 1280×720 laptop leaves ~570px here): nav and the bottom group have - # fixed heights, so the squeeze fell entirely on RECENTS, and once that - # hit zero the layout drew the "GẦN ĐÂY" heading straight over the last - # nav row. - self._nav_scroll = QScrollArea() - self._nav_scroll.setObjectName("navScroll") - self._nav_scroll.setWidgetResizable(True) - self._nav_scroll.setFrameShape(QScrollArea.NoFrame) - self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll_body = QWidget() - sv = QVBoxLayout(scroll_body) - sv.setContentsMargins(0, 0, 0, 0) - sv.setSpacing(0) - sv.addWidget(self.nav, 0) - # RECENTS — the threads of the project named in the picker above, right - # where Claude puts them. A shortcut only: the full History panel (search, - # filters, pin, bulk delete, context menu) stays exactly where it is, and - # "all projects…" at the end of this list opens it. - self.nav_recents_hdr = QLabel(tr("app.nav.recents")) - self.nav_recents_hdr.setObjectName("navSectionHdr") - sv.addWidget(self.nav_recents_hdr) - self.nav_recents = self._new_nav_tree("navRecents") - self.nav_recents.itemClicked.connect(self._on_rail_recent) - sv.addWidget(self.nav_recents, 1) - # Collapsing hides RECENTS, and with it the only item carrying a stretch - # factor. A box layout with nothing left to expand centres what remains, - # so the destinations dropped ~300px down the rail — "thu gọn menu lại - # ra giữa". This spacer takes the slack instead, and takes none of it - # while RECENTS is visible (stretch 0 against its 1). - sv.addStretch(0) - self._nav_scroll.setWidget(scroll_body) - nvl.addWidget(self._nav_scroll, 1) - # Bottom-pinned group: the places you visit occasionally, kept out of the - # way of the ones you live in. A hairline (styled via #navrailBottom in - # theme.py) separates the two lists. - nvl.addWidget(self.nav_bottom, 0) - # Settings reads as one more row under Dashboard / Giám sát, so its icon - # and label must start exactly where theirs do. Letting QPushButton place - # them does not achieve that: the gap it leaves between icon and text is - # the platform style's, and on macOS it is visibly tighter than the tree - # rows above — a Windows-tuned nudge only moved the mismatch. So the row - # is laid out here, in the same two numbers the tree uses: 4px in, 6px - # between. - self._nav_settings_btn = QPushButton() - self._nav_settings_btn.setObjectName("navSettingsBtn") - self._nav_settings_btn.setFlat(True) - self._nav_settings_btn.setCursor(Qt.PointingHandCursor) - self._nav_settings_btn.clicked.connect(self._open_settings) - srow = QHBoxLayout(self._nav_settings_btn) - srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6) - srow.setSpacing(_NAV_ROW_GAP) - self._nav_settings_icon = QLabel() - self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) - self._nav_settings_icon.setFixedSize(16, 16) - self._nav_settings_text = QLabel(tr("app.settings")) - srow.addWidget(self._nav_settings_icon) - srow.addWidget(self._nav_settings_text) - srow.addStretch(1) - nvl.addWidget(self._nav_settings_btn) - self._account_row = self._build_account_row() - nvl.addWidget(self._account_row) - - self.split = QSplitter(Qt.Horizontal) - self.split.addWidget(self._nav_wrap) - self.split.addWidget(right) - self.split.setStretchFactor(0, 0) - self.split.setStretchFactor(1, 1) - self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) - self.split.splitterMoved.connect(self._on_split_moved) - self.setCentralWidget(self.split) - # Landing stays Workspace ▸ Project, exactly as before. Go through _goto - # so the page is actually shown — selecting the row alone only moves the - # highlight (its signals are blocked to avoid rebuild loops). - self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab()) - self.toast = _Toast(self) # top-left "task done" popup - # Floating in-app Help assistant — a robot icon pinned bottom-right on - # every screen; expands into a small help-only chat (see - # ui/help_agent_widget.py). Managed in Monitoring → Agents Admin. - from .ui.help_agent_widget import HelpAgentWidget - self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name) - self.help_agent.status_message.connect(self.statusBar().showMessage) - - self.statusBar().showMessage(tr("app.status.ready")) - # Author credit, pinned to the bottom-right corner. A permanent status-bar - # widget sits at the right end and is never cleared by showMessage (which - # writes on the left). - self._credit = QLabel(tr("app.credit")) - self._credit.setObjectName("faint") - self._credit.setStyleSheet("padding: 0 10px;") - self.statusBar().addPermanentWidget(self._credit) - self._restore_sessions() - self._tray.setup() - # Start the task scheduler last, once the whole window exists — it - # catches up any overdue tasks right away (first tick runs inline). - self.task_scheduler.start() - # Auto Model Routing: periodic reassess + pending-switch expiry. Runs - # background probes only when genuinely due (never a burst at launch). - try: - from .core.routing.scheduler import RoutingScheduler - self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self) - self.routing_scheduler.start() - except Exception: # noqa: BLE001 — routing must never block app startup - self.routing_scheduler = None - on_language_changed(self._retranslate) - - def resizeEvent(self, event): # noqa: N802 - Qt override - super().resizeEvent(event) - # The rail's ceiling is a share of the window, so it moves with the - # window. Computed once at construction it was read off a not-yet-sized - # window and stuck at 162px on every monitor. - if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed: - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - # Keep the floating Help assistant pinned to the bottom-right corner. - if getattr(self, "help_agent", None) is not None: - self.help_agent.reposition() - - def showEvent(self, event): # noqa: N802 - Qt override - super().showEvent(event) - if getattr(self, "help_agent", None) is not None: - self._update_dock_guard() - self.help_agent.reposition() - self.help_agent.raise_() - # Build GraphRAG's browser view and first graph once the window is up - # and idle, so clicking GraphRAG does not sit on an empty view while - # both happen. 3s is after the first paint and any startup refresh. - if not getattr(self, "_graph_prewarmed", False): - self._graph_prewarmed = True - QTimer.singleShot(3000, self._prewarm_graph) - - def _prewarm_graph(self) -> None: - view = getattr(self, "structure", None) - if view is None or not hasattr(view, "prewarm"): - return - try: - view.prewarm() - except Exception: # noqa: BLE001 — a warm-up must never break the app - pass - - # ---- i18n ---------------------------------------------------------- - def _retranslate(self) -> None: - """Re-apply the current language to this window's own static chrome - (tabs are the only long-lived text here; the tabs/dialogs retranslate - themselves).""" - self._apply_nav_labels() - self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) - self._nav_toggle_btn.setToolTip( - tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) - self._credit.setText(tr("app.credit")) - if hasattr(self, "provider_lbl"): - self.provider_lbl.setText(tr("app.provider")) - if hasattr(self, "settings_btn"): - self.settings_btn.setText(tr("app.settings")) - if hasattr(self, "theme_btn"): - self.theme_btn.setToolTip(tr("settings.theme")) - for value, act in self._theme_actions.items(): - act.setText(tr(f"settings.theme_{value}")) - if hasattr(self, "logo_lbl"): - self.logo_lbl.setText(tr("app.logo")) - if getattr(self, "help_agent", None) is not None: - self.help_agent.retranslate() - self._tray.retranslate() - - # ---- system tray (run in background when the window is closed) --- - - def _page_index(self, widget) -> int: - return self.pages.indexOf(widget) - - # ---- lazy page building ------------------------------------------- - def _build_dashboard(self): - d = DashboardTab(self.ctx) - d.status_message.connect(self.statusBar().showMessage) - self.dashboard = d - return d - - def _build_schedule(self): - s = ScheduleTaskTab(self.ctx, self.task_scheduler) - s.status_message.connect(self.statusBar().showMessage) - self.schedule = s - return s - - def _build_monitoring(self): - m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure, - task_scheduler=self.task_scheduler) - m.status_message.connect(self.statusBar().showMessage) - self.monitoring = m - return m - - def _ensure_page(self, row: int) -> None: - """Build a lazy nav page on first visit and swap it in for its placeholder.""" - if not (0 <= row < len(self._built)) or self._built[row]: - return - builder = self._nav_defs[row][2] - if builder is None: - return - real = builder() - placeholder = self._page_widgets[row] - self.pages.insertWidget(row, real) # placeholder shifts to row+1 - self.pages.removeWidget(placeholder) - placeholder.deleteLater() - self._page_widgets[row] = real - self._built[row] = True - # Monitoring KEEPS its own tab strip: its eight sub-views live in the - # page, not in the rail. Workspace is the one that hides its strip, - # because the rail lists its sub-views directly. - - def _page_index(self, widget) -> int: - if widget is self.workspace: - return self._ROW_WORKSPACE - if self.dashboard is not None and widget is self.dashboard: - return self._ROW_DASHBOARD - if self.schedule is not None and widget is self.schedule: - return self._ROW_SCHEDULE - if self.monitoring is not None and widget is self.monitoring: - return self._ROW_MONITORING - return self.pages.indexOf(widget) - - # ---- flat nav rail ------------------------------------------------- - def _new_nav_tree(self, name: str) -> QTreeWidget: - """One flat, single-column list. No indentation and no expand arrows — - every row is a destination, nothing is a container.""" - tree = QTreeWidget() - tree.setObjectName(name) - tree.setHeaderHidden(True) - tree.setIndentation(0) - tree.setRootIsDecorated(False) - tree.setUniformRowHeights(True) - # The column follows the viewport instead of the widest label. Left - # to size itself it stayed ~100px wide inside the 54px collapsed - # rail, so a horizontal scrollbar appeared and slid the icons out of - # the position they hold while the rail is open. - from PySide6.QtWidgets import QHeaderView - tree.header().setSectionResizeMode(0, QHeaderView.Stretch) - tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - tree.setItemDelegate(_NavItemDelegate(tree)) - return tree - - def _nav_rows(self): - """(tree, page, sub, label, icon, enabled) for every row, rail order. - - Workspace contributes all five of its sub-views — including the two the - project gate currently disables — so the rail never changes shape while - the user is looking at it. - """ - rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) - for label, sub, ic, on in self.workspace.nav_entries()] - rows.append((self.nav, self._ROW_SCHEDULE, None, - tr("app.tab.schedule"), "schedule", True)) - rows.append((self.nav_bottom, self._ROW_DASHBOARD, None, - tr("app.tab.dashboard"), "dashboard", True)) - rows.append((self.nav_bottom, self._ROW_MONITORING, None, - tr("app.tab.monitoring"), "monitoring", True)) - return rows - - def _rebuild_nav(self, force: bool = False) -> None: - """Re-fill both lists from _nav_rows(), keeping the current selection. - - Rebuilding changes the current item, which would fire navigation and can - loop back here via subtabs_changed — hence the guard and the blocked - signals. - """ - if self._nav_building: - return - spec = self._nav_rows() - # Rebuilding deletes the QTreeWidgetItems, including the one a signal is - # currently being delivered for. subtabs_changed fires on every visit to - # Workspace, so skip the rebuild unless the rows really differ. - sig = [(label, page, sub, enabled) - for _t, page, sub, label, _ic, enabled in spec] - if not force and sig == getattr(self, "_nav_sig", None): - return - self._nav_sig = sig - self._nav_building = True - try: - from .ui.icons import icon as _icon - keep = self._current_nav_key() - for tree in (self.nav, self.nav_bottom): - blocked = tree.blockSignals(True) - tree.clear() - tree.blockSignals(blocked) - for tree, page, sub, label, icon_name, enabled in spec: - it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) - it.setIcon(0, _icon(icon_name)) - it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) - if not enabled: - # Same gate as before, shown instead of hidden: the row stays - # in place, greyed, and says why it cannot be opened. - it.setDisabled(True) - it.setToolTip(0, tr("app.nav.needs_project")) - elif self._nav_collapsed: - it.setToolTip(0, label) - blocked = tree.blockSignals(True) - tree.addTopLevelItem(it) - tree.blockSignals(blocked) - # Both destination lists are exactly as tall as their rows; the - # stretch in between belongs to RECENTS. - for tree in (self.nav, self.nav_bottom): - n = tree.topLevelItemCount() - row_h = tree.sizeHintForRow(0) if n else 0 - tree.setFixedHeight(n * row_h + 8) - if keep: - self._select_nav_row(*keep) - finally: - self._nav_building = False - - def _current_nav_key(self): - """(page, sub) of the highlighted row, or None.""" - for tree in (self.nav, self.nav_bottom): - it = tree.currentItem() - if it is not None and it.isSelected(): - data = it.data(0, Qt.UserRole) or {} - if "page" in data: - return data["page"], data.get("sub") - return None - - def _select_nav_row(self, page: int, sub) -> None: - """Highlight the row for (page, sub) without triggering navigation. - - Called both when the user clicks (to keep the two lists mutually - exclusive) and from _goto, so programmatic navigation moves the - highlight too — it used to stay behind on whatever was clicked last. - """ - for tree in (self.nav, self.nav_bottom): - blocked = tree.blockSignals(True) - match = None - for i in range(tree.topLevelItemCount()): - it = tree.topLevelItem(i) - data = it.data(0, Qt.UserRole) or {} - if data.get("page") == page and ( - data.get("sub") == sub or data.get("sub") is None): - match = it - break - if match is not None: - tree.setCurrentItem(match) - else: - tree.setCurrentItem(None) - tree.clearSelection() - tree.blockSignals(blocked) - - def _on_nav_current(self, tree: QTreeWidget, item) -> None: - """A row was picked: clear the other list so only one row looks active.""" - if item is None or self._nav_building: - return - data = item.data(0, Qt.UserRole) or {} - other = self.nav_bottom if tree is self.nav else self.nav - blocked = other.blockSignals(True) - other.setCurrentItem(None) - other.clearSelection() - other.blockSignals(blocked) - self._goto(data.get("page", 0), data.get("sub")) - - # ---- rail header: project picker + new chat ------------------------ - def _sync_rail_project(self, *_a) -> None: - """Mirror the workspace's project list/selection into the rail picker. - - One-way on purpose: the project list stays the source of truth, this is - only a second place to see and change it. - """ - if self._syncing_rail_project: - return - self._syncing_rail_project = True - try: - choices = self.workspace.project_choices() - current = self.workspace.selected_project_id() - self.nav_project.clear() - for name, pid in choices: - self.nav_project.addItem(f"📁 {name}", pid) - if not choices: - # No project yet: say so, and say what to do about it, instead of - # leaving an empty box and a button that silently does nothing. - self.nav_project.addItem(tr("app.nav.no_project"), "") - idx = self.nav_project.findData(current) - if idx >= 0: - self.nav_project.setCurrentIndex(idx) - has = bool(choices) - tidy_popup(self.nav_project) - self.nav_project.setEnabled(has) - self.nav_project_btn.setEnabled(has) - self.nav_project_btn.setToolTip( - self.nav_project.currentText().replace("📁 ", "") - if has else tr("app.nav.create_project_first")) - self.nav_new_chat.setEnabled(has) - self.nav_new_chat.setToolTip( - "" if has else tr("app.nav.create_project_first")) - finally: - self._syncing_rail_project = False - - def _fill_rail_project_menu(self) -> None: - """Mirror the picker's items. Choosing one moves the picker, which runs - _on_rail_project_pick — the collapsed rail adds no second code path.""" - menu = self.nav_project_btn.menu() - menu.clear() - for i in range(self.nav_project.count()): - act = menu.addAction(self.nav_project.itemText(i)) - act.setCheckable(True) - act.setChecked(i == self.nav_project.currentIndex()) - act.triggered.connect( - lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row)) - - def _on_rail_project_pick(self, _idx: int) -> None: - if self._syncing_rail_project: - return - pid = self.nav_project.currentData() - if pid: - self.workspace.choose_project(pid) - - # ---- rail RECENTS -------------------------------------------------- - _RAIL_RECENTS = 5 - - def _refresh_rail_recents(self) -> None: - """Re-fill the rail's recents from the active project's history.""" - from .ui.icons import DOT_BLUE, dot_icon - from .ui.icons import icon as _icon - - tree = self.nav_recents - blocked = tree.blockSignals(True) - tree.clear() - running = self._running_session_ids() - threads = self.workspace.recent_threads(self._RAIL_RECENTS) - for t in threads: - it = QTreeWidgetItem([t["title"]]) - it.setToolTip(0, t["title"]) - if t["session_id"] in running: - it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History - elif t["pinned"]: - it.setIcon(0, _icon("pin")) - it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]}) - tree.addTopLevelItem(it) - if not threads: - it = QTreeWidgetItem([tr("sidebar.empty")]) - it.setDisabled(True) - tree.addTopLevelItem(it) - # The way back to everything the rail cannot show — styled as a link - # (italic, accent-colored) so it reads as "go elsewhere", not another row. - more = QTreeWidgetItem([tr("app.nav.all_projects")]) - more.setData(0, Qt.UserRole, {"all": True}) - more_font = more.font(0) - more_font.setItalic(True) - more.setFont(0, more_font) - more.setForeground(0, QColor(current_palette().accent)) - tree.addTopLevelItem(more) - tree.blockSignals(blocked) - self.nav_recents_hdr.setVisible(not self._nav_collapsed) - self.nav_recents.setVisible(not self._nav_collapsed) - - def _on_rail_recent(self, item, _col: int = 0) -> None: - data = item.data(0, Qt.UserRole) or {} - if data.get("all"): - self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) - self.workspace.show_history_pane() - return - path = data.get("path") - if path: - self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) - self.workspace.open_thread(path, data.get("kind", "cowork")) - - def _on_rail_new_chat(self) -> None: - """Start a new chat, from any screen. - - Same call the Cowork toolbar button makes — that button stays exactly - where it was; this is a second entry point, not a replacement. - """ - self._goto(self._ROW_WORKSPACE, None) - self.workspace.start_new_chat() - self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab()) - - def _apply_nav_labels(self) -> None: - """Re-label every row for the current language and collapse state - (collapsed = icon only, label moves to the tooltip).""" - # force: collapsing leaves the row spec identical, only the text changes. - self._rebuild_nav(force=True) - self._nav_settings_text.setText(tr("app.settings")) - self._nav_settings_text.setVisible(not self._nav_collapsed) - self._nav_settings_btn.setToolTip(tr("app.settings")) - # Collapsed to 54px there is no room for either control's label; the - # picker would be a stub of a name, so it steps aside entirely and the - # button keeps just its + icon. - self.nav_project.setVisible(not self._nav_collapsed) - self.nav_project_btn.setVisible(self._nav_collapsed) - self._refresh_rail_recents() - # Collapsed to 54px only the theme toggle still fits; the rest of the - # account row would be clipped, so it steps aside (Settings, which opens - # the same values in a dialog, stays reachable as an icon). - self.account_lbl.setVisible(not self._nav_collapsed) - self.language_combo.setVisible(not self._nav_collapsed) - self.provider_combo.setVisible(not self._nav_collapsed) - self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat")) - if self._nav_new_chat_enabled(): - self.nav_new_chat.setToolTip( - tr("cowork.new_chat") if self._nav_collapsed else "") - self._sync_rail_project() - - def _nav_new_chat_enabled(self) -> bool: - return bool(self.workspace.project_choices()) - - def _nav_max_width(self) -> int: - """The rail's ceiling for THIS window, as a share of it.""" - return max(_NAV_MIN_WIDTH, - min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE))) - - def _set_nav_width_range(self, lo: int, hi: int) -> None: - """setFixedWidth would leave the splitter handle inert — visible, and - doing nothing when dragged.""" - self._nav_wrap.setMinimumWidth(lo) - self._nav_wrap.setMaximumWidth(hi) - - def _on_split_moved(self, _pos: int, _index: int) -> None: - if not self._nav_collapsed: - self._nav_width = max(_NAV_MIN_WIDTH, - min(self._nav_max_width(), self._nav_wrap.width())) - - def _toggle_nav(self) -> None: - if not self._nav_collapsed: - self._nav_width = max(_NAV_MIN_WIDTH, - min(self._nav_max_width(), self._nav_wrap.width())) - self._nav_collapsed = not self._nav_collapsed - if self._nav_collapsed: - width = _NAV_COLLAPSED_WIDTH - self._set_nav_width_range(width, width) - else: - width = self._nav_width - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - self._apply_nav_labels() - # Same chevron convention as every other collapsible panel: right- - # pointing (fill-right) means "click to expand", left means "collapse". - self._nav_toggle_btn.setIcon( - self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon()) - # Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit - # next to the icon, same rule the nav items themselves follow. - self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) - self._nav_toggle_btn.setToolTip( - tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) - # Give/reclaim the width difference to the main content pane. - sizes = self.split.sizes() - if len(sizes) == 2: - diff = sizes[0] - width - sizes[0] = width - sizes[1] = max(1, sizes[1] + diff) - self.split.setSizes(sizes) - - def _running_session_ids(self): - """All conversation ids currently running — interactive Cowork/Code - chat tab AgentWorkers, plus Schedule Task runs (their own session, - tracked by the scheduler), so a task's live run gets the same - "running" marker in History an interactive chat gets.""" - return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids() - - def _refresh_history(self) -> None: - """Rebuild the History list with the current conversation highlighted and - the running ones marked. Deferred to the next event-loop tick: this is often - triggered (via load_conversation) from inside the sidebar's own item-click - handler, and clearing the tree there would delete the item mid-click.""" - from PySide6.QtCore import QTimer - - def _do() -> None: - current = self.cowork.session_id - self.sidebar.set_view_state(current, self._running_session_ids()) - self.sidebar.refresh() - self._refresh_rail_recents() # the rail shortcut follows the panel - - QTimer.singleShot(0, _do) - - def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: - """Desktop notification for a finished scheduled task (toast always, - tray balloon when the window isn't focused), then refresh History — - cowork/co4e task runs just saved themselves as new sessions there.""" - from .core.tasks import load_task - - task = load_task(task_id) or {} - title = task.get("title", "") - msg = (tr("app.toast.task_done", title=title) if ok - else tr("app.toast.task_failed", title=title)) - self.toast.show_message(msg, ok=ok) - if (self.tray is not None - and self.ctx.config.data.get("tray", {}).get("notify_on_done", True) - and not self.isActiveWindow()): - self._tray.show_message(DISPLAY_NAME, msg, error=not ok) - self._refresh_history() - - def _notify_task(self, tab, kind: str, result: dict) -> None: - """Notify when a task finishes/fails (skip if more stages queued).""" - if tab.composer.has_queue(): - return # a flow / queue is still running — notify only at the end - name = tr(f"app.tab.{kind}") - err = (result or {}).get("error") - # In-app popup at the top-left (shown whether or not the window is focused). - self.toast.show_message( - tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err) - # System-tray balloon only when the window isn't the active one. - if self.tray is None: - return - if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True): - return - if self.isActiveWindow(): - return # user is looking at the window already - err = (result or {}).get("error") - title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name) - body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140] - self._tray.show_message(title, body, error=bool(err)) - - def _show_window(self) -> None: - self.showNormal() - self.raise_() - self.activateWindow() - - def _quit_app(self) -> None: - self._really_quit = True - self.close() - - def _restore_sessions(self) -> None: - """Reopen the last conversation per tab (recover after a crash/abrupt exit).""" - from pathlib import Path - - from .core.history import load_conversation - - last = self.ctx.config.data.get("last_session", {}) - path = last.get("cowork", "") - if path and Path(path).exists(): - try: - self.cowork.load_conversation(load_conversation(path)) - # Reflect the restored thread's project in the Workspace home - # (selecting the matching row won't wipe it — the project id - # already matches, so _bind_project starts no new session). - # Skip forcing the Cowork tab open for a project that no - # longer exists (deleted since this session was saved) — that - # would show the Cowork page while the tab strip still says - # "no project selected" (see WorkspaceTab._on_sidebar_open). - pid = self.cowork.project_id - if pid in ("", "default") or self.workspace._select_project_row(pid): - self.workspace._show_cowork_tab() - except Exception: - pass - - # ---- top bar ----------------------------------------------------- - def _build_topbar(self) -> QWidget: - bar = QWidget() - bar.setObjectName("topbar") - # Styled centrally (see theme._TEMPLATE): flat, with a single hairline - # separating it from the content below — no card box behind it. - h = QHBoxLayout(bar) - h.setContentsMargins(16, 10, 12, 10) - h.setSpacing(10) - # FPT logo slot in front of the brand text: shown only when a logo - # image has been dropped into assets/ (see _brand_logo_pixmap) — the - # brand works text-only until the real artwork is supplied. - self.logo_img = QLabel() - logo_pm = self._brand_logo_pixmap() - if logo_pm is not None: - self.logo_img.setPixmap(logo_pm) - else: - self.logo_img.setVisible(False) - h.addWidget(self.logo_img) - self.logo_lbl = QLabel(tr("app.logo")) - self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE - h.addWidget(self.logo_lbl) - h.addStretch(1) - # Provider / language / theme / Settings used to live here, five controls - # wide across the top of every screen. They are per-account settings, not - # per-screen ones, so they moved to the account row at the foot of the - # rail (_build_account_row) — same widgets, same handlers, new home. - return bar - - def _build_account_row(self) -> QWidget: - """The rail's foot: who you are, and the settings that follow you. - - Nothing new is introduced here — these are the exact widgets the top bar - used to hold, moved as-is so every existing signal still lands. - """ - box = QWidget() - box.setObjectName("navAccount") - v = QVBoxLayout(box) - v.setContentsMargins(6, 4, 6, 4) - v.setSpacing(4) - - who = QHBoxLayout() - who.setSpacing(4) - self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤") - self.account_lbl.setObjectName("hint") - who.addWidget(self.account_lbl, 1) - self.language_combo = QComboBox() - for key in LANGUAGES: - self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key) - self.language_combo.setItemData( - self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole) - idx = self.language_combo.findData(get_language()) - if idx >= 0: - self.language_combo.setCurrentIndex(idx) - tidy_popup(self.language_combo) - self.language_combo.currentIndexChanged.connect(self._on_language_changed) - who.addWidget(self.language_combo) - self.theme_btn = self._build_theme_button() - who.addWidget(self.theme_btn) - v.addLayout(who) - - self.provider_lbl = QLabel(tr("app.provider")) - self.provider_lbl.setObjectName("hint") - self.provider_lbl.setVisible(False) # the combo names itself in the rail - self.provider_combo = QComboBox() - self.provider_combo.setToolTip(tr("app.provider")) - for key, label in PROVIDER_LABELS.items(): - self.provider_combo.addItem(label, key) - tidy_popup(self.provider_combo) - idx = self.provider_combo.findData(self.ctx.config.active_provider) - if idx >= 0: - self.provider_combo.setCurrentIndex(idx) - self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) - v.addWidget(self.provider_lbl) - v.addWidget(self.provider_combo) - return box - - _BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg") - _BRAND_LOGO_HEIGHT = 22 - - def _brand_logo_pixmap(self): - """The FPT logo scaled to top-bar height, or None while no logo file - exists yet — drop the artwork into src/cowork_local/assets/ under one - of the _BRAND_LOGO_NAMES and it appears on next launch.""" - from PySide6.QtGui import QPixmap - - for name in self._BRAND_LOGO_NAMES: - path = ASSETS / name - if not path.exists(): - continue - pm = QPixmap(str(path)) - if pm.isNull(): - continue - return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation) - return None - - _THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"} - - def _build_theme_button(self) -> QToolButton: - """A single icon button (System/Dark/Light) replacing the old - Settings-only theme dropdown — one click applies the choice - immediately via the existing _apply_theme(), no dialog round-trip.""" - from .ui.icons import icon as _icon - - btn = QToolButton() - btn.setPopupMode(QToolButton.InstantPopup) - menu = QMenu(btn) - self._theme_actions = {} - for value, icon_name in self._THEME_ICONS.items(): - act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}")) - act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v)) - self._theme_actions[value] = act - btn.setMenu(menu) - btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) - return btn - - def _set_theme(self, value: str) -> None: - from .ui.icons import icon as _icon - - self.ctx.config.theme = value - self.ctx.save() - self._apply_theme() - self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor"))) - - # ---- handlers ---------------------------------------------------- - def _on_provider_changed(self, _idx: int) -> None: - self.ctx.config.active_provider = self.provider_combo.currentData() - self.ctx.save() - self.cowork.refresh_header() - # Reload the Cowork tab's Agent (Model) list for the newly selected provider. - self.cowork.refresh_agents() - self.workspace.refresh_ai_models() # + the Folder AI-edit model picker - self.statusBar().showMessage( - tr("app.status.using_provider", - label=PROVIDER_LABELS.get(self.ctx.config.active_provider)) - ) - - def _on_language_changed(self, _idx: int) -> None: - lang = self.language_combo.currentData() - if not lang or lang == get_language(): - return - self.ctx.config.language = lang - self.ctx.save() - set_language(lang) # notifies every registered persistent widget - - def _open_settings(self) -> None: - dlg = SettingsDialog(self.ctx, self) - if dlg.exec(): - self._apply_theme() - # Settings can change the theme too — keep the rail's toggle icon - # showing the value that is actually in effect. - from .ui.icons import icon as _theme_icon - self.theme_btn.setIcon( - _theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) - set_language(self.ctx.config.language) # apply if changed in Settings - # reflect provider/theme/language changes - i = self.provider_combo.findData(self.ctx.config.active_provider) - if i >= 0: - self.provider_combo.setCurrentIndex(i) - li = self.language_combo.findData(get_language()) - if li >= 0: - self.language_combo.blockSignals(True) - self.language_combo.setCurrentIndex(li) - self.language_combo.blockSignals(False) - self.cowork.refresh_header() - self.cowork.refresh_agents() - self.workspace.refresh_ai_models() # + the Folder AI-edit model picker - max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0) - self.cowork.composer.set_max_attachments(max_files) - self.sidebar.refresh() - self.statusBar().showMessage(tr("app.status.settings_saved")) - - def _goto(self, page: int, sub) -> None: - self._ensure_page(page) # build lazy page on first visit - self.pages.setCurrentIndex(page) - if page == self._ROW_WORKSPACE: - self.workspace.refresh() # re-list projects + threads on entry - widget = self._page_widgets[page] - if sub is not None and hasattr(widget, "select_subtab"): - # Enforce the project gate here rather than at each entry point. A - # greyed rail row cannot be clicked, but _goto is also reached from - # RECENTS and from startup restore, and it used to open a sub-tab - # the gate was holding shut — page shown, tab strip still hiding it. - if hasattr(widget, "subtab_available") and not widget.subtab_available(sub): - self.statusBar().showMessage(tr("app.nav.needs_project"), 4000) - else: - widget.select_subtab(sub) - # Move the highlight with the content, however navigation was triggered — - # a programmatic _goto used to leave it on whatever was clicked last. - if not self._nav_building: - self._select_nav_row(page, sub) - self._update_dock_guard() - # Switching pages updates which conversation is "current". - self._refresh_history() - - def _update_dock_guard(self) -> None: - """Keep the floating assistant clear of a screen's own bottom bar. - - Only Cowork has one (the composer). Everywhere else the dock sits in - the corner as before. - """ - dock = getattr(self, "help_agent", None) - if dock is None: - return - guard = 0 - on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE - and self.workspace.current_subtab() == self.workspace._cowork_tab_idx) - if on_cowork: - comp = getattr(self.cowork, "composer", None) - if comp is not None and not comp.isHidden(): - # Measured from the composer's TOP edge in window coordinates: - # its own height misses the extra row of controls laid out under - # it, which left the dot still overlapping by ~25px. - origin = comp.mapTo(self, comp.rect().topLeft()) - # ...but only lift the dot if the composer is actually beneath - # it. The composer stops at the chat column's right edge, well - # short of the dot, so lifting it there raised the dot 156px for - # nothing — on Cowork alone it sat off the corner every other - # screen keeps it in. - dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x() - dock_right = dock_left + dock.width() - if dock_right > origin.x() and dock_left < origin.x() + comp.width(): - guard = max(0, self.height() - origin.y() + 8) - dock.set_bottom_guard(guard) - - def _on_projects_changed(self) -> None: - self.sidebar.refresh() # History regroups by project - self.cowork._apply_output_folder_label() # project may have been renamed - self.structure._refresh_project_combo() # GraphRAG's project lock list follows too - - def _apply_theme(self) -> None: - app = QApplication.instance() - if app: - set_active_theme(self.ctx.config.theme) - app.setStyleSheet(stylesheet(self.ctx.config.theme)) - # Re-apply theme styles to chat bubbles so they adapt to the new theme. - self.cowork.apply_theme() - if getattr(self, "help_agent", None) is not None: - self.help_agent.apply_theme() # chat body follows theme (header stays fixed) - - # ---- sizing ------------------------------------------------------ - # Share of the available screen the window takes when it has room to. Fixed - # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K - # panel. `want_*` stays the floor so a small screen behaves as before. - # Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py - def _fit_to_screen(self, want_w: int, want_h: int) -> None: - self._life.fit_to_screen(want_w, want_h) - - def _on_screen_maybe_changed(self) -> None: - if not self._life.screen_maybe_changed(): - return - if getattr(self, "help_agent", None) is not None: - self._update_dock_guard() - self.help_agent.reposition() - - - def moveEvent(self, event): # noqa: N802 - Qt override - super().moveEvent(event) - # Dragged to another monitor: its work area (and scaling) may differ, so - # the floating assistant re-pins and the panes re-decide if they fit. - self._on_screen_maybe_changed() - - - # ---- lifecycle --------------------------------------------------- - def closeEvent(self, event) -> None: # noqa: N802 - if self._life.should_keep_running(): - # Chạy nền tiếp: task vẫn chạy và vẫn tự lưu. - event.ignore() - self.hide() - self._tray.show_message(DISPLAY_NAME, tr("app.tray.running_body"), msec=4000) - return - # Real quit: stop every running turn (a tab may have several), then close. - self._life.shutdown() - self._tray.hide() - super().closeEvent(event) - def _set_windows_app_id() -> None: """Make Windows use our window icon on the taskbar (not python.exe's).""" diff --git a/presentation/shell/branding.py b/presentation/shell/branding.py new file mode 100644 index 0000000..1dd2f2c --- /dev/null +++ b/presentation/shell/branding.py @@ -0,0 +1,26 @@ +"""Tên gọi và biểu tượng của ứng dụng — R08-T10. + +Tách riêng vì cả cửa sổ chính lẫn thanh trên cùng đều cần, mà để ở một trong +hai thì file kia phải import ngược lại — vòng import. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtGui import QIcon + +#: branding.py nằm sâu 2 cấp nên phải trỏ ngược lên gốc gói. +ASSETS = Path(__file__).resolve().parents[2] / "assets" + + +def app_icon() -> QIcon: + """The buffalo app icon, used everywhere (window title bar, Windows taskbar and + the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right + pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp + at large sizes. This keeps the taskbar icon identical to the app's icon.""" + icon = QIcon() + for name in ("icon.ico", "icon.png"): + path = ASSETS / name + if path.exists(): + icon.addFile(str(path)) + return icon diff --git a/presentation/shell/main_window.py b/presentation/shell/main_window.py new file mode 100644 index 0000000..f15572a --- /dev/null +++ b/presentation/shell/main_window.py @@ -0,0 +1,362 @@ +"""Cửa sổ chính — R08-T10. + +Bóc nguyên khối ra khỏi ``app.py``. ``app.py`` giờ chỉ còn điểm vào của chương +trình: dựng QApplication, gọi Composition Root, mở cửa sổ. + +Vì sao tách: ``app.py`` là nơi mọi thứ đổ về — nó vừa là điểm vào, vừa giữ cửa +sổ, vừa giữ thanh điều hướng, vừa giữ thanh trên cùng. Ai sửa bất cứ mảng nào +cũng phải mở đúng một file 1.293 dòng, và ba người sửa ba mảng khác nhau thì +đụng nhau ở cùng một chỗ. +""" +from __future__ import annotations + +""" +pages (Dashboard / Schedule / Workspace / Cowork / Structure) and top bar.""" + +import sys + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget, QVBoxLayout, QWidget + +from ... import DISPLAY_NAME, __version__ +from ...i18n import on_language_changed, tr +from .branding import app_icon +from .lifecycle_coordinator import LifecycleCoordinator +from .page_registry import PageRegistryMixin +from .rail_project import RailProjectMixin +from .session_events import SessionEventsMixin +from .toast import Toast +from .top_bar import TopBarMixin +from .nav_rail import NavRailMixin +from .rail_metrics import _NAV_MIN_WIDTH +from .tray_manager import TrayManager +from ...state import AppContext +from ...core.task_scheduler import TaskScheduler +from ...ui.cowork_tab import CoworkTab +from ...ui.sidebar import HistorySidebar +from ...ui.structure_graph_view import StructureGraphView +from ...ui.workspace_tab import WorkspaceTab + + + + +# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed +# shows icon-only (still fully clickable, just narrower). +# The splitter between rail and content draws a drag handle. It only means +# something if the rail can actually take a width from it, so the expanded rail +# is a range rather than one number; long project and thread names in RECENTS +# are the reason someone would widen it. +# +# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter +# of a 1440 screen and more than a quarter of a 1280 one, where it left the +# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on +# every monitor. +# Where a rail row starts, and how much air sits between its icon and its +# label. The tree rows get these from the style; anything laid out by hand +# beside them has to use the same two numbers or it will not line up. + + + + + + + +class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin, + PageRegistryMixin, SessionEventsMixin, QMainWindow): + #: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì + #: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã + #: chuyển sang self._tray (TrayManager). + tray = property(lambda self: self._tray.icon) + + # Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page). + _ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3 + + def __init__(self, ctx: AppContext, user_name: str = ""): + super().__init__() + self.ctx = ctx + self._user_name = user_name + self._really_quit = False + self._life = LifecycleCoordinator(self) + # Khay hệ thống: presentation/shell/tray_manager.py (R08-T10). + self._tray = TrayManager(self, icon=app_icon, tooltip=DISPLAY_NAME, tr=tr) + self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav) + self._history_collapsed = False # remembers History's own collapse-to-strip state + self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}") + self.setWindowIcon(app_icon()) + # Fit to the available screen so the window never opens larger than the + # monitor (auto-fit). Keep a modest minimum that still fits small laptops. + self._fit_to_screen(1180, 760) + + self.sidebar = HistorySidebar(ctx) + # Task scheduler ENGINE runs in the background whether or not its Kanban + # UI (built lazily) is on screen — scheduled tasks must fire regardless. + self.task_scheduler = TaskScheduler(ctx, parent=self) + # Desktop notification when a scheduled task finishes; also refresh + # History — a cowork/code task run saves itself as a new session there. + self.task_scheduler.task_finished.connect(self._on_scheduled_task_done) + # NOTE: task_started fires BEFORE the worker thread even begins, so its + # session doesn't exist on disk yet — refreshing History here would + # find nothing. history_ready fires once the session is actually + # saved (right as the run starts, then again after each turn), which + # is what really makes a Running task's session show up live. + self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history()) + + # Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the + # Workspace screen (per selected project). GraphRAG's heavy + # QtWebEngine is still built lazily on first display + # (StructureGraphView._ensure_web). + self.cowork = CoworkTab(ctx) + self.structure = StructureGraphView(ctx) + self.structure.status_message.connect(self.statusBar().showMessage) + self.cowork.output_changed.connect(self.structure.schedule_rescan) + self.cowork.status_message.connect(self.statusBar().showMessage) + # Refresh History (list + running markers + current highlight) whenever a + # conversation is created/updated or a turn finishes. + self.cowork.turn_finished.connect(lambda *_: self._refresh_history()) + self.cowork.history_changed.connect(self._refresh_history) + self.cowork.turn_finished.connect( + lambda result: self._notify_task(self.cowork, "cowork", result)) + + # Workspace screen — the app HOME: project management + the per-project + # Cowork / GraphRAG sub-tabs and History. + self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure, + sidebar=self.sidebar) + self.workspace.status_message.connect(self.statusBar().showMessage) + self.workspace.projects_changed.connect(self._on_projects_changed) + self.workspace.open_chat.connect(lambda *_: self._refresh_history()) + self.workspace.new_chat.connect(lambda *_: self._refresh_history()) + + # Dashboard + Schedule pages are built lazily on first visit (lazy page + # creation — keeps startup light); None until then. + self.dashboard = None + self.schedule = None + self.monitoring = None + + # --- right side: top bar + pages (nav rail drives the stack) --- + right = QWidget() + right.setObjectName("contentArea") + rlay = QVBoxLayout(right) + rlay.setContentsMargins(10, 10, 10, 10) + rlay.setSpacing(10) + rlay.addWidget(self._build_topbar()) + + self.pages = QStackedWidget() + # (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index + self._nav_defs = [ + ("app.tab.dashboard", "dashboard", self._build_dashboard, None), + ("app.tab.schedule", "schedule", self._build_schedule, None), + ("app.tab.workspace", "workspaces", None, self.workspace), + ("app.tab.monitoring", "monitoring", self._build_monitoring, None), + ] + self._page_widgets = [] # page index → widget (placeholder until lazily built) + self._built = [] + for _key, _icon_name, _builder, widget in self._nav_defs: + page = widget if widget is not None else QWidget() + self.pages.addWidget(page) + self._page_widgets.append(page) + self._built.append(widget is not None) + + self._build_nav_rail(right, rlay) + # Landing stays Workspace ▸ Project, exactly as before. Go through _goto + # so the page is actually shown — selecting the row alone only moves the + # highlight (its signals are blocked to avoid rebuild loops). + self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab()) + self.toast = Toast(self) # top-left "task done" popup + # Floating in-app Help assistant — a robot icon pinned bottom-right on + # every screen; expands into a small help-only chat (see + # ui/help_agent_widget.py). Managed in Monitoring → Agents Admin. + from ...ui.help_agent_widget import HelpAgentWidget + self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name) + self.help_agent.status_message.connect(self.statusBar().showMessage) + + self.statusBar().showMessage(tr("app.status.ready")) + # Author credit, pinned to the bottom-right corner. A permanent status-bar + # widget sits at the right end and is never cleared by showMessage (which + # writes on the left). + self._credit = QLabel(tr("app.credit")) + self._credit.setObjectName("faint") + self._credit.setStyleSheet("padding: 0 10px;") + self.statusBar().addPermanentWidget(self._credit) + self._restore_sessions() + self._tray.setup() + # Start the task scheduler last, once the whole window exists — it + # catches up any overdue tasks right away (first tick runs inline). + self.task_scheduler.start() + # Auto Model Routing: periodic reassess + pending-switch expiry. Runs + # background probes only when genuinely due (never a burst at launch). + try: + from ...core.routing.scheduler import RoutingScheduler + self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self) + self.routing_scheduler.start() + except Exception: # noqa: BLE001 — routing must never block app startup + self.routing_scheduler = None + on_language_changed(self._retranslate) + + def resizeEvent(self, event): # noqa: N802 - Qt override + super().resizeEvent(event) + # The rail's ceiling is a share of the window, so it moves with the + # window. Computed once at construction it was read off a not-yet-sized + # window and stuck at 162px on every monitor. + if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed: + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) + # Keep the floating Help assistant pinned to the bottom-right corner. + if getattr(self, "help_agent", None) is not None: + self.help_agent.reposition() + + def showEvent(self, event): # noqa: N802 - Qt override + super().showEvent(event) + if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() + self.help_agent.reposition() + self.help_agent.raise_() + # Build GraphRAG's browser view and first graph once the window is up + # and idle, so clicking GraphRAG does not sit on an empty view while + # both happen. 3s is after the first paint and any startup refresh. + if not getattr(self, "_graph_prewarmed", False): + self._graph_prewarmed = True + QTimer.singleShot(3000, self._prewarm_graph) + + def _prewarm_graph(self) -> None: + view = getattr(self, "structure", None) + if view is None or not hasattr(view, "prewarm"): + return + try: + view.prewarm() + except Exception: # noqa: BLE001 — a warm-up must never break the app + pass + + # ---- i18n ---------------------------------------------------------- + def _retranslate(self) -> None: + """Re-apply the current language to this window's own static chrome + (tabs are the only long-lived text here; the tabs/dialogs retranslate + themselves).""" + self._apply_nav_labels() + self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) + self._nav_toggle_btn.setToolTip( + tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) + self._credit.setText(tr("app.credit")) + if hasattr(self, "provider_lbl"): + self.provider_lbl.setText(tr("app.provider")) + if hasattr(self, "settings_btn"): + self.settings_btn.setText(tr("app.settings")) + if hasattr(self, "theme_btn"): + self.theme_btn.setToolTip(tr("settings.theme")) + for value, act in self._theme_actions.items(): + act.setText(tr(f"settings.theme_{value}")) + if hasattr(self, "logo_lbl"): + self.logo_lbl.setText(tr("app.logo")) + if getattr(self, "help_agent", None) is not None: + self.help_agent.retranslate() + self._tray.retranslate() + + # ---- system tray (run in background when the window is closed) --- + + + # ---- lazy page building ------------------------------------------- + + + + # Monitoring KEEPS its own tab strip: its eight sub-views live in the + # page, not in the rail. Workspace is the one that hides its strip, + # because the rail lists its sub-views directly. + + + # ---- flat nav rail ------------------------------------------------- + + + + + + def _show_window(self) -> None: + self.showNormal() + self.raise_() + self.activateWindow() + + def _quit_app(self) -> None: + self._really_quit = True + self.close() + + + # ---- top bar ----------------------------------------------------- + + + _BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg") + _BRAND_LOGO_HEIGHT = 22 + + + _THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"} + + + + # ---- handlers ---------------------------------------------------- + + + + + def _update_dock_guard(self) -> None: + """Keep the floating assistant clear of a screen's own bottom bar. + + Only Cowork has one (the composer). Everywhere else the dock sits in + the corner as before. + """ + dock = getattr(self, "help_agent", None) + if dock is None: + return + guard = 0 + on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE + and self.workspace.current_subtab() == self.workspace._cowork_tab_idx) + if on_cowork: + comp = getattr(self.cowork, "composer", None) + if comp is not None and not comp.isHidden(): + # Measured from the composer's TOP edge in window coordinates: + # its own height misses the extra row of controls laid out under + # it, which left the dot still overlapping by ~25px. + origin = comp.mapTo(self, comp.rect().topLeft()) + # ...but only lift the dot if the composer is actually beneath + # it. The composer stops at the chat column's right edge, well + # short of the dot, so lifting it there raised the dot 156px for + # nothing — on Cowork alone it sat off the corner every other + # screen keeps it in. + dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x() + dock_right = dock_left + dock.width() + if dock_right > origin.x() and dock_left < origin.x() + comp.width(): + guard = max(0, self.height() - origin.y() + 8) + dock.set_bottom_guard(guard) + + + + # ---- sizing ------------------------------------------------------ + # Share of the available screen the window takes when it has room to. Fixed + # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K + # panel. `want_*` stays the floor so a small screen behaves as before. + # Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py + def _fit_to_screen(self, want_w: int, want_h: int) -> None: + self._life.fit_to_screen(want_w, want_h) + + def _on_screen_maybe_changed(self) -> None: + if not self._life.screen_maybe_changed(): + return + if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() + self.help_agent.reposition() + + + def moveEvent(self, event): # noqa: N802 - Qt override + super().moveEvent(event) + # Dragged to another monitor: its work area (and scaling) may differ, so + # the floating assistant re-pins and the panes re-decide if they fit. + self._on_screen_maybe_changed() + + + # ---- lifecycle --------------------------------------------------- + def closeEvent(self, event) -> None: # noqa: N802 + if self._life.should_keep_running(): + # Chạy nền tiếp: task vẫn chạy và vẫn tự lưu. + event.ignore() + self.hide() + self._tray.show_message(DISPLAY_NAME, tr("app.tray.running_body"), msec=4000) + return + # Real quit: stop every running turn (a tab may have several), then close. + self._life.shutdown() + self._tray.hide() + super().closeEvent(event) diff --git a/presentation/shell/nav_rail.py b/presentation/shell/nav_rail.py new file mode 100644 index 0000000..5ad5c9c --- /dev/null +++ b/presentation/shell/nav_rail.py @@ -0,0 +1,385 @@ +"""Thanh điều hướng bên trái — R08-T10. + +Bóc từ ``MainWindow``: 18 phương thức dựng và điều khiển thanh rail, cộng danh +sách RECENTS, bộ chọn project, và việc thu gọn về dải icon 54px. + +Đây là **mixin**, không phải widget rời — nói thẳng để khỏi hiểu nhầm. Cả 18 +phương thức đọc/ghi state của cửa sổ (``self._page_widgets``, ``self.workspace``, +``self.splitter``…). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ +``self.X`` thành ``self.window.X``, tức sửa gần 300 dòng chỉ để đổi cách gọi — +rủi ro cao mà không đổi hành vi. Mixin cho được thứ đang cần: mỗi mảng nằm ở +một file, ai sửa rail thì mở file rail. + +Chuyển thành widget thật khi thanh rail cần dùng lại ở cửa sổ khác — hiện chưa. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QScrollArea, QSizePolicy, QSplitter, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +from ...i18n import tr +from .rail_metrics import _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, _NAV_MIN_WIDTH, _NavItemDelegate +from ...ui.widgets import tidy_popup + + + + +class NavRailMixin: + """18 phương thức thanh rail. Trộn vào MainWindow.""" + + def _build_nav_rail(self, right, rlay) -> None: + """Dựng toàn bộ thanh rail và ghép với vùng nội dung. + + Bóc khỏi ``MainWindow.__init__`` — 162 dòng dựng rail nằm lẫn giữa + phần dựng trang và phần khởi động scheduler, nên đọc ``__init__`` là + phải lội qua cả rail mới tới được thứ mình cần. + """ + # Left nav rail — ONE FLAT LIST, no accordion. Every screen the user + # works in is one click away: the Workspace sub-views are listed + # directly instead of hiding behind an expandable parent. The two + # occasional admin destinations sit in a second, bottom-pinned list. + # + # Monitoring is the exception that keeps its sub-views OUT of the rail: + # it has eight, which would double the rail's length for screens opened + # once a week. Its own tab strip is left visible instead (it was hidden + # while the rail carried its children), so all eight stay reachable. + self.nav = self._new_nav_tree("navrail") + self.nav_bottom = self._new_nav_tree("navrailBottom") + self._nav_building = False # guards the rebuild → select → rebuild loop + self.workspace.hide_tab_bar() + self._rebuild_nav() + self.workspace.subtabs_changed.connect(self._rebuild_nav) + for tree in (self.nav, self.nav_bottom): + tree.currentItemChanged.connect( + lambda cur, _prev, t=tree: self._on_nav_current(t, cur)) + rlay.addWidget(self.pages, 1) + + # Nav rail wrapper: a small toggle button ABOVE the page list so the + # whole rail can collapse to icon-only (still fully clickable). Same + # collapse/expand chevron iconography as every other collapsible panel. + from ...ui.icons import collapse_left_icon, collapse_right_icon + from ...ui.icons import icon as _icon + self._collapse_left_icon = collapse_left_icon + self._collapse_right_icon = collapse_right_icon + self._nav_wrap = QWidget() + self._nav_wrap.setObjectName("navWrap") + self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) + nvl = QVBoxLayout(self._nav_wrap) + nvl.setContentsMargins(0, 0, 0, 0) + nvl.setSpacing(0) + # Small, left-aligned "MENU" button (icon + label) instead of a + # full-width centered icon — sits flush with the rail's left edge, + # matching how the nav items themselves align their icon+label. + self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label")) + self._nav_toggle_btn.setIcon(collapse_left_icon()) + self._nav_toggle_btn.setObjectName("navMenuBtn") + self._nav_toggle_btn.setFlat(True) + self._nav_toggle_btn.setCursor(Qt.PointingHandCursor) + self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self._nav_toggle_btn.clicked.connect(self._toggle_nav) + # Zero left margin: the button's own QSS padding (6px) then lines its + # 16px icon up with the nav items' icons below (1px list frame + item + # padding) — same indent level, same icon size as e.g. Dashboard. + toggle_row = QHBoxLayout() + toggle_row.setContentsMargins(0, 8, 10, 8) + toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) + toggle_row.addStretch(1) + nvl.addLayout(toggle_row) + # Primary action at the top of the rail, with the project it will land + # in named right above it. Before, starting a chat in another project + # meant leaving Cowork → Project tab → click a row → come back. + self.nav_project = QComboBox() + self.nav_project.setObjectName("navProjectPick") + self.nav_project.setToolTip(tr("app.nav.project_pick")) + self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick) + tidy_popup(self.nav_project) + self.nav_new_chat = QPushButton(tr("cowork.new_chat")) + self.nav_new_chat.setObjectName("navNewChatBtn") + self.nav_new_chat.setIcon(_icon("plus")) + self.nav_new_chat.setCursor(Qt.PointingHandCursor) + self.nav_new_chat.clicked.connect(self._on_rail_new_chat) + # At 54px the picker cannot show a name, but dropping it altogether left + # the collapsed rail with no way to change project at all. This stands in + # for it: same list, same handler, just the folder icon and a tooltip. + self.nav_project_btn = QToolButton() + self.nav_project_btn.setObjectName("navProjectPickMini") + self.nav_project_btn.setIcon(_icon("folder")) + self.nav_project_btn.setCursor(Qt.PointingHandCursor) + self.nav_project_btn.setPopupMode(QToolButton.InstantPopup) + self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.nav_project_btn.setMenu(QMenu(self.nav_project_btn)) + self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) + self.nav_project_btn.setVisible(False) + head = QVBoxLayout() + head.setContentsMargins(6, 0, 6, 6) + head.setSpacing(6) + head.addWidget(self.nav_project) + head.addWidget(self.nav_project_btn) + head.addWidget(self.nav_new_chat) + nvl.addLayout(head) + self.workspace.project_selected.connect(self._sync_rail_project) + self.workspace.projects_changed.connect(self._sync_rail_project) + self._syncing_rail_project = False + self._sync_rail_project() + # The destinations and RECENTS scroll together; the bottom group, the + # Settings button and the account row stay pinned below them. + # + # Without this the rail simply ran out of room on a short window (a + # 1280×720 laptop leaves ~570px here): nav and the bottom group have + # fixed heights, so the squeeze fell entirely on RECENTS, and once that + # hit zero the layout drew the "GẦN ĐÂY" heading straight over the last + # nav row. + self._nav_scroll = QScrollArea() + self._nav_scroll.setObjectName("navScroll") + self._nav_scroll.setWidgetResizable(True) + self._nav_scroll.setFrameShape(QScrollArea.NoFrame) + self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_body = QWidget() + sv = QVBoxLayout(scroll_body) + sv.setContentsMargins(0, 0, 0, 0) + sv.setSpacing(0) + sv.addWidget(self.nav, 0) + # RECENTS — the threads of the project named in the picker above, right + # where Claude puts them. A shortcut only: the full History panel (search, + # filters, pin, bulk delete, context menu) stays exactly where it is, and + # "all projects…" at the end of this list opens it. + self.nav_recents_hdr = QLabel(tr("app.nav.recents")) + self.nav_recents_hdr.setObjectName("navSectionHdr") + sv.addWidget(self.nav_recents_hdr) + self.nav_recents = self._new_nav_tree("navRecents") + self.nav_recents.itemClicked.connect(self._on_rail_recent) + sv.addWidget(self.nav_recents, 1) + # Collapsing hides RECENTS, and with it the only item carrying a stretch + # factor. A box layout with nothing left to expand centres what remains, + # so the destinations dropped ~300px down the rail — "thu gọn menu lại + # ra giữa". This spacer takes the slack instead, and takes none of it + # while RECENTS is visible (stretch 0 against its 1). + sv.addStretch(0) + self._nav_scroll.setWidget(scroll_body) + nvl.addWidget(self._nav_scroll, 1) + self._build_rail_bottom(nvl) + nvl.addWidget(self._account_row) + + self.split = QSplitter(Qt.Horizontal) + self.split.addWidget(self._nav_wrap) + self.split.addWidget(right) + self.split.setStretchFactor(0, 0) + self.split.setStretchFactor(1, 1) + self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) + self.split.splitterMoved.connect(self._on_split_moved) + self.setCentralWidget(self.split) + + def _new_nav_tree(self, name: str) -> QTreeWidget: + """One flat, single-column list. No indentation and no expand arrows — + every row is a destination, nothing is a container.""" + tree = QTreeWidget() + tree.setObjectName(name) + tree.setHeaderHidden(True) + tree.setIndentation(0) + tree.setRootIsDecorated(False) + tree.setUniformRowHeights(True) + # The column follows the viewport instead of the widest label. Left + # to size itself it stayed ~100px wide inside the 54px collapsed + # rail, so a horizontal scrollbar appeared and slid the icons out of + # the position they hold while the rail is open. + from PySide6.QtWidgets import QHeaderView + tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + tree.setItemDelegate(_NavItemDelegate(tree)) + return tree + + def _nav_rows(self): + """(tree, page, sub, label, icon, enabled) for every row, rail order. + + Workspace contributes all five of its sub-views — including the two the + project gate currently disables — so the rail never changes shape while + the user is looking at it. + """ + rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) + for label, sub, ic, on in self.workspace.nav_entries()] + rows.append((self.nav, self._ROW_SCHEDULE, None, + tr("app.tab.schedule"), "schedule", True)) + rows.append((self.nav_bottom, self._ROW_DASHBOARD, None, + tr("app.tab.dashboard"), "dashboard", True)) + rows.append((self.nav_bottom, self._ROW_MONITORING, None, + tr("app.tab.monitoring"), "monitoring", True)) + return rows + + def _rebuild_nav(self, force: bool = False) -> None: + """Re-fill both lists from _nav_rows(), keeping the current selection. + + Rebuilding changes the current item, which would fire navigation and can + loop back here via subtabs_changed — hence the guard and the blocked + signals. + """ + if self._nav_building: + return + spec = self._nav_rows() + # Rebuilding deletes the QTreeWidgetItems, including the one a signal is + # currently being delivered for. subtabs_changed fires on every visit to + # Workspace, so skip the rebuild unless the rows really differ. + sig = [(label, page, sub, enabled) + for _t, page, sub, label, _ic, enabled in spec] + if not force and sig == getattr(self, "_nav_sig", None): + return + self._nav_sig = sig + self._nav_building = True + try: + from ...ui.icons import icon as _icon + keep = self._current_nav_key() + for tree in (self.nav, self.nav_bottom): + blocked = tree.blockSignals(True) + tree.clear() + tree.blockSignals(blocked) + for tree, page, sub, label, icon_name, enabled in spec: + it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) + it.setIcon(0, _icon(icon_name)) + it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) + if not enabled: + # Same gate as before, shown instead of hidden: the row stays + # in place, greyed, and says why it cannot be opened. + it.setDisabled(True) + it.setToolTip(0, tr("app.nav.needs_project")) + elif self._nav_collapsed: + it.setToolTip(0, label) + blocked = tree.blockSignals(True) + tree.addTopLevelItem(it) + tree.blockSignals(blocked) + # Both destination lists are exactly as tall as their rows; the + # stretch in between belongs to RECENTS. + for tree in (self.nav, self.nav_bottom): + n = tree.topLevelItemCount() + row_h = tree.sizeHintForRow(0) if n else 0 + tree.setFixedHeight(n * row_h + 8) + if keep: + self._select_nav_row(*keep) + finally: + self._nav_building = False + + def _current_nav_key(self): + """(page, sub) of the highlighted row, or None.""" + for tree in (self.nav, self.nav_bottom): + it = tree.currentItem() + if it is not None and it.isSelected(): + data = it.data(0, Qt.UserRole) or {} + if "page" in data: + return data["page"], data.get("sub") + return None + + def _select_nav_row(self, page: int, sub) -> None: + """Highlight the row for (page, sub) without triggering navigation. + + Called both when the user clicks (to keep the two lists mutually + exclusive) and from _goto, so programmatic navigation moves the + highlight too — it used to stay behind on whatever was clicked last. + """ + for tree in (self.nav, self.nav_bottom): + blocked = tree.blockSignals(True) + match = None + for i in range(tree.topLevelItemCount()): + it = tree.topLevelItem(i) + data = it.data(0, Qt.UserRole) or {} + if data.get("page") == page and ( + data.get("sub") == sub or data.get("sub") is None): + match = it + break + if match is not None: + tree.setCurrentItem(match) + else: + tree.setCurrentItem(None) + tree.clearSelection() + tree.blockSignals(blocked) + + def _on_nav_current(self, tree: QTreeWidget, item) -> None: + """A row was picked: clear the other list so only one row looks active.""" + if item is None or self._nav_building: + return + data = item.data(0, Qt.UserRole) or {} + other = self.nav_bottom if tree is self.nav else self.nav + blocked = other.blockSignals(True) + other.setCurrentItem(None) + other.clearSelection() + other.blockSignals(blocked) + self._goto(data.get("page", 0), data.get("sub")) + + # ---- rail header: project picker + new chat ------------------------ + + + + # ---- rail RECENTS -------------------------------------------------- + _RAIL_RECENTS = 5 + + + + + def _apply_nav_labels(self) -> None: + """Re-label every row for the current language and collapse state + (collapsed = icon only, label moves to the tooltip).""" + # force: collapsing leaves the row spec identical, only the text changes. + self._rebuild_nav(force=True) + self._nav_settings_text.setText(tr("app.settings")) + self._nav_settings_text.setVisible(not self._nav_collapsed) + self._nav_settings_btn.setToolTip(tr("app.settings")) + # Collapsed to 54px there is no room for either control's label; the + # picker would be a stub of a name, so it steps aside entirely and the + # button keeps just its + icon. + self.nav_project.setVisible(not self._nav_collapsed) + self.nav_project_btn.setVisible(self._nav_collapsed) + self._refresh_rail_recents() + # Collapsed to 54px only the theme toggle still fits; the rest of the + # account row would be clipped, so it steps aside (Settings, which opens + # the same values in a dialog, stays reachable as an icon). + self.account_lbl.setVisible(not self._nav_collapsed) + self.language_combo.setVisible(not self._nav_collapsed) + self.provider_combo.setVisible(not self._nav_collapsed) + self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat")) + if self._nav_new_chat_enabled(): + self.nav_new_chat.setToolTip( + tr("cowork.new_chat") if self._nav_collapsed else "") + self._sync_rail_project() + + + def _nav_max_width(self) -> int: + """The rail's ceiling for THIS window, as a share of it.""" + return max(_NAV_MIN_WIDTH, + min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE))) + + def _set_nav_width_range(self, lo: int, hi: int) -> None: + """setFixedWidth would leave the splitter handle inert — visible, and + doing nothing when dragged.""" + self._nav_wrap.setMinimumWidth(lo) + self._nav_wrap.setMaximumWidth(hi) + + def _on_split_moved(self, _pos: int, _index: int) -> None: + if not self._nav_collapsed: + self._nav_width = max(_NAV_MIN_WIDTH, + min(self._nav_max_width(), self._nav_wrap.width())) + + def _toggle_nav(self) -> None: + if not self._nav_collapsed: + self._nav_width = max(_NAV_MIN_WIDTH, + min(self._nav_max_width(), self._nav_wrap.width())) + self._nav_collapsed = not self._nav_collapsed + if self._nav_collapsed: + width = _NAV_COLLAPSED_WIDTH + self._set_nav_width_range(width, width) + else: + width = self._nav_width + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) + self._apply_nav_labels() + # Same chevron convention as every other collapsible panel: right- + # pointing (fill-right) means "click to expand", left means "collapse". + self._nav_toggle_btn.setIcon( + self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon()) + # Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit + # next to the icon, same rule the nav items themselves follow. + self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) + self._nav_toggle_btn.setToolTip( + tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) + # Give/reclaim the width difference to the main content pane. + sizes = self.split.sizes() + if len(sizes) == 2: + diff = sizes[0] - width + sizes[0] = width + sizes[1] = max(1, sizes[1] + diff) + self.split.setSizes(sizes) diff --git a/presentation/shell/page_registry.py b/presentation/shell/page_registry.py new file mode 100644 index 0000000..4c48e53 --- /dev/null +++ b/presentation/shell/page_registry.py @@ -0,0 +1,82 @@ +"""Bốn màn chính và cách chuyển giữa chúng — R08-T10. + +Dashboard và Lịch chỉ được dựng ở lần mở đầu tiên (dựng lười) — mở app không +phải trả giá cho hai màn có thể cả phiên không ai vào. ``_ensure_page`` là chỗ +duy nhất biết điều đó, nên mọi đường tới một trang đều phải đi qua ``_goto``. + +Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from ...i18n import tr +from ...ui.dashboard_tab import DashboardTab +from ...ui.monitoring_tab import MonitoringTab +from ...ui.schedule_task_tab import ScheduleTaskTab + + +class PageRegistryMixin: + def _page_index(self, widget) -> int: + return self.pages.indexOf(widget) + def _build_dashboard(self): + d = DashboardTab(self.ctx) + d.status_message.connect(self.statusBar().showMessage) + self.dashboard = d + return d + def _build_schedule(self): + s = ScheduleTaskTab(self.ctx, self.task_scheduler) + s.status_message.connect(self.statusBar().showMessage) + self.schedule = s + return s + def _build_monitoring(self): + m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure, + task_scheduler=self.task_scheduler) + m.status_message.connect(self.statusBar().showMessage) + self.monitoring = m + return m + def _ensure_page(self, row: int) -> None: + """Build a lazy nav page on first visit and swap it in for its placeholder.""" + if not (0 <= row < len(self._built)) or self._built[row]: + return + builder = self._nav_defs[row][2] + if builder is None: + return + real = builder() + placeholder = self._page_widgets[row] + self.pages.insertWidget(row, real) # placeholder shifts to row+1 + self.pages.removeWidget(placeholder) + placeholder.deleteLater() + self._page_widgets[row] = real + self._built[row] = True + def _page_index(self, widget) -> int: + if widget is self.workspace: + return self._ROW_WORKSPACE + if self.dashboard is not None and widget is self.dashboard: + return self._ROW_DASHBOARD + if self.schedule is not None and widget is self.schedule: + return self._ROW_SCHEDULE + if self.monitoring is not None and widget is self.monitoring: + return self._ROW_MONITORING + return self.pages.indexOf(widget) + def _goto(self, page: int, sub) -> None: + self._ensure_page(page) # build lazy page on first visit + self.pages.setCurrentIndex(page) + if page == self._ROW_WORKSPACE: + self.workspace.refresh() # re-list projects + threads on entry + widget = self._page_widgets[page] + if sub is not None and hasattr(widget, "select_subtab"): + # Enforce the project gate here rather than at each entry point. A + # greyed rail row cannot be clicked, but _goto is also reached from + # RECENTS and from startup restore, and it used to open a sub-tab + # the gate was holding shut — page shown, tab strip still hiding it. + if hasattr(widget, "subtab_available") and not widget.subtab_available(sub): + self.statusBar().showMessage(tr("app.nav.needs_project"), 4000) + else: + widget.select_subtab(sub) + # Move the highlight with the content, however navigation was triggered — + # a programmatic _goto used to leave it on whatever was clicked last. + if not self._nav_building: + self._select_nav_row(page, sub) + self._update_dock_guard() + # Switching pages updates which conversation is "current". + self._refresh_history() diff --git a/presentation/shell/rail_metrics.py b/presentation/shell/rail_metrics.py new file mode 100644 index 0000000..6c5aaf7 --- /dev/null +++ b/presentation/shell/rail_metrics.py @@ -0,0 +1,37 @@ +"""Kích thước và cách vẽ một hàng trên thanh rail — R08-T10. + +Chỉ số và cách vẽ, không có hành vi. Tách riêng vì ``theme.py`` cũng phải biết +mấy con số này (nó style ``#navrailBottom`` theo cùng lề), và vì thứ hay phải +tra lại nhất khi chỉnh giao diện là chúng — không nên nằm lẫn trong 400 dòng +dựng widget. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QStyledItemDelegate + +# ---- kích thước --------------------------------------------------------- +_NAV_EXPANDED_WIDTH = 150 +_NAV_COLLAPSED_WIDTH = 54 +_NAV_ROW_INSET = 4 +_NAV_ROW_GAP = 6 +_NAV_MIN_WIDTH = 132 +_NAV_MAX_SHARE = 0.22 +_NAV_MAX_CEILING = 360 + + +class _NavItemDelegate(QStyledItemDelegate): + """Keep a rail row's icon on the left edge, whatever the column is doing. + + QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so + a row with no label — every row once the rail collapses to 54px — has its + icon centred inside whatever box the column happens to give it. That box + tracks the column width, which is not stable: stretched to the viewport the + icons land in the middle of the rail, while a column left wider than the + view leaves them at the left. Same code, two different pictures, which is + why a test render disagreed with the running app. + """ + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter diff --git a/presentation/shell/rail_project.py b/presentation/shell/rail_project.py new file mode 100644 index 0000000..3fa0649 --- /dev/null +++ b/presentation/shell/rail_project.py @@ -0,0 +1,132 @@ +"""Bộ chọn project và danh sách RECENTS trên thanh rail — R08-T10. + +Tách khỏi ``nav_rail.py``: thanh rail có hai phần đời sống khác hẳn nhau. + +Phần điểm đến (Dashboard, Workspace, Giám sát…) là **tĩnh** — dựng một lần, +đổi khi đổi ngôn ngữ. Phần này thì **động**: đổi mỗi lần người dùng chọn +project khác, mỗi lần một cuộc trò chuyện được tạo hay kết thúc. + +Trộn chung một file thì mỗi lần sửa danh sách gần đây lại phải cuộn qua toàn +bộ phần dựng rail. Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem +from ...i18n import tr +from ...ui.widgets import tidy_popup +from ...theme import current_palette + + + +class RailProjectMixin: + """Bộ chọn project + RECENTS. Trộn vào MainWindow.""" + + def _sync_rail_project(self, *_a) -> None: + """Mirror the workspace's project list/selection into the rail picker. + + One-way on purpose: the project list stays the source of truth, this is + only a second place to see and change it. + """ + if self._syncing_rail_project: + return + self._syncing_rail_project = True + try: + choices = self.workspace.project_choices() + current = self.workspace.selected_project_id() + self.nav_project.clear() + for name, pid in choices: + self.nav_project.addItem(f"📁 {name}", pid) + if not choices: + # No project yet: say so, and say what to do about it, instead of + # leaving an empty box and a button that silently does nothing. + self.nav_project.addItem(tr("app.nav.no_project"), "") + idx = self.nav_project.findData(current) + if idx >= 0: + self.nav_project.setCurrentIndex(idx) + has = bool(choices) + tidy_popup(self.nav_project) + self.nav_project.setEnabled(has) + self.nav_project_btn.setEnabled(has) + self.nav_project_btn.setToolTip( + self.nav_project.currentText().replace("📁 ", "") + if has else tr("app.nav.create_project_first")) + self.nav_new_chat.setEnabled(has) + self.nav_new_chat.setToolTip( + "" if has else tr("app.nav.create_project_first")) + finally: + self._syncing_rail_project = False + def _fill_rail_project_menu(self) -> None: + """Mirror the picker's items. Choosing one moves the picker, which runs + _on_rail_project_pick — the collapsed rail adds no second code path.""" + menu = self.nav_project_btn.menu() + menu.clear() + for i in range(self.nav_project.count()): + act = menu.addAction(self.nav_project.itemText(i)) + act.setCheckable(True) + act.setChecked(i == self.nav_project.currentIndex()) + act.triggered.connect( + lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row)) + def _on_rail_project_pick(self, _idx: int) -> None: + if self._syncing_rail_project: + return + pid = self.nav_project.currentData() + if pid: + self.workspace.choose_project(pid) + def _refresh_rail_recents(self) -> None: + """Re-fill the rail's recents from the active project's history.""" + from ...ui.icons import DOT_BLUE, dot_icon + from ...ui.icons import icon as _icon + + tree = self.nav_recents + blocked = tree.blockSignals(True) + tree.clear() + running = self._running_session_ids() + threads = self.workspace.recent_threads(self._RAIL_RECENTS) + for t in threads: + it = QTreeWidgetItem([t["title"]]) + it.setToolTip(0, t["title"]) + if t["session_id"] in running: + it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History + elif t["pinned"]: + it.setIcon(0, _icon("pin")) + it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]}) + tree.addTopLevelItem(it) + if not threads: + it = QTreeWidgetItem([tr("sidebar.empty")]) + it.setDisabled(True) + tree.addTopLevelItem(it) + # The way back to everything the rail cannot show — styled as a link + # (italic, accent-colored) so it reads as "go elsewhere", not another row. + more = QTreeWidgetItem([tr("app.nav.all_projects")]) + more.setData(0, Qt.UserRole, {"all": True}) + more_font = more.font(0) + more_font.setItalic(True) + more.setFont(0, more_font) + more.setForeground(0, QColor(current_palette().accent)) + tree.addTopLevelItem(more) + tree.blockSignals(blocked) + self.nav_recents_hdr.setVisible(not self._nav_collapsed) + self.nav_recents.setVisible(not self._nav_collapsed) + def _on_rail_recent(self, item, _col: int = 0) -> None: + data = item.data(0, Qt.UserRole) or {} + if data.get("all"): + self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) + self.workspace.show_history_pane() + return + path = data.get("path") + if path: + self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) + self.workspace.open_thread(path, data.get("kind", "cowork")) + def _on_rail_new_chat(self) -> None: + """Start a new chat, from any screen. + + Same call the Cowork toolbar button makes — that button stays exactly + where it was; this is a second entry point, not a replacement. + """ + self._goto(self._ROW_WORKSPACE, None) + self.workspace.start_new_chat() + self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab()) + def _nav_new_chat_enabled(self) -> bool: + return bool(self.workspace.project_choices()) diff --git a/presentation/shell/session_events.py b/presentation/shell/session_events.py new file mode 100644 index 0000000..dbc2d2d --- /dev/null +++ b/presentation/shell/session_events.py @@ -0,0 +1,104 @@ +"""Lịch sử hội thoại và thông báo khi task chạy xong — R08-T10. + +Gom những gì phản ứng với việc **có chuyện xảy ra ở nơi khác**: một task đã lên +lịch chạy xong, một phiên được lưu, danh sách project đổi. + +Điểm dễ sai đã ghi lại trong ``_on_scheduled_task_done``: tín hiệu +``task_started`` bắn TRƯỚC khi luồng chạy bắt đầu, lúc đó phiên chưa có trên +đĩa — làm mới Lịch sử ở đó thì không thấy gì. Phải bám ``history_ready``. + +Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``. +""" +from __future__ import annotations + +from pathlib import Path +from PySide6.QtCore import Qt, QTimer +from ... import DISPLAY_NAME +from ...i18n import tr +from ...ui.workspace_tab import WorkspaceTab + + +class SessionEventsMixin: + def _running_session_ids(self): + """All conversation ids currently running — interactive Cowork/Code + chat tab AgentWorkers, plus Schedule Task runs (their own session, + tracked by the scheduler), so a task's live run gets the same + "running" marker in History an interactive chat gets.""" + return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids() + def _refresh_history(self) -> None: + """Rebuild the History list with the current conversation highlighted and + the running ones marked. Deferred to the next event-loop tick: this is often + triggered (via load_conversation) from inside the sidebar's own item-click + handler, and clearing the tree there would delete the item mid-click.""" + from PySide6.QtCore import QTimer + + def _do() -> None: + current = self.cowork.session_id + self.sidebar.set_view_state(current, self._running_session_ids()) + self.sidebar.refresh() + self._refresh_rail_recents() # the rail shortcut follows the panel + + QTimer.singleShot(0, _do) + def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: + """Desktop notification for a finished scheduled task (toast always, + tray balloon when the window isn't focused), then refresh History — + cowork/co4e task runs just saved themselves as new sessions there.""" + from ...core.tasks import load_task + + task = load_task(task_id) or {} + title = task.get("title", "") + msg = (tr("app.toast.task_done", title=title) if ok + else tr("app.toast.task_failed", title=title)) + self.toast.show_message(msg, ok=ok) + if (self.tray is not None + and self.ctx.config.data.get("tray", {}).get("notify_on_done", True) + and not self.isActiveWindow()): + self._tray.show_message(DISPLAY_NAME, msg, error=not ok) + self._refresh_history() + def _notify_task(self, tab, kind: str, result: dict) -> None: + """Notify when a task finishes/fails (skip if more stages queued).""" + if tab.composer.has_queue(): + return # a flow / queue is still running — notify only at the end + name = tr(f"app.tab.{kind}") + err = (result or {}).get("error") + # In-app popup at the top-left (shown whether or not the window is focused). + self.toast.show_message( + tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err) + # System-tray balloon only when the window isn't the active one. + if self.tray is None: + return + if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True): + return + if self.isActiveWindow(): + return # user is looking at the window already + err = (result or {}).get("error") + title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name) + body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140] + self._tray.show_message(title, body, error=bool(err)) + def _restore_sessions(self) -> None: + """Reopen the last conversation per tab (recover after a crash/abrupt exit).""" + from pathlib import Path + + from ...core.history import load_conversation + + last = self.ctx.config.data.get("last_session", {}) + path = last.get("cowork", "") + if path and Path(path).exists(): + try: + self.cowork.load_conversation(load_conversation(path)) + # Reflect the restored thread's project in the Workspace home + # (selecting the matching row won't wipe it — the project id + # already matches, so _bind_project starts no new session). + # Skip forcing the Cowork tab open for a project that no + # longer exists (deleted since this session was saved) — that + # would show the Cowork page while the tab strip still says + # "no project selected" (see WorkspaceTab._on_sidebar_open). + pid = self.cowork.project_id + if pid in ("", "default") or self.workspace._select_project_row(pid): + self.workspace._show_cowork_tab() + except Exception: + pass + def _on_projects_changed(self) -> None: + self.sidebar.refresh() # History regroups by project + self.cowork._apply_output_folder_label() # project may have been renamed + self.structure._refresh_project_combo() # GraphRAG's project lock list follows too diff --git a/presentation/shell/toast.py b/presentation/shell/toast.py new file mode 100644 index 0000000..3a0b34c --- /dev/null +++ b/presentation/shell/toast.py @@ -0,0 +1,40 @@ +"""Thông báo nhỏ tự ẩn ở góc trên trái cửa sổ — R08-T10. + +Hiện ngay trong app, khác với bong bóng khay hệ thống ở ``tray_manager.py``: +cái này hiện dù cửa sổ có đang được focus hay không, cái kia chỉ hiện khi +người dùng đang nhìn chỗ khác. +""" +from __future__ import annotations + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QLabel + +from ...theme import current_palette + + +class Toast(QLabel): + """A small auto-hiding notification shown at the window's top-left.""" + + def __init__(self, parent): + super().__init__(parent) + self.setObjectName("toast") + self.setWordWrap(True) + self.setMaximumWidth(380) + self.setVisible(False) + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self.hide) + + def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None: + p = current_palette() + bg = p.success_soft if ok else p.danger_soft + fg = p.success if ok else p.danger + self.setStyleSheet( + f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};" + f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}") + self.setText(text) + self.adjustSize() + self.move(14, 14) # top-left of the window + self.raise_() + self.setVisible(True) + self._timer.start(ms) diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py new file mode 100644 index 0000000..36b2ae6 --- /dev/null +++ b/presentation/shell/top_bar.py @@ -0,0 +1,234 @@ +"""Thanh trên cùng và hàng tài khoản — R08-T10. + +Bóc từ ``MainWindow``: logo, chọn provider, chọn ngôn ngữ, nút đổi giao diện, +và lối mở hộp thoại Cài đặt. + +Cùng lý do mixin như ``nav_rail.py``: các phương thức này đọc/ghi state của cửa +sổ. Xem ghi chú ở đầu file đó. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QToolButton, QVBoxLayout, QWidget +from ...config import PROVIDER_LABELS +from ...i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr +from .branding import ASSETS +from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET +from ...ui.widgets import tidy_popup +from ...theme import set_active_theme, stylesheet +from ...ui.settings_dialog import SettingsDialog + + + + +class TopBarMixin: + """Thanh trên cùng. Trộn vào MainWindow.""" + + def _build_rail_bottom(self, nvl) -> None: + """Đáy thanh rail: nhóm ghim dưới, nút Cài đặt, hàng tài khoản. + + Nằm ở file thanh trên cùng chứ không phải file rail, vì ba thứ này + đều là "tài khoản và thiết lập" — cùng mối quan tâm với + ``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình. + """ + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton + from ...i18n import tr + from ...ui.icons import icon as _icon + from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET + + # Bottom-pinned group: the places you visit occasionally, kept out of the + # way of the ones you live in. A hairline (styled via #navrailBottom in + # theme.py) separates the two lists. + nvl.addWidget(self.nav_bottom, 0) + # Settings reads as one more row under Dashboard / Giám sát, so its icon + # and label must start exactly where theirs do. Letting QPushButton place + # them does not achieve that: the gap it leaves between icon and text is + # the platform style's, and on macOS it is visibly tighter than the tree + # rows above — a Windows-tuned nudge only moved the mismatch. So the row + # is laid out here, in the same two numbers the tree uses: 4px in, 6px + # between. + self._nav_settings_btn = QPushButton() + self._nav_settings_btn.setObjectName("navSettingsBtn") + self._nav_settings_btn.setFlat(True) + self._nav_settings_btn.setCursor(Qt.PointingHandCursor) + self._nav_settings_btn.clicked.connect(self._open_settings) + srow = QHBoxLayout(self._nav_settings_btn) + srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6) + srow.setSpacing(_NAV_ROW_GAP) + self._nav_settings_icon = QLabel() + self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) + self._nav_settings_icon.setFixedSize(16, 16) + self._nav_settings_text = QLabel(tr("app.settings")) + srow.addWidget(self._nav_settings_icon) + srow.addWidget(self._nav_settings_text) + srow.addStretch(1) + nvl.addWidget(self._nav_settings_btn) + self._account_row = self._build_account_row() + + def _build_topbar(self) -> QWidget: + bar = QWidget() + bar.setObjectName("topbar") + # Styled centrally (see theme._TEMPLATE): flat, with a single hairline + # separating it from the content below — no card box behind it. + h = QHBoxLayout(bar) + h.setContentsMargins(16, 10, 12, 10) + h.setSpacing(10) + # FPT logo slot in front of the brand text: shown only when a logo + # image has been dropped into assets/ (see _brand_logo_pixmap) — the + # brand works text-only until the real artwork is supplied. + self.logo_img = QLabel() + logo_pm = self._brand_logo_pixmap() + if logo_pm is not None: + self.logo_img.setPixmap(logo_pm) + else: + self.logo_img.setVisible(False) + h.addWidget(self.logo_img) + self.logo_lbl = QLabel(tr("app.logo")) + self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE + h.addWidget(self.logo_lbl) + h.addStretch(1) + # Provider / language / theme / Settings used to live here, five controls + # wide across the top of every screen. They are per-account settings, not + # per-screen ones, so they moved to the account row at the foot of the + # rail (_build_account_row) — same widgets, same handlers, new home. + return bar + def _build_account_row(self) -> QWidget: + """The rail's foot: who you are, and the settings that follow you. + + Nothing new is introduced here — these are the exact widgets the top bar + used to hold, moved as-is so every existing signal still lands. + """ + box = QWidget() + box.setObjectName("navAccount") + v = QVBoxLayout(box) + v.setContentsMargins(6, 4, 6, 4) + v.setSpacing(4) + + who = QHBoxLayout() + who.setSpacing(4) + self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤") + self.account_lbl.setObjectName("hint") + who.addWidget(self.account_lbl, 1) + self.language_combo = QComboBox() + for key in LANGUAGES: + self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key) + self.language_combo.setItemData( + self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole) + idx = self.language_combo.findData(get_language()) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + tidy_popup(self.language_combo) + self.language_combo.currentIndexChanged.connect(self._on_language_changed) + who.addWidget(self.language_combo) + self.theme_btn = self._build_theme_button() + who.addWidget(self.theme_btn) + v.addLayout(who) + + self.provider_lbl = QLabel(tr("app.provider")) + self.provider_lbl.setObjectName("hint") + self.provider_lbl.setVisible(False) # the combo names itself in the rail + self.provider_combo = QComboBox() + self.provider_combo.setToolTip(tr("app.provider")) + for key, label in PROVIDER_LABELS.items(): + self.provider_combo.addItem(label, key) + tidy_popup(self.provider_combo) + idx = self.provider_combo.findData(self.ctx.config.active_provider) + if idx >= 0: + self.provider_combo.setCurrentIndex(idx) + self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) + v.addWidget(self.provider_lbl) + v.addWidget(self.provider_combo) + return box + def _brand_logo_pixmap(self): + """The FPT logo scaled to top-bar height, or None while no logo file + exists yet — drop the artwork into src/cowork_local/assets/ under one + of the _BRAND_LOGO_NAMES and it appears on next launch.""" + from PySide6.QtGui import QPixmap + + for name in self._BRAND_LOGO_NAMES: + path = ASSETS / name + if not path.exists(): + continue + pm = QPixmap(str(path)) + if pm.isNull(): + continue + return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation) + return None + def _build_theme_button(self) -> QToolButton: + """A single icon button (System/Dark/Light) replacing the old + Settings-only theme dropdown — one click applies the choice + immediately via the existing _apply_theme(), no dialog round-trip.""" + from ...ui.icons import icon as _icon + + btn = QToolButton() + btn.setPopupMode(QToolButton.InstantPopup) + menu = QMenu(btn) + self._theme_actions = {} + for value, icon_name in self._THEME_ICONS.items(): + act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}")) + act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v)) + self._theme_actions[value] = act + btn.setMenu(menu) + btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) + return btn + def _set_theme(self, value: str) -> None: + from ...ui.icons import icon as _icon + + self.ctx.config.theme = value + self.ctx.save() + self._apply_theme() + self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor"))) + def _on_provider_changed(self, _idx: int) -> None: + self.ctx.config.active_provider = self.provider_combo.currentData() + self.ctx.save() + self.cowork.refresh_header() + # Reload the Cowork tab's Agent (Model) list for the newly selected provider. + self.cowork.refresh_agents() + self.workspace.refresh_ai_models() # + the Folder AI-edit model picker + self.statusBar().showMessage( + tr("app.status.using_provider", + label=PROVIDER_LABELS.get(self.ctx.config.active_provider)) + ) + def _on_language_changed(self, _idx: int) -> None: + lang = self.language_combo.currentData() + if not lang or lang == get_language(): + return + self.ctx.config.language = lang + self.ctx.save() + set_language(lang) # notifies every registered persistent widget + def _open_settings(self) -> None: + dlg = SettingsDialog(self.ctx, self) + if dlg.exec(): + self._apply_theme() + # Settings can change the theme too — keep the rail's toggle icon + # showing the value that is actually in effect. + from ...ui.icons import icon as _theme_icon + self.theme_btn.setIcon( + _theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) + set_language(self.ctx.config.language) # apply if changed in Settings + # reflect provider/theme/language changes + i = self.provider_combo.findData(self.ctx.config.active_provider) + if i >= 0: + self.provider_combo.setCurrentIndex(i) + li = self.language_combo.findData(get_language()) + if li >= 0: + self.language_combo.blockSignals(True) + self.language_combo.setCurrentIndex(li) + self.language_combo.blockSignals(False) + self.cowork.refresh_header() + self.cowork.refresh_agents() + self.workspace.refresh_ai_models() # + the Folder AI-edit model picker + max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0) + self.cowork.composer.set_max_attachments(max_files) + self.sidebar.refresh() + self.statusBar().showMessage(tr("app.status.settings_saved")) + def _apply_theme(self) -> None: + app = QApplication.instance() + if app: + set_active_theme(self.ctx.config.theme) + app.setStyleSheet(stylesheet(self.ctx.config.theme)) + # Re-apply theme styles to chat bubbles so they adapt to the new theme. + self.cowork.apply_theme() + if getattr(self, "help_agent", None) is not None: + self.help_agent.apply_theme() # chat body follows theme (header stays fixed) diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py index 21f1d82..71652b5 100644 --- a/tools/check_probes_bite.py +++ b/tools/check_probes_bite.py @@ -1,132 +1,135 @@ -"""Round 5: do the checks actually bite? - -Rounds 1–4 all report green. That is only worth something if the checks would -have turned red had the work not been done. So this round breaks the app on -purpose, one feature at a time, and fails if the corresponding check still -passes — a check that cannot fail is not evidence. - -Each mutation is applied by monkey-patching the module BEFORE the checker -builds its own window, then undone. - -Run: python tools/check_probes_bite.py -""" -from __future__ import annotations - -import io -import os -import runpy -import subprocess -import sys - -sys.stdout.reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(REPO.parent)) -sys.path.insert(0, str(Path(__file__).resolve().parent)) -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -# (name, file, find, replace, checker that must FAIL because of it) -MUTATIONS = [ - ("phong to cham tro ly gap doi khai bao", - "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", - "check_layout_geometry.py"), - ("tra lane Running ve khong vien", - "ui/schedule_task_tab.py", - 'if status == "running" and counts[status]:', - 'if False:', - "check_design_parity.py"), - ("bo cot muc luc cua Cai dat", - "ui/settings_dialog.py", - "self.section_list, self.section_stack = section_panels(pages)", - "self.section_list, self.section_stack = section_panels(pages[:1])", - "check_dialogs.py"), - ("noi lai dai tab flow Co4E", - "ui/co4e_tab.py", - "self.flow_scroll.setVisible(False)", - "self.flow_scroll.setVisible(True)", - "check_co4e.py"), - ("bo dong 'Tat ca project...' khoi GAN DAY", - "app.py", - 'more.setData(0, Qt.UserRole, {"all": True})', - 'more.setData(0, Qt.UserRole, {})', - "check_design_parity.py"), - ("tra thanh menu ve accordion (bo nhom day)", - "app.py", - 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,', - 'rows.append((self.nav, self._ROW_DASHBOARD, None,', - "check_layout_geometry.py"), -] - - -def run_checker(script: str) -> int: - """Run a checker in a fresh process; return its exit code.""" - proc = subprocess.run( - [sys.executable, str(REPO / "tools" / script)], - cwd=REPO, capture_output=True, text=True, encoding="utf-8", - errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen", - "PYTHONIOENCODING": "utf-8"}) - return proc.returncode - - -def tree_state() -> str: - return subprocess.run(["git", "status", "--short"], cwd=REPO, - capture_output=True, text=True).stdout.strip() - - -def main() -> int: - fails: list[str] = [] - # Compare the tree BEFORE and AFTER, not against a clean tree: work in - # progress is legitimately uncommitted, and demanding a clean tree made this - # round fail for a reason that has nothing to do with the mutations. - before = tree_state() - print(f"{'hong gi':44} {'phep do':26} ket qua") - print("-" * 88) - for name, rel, find, repl, checker in MUTATIONS: - path = REPO / rel - # newline="" both ways: the default translates on read AND write, so a - # LF file came back as CRLF and every mutated file was left "modified" - # even after being restored. - with io.open(path, "r", encoding="utf-8", newline="") as fh: - original = fh.read() - if find not in original: - fails.append(f"{name}: khong tim thay doan can sua trong {rel}") - print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***") - continue - - def write(text: str) -> None: - with io.open(path, "w", encoding="utf-8", newline="") as fh: - fh.write(text) - - write(original.replace(find, repl, 1)) - try: - code = run_checker(checker) - finally: - write(original) # always restore - bit = code != 0 - print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}") - if not bit: - fails.append(f"{name}: {checker} van bao xanh du da lam hong") - - # Everything must be back exactly as it was before this run. - after = tree_state() - same = after == before - print() - print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***") - if not same: - print(" truoc:", before.replace("\n", " | ") or "(sach)") - print(" sau :", after.replace("\n", " | ") or "(sach)") - fails.append("file chua duoc khoi phuc sau khi thu") - - print() - if fails: - print("*** VONG 5 THAT BAI ***") - for f in fails: - print(" " + f) - return 1 - print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Round 5: do the checks actually bite? + +Rounds 1–4 all report green. That is only worth something if the checks would +have turned red had the work not been done. So this round breaks the app on +purpose, one feature at a time, and fails if the corresponding check still +passes — a check that cannot fail is not evidence. + +Each mutation is applied by monkey-patching the module BEFORE the checker +builds its own window, then undone. + +Run: python tools/check_probes_bite.py +""" +from __future__ import annotations + +import io +import os +import runpy +import subprocess +import sys + + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +# (name, file, find, replace, checker that must FAIL because of it) +MUTATIONS = [ + ("phong to cham tro ly gap doi khai bao", + "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", + "check_layout_geometry.py"), + ("tra lane Running ve khong vien", + "ui/schedule_task_tab.py", + 'if status == "running" and counts[status]:', + 'if False:', + "check_design_parity.py"), + ("bo cot muc luc cua Cai dat", + "ui/settings_dialog.py", + "self.section_list, self.section_stack = section_panels(pages)", + "self.section_list, self.section_stack = section_panels(pages[:1])", + "check_dialogs.py"), + ("noi lai dai tab flow Co4E", + "ui/co4e_tab.py", + "self.flow_scroll.setVisible(False)", + "self.flow_scroll.setVisible(True)", + "check_co4e.py"), + ("bo dong 'Tat ca project...' khoi GAN DAY", + # R08-T10 doi cho: MainWindow bi boc khoi app.py sang presentation/shell/, + # RECENTS nam o rail_project.py, cay dieu huong o nav_rail.py. + "presentation/shell/rail_project.py", + 'more.setData(0, Qt.UserRole, {"all": True})', + 'more.setData(0, Qt.UserRole, {})', + "check_design_parity.py"), + ("tra thanh menu ve accordion (bo nhom day)", + "presentation/shell/nav_rail.py", + 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,', + 'rows.append((self.nav, self._ROW_DASHBOARD, None,', + "check_layout_geometry.py"), +] + + +def run_checker(script: str) -> int: + """Run a checker in a fresh process; return its exit code.""" + proc = subprocess.run( + [sys.executable, str(REPO / "tools" / script)], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", + errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen", + "PYTHONIOENCODING": "utf-8"}) + return proc.returncode + + +def tree_state() -> str: + return subprocess.run(["git", "status", "--short"], cwd=REPO, + capture_output=True, text=True).stdout.strip() + + +def main() -> int: + fails: list[str] = [] + # Compare the tree BEFORE and AFTER, not against a clean tree: work in + # progress is legitimately uncommitted, and demanding a clean tree made this + # round fail for a reason that has nothing to do with the mutations. + before = tree_state() + print(f"{'hong gi':44} {'phep do':26} ket qua") + print("-" * 88) + for name, rel, find, repl, checker in MUTATIONS: + path = REPO / rel + # newline="" both ways: the default translates on read AND write, so a + # LF file came back as CRLF and every mutated file was left "modified" + # even after being restored. + with io.open(path, "r", encoding="utf-8", newline="") as fh: + original = fh.read() + if find not in original: + fails.append(f"{name}: khong tim thay doan can sua trong {rel}") + print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***") + continue + + def write(text: str) -> None: + with io.open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(text) + + write(original.replace(find, repl, 1)) + try: + code = run_checker(checker) + finally: + write(original) # always restore + bit = code != 0 + print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}") + if not bit: + fails.append(f"{name}: {checker} van bao xanh du da lam hong") + + # Everything must be back exactly as it was before this run. + after = tree_state() + same = after == before + print() + print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***") + if not same: + print(" truoc:", before.replace("\n", " | ") or "(sach)") + print(" sau :", after.replace("\n", " | ") or "(sach)") + fails.append("file chua duoc khoi phuc sau khi thu") + + print() + if fails: + print("*** VONG 5 THAT BAI ***") + for f in fails: + print(" " + f) + return 1 + print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())