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