diff --git a/i18n/login_dialog.py b/i18n/login_dialog.py index 9351670..c5db3ae 100644 --- a/i18n/login_dialog.py +++ b/i18n/login_dialog.py @@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Project cho đoạn chat mới"}, "app.nav.no_project": { "en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"}, + # KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào. + "app.nav.pick_project": { + "en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"}, "app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"}, "app.nav.all_projects": { "en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"}, diff --git a/presentation/shell/nav_rail.py b/presentation/shell/nav_rail.py index 32fdfb8..f1ac0af 100644 --- a/presentation/shell/nav_rail.py +++ b/presentation/shell/nav_rail.py @@ -197,9 +197,9 @@ class NavRailMixin: 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. + Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ + những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện + chúng ở dạng mờ. """ rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) for label, sub, ic, on in self.workspace.nav_entries()] @@ -238,15 +238,13 @@ class NavRailMixin: tree.clear() tree.blockSignals(blocked) for tree, page, sub, label, icon_name, enabled in spec: + if not enabled: + # Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa. + continue 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: + if self._nav_collapsed: it.setToolTip(0, label) blocked = tree.blockSignals(True) tree.addTopLevelItem(it) diff --git a/presentation/shell/rail_project.py b/presentation/shell/rail_project.py index 706accc..77442dd 100644 --- a/presentation/shell/rail_project.py +++ b/presentation/shell/rail_project.py @@ -42,6 +42,12 @@ class RailProjectMixin: # 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"), "") + elif not current: + # Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá + # project đang mở). Không có mục này thì combo rơi về mục 0 và + # chỉ bừa vào project đầu danh sách, trong khi cổng + # Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng. + self.nav_project.insertItem(0, tr("app.nav.pick_project"), "") idx = self.nav_project.findData(current) if idx >= 0: self.nav_project.setCurrentIndex(idx) diff --git a/tests/ui/test_project_gate_subtabs.py b/tests/ui/test_project_gate_subtabs.py new file mode 100644 index 0000000..c6fca99 --- /dev/null +++ b/tests/ui/test_project_gate_subtabs.py @@ -0,0 +1,141 @@ +"""Cổng project: Cowork và GraphRAG chỉ hiện khi đã chọn một project cụ thể. + +Cổng có hai mặt và trước đây chỉ mặt thứ nhất làm đúng: + +* **Sub-tab trong màn Workspace** — ``_update_tab_visibility`` vốn đã ẩn/hiện + đúng. Chỗ hỏng nằm ở ``refresh()``: nó mặc định ``row_to_select = 0`` nên lúc + mở app (chưa ai bấm gì) danh sách tự chọn hộ project đầu tiên, mở cổng cho một + project người dùng chưa hề chọn. +* **Hàng trên menu trái** — ``NavRailMixin._rebuild_nav`` từng dựng hàng ở dạng + mờ kèm tooltip thay vì bỏ đi ("shown instead of hidden"), nên người dùng vẫn + thấy Cowork/GraphRAG trên menu dù cổng đang đóng. + +Các bài dưới đây chốt cả hai mặt, ở cả ba trạng thái: chưa chọn → ẩn, chọn rồi → +hiện, bỏ chọn → ẩn lại. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + + +@pytest.fixture +def win(qapp, tmp_path): + """MainWindow thật — cần cả cửa sổ vì phải kiểm cả menu trái. + + Đọc project từ ``~/.cowork_local`` như bản cài thật (``core/projects.py`` + gắn ``PROJECTS_DIR`` vào đó) nên các bài này KHÔNG tạo/xoá project nào. + Bài nào cần cổng MỞ thì gọi thẳng ``_update_tab_visibility(True)`` thay vì + tạo project trên đĩa của người chạy test. + """ + 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) + window = MainWindow(build_context(config_path)) + yield window + window.close() + + +def _cong(ws): + """Hai sub-tab nằm sau cổng project, bỏ qua bản dựng không có chúng.""" + return [(ten, idx) for ten, idx in + (("Cowork", ws._cowork_tab_idx), ("GraphRAG", ws._graphrag_tab_idx)) + if idx >= 0] + + +def _hang_menu(win): + """Nhãn của mọi hàng đang có trên cột menu trái.""" + return [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] + + +# ---- mặt 1: không tự chọn hộ project ------------------------------------ + +def test_mo_app_len_chua_chon_thi_khong_tu_chon_ho(win): + """Đây là nguyên nhân gốc: ``refresh()`` từng mặc định chọn dòng 0.""" + ws = win.workspace + + assert ws._current_id == "" + assert ws.project_list.currentRow() == -1 + + +def test_chua_chon_project_thi_hai_sub_tab_deu_an(win): + ws = win.workspace + + for ten, idx in _cong(ws): + assert ws.tabs.isTabVisible(idx) is False, f"{ten} hiện khi chưa chọn project" + assert ws.subtab_available(idx) is False, f"{ten} vẫn mở cổng" + + +def test_chua_chon_project_thi_dung_o_tab_project(win): + """Ẩn hai tab kia mà lại đứng ở một tab đã ẩn thì màn hình trống trơn.""" + ws = win.workspace + + assert ws.current_subtab() == ws._project_tab_idx + + +def test_refresh_giu_nguyen_project_dang_chon(win): + """Sửa cổng không được làm mất lựa chọn hiện có: ``keep`` vẫn phải thắng.""" + ws = win.workspace + if ws.project_list.count() == 0: + pytest.skip("máy chạy test chưa có project nào để chọn") + + ws.project_list.setCurrentRow(0) + dang_chon = ws._current_id + + ws.refresh() + + assert ws._current_id == dang_chon + assert ws.project_list.currentRow() >= 0 + + +# ---- mặt 2: menu trái bỏ hẳn hàng, không hiện dạng mờ ------------------- + +def test_chua_chon_project_thi_menu_trai_khong_co_hai_hang(win): + """Đây là thứ người dùng nhìn thấy — trước đây hai hàng vẫn nằm đó, chỉ mờ.""" + nhan = _hang_menu(win) + + assert "Cowork" not in nhan, f"Cowork vẫn trên menu: {nhan}" + assert "GraphRAG" not in nhan, f"GraphRAG vẫn trên menu: {nhan}" + + +def test_mo_cong_thi_hai_hang_quay_lai_menu_trai(win): + """Bỏ hàng phải đảo ngược được, nếu không thì chọn project xong vẫn kẹt.""" + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + + nhan = _hang_menu(win) + assert "Cowork" in nhan, f"Cowork không quay lại: {nhan}" + assert "GraphRAG" in nhan, f"GraphRAG không quay lại: {nhan}" + + +def test_mo_cong_thi_hai_sub_tab_cung_hien_lai(win): + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + + for ten, idx in _cong(ws): + assert ws.tabs.isTabVisible(idx) is True, f"{ten} vẫn ẩn khi cổng đã mở" + + +def test_dong_cong_lai_thi_hai_hang_bien_mat(win): + """Cổng phải đóng lại được, không chỉ mở một chiều.""" + ws = win.workspace + ws._current_id = "gia-lap" + ws._update_tab_visibility(True) + ws._current_id = "" + ws._update_tab_visibility(False) + + nhan = _hang_menu(win) + assert "Cowork" not in nhan and "GraphRAG" not in nhan, nhan + + +def test_cac_hang_khac_khong_bi_anh_huong(win): + """Chỉ hai hàng sau cổng bị bỏ — phần còn lại của menu giữ nguyên.""" + nhan = _hang_menu(win) + + for bat_buoc in ("Project", "Co4E"): + assert bat_buoc in nhan, f"{bat_buoc} biến mất khỏi menu: {nhan}" diff --git a/tests/ui/test_startup_landing.py b/tests/ui/test_startup_landing.py index ed391b1..a0f0f6d 100644 --- a/tests/ui/test_startup_landing.py +++ b/tests/ui/test_startup_landing.py @@ -128,19 +128,24 @@ def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window): 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. + """Nhánh của người dùng ĐÃ chọn một 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. + Trước đây bài này mở cổng bằng cửa sau ``setTabVisible(True)`` vì môi trường + test không có project nào (``core/projects.py`` ghi vào ``~/.cowork_local`` + thật, nên test không tạo project). Cửa sau đó hết tác dụng từ khi cổng được + điều khiển bằng ``_current_id``: ``refresh()``/``goto_all_projects()`` đóng + lại ngay. Giờ mở cổng bằng đúng đường thật — chọn một project — và bỏ qua + bài này trên máy chưa có project nào. """ 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") + if ws.project_list.count() == 0: + pytest.skip("máy chạy test chưa có project nào — cổng không mở được") - ws.tabs.setTabVisible(ws._cowork_tab_idx, True) + ws.project_list.setCurrentRow(0) try: window.goto_all_projects() @@ -154,5 +159,5 @@ def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window): 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) + ws.project_list.setCurrentRow(-1) # đóng cổng lại đúng đường thật window.goto_all_projects() diff --git a/tools/check_nav.py b/tools/check_nav.py index 8fe0a41..626aeb5 100644 --- a/tools/check_nav.py +++ b/tools/check_nav.py @@ -74,22 +74,30 @@ def main() -> int: main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom) n_total = len(main_rows) + len(bottom_rows) - # Five Workspace sub-views + Schedule, then Dashboard + Monitoring. - if len(main_rows) != 6: - fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6") + # Workspace gop cac sub-view DANG MO CONG + Schedule, roi Dashboard + + # Monitoring. Cowork/GraphRAG chi co mat khi da chon mot project, nen so + # dong doi theo cong thay vi co dinh 6. + mo_cong = sum(1 for _l, _i, _ic, on in win.workspace.nav_entries() if on) + cho_chinh = mo_cong + 1 + if len(main_rows) != cho_chinh: + fails.append(f"thanh chinh co {len(main_rows)} dong, cho {cho_chinh}") if len(bottom_rows) != 2: fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2") if any(sub is not None for _l, _p, sub, _o in bottom_rows): fails.append("nhom day khong duoc mang sub-tab") - # The two gated rows must be PRESENT (that is the point) — greyed is fine. + # Hang bi cong project dong thi BO HAN khoi menu; hang dang mo phai co mat. labels = [r[0] for r in main_rows] - ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()] - for lab in ws_labels: + ws_mo = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if on] + ws_dong = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if not on] + for lab in ws_mo: if lab not in labels: - fails.append(f"mat dong Workspace: {lab}") - print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}" - f" ({', '.join(ws_labels)})") + fails.append(f"mat dong Workspace dang mo cong: {lab}") + for lab in ws_dong: + if lab in labels: + fails.append(f"dong Workspace dang dong cong van tren menu: {lab}") + print(f"man Workspace dang mo cong tren menu: {all(l in labels for l in ws_mo)}" + f" ({', '.join(ws_mo) or 'khong co'})") # Highlight must follow the content for every row, both ways round. # Re-fetch items by index every time: navigating can rebuild the rail, which @@ -145,19 +153,18 @@ def main() -> int: if ws_strip: fails.append("dai tab Workspace hien lai — trung voi thanh menu") - # The whole point of the change: with no project selected the two gated rows - # must stay in place, greyed — not vanish and resize the menu. + # Yeu cau: chua chon project thi Cowork/GraphRAG khong duoc hien tren menu. win.workspace._update_tab_visibility(False) app.processEvents() gated = rows(win.nav) - off = [lab for lab, _p, _s, on in gated if not on] + nhan_gated = [lab for lab, _p, _s, _on in gated] print() - print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}") - if len(gated) != len(main_rows): - fails.append(f"chua chon project thi thanh menu con {len(gated)} dong " - f"(truoc {len(main_rows)}) — item van bien mat") - if len(off) != 2: - fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}") + print(f"chua chon project : con {len(gated)} dong ({', '.join(nhan_gated)})") + for lab in ("Cowork", "GraphRAG"): + if lab in nhan_gated: + fails.append(f"chua chon project ma {lab} van tren menu") + if any(not on for _l, _p, _s, on in gated): + fails.append("con dong bi mo tren menu — dang le phai bo han") # --- rail header: project picker + new chat (Phase A) ------------------ print() diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 2900a88..a929d92 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -63,10 +63,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): """(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones included. - The rail lists all five all the time and greys out the ones the project - gate is currently closing (Cowork, GraphRAG) instead of removing them — - same gate, shown rather than hidden, so the menu stops changing shape - under the user's hand. See nav_subtabs() for the visible-only view. + Cột ``enabled`` là trạng thái cổng project; ``NavRailMixin._rebuild_nav`` + bỏ hẳn những hàng đang đóng (Cowork, GraphRAG) khỏi menu trái cho tới khi + người dùng chọn một project. See nav_subtabs() for the visible-only view. """ icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat", self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder", @@ -78,8 +77,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): def subtab_available(self, index: int) -> bool: """False while the project gate is holding this sub-tab shut. - The rail greys those rows out, but that only guards the rail. This lets - every other route ask the same question of the same state. + Rail bỏ hẳn những hàng đó khỏi menu, nhưng đó chỉ chắn được đường vào + qua rail. Hàm này để mọi đường vào khác hỏi cùng một trạng thái. """ return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index)) @@ -610,7 +609,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): counts = self._project_counts() self.project_list.blockSignals(True) self.project_list.clear() - row_to_select = 0 + # -1 chứ không phải 0: chưa chọn gì thì KHÔNG tự chọn hộ project đầu + # danh sách. Chọn hộ là mở luôn cổng Cowork/GraphRAG (xem + # _update_tab_visibility) cho một project người dùng chưa hề bấm vào — + # lúc mở app, và cả sau khi xoá project đang mở. Có ``keep`` khớp thì + # vẫn giữ đúng dòng cũ như trước. + row_to_select = -1 for i, p in enumerate(list_projects()): chats, tasks = counts.get(p.project_id, (0, 0)) # No text on the item: the row widget paints the name, and setting