fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project

Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 15:32:08 +09:00
co-authored by Claude Opus 5
parent 10799dc67b
commit d0df96c726
7 changed files with 204 additions and 40 deletions
+3
View File
@@ -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…"},
+7 -9
View File
@@ -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)
+6
View File
@@ -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)
+141
View File
@@ -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}"
+11 -6
View File
@@ -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()
+25 -18
View File
@@ -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()
+11 -7
View File
@@ -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