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())