Files
cowork-local/tools/check_nav.py
T
NamPDTandClaude Opus 5 0fa61b6a95 feat(ui): flat nav rail, compact assistant, responsive layouts
Implements the redesign from docs/ui-audit.html. Rearrangement only — no
feature was removed; every control that moved kept its handler, and the
gates that used to hide things now grey them out instead.

Navigation
  * The rail is one flat list: the five Workspace sub-views sit at the top
    level instead of behind an accordion, with Dashboard/Monitoring pinned
    at the foot and Settings below them.
  * Cowork and GraphRAG stay listed and greyed while no project is
    selected, rather than vanishing and resizing the menu under the user.
  * Monitoring keeps its eight sub-views in its own tab strip (unhidden)
    instead of doubling the rail's length.
  * _goto now moves the highlight itself, fixing a long-standing bug where
    programmatic navigation left the rail pointing at the previous screen.
  * Rail header gained the project picker and "New chat"; RECENTS lists the
    active project's threads. Both are second views of existing state — the
    Cowork toolbar button and the full History panel are untouched.
  * Provider / language / theme moved from the top bar to an account row at
    the foot of the rail (same widgets, same signals).

Screens
  * Co4E: the flow tab strip is gone (per the design); Flow Status became a
    toolbar toggle with its own way back, and the three icon-only tabs became
    four labelled, foldable sections in one column. One flow open at a time
    is the one capability this costs; background runs are unaffected.
  * Dashboard: header split into two rows; cost promoted to a hero card.
  * Monitoring Overview: one scrolling column of titled sections; the model
    price table got its own full-width section instead of sharing a row with
    the CPU meters.
  * Settings and Task editor gained a section index down the left.
  * Help dock: 84x64 launcher + chevron became one 26px dot that expands to
    a labelled pill on hover; "hide to the edge" moved into the panel's menu.

Layout
  * The window's minimum width dropped from 1453px to 768px. The main cause
    was a QTabWidget taking its minimum from the widest page even when that
    page is hidden, so Co4E was forcing Project and Cowork wide.
  * Secondary panes fold themselves on a narrow window and restore when it
    grows, never overriding a fold the user made.
  * The long dialogs no longer scroll sideways at any font size.

Verification: tools/check_*.py build a real MainWindow offscreen against a
copy of ~/.cowork_local with the schedulers no-oped. check_design_parity.py
reads its checklist straight from the audit page's own proposals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:40:13 +09:00

302 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Smoke-test the flat nav rail against a real MainWindow.
Builds the window offscreen on a COPY of ~/.cowork_local (schedulers no-oped, so
nothing scheduled can fire) and answers the questions the redesign has to get
right:
* is every destination that used to be reachable still reachable?
* does the rail highlight follow the content, from clicks AND from _goto?
* do the project-gated rows stay listed (greyed) instead of disappearing?
* does Monitoring still expose all eight sub-views, now via its own tab strip?
Run: python tools/check_nav.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO.parent)) # `import cowork_local`
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
def rows(tree):
from PySide6.QtCore import Qt
out = []
for i in range(tree.topLevelItemCount()):
it = tree.topLevelItem(i)
data = it.data(0, Qt.UserRole) or {}
out.append((it.text(0), data.get("page"), data.get("sub"),
not it.isDisabled()))
return out
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
from cowork_local.config import CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from seed_demo_data import seed
seed()
from cowork_local.app import MainWindow
from cowork_local.config import AppConfig
from cowork_local.i18n import set_language
from cowork_local.state import AppContext
set_language("vi")
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
app.processEvents()
fails: list[str] = []
print("THANH MENU CHINH")
for label, page, sub, on in rows(win.nav):
print(f" {label:22} page={page} sub={sub} {'' if on else '(mo — chua chon project)'}")
print("NHOM GHIM DAY")
for label, page, sub, on in rows(win.nav_bottom):
print(f" {label:22} page={page} sub={sub}")
print(f"NUT: {win._nav_settings_btn.text()}")
print()
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")
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.
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:
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)})")
# Highlight must follow the content for every row, both ways round.
# Re-fetch items by index every time: navigating can rebuild the rail, which
# deletes the C++ objects a held reference points at.
ok_click = ok_goto = 0
for which, name in ((win.nav, "chinh"), (win.nav_bottom, "day")):
for i in range(which.topLevelItemCount()):
label, page, sub, on = rows(which)[i]
if not on:
continue
which.setCurrentItem(which.topLevelItem(i)) # as if clicked
app.processEvents()
if win.pages.currentIndex() == page:
ok_click += 1
else:
fails.append(f"bam '{label}' ({name}) khong mo dung trang")
win._goto(page, sub) # programmatic
app.processEvents()
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
if t.currentItem() is not None and t.currentItem().isSelected()), None)
if cur is not None and cur.text(0) == label:
ok_goto += 1
else:
fails.append(f"_goto toi '{label}' nhung vet sang o "
f"'{cur.text(0) if cur else 'khong dau'}'")
n_live = sum(1 for r in main_rows + bottom_rows if r[3])
print(f"bam mo dung trang : {ok_click}/{n_live}")
print(f"vet sang theo _goto : {ok_goto}/{n_live}")
# Only one row may look active across the two lists.
lit = sum(1 for t in (win.nav, win.nav_bottom) for i in range(t.topLevelItemCount())
if t.topLevelItem(i).isSelected())
print(f"so dong dang sang : {lit} (phai la 1)")
if lit != 1:
fails.append(f"{lit} dong cung sang")
# Monitoring's eight sub-views moved to its own tab strip — check it is shown.
win._ensure_page(win._ROW_MONITORING)
mon = win._page_widgets[win._ROW_MONITORING]
# isVisible() is False for everything while the window has never been shown;
# isHidden() asks the question that actually matters here.
strip_visible = not mon.tabs.tabBar().isHidden() if hasattr(mon, "tabs") else False
n_sub = len(mon.nav_subtabs())
print(f"Monitoring: {n_sub} man, dai tab hien = {strip_visible}")
if n_sub != 8:
fails.append(f"Monitoring chi con {n_sub} man")
if not strip_visible:
fails.append("dai tab Monitoring van bi an — 8 man khong toi duoc")
# Workspace's own strip stays hidden: the rail lists those five instead.
ws_strip = not win.workspace.tabs.tabBar().isHidden()
print(f"Workspace: dai tab hien = {ws_strip} (phai la False — thanh menu lo roi)")
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.
win.workspace._update_tab_visibility(False)
app.processEvents()
gated = rows(win.nav)
off = [lab for lab, _p, _s, on in gated if not on]
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)}")
# --- rail header: project picker + new chat (Phase A) ------------------
print()
n_proj = win.nav_project.count()
print(f"bo chon project : {n_proj} muc · dang chon "
f"{win.nav_project.currentText()!r}")
print(f"nut chat moi : {win.nav_new_chat.text()!r} "
f"(bat = {win.nav_new_chat.isEnabled()})")
if win.nav_project.currentData() != win.workspace.selected_project_id():
fails.append("bo chon project khong khop voi project dang chon")
# Picking in the rail must move the real selection, not just the combo.
if n_proj > 1:
other = next(i for i in range(n_proj)
if win.nav_project.itemData(i) != win.workspace.selected_project_id())
want = win.nav_project.itemData(other)
win.nav_project.setCurrentIndex(other)
app.processEvents()
got = win.workspace.selected_project_id()
print(f"doi project tu rail: chon {want} -> workspace dang o {got}")
if got != want:
fails.append("doi project tren rail khong doi project that")
if win.nav_project.currentData() != got:
fails.append("bo chon khong dong bo nguoc lai")
# New chat from any screen: lands on Cowork with an empty thread, and the
# old toolbar button must still be there.
win._goto(win._ROW_DASHBOARD, None)
app.processEvents()
before = win.cowork.current_session_id() if hasattr(win.cowork, "current_session_id") else None
win._on_rail_new_chat()
app.processEvents()
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
print(f"bam '+ chat moi' tu Dashboard -> dung o Cowork: {on_cowork}")
if not on_cowork:
fails.append("nut chat moi khong dua toi Cowork")
old_btn = getattr(win.cowork, "_new_btn", None)
print(f"nut cu tren thanh Cowork con nguyen: {old_btn is not None} "
f"({old_btn.text()!r})" if old_btn is not None else "MAT NUT CU")
if old_btn is None:
fails.append("nut 'Cuoc tro chuyen moi' cu tren Cowork bi mat")
# --- rail RECENTS (Phase B) --------------------------------------------
from PySide6.QtCore import Qt as _Qt
win._refresh_rail_recents()
app.processEvents()
rec = win.nav_recents
items = [(rec.topLevelItem(i).text(0), rec.topLevelItem(i).data(0, _Qt.UserRole) or {})
for i in range(rec.topLevelItemCount())]
threads = [t for t, d in items if d.get("path")]
print()
print(f"GAN DAY ({win.nav_recents_hdr.text()}): {len(threads)} thread"
f" + dong '{items[-1][0]}'")
for t in threads:
print(f" {t}")
if not items[-1][1].get("all"):
fails.append("thieu dong 'Tat ca project…'")
if len(threads) > win._RAIL_RECENTS:
fails.append(f"GAN DAY liet ke {len(threads)} thread, toi da {win._RAIL_RECENTS}")
# Scoped to the active project — a flat cross-project list would lose that.
pid = win.workspace.selected_project_id()
all_titles = {t["title"] for t in win.workspace.recent_threads(99)}
other_pid = next((p for _n, p in win.workspace.project_choices() if p != pid), "")
if other_pid:
win.workspace.choose_project(other_pid)
app.processEvents()
win._refresh_rail_recents()
other_titles = {t["title"] for t in win.workspace.recent_threads(99)}
print(f"doi sang project khac: danh sach doi = {other_titles != all_titles}")
if other_titles & all_titles and other_titles == all_titles:
fails.append("GAN DAY khong gom theo project — hai project cung mot danh sach")
win.workspace.choose_project(pid)
app.processEvents()
win._refresh_rail_recents()
# Clicking a thread must open it through the normal route.
if threads:
win._goto(win._ROW_DASHBOARD, None)
app.processEvents()
# Re-fetch: the project switch above rebuilt this list, deleting the
# items a held reference would point at.
win._on_rail_recent(win.nav_recents.topLevelItem(0))
app.processEvents()
on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE
and win.workspace.current_subtab() == win.workspace._cowork_tab_idx)
print(f"bam thread gan day -> mo o Cowork: {on_cowork}")
if not on_cowork:
fails.append("bam thread trong GAN DAY khong mo duoc")
# The full History panel must still exist, with all its controls.
sb = win.sidebar
kept = [n for n in ("search_box", "search_btn", "tree", "_collapse_btn")
if getattr(sb, n, None) is not None]
print(f"khung History day du van con: {len(kept)}/4 control goc {kept}")
if len(kept) != 4:
fails.append("khung History bi mat control")
# --- account row moved off the top bar (Phase C) -----------------------
print()
from PySide6.QtWidgets import QWidget as _QWidget
top_kids = {w.objectName() or type(w).__name__
for w in win.findChildren(_QWidget)
if w.parent() is not None and w.parent().objectName() == "topbar"}
print(f"top bar con lai : {sorted(top_kids)}")
for name in ("provider_combo", "language_combo", "theme_btn"):
w = getattr(win, name, None)
if w is None:
fails.append(f"mat control {name}")
continue
in_rail = win._nav_wrap.isAncestorOf(w)
print(f" {name:16} nam trong rail = {in_rail}")
if not in_rail:
fails.append(f"{name} chua chuyen xuong rail")
# They must still work, not just exist: flipping the language must retranslate.
from cowork_local.i18n import get_language
before_lang = get_language()
other = next(i for i in range(win.language_combo.count())
if win.language_combo.itemData(i) != before_lang)
win.language_combo.setCurrentIndex(other)
app.processEvents()
after_lang = get_language()
print(f"doi ngon ngu tu rail: {before_lang} -> {after_lang}")
if after_lang == before_lang:
fails.append("combo ngon ngu o rail khong doi duoc ngon ngu")
win.language_combo.setCurrentIndex(win.language_combo.findData(before_lang))
app.processEvents()
print(f"provider dang chon : {win.provider_combo.currentText()!r}")
print(f"tai khoan : {win.account_lbl.text()!r}")
print()
print(f"tong dong dieu huong: {n_total} + nut Cai dat")
if fails:
print("*** LOI ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA: thanh menu phang chay dung")
return 0
if __name__ == "__main__":
raise SystemExit(main())