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>
This commit is contained in:
NamPDT
2026-08-17 11:40:13 +09:00
co-authored by Claude Opus 5
parent 291a611737
commit 0fa61b6a95
27 changed files with 4346 additions and 384 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Catch controls orphaned by a neighbouring container being removed.
The audit page defaults every control to "giữ nguyên tại chỗ" and lists only the
ones that move. That default is unsafe when the thing a control sits *with* is
removed — then "unchanged" is impossible and the control has quietly lost its
home. This is how the Co4E "+ new workflow" button vanished from the proposal:
it lives in the same layout row as the flow tab strip, and the strip was proposed
for removal.
Note the relationship is SIBLING, not parent/child: `flow_row` holds both the
scroller (wrapping `flow_bar`) and `flow_add_btn`. An earlier version of this
check looked only for `container.addWidget(child)` and therefore found nothing —
it passed while the bug was live. Verify any change here with --selftest.
Run: python tools/check_orphans.py [--selftest]
"""
from __future__ import annotations
import ast
import io
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "tools"))
# A MOVES note containing one of these means the thing is going away, so anything
# that only existed alongside it needs a new home.
REMOVAL_WORDS = ("bỏ;", "bỏ ", "gộp", "thay thế")
def layout_map(path: Path) -> tuple[dict[str, list[str]], dict[str, str]]:
"""(layout var -> widget vars added to it, wrapper var -> widget it wraps)."""
members: dict[str, list[str]] = {}
alias: dict[str, str] = {}
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
try:
owner = ast.unparse(node.func.value)
args = [ast.unparse(a) for a in node.args]
except Exception: # noqa: BLE001
continue
if not args:
continue
if node.func.attr in ("addWidget", "addLayout"):
members.setdefault(owner, []).append(args[0])
elif node.func.attr == "setWidget":
# QScrollArea(inner): the scroller stands in for what it holds.
alias[owner] = args[0]
return members, alias
def main(argv: list[str]) -> int:
import build_audit_page as B
selftest = "--selftest" in argv
moves = dict(B.MOVES)
if selftest:
# Re-create the original bug and prove the check reports it.
moves.pop("self.flow_add_btn", None)
removed = {k for k, v in moves.items()
if any(w in v.lower() for w in REMOVAL_WORDS)}
ctl = json.loads((REPO / "docs" / "screens" / "controls.json")
.read_text(encoding="utf-8"))
problems: list[tuple[str, str, str, str]] = []
n_sib = 0
for rec in ctl:
path = REPO / rec["file"]
if not path.exists():
continue
members, alias = layout_map(path)
labels = {c["var"]: (c.get("label_vi") or c.get("label") or "?")
for c in rec["controls"]}
for layout, kids in members.items():
# Resolve wrappers so a scroller counts as the widget it holds.
resolved = {k: alias.get(k, k) for k in kids}
gone = [k for k, r in resolved.items() if r in removed]
if not gone:
continue
for kid in kids:
if resolved[kid] in removed or kid not in labels:
continue
n_sib += 1
if kid not in moves:
problems.append((rec["file"], kid, labels[kid],
f"cung hang voi {resolved[gone[0]]}"))
print(f"control nam canh mot thanh phan bi bo : {n_sib}")
print(f"thanh phan bi bo trong MOVES : {len(removed)}"
f" {sorted(removed) if removed else ''}")
print()
if problems:
print("*** CONTROL MO COI ***")
for f, var, label, why in problems:
print(f" {f}: {var} ({label}) — {why}")
print()
print(f"KET QUA: {len(problems)} control mat cho, can khai bao trong MOVES")
return 0 if selftest else 1
if selftest:
print("KET QUA SELFTEST: *** THAT BAI — phep kiem KHONG bat duoc loi da biet ***")
return 1
print("KET QUA: khong co control nao bi mo coi")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))