Feature/delta team/epic r04 #7
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+135
-132
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user