refactor(shell): R08-T10 — bootstrap + tách TrayManager và LifecycleCoordinator

app.py 1356 -> 1293 dòng. presentation/shell/ có 3 file:

  bootstrap.py               Composition Root (đã vào ở commit trước)
  tray_manager.py            khay hệ thống + thông báo bong bóng
  lifecycle_coordinator.py   canh cửa sổ theo màn hình + tắt cho sạch

Vì sao tách khay: khay là thứ CÓ THỂ KHÔNG TỒN TẠI (một số môi trường Linux,
phiên RDP). Trước đây mỗi chỗ dùng phải tự nhớ kiểm `if self.tray is not None`
— có 6 chỗ như thế, và 3 chỗ còn phải tự bọc try/except quanh showMessage.
Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra.

Vì sao tách vòng đời: hai việc trong đó không phải việc của giao diện. Canh
cửa sổ theo màn hình là số học thuần (anh Nam có hai màn khác độ phân giải và
khác tỉ lệ phóng — kéo qua lại là vùng làm việc đổi). Còn shutdown là thứ tự
dừng có ý nghĩa: bộ lập lịch trước để nó không kịp khởi động việc mới trong
lúc ta đang dừng việc cũ, rồi mới tới worker, rồi ngắt tiến trình MCP.

closeEvent/moveEvent/resizeEvent vẫn ở lớp cửa sổ vì Qt gọi thẳng vào đó,
nhưng phần quyết định đã chuyển đi. closeEvent từ 30 dòng còn 11.

Giữ self.tray thành property trỏ vào self._tray.icon — vài chỗ còn đọc tên cũ.

Đã lấy mốc trước khi bóc rồi so lại sau: 24/24 checker trong tools/ qua cả hai
lần. Đây là bộ đặc tả thật cho MainWindow (check_nav, check_rail_align,
check_layout_geometry, check_controls_alive... dựng cửa sổ thật offscreen trên
BẢN SAO của ~/.cowork_local, scheduler bị vô hiệu hoá). 632 test xanh.

CHƯA làm hết R08-T10: plan ghi tách thành main_window.py + tray_manager.py +
lifecycle_coordinator.py. Hai file sau đã xong, main_window.py thì chưa —
MainWindow vẫn nằm trong app.py và vẫn 1095 dòng. Đo lại thì khối lượng không
nằm ở ba cụm plan nêu mà ở hai cụm khác:

    nav rail    18 method, ~340 dòng
    topbar      8 method,  ~157 dòng
    __init__    279 dòng

Hai cụm đó dính chặt vào state của cửa sổ, chuyển đi cần đổi giao diện giữa
chúng chứ không phải dời chỗ, nên tôi dừng ở đây thay vì làm nửa vời.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-25 19:56:37 +09:00
co-authored by Claude Opus 5
parent 2246d55286
commit c77ce36191
3 changed files with 214 additions and 91 deletions
+28 -91
View File
@@ -20,6 +20,8 @@ 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.lifecycle_coordinator import LifecycleCoordinator
from .presentation.shell.tray_manager import TrayManager
from .state import AppContext
from .ui.widgets import tidy_popup
from .theme import current_palette, set_active_theme, stylesheet
@@ -117,6 +119,11 @@ class _NavItemDelegate(QStyledItemDelegate):
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
@@ -125,7 +132,9 @@ class MainWindow(QMainWindow):
self.ctx = ctx
self._user_name = user_name
self._really_quit = False
self.tray = None
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__}")
@@ -386,7 +395,7 @@ class MainWindow(QMainWindow):
self._credit.setStyleSheet("padding: 0 10px;")
self.statusBar().addPermanentWidget(self._credit)
self._restore_sessions()
self._setup_tray()
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()
@@ -455,31 +464,9 @@ class MainWindow(QMainWindow):
self.logo_lbl.setText(tr("app.logo"))
if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate()
if self.tray is not None:
self.tray.setToolTip(DISPLAY_NAME)
if hasattr(self, "_tray_open_act"):
self._tray_open_act.setText(tr("app.tray.open"))
self._tray_quit_act.setText(tr("app.tray.quit"))
self._tray.retranslate()
# ---- system tray (run in background when the window is closed) ---
def _setup_tray(self) -> None:
from PySide6.QtGui import QAction
if not QSystemTrayIcon.isSystemTrayAvailable():
return
self.tray = QSystemTrayIcon(app_icon(), self)
self.tray.setToolTip(DISPLAY_NAME)
menu = QMenu()
self._tray_open_act = QAction(tr("app.tray.open"), self)
self._tray_open_act.triggered.connect(self._show_window)
self._tray_quit_act = QAction(tr("app.tray.quit"), self)
self._tray_quit_act.triggered.connect(self._quit_app)
menu.addAction(self._tray_open_act)
menu.addAction(self._tray_quit_act)
self.tray.setContextMenu(menu)
self.tray.activated.connect(
lambda reason: self._show_window() if reason == QSystemTrayIcon.Trigger else None)
self.tray.show()
def _page_index(self, widget) -> int:
return self.pages.indexOf(widget)
@@ -892,12 +879,7 @@ class MainWindow(QMainWindow):
if (self.tray is not None
and self.ctx.config.data.get("tray", {}).get("notify_on_done", True)
and not self.isActiveWindow()):
try:
self.tray.showMessage(
DISPLAY_NAME, msg,
QSystemTrayIcon.Information if ok else QSystemTrayIcon.Warning, 5000)
except Exception: # noqa: BLE001
pass
self._tray.show_message(DISPLAY_NAME, msg, error=not ok)
self._refresh_history()
def _notify_task(self, tab, kind: str, result: dict) -> None:
@@ -919,11 +901,7 @@ class MainWindow(QMainWindow):
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]
icon = QSystemTrayIcon.Critical if err else QSystemTrayIcon.Information
try:
self.tray.showMessage(title, body, icon, 5000)
except Exception:
pass
self._tray.show_message(title, body, error=bool(err))
def _show_window(self) -> None:
self.showNormal()
@@ -1202,27 +1180,17 @@ class MainWindow(QMainWindow):
# 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.
_SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85
# 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:
screen = self.screen() or QGuiApplication.primaryScreen()
avail = screen.availableGeometry() if screen else None
if avail is None:
self.resize(want_w, want_h)
self._life.fit_to_screen(want_w, want_h)
def _on_screen_maybe_changed(self) -> None:
if not self._life.screen_maybe_changed():
return
margin = 60
# Take a share of the screen, never less than the asked-for size and
# never more than the screen can show.
w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)),
avail.width() - margin)
h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)),
avail.height() - margin)
# minimum must never exceed what the screen can show
self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin))
self.resize(max(w, 1), max(h, 1))
frame = self.frameGeometry()
frame.moveCenter(avail.center())
self.move(frame.topLeft())
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)
@@ -1230,49 +1198,18 @@ class MainWindow(QMainWindow):
# the floating assistant re-pins and the panes re-decide if they fit.
self._on_screen_maybe_changed()
def _on_screen_maybe_changed(self) -> None:
screen = self.screen()
if screen is getattr(self, "_last_screen", None):
return
self._last_screen = screen
avail = screen.availableGeometry() if screen else None
if avail is not None:
self.setMinimumSize(min(820, avail.width() - 60),
min(520, avail.height() - 60))
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
self.help_agent.reposition()
# ---- lifecycle ---------------------------------------------------
def closeEvent(self, event) -> None: # noqa: N802
keep = (self.tray is not None
and self.ctx.config.data.get("tray", {}).get("minimize_on_close", True))
if keep and not self._really_quit:
# Keep running in the background; tasks continue and autosave.
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()
try:
self.tray.showMessage(
DISPLAY_NAME, tr("app.tray.running_body"),
QSystemTrayIcon.Information, 4000)
except Exception:
pass
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.task_scheduler.stop() # also stops any scheduled tasks
if getattr(self, "routing_scheduler", None) is not None:
self.routing_scheduler.stop()
for tab in (self.cowork,):
for w in tab.active_workers():
if w.isRunning():
w.request_stop()
w.wait(1500)
# Safely stop codebase-memory UI if the method exists
if hasattr(self.structure, 'stop_cmem_ui'):
self.structure.stop_cmem_ui()
self.ctx.stop_mcp_connections() # never leave a connected MCP server subprocess behind
if self.tray is not None:
self.tray.hide()
self._life.shutdown()
self._tray.hide()
super().closeEvent(event)