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>
158 lines
6.5 KiB
Python
158 lines
6.5 KiB
Python
"""Lift the hand-written audit sections out of 10-18.ui-audit.html.
|
|
|
|
That file was edited by hand: eight sections carry richer wireframes, prose and
|
|
interactive tables than the generator produces, plus the CSS and scripts they
|
|
need. Keeping two HTML files around means they drift, so this pulls the
|
|
hand-written parts into ``tools/audit_handwritten.py`` — a data module the
|
|
builder merges back in, making ``docs/ui-audit.html`` the single output again.
|
|
|
|
Two things are tokenised out before storing, so they stay generated rather than
|
|
frozen at extraction time:
|
|
{{SHOT}} the screenshot block (keeps ~8 MB of base64 out of the module)
|
|
{{CONTROLS}} the AST-derived control inventory (must track controls.json)
|
|
|
|
Workflow when you hand-edit one of those sections directly in the page:
|
|
1. edit docs/ui-audit.html
|
|
2. python tools/extract_handwritten.py (reads it back into the module)
|
|
3. python tools/build_audit_page.py (regenerates, edits preserved)
|
|
Pass another filename to import sections from a different copy.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
DOCS = REPO / "docs"
|
|
OUT = REPO / "tools" / "audit_handwritten.py"
|
|
|
|
# Hand-written sections are DETECTED, not listed: every section whose text
|
|
# differs from what the generator alone would emit is stored.
|
|
#
|
|
# There used to be a fixed list here, and it cost a section — "Monitoring ▸ Công
|
|
# cụ" was hand-written but missing from the list, so each rebuild quietly put
|
|
# the generated version back. Diffing against the live output cannot work (it
|
|
# already contains the merged result and would find nothing), so the reference
|
|
# is a generator-only render produced in-process, with the merge disabled.
|
|
#
|
|
# These are the ones known so far; anything else detected is added on top.
|
|
KNOWN = [
|
|
"monitoring-sự-kiện-bảo-mật", "monitoring-lịch-sử-gọi-mcp",
|
|
"monitoring-nhật-ký-hành-động", "monitoring-trạng-thái-agent",
|
|
"monitoring-agents-admin", "monitoring-icon", "monitoring-công-cụ",
|
|
"dialog-settings", "dialog-task-editor",
|
|
]
|
|
|
|
SECTION = re.compile(r'<section class="sec" id="([^"]+)">(.*?)</section>', re.S)
|
|
BODY = re.compile(r'(<div class="bd">.*)', re.S)
|
|
SHOT = re.compile(r'<div class="shot">.*?</div>', re.S)
|
|
CONTROLS = re.compile(r'<details class="ctl">.*?</details>', re.S)
|
|
STYLE = re.compile(r"<style>(.*?)</style>", re.S)
|
|
SCRIPT = re.compile(r"<script>(.*?)</script>", re.S)
|
|
|
|
|
|
def bodies(html: str) -> dict[str, str]:
|
|
"""slug -> the section's <div class="bd"> … </div>, header excluded."""
|
|
out = {}
|
|
for m in SECTION.finditer(html):
|
|
b = BODY.search(m.group(2))
|
|
if b:
|
|
out[m.group(1)] = b.group(1).strip()
|
|
return out
|
|
|
|
|
|
def norm(s: str) -> str:
|
|
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip()
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
src_path = DOCS / (argv[0] if argv else "ui-audit.html")
|
|
if not src_path.exists():
|
|
print(f"khong thay {src_path}")
|
|
return 1
|
|
|
|
src = src_path.read_text(encoding="utf-8")
|
|
# Screenshots are re-embedded by the builder; keep the base64 out of here.
|
|
hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>", src))
|
|
|
|
sys.path.insert(0, str(REPO / "tools"))
|
|
import build_audit_page as B
|
|
|
|
# Render what the generator ALONE would produce, into a temp file, and treat
|
|
# every section that differs from it as hand-written.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
keep_out, keep_hand = B.OUT, B.HAND_SECTIONS
|
|
B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {}
|
|
try:
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
B.main()
|
|
made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>",
|
|
B.OUT.read_text(encoding="utf-8")))
|
|
finally:
|
|
B.OUT, B.HAND_SECTIONS = keep_out, keep_hand
|
|
|
|
detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, "")))
|
|
slugs = [s for s in hand if s in set(detected) | set(KNOWN)]
|
|
new = [s for s in detected if s not in KNOWN]
|
|
gone = [s for s in KNOWN if s in hand and s not in detected]
|
|
if new:
|
|
print(f"phat hien them section viet tay: {new}")
|
|
if gone:
|
|
# Not an error: a hand section can be edited back to match the generator.
|
|
print(f"section trong KNOWN nay giong ban sinh: {gone}")
|
|
|
|
stored = {}
|
|
for slug in slugs:
|
|
body = hand[slug]
|
|
body = SHOT.sub("{{SHOT}}", body, count=1)
|
|
body = CONTROLS.sub("{{CONTROLS}}", body, count=1)
|
|
stored[slug] = body
|
|
|
|
# CSS rules and scripts the hand edits added. Compared against the builder's
|
|
# OWN constants, not its output — the output already carries the merge.
|
|
|
|
extra_css = "\n".join(
|
|
ln for ln in STYLE.search(src).group(1).splitlines()
|
|
if ln.strip() and ln not in B.CSS)
|
|
# Scripts already sitting INSIDE a stored section travel with it — collecting
|
|
# them again would bind every listener twice (the +/- steppers would then
|
|
# count by two). Only page-level scripts belong in EXTRA_JS.
|
|
gen_js = {norm(B.JS)}
|
|
in_section = "".join(stored.values())
|
|
extra_js = [j for j in SCRIPT.findall(src)
|
|
if norm(j) not in gen_js and j not in in_section]
|
|
|
|
parts = [
|
|
'"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n'
|
|
"GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n"
|
|
"source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n"
|
|
'{{CONTROLS}} so those stay generated.\n"""\n',
|
|
"SECTIONS = {",
|
|
]
|
|
for slug, body in stored.items():
|
|
parts.append(f" {slug!r}: {body!r},")
|
|
parts.append("}\n")
|
|
parts.append(f"EXTRA_CSS = {extra_css!r}\n")
|
|
parts.append("EXTRA_JS = [")
|
|
for j in extra_js:
|
|
parts.append(f" {j!r},")
|
|
parts.append("]\n")
|
|
OUT.write_text("\n".join(parts), encoding="utf-8")
|
|
|
|
print(f"section viet tay : {len(stored)}")
|
|
for slug, body in stored.items():
|
|
print(f" {slug:32} {len(body):>7,} ky tu"
|
|
f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}")
|
|
print(f"CSS them : {len(extra_css.splitlines())} dong")
|
|
print(f"script them : {len(extra_js)}")
|
|
print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|