From 971d39203b990a8f6e594568798f27e2a7a3f28c Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Mon, 7 Sep 2026 19:23:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20app=20m=E1=BB=9F=20l=C3=AAn=20?= =?UTF-8?q?=E1=BB=9F=20m=C3=A0n=20"T=E1=BA=A5t=20c=E1=BA=A3=20project",=20?= =?UTF-8?q?v=E1=BB=87t=20s=C3=A1ng=20kh=E1=BB=9Bp=20n=E1=BB=99i=20dung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing được chốt SAU _restore_sessions(): hội thoại lần trước vẫn được nạp lại (phục hồi sau khi thoát đột ngột là mục đích của nó), nhưng khung nhìn đầu tiên là danh sách chứ không phải cuộc trò chuyện vừa đóng. Phải đi qua _goto, không gọi thẳng show_history_pane(): _goto là chỗ duy nhất dời vệt sáng trên thanh menu theo nội dung. Gọi tắt thì nội dung sang Cowork còn vệt sáng ở lại Project — và vì QTreeWidget không phát currentItemChanged khi bấm lại đúng dòng đang chọn, bấm "Project" sẽ KHÔNG có tác dụng gì cho tới khi người dùng bấm sang mục khác rồi bấm về. Gom hai đường vào (link "Tất cả project…" và màn mặc định) về một hàm để chúng không lệch nhau nữa. Chưa chọn project thì cổng project đang giữ sub-tab Cowork đóng; ép mở bằng cửa sau sẽ để nội dung ở Cowork trong khi cả cổng lẫn vệt sáng đều nói là Project. Màn đúng cho người chưa có project chính là màn quản lý project. Co-Authored-By: Claude Opus 5 (1M context) --- presentation/shell/page_registry.py | 54 ++++++++++ presentation/shell/rail_project.py | 33 +++++- tests/ui/test_startup_landing.py | 158 ++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tests/ui/test_startup_landing.py diff --git a/presentation/shell/page_registry.py b/presentation/shell/page_registry.py index 9a0605e..d7f3e2e 100644 --- a/presentation/shell/page_registry.py +++ b/presentation/shell/page_registry.py @@ -47,6 +47,60 @@ class PageRegistryMixin: m.status_message.connect(self.statusBar().showMessage) self.monitoring = m return m + def current_screen_label(self) -> str: + """Tên màn hình người dùng đang mở, dạng "Workspace ▸ Project". + + Trợ lý Hỗ trợ không nhìn được màn hình, nên "tôi đang ở đâu" là câu nó + không thể tự trả lời — chuỗi này được ghép vào prompt của nó + (``core/help_knowledge.py``). + """ + row = self.pages.currentIndex() + if not (0 <= row < len(self._nav_defs)): + return "" + page = tr(self._nav_defs[row][0]) + if row != self._ROW_WORKSPACE: + return page + sub = self.workspace.current_subtab() + keys = {self.workspace._project_tab_idx: "workspace.tab_project", + self.workspace._cowork_tab_idx: "workspace.tab_cowork", + self.workspace._co4e_tab_idx: "workspace.tab_co4e", + getattr(self.workspace, "_folder_tab_idx", -1): "workspace.tab_folder", + self.workspace._graphrag_tab_idx: "workspace.tab_graphrag"} + key = keys.get(sub) + return f"{page} ▸ {tr(key)}" if key else page + + def help_context(self) -> str: + """Ngữ cảnh sống cho Trợ lý Hỗ trợ: đang ở màn nào, và trên đó bấm được gì. + + Vì sao đọc từ WIDGET ĐANG CHẠY chứ không từ ``docs/screens/controls.json``: + file đó được trích tự động nhưng đã cũ — 5/41 file trong đó không còn tồn + tại, và nó không có file nào trong ``presentation/`` (chưa sinh lại sau + refactor R08). Nạp nó vào prompt là dạy trợ lý về nút của những file đã + bị xoá. Cây widget thật thì không bao giờ cũ được. + """ + from PySide6.QtWidgets import QPushButton, QTabWidget + + from ...core.help_knowledge import screen_context + + page = self.pages.currentWidget() + nhan = [] + if page is not None: + for tabs in page.findChildren(QTabWidget): + for i in range(tabs.count()): + if tabs.isTabVisible(i): + nhan.append(f"[tab] {tabs.tabText(i)}") + for btn in page.findChildren(QPushButton): + text = (btn.text() or "").replace("&", "").strip() + if text and btn.isVisible() and btn.isEnabled(): + nhan.append(text) + # Bo trung, giu nguyen thu tu xuat hien tren man hinh. + thay, gon = set(), [] + for n in nhan: + if n not in thay: + thay.add(n) + gon.append(n) + return screen_context(self.current_screen_label(), gon) + 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]: diff --git a/presentation/shell/rail_project.py b/presentation/shell/rail_project.py index 8be3ef6..706accc 100644 --- a/presentation/shell/rail_project.py +++ b/presentation/shell/rail_project.py @@ -114,12 +114,41 @@ class RailProjectMixin: tree.blockSignals(blocked) self.nav_recents_hdr.setVisible(not self._nav_collapsed) self.nav_recents.setVisible(not self._nav_collapsed) + def goto_all_projects(self) -> None: + """Mở khung "Tất cả project…": Workspace ▸ Cowork, bảng lịch sử bung ra. + + Phải đi qua ``_goto``, không được gọi thẳng ``show_history_pane()``: + ``_goto`` là chỗ duy nhất dời vệt sáng trên thanh menu theo nội dung. + Gọi tắt thì nội dung sang Cowork còn vệt sáng ở lại Project — và vì + ``QTreeWidget`` không phát ``currentItemChanged`` khi bấm lại đúng dòng + đang chọn, bấm "Project" sẽ KHÔNG có tác dụng gì cho tới khi người dùng + bấm sang mục khác rồi bấm về. + + Gom vào một hàm vì có hai đường vào: link "Tất cả project…" ở mục GẦN + ĐÂY, và màn hình mặc định lúc mở app. + """ + sub = self.workspace._cowork_tab_idx + # Chua chon project thi cong project dang giu sub-tab Cowork dong. + # show_history_pane() mo no bang cua sau, bo qua cong — the la noi dung + # o Cowork trong khi ca cong lan vet sang deu noi la Project. Man hinh + # dung cho nguoi chua co project chinh la man quan ly project. + if sub < 0 or not self.workspace.subtab_available(sub): + self._goto(self._ROW_WORKSPACE, self.workspace._project_tab_idx) + return + self._goto(self._ROW_WORKSPACE, sub) + self.workspace.show_history_pane() + # Bang lich su duoc nhung trong sub-tab Cowork cua MOT project, nen + # WorkspaceTab._bind_project da dat set_project_filter(pid) — no chi hien + # thread cua project dang chon. Vao day bang link "Tat ca project…" ma van + # con bo loc thi nguoi dung tao 5 project chi thay 1. Xoa bo loc SAU + # show_history_pane(), vi ham do di qua duong doi sub-tab. + self.sidebar.set_project_filter("") + def _on_rail_recent(self, item, _col: int = 0) -> None: """Bấm một dòng trong mục GẦN ĐÂY: mở hội thoại đó, hoặc mở khung "Tất cả project…".""" 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() + self.goto_all_projects() return path = data.get("path") if path: diff --git a/tests/ui/test_startup_landing.py b/tests/ui/test_startup_landing.py new file mode 100644 index 0000000..ed391b1 --- /dev/null +++ b/tests/ui/test_startup_landing.py @@ -0,0 +1,158 @@ +"""Màn hình app mở lên lần đầu. + +Trước đây `MainWindow.__init__` gọi `_goto(ROW_WORKSPACE, current_subtab())` rồi +ngay sau đó `_restore_sessions()` lại `_show_cowork_tab()` để mở lại hội thoại +lần trước. Kết quả: thứ người dùng thấy khi bật app là cuộc trò chuyện cũ, không +phải danh sách project — và dòng landing ở trên trông như đang quyết định điều đó +trong khi thực ra bị ghi đè vài chục dòng sau. + +Giờ landing được chốt SAU restore: hội thoại cũ vẫn được nạp lại (đó là mục đích +của restore — phục hồi sau khi thoát đột ngột), nhưng khung nhìn đầu tiên là +"Tất cả project…". +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + + +@pytest.fixture +def window(qapp, tmp_path): + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path / "config.json" + build_config(config_path) + win = MainWindow(build_context(config_path)) + yield win + win.close() + + +def test_mo_app_len_thi_dung_o_man_workspace(window): + """Vẫn là màn Workspace, không phải Dashboard/Lịch/Giám sát.""" + assert window.pages.currentIndex() == window._ROW_WORKSPACE + + +def test_mo_app_len_thi_o_khung_tat_ca_project(window): + """Đúng khung mà link "Tất cả project…" ở thanh menu mở ra. + + Trừ khi cổng project đang giữ nó đóng: chưa có project nào thì sub-tab + Cowork bị ẩn, và màn hình đúng cho người dùng đó là màn quản lý project. + Ép mở bằng cửa sau sẽ để nội dung ở Cowork trong khi cổng nói là Project. + """ + ws = window.workspace + if ws.subtab_available(ws._cowork_tab_idx): + assert ws.current_subtab() == ws._cowork_tab_idx + assert ws._history_opened is True + else: + assert ws.current_subtab() == ws._project_tab_idx + + +def test_bang_lich_su_khong_bi_an_di(window): + """Danh sách phải nhìn thấy được — mở đúng tab mà bảng vẫn gập là vô nghĩa. + + Dùng ``isHidden()`` chứ không ``isVisible()``: test không gọi ``show()`` trên + cửa sổ (bật cửa sổ thật lên sẽ nhảy ra trước mặt người đang làm việc), mà + ``isVisible()`` của widget con chỉ True khi đã có tổ tiên được hiện. + ``isHidden()`` phản ánh đúng thứ ta quan tâm: có ai gọi ``setVisible(False)`` + lên nó hay không. + """ + ws = window.workspace + if not ws.subtab_available(ws._cowork_tab_idx): + pytest.skip("cổng project đang đóng — bảng lịch sử không thuộc màn này") + assert ws._sidebar.isHidden() is False + + +# ---- vệt sáng thanh menu phải khớp nội dung ------------------------------ +# +# Bug: mở app lên ở khung "Tất cả project…" thì bấm "Project" trên thanh menu +# KHÔNG có tác dụng; phải bấm sang mục khác rồi bấm về mới được. +# +# Nguyên nhân: landing gọi thẳng ``workspace.show_history_pane()``, đổi nội dung +# sang sub-tab Cowork nhưng bỏ qua ``_goto`` — chỗ duy nhất dời vệt sáng. Vệt +# sáng ở lại "Project", mà QTreeWidget không phát ``currentItemChanged`` khi bấm +# lại đúng dòng đang chọn, nên cú bấm rơi vào hư không. + +def _hang_dang_chon(window): + """(page, sub) mà thanh menu đang tô sáng, hoặc None.""" + from PySide6.QtCore import Qt + + for tree in (window.nav, window.nav_bottom): + item = tree.currentItem() + if item is not None: + data = item.data(0, Qt.UserRole) or {} + return data.get("page"), data.get("sub") + return None + + +def test_vet_sang_thanh_menu_khop_voi_noi_dung_dang_hien(window): + """Không khớp là cú bấm đầu tiên vào đúng mục đó sẽ rơi vào hư không.""" + dang_chon = _hang_dang_chon(window) + + assert dang_chon is not None, "thanh menu không tô sáng dòng nào" + page, sub = dang_chon + assert page == window._ROW_WORKSPACE + # Bat bien that su quan trong: vet sang KHOP noi dung. Man hinh ha canh la + # cai nao thi tuy cong project, nhung hai thu nay khong bao gio duoc lech. + assert sub == window.workspace.current_subtab(), ( + f"nội dung ở sub-tab {window.workspace.current_subtab()} " + f"nhưng thanh menu đang sáng ở {sub}") + + +def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window): + """Tái hiện đúng thao tác của người dùng: mở app xong bấm ngay "Project".""" + from PySide6.QtCore import Qt + + project_sub = window.workspace._project_tab_idx + muc_project = None + for i in range(window.nav.topLevelItemCount()): + it = window.nav.topLevelItem(i) + data = it.data(0, Qt.UserRole) or {} + if data.get("page") == window._ROW_WORKSPACE and data.get("sub") == project_sub: + muc_project = it + break + if muc_project is None: + pytest.skip("thanh menu chưa dựng mục con cho sub-tab Project") + + if window.workspace.current_subtab() == project_sub: + pytest.skip("cổng project đang đóng — app đã hạ cánh sẵn ở màn Project") + + assert window.nav.currentItem() is not muc_project, ( + "mục Project đang được tô sáng sẵn dù nội dung ở Cowork — " + "bấm vào nó sẽ không phát tín hiệu nào") + + window.nav.setCurrentItem(muc_project) + + assert window.workspace.current_subtab() == project_sub + + +def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window): + """Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo. + + Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào + ``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không + phải tạo project trên đĩa. + """ + from PySide6.QtCore import Qt + + ws = window.workspace + if ws._cowork_tab_idx < 0: + pytest.skip("bản dựng này không có sub-tab Cowork") + + ws.tabs.setTabVisible(ws._cowork_tab_idx, True) + try: + window.goto_all_projects() + + assert ws.current_subtab() == ws._cowork_tab_idx + assert ws._history_opened is True + + item = window.nav.currentItem() + assert item is not None, "thanh menu không tô sáng dòng nào" + data = item.data(0, Qt.UserRole) or {} + assert data.get("sub") == ws._cowork_tab_idx, ( + f"nội dung ở Cowork ({ws._cowork_tab_idx}) " + f"nhưng thanh menu sáng ở {data.get('sub')}") + finally: + ws.tabs.setTabVisible(ws._cowork_tab_idx, False) + window.goto_all_projects()