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
+117
View File
@@ -0,0 +1,117 @@
"""Measure what actually breaks on a small screen, screen by screen.
A pane is "clipped" when the width it is given is smaller than the width it says
it needs (minimumSizeHint): Qt then cuts content off instead of shrinking it,
which is what shows up as half-drawn buttons and cut-off labels.
Reports per destination, at a few window sizes, and lists the widest offenders
so a fix can be aimed at the right widget instead of guessed at.
Run: python tools/check_responsive.py [width height ...]
"""
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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
SIZES = [(1920, 1080), (1366, 768), (1280, 720)]
def panes(widget):
"""Direct children worth measuring: splitter panes and page-level boxes."""
from PySide6.QtWidgets import QSplitter
out = []
for sp in widget.findChildren(QSplitter):
for i in range(sp.count()):
w = sp.widget(i)
if w is not None and not w.isHidden():
out.append((f"{sp.objectName() or 'splitter'}[{i}] {type(w).__name__}", w))
return out
def main(argv) -> int:
sizes = SIZES
if len(argv) >= 2:
sizes = [(int(argv[i]), int(argv[i + 1])) for i in range(0, len(argv) - 1, 2)]
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
from cowork_local.config import AppConfig, 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.i18n import set_language
from cowork_local.state import AppContext
from cowork_local.theme import set_active_theme, stylesheet
set_language("vi")
cfg = AppConfig.load()
set_active_theme(cfg.theme)
app.setStyleSheet(stylesheet(cfg.theme))
win = MainWindow(AppContext(cfg), user_name="local")
win.show()
for _ in range(6):
app.processEvents()
dests = [("Project", win._ROW_WORKSPACE, win.workspace._project_tab_idx),
("Cowork", win._ROW_WORKSPACE, win.workspace._cowork_tab_idx),
("Co4E", win._ROW_WORKSPACE, win.workspace._co4e_tab_idx),
("Folder", win._ROW_WORKSPACE, win.workspace._folder_tab_idx),
("GraphRAG", win._ROW_WORKSPACE, win.workspace._graphrag_tab_idx),
("Schedule", win._ROW_SCHEDULE, None),
("Dashboard", win._ROW_DASHBOARD, None),
("Monitoring", win._ROW_MONITORING, None)]
print(f"cua so: minimumSizeHint = {win.minimumSizeHint().width()}"
f"x{win.minimumSizeHint().height()}px")
print()
worst: dict[str, int] = {}
for w, h in sizes:
win.resize(w, h)
for _ in range(4):
app.processEvents()
print(f"=== {w}x{h} ===")
for name, page, sub in dests:
win._goto(page, sub)
for _ in range(4):
app.processEvents()
widget = win._page_widgets[page]
need = widget.minimumSizeHint().width()
have = widget.width()
tight = [(n, p.minimumSizeHint().width(), p.width())
for n, p in panes(widget)
if p.minimumSizeHint().width() > p.width() + 1]
flag = "" if need <= have else f" <-- THIEU {need - have}px"
print(f" {name:11} can {need:5}px · duoc {have:5}px{flag}")
for n, nd, hv in tight:
print(f" · {n:34} can {nd:4} duoc {hv:4}")
worst[f"{name}/{n}"] = max(worst.get(f"{name}/{n}", 0), nd - hv)
print()
if worst:
print("BO BO NHIEU NHAT:")
for k, v in sorted(worst.items(), key=lambda kv: -kv[1])[:12]:
print(f" {v:5}px {k}")
else:
print("KET QUA: khong pane nao bi bo o cac co da thu")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))