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
File diff suppressed because one or more lines are too long
+317 -22
View File
@@ -23,6 +23,20 @@ DOCS = REPO / "docs"
MANIFEST = DOCS / "screens" / "manifest.json"
OUT = DOCS / "ui-audit.html"
# Eight sections were written by hand and are richer than anything this script
# produces. They live in a data module (rebuilt by tools/extract_handwritten.py)
# and are merged in below, so ui-audit.html stays the ONE output file instead of
# a generated file plus a hand-edited copy that drift apart.
try:
from audit_handwritten import EXTRA_CSS as HAND_CSS
from audit_handwritten import EXTRA_JS as HAND_JS
from audit_handwritten import SECTIONS as HAND_SECTIONS
except ImportError: # pragma: no cover
sys.path.insert(0, str(REPO / "tools"))
from audit_handwritten import EXTRA_CSS as HAND_CSS
from audit_handwritten import EXTRA_JS as HAND_JS
from audit_handwritten import SECTIONS as HAND_SECTIONS
# Screenshots are inlined as data: URIs so the page is ONE self-contained file —
# copy it anywhere and the images travel with it. `--external` opts out, leaving
# the images as `screens/*.png` next to a much smaller HTML.
@@ -43,11 +57,46 @@ STANDALONE = "--external" not in sys.argv
RECENTS = ["📌 Gom số liệu doanh thu", "Dựng slide trình bày Q3"]
def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str:
"""The proposed flat sidebar, with `active` highlighted."""
# Floating on every screen, pinned bottom-right — same corner as the app.
# The app spends 84×64px there: a 64px badge plus an 18px chevron beside it
# (help_agent_widget.py:34-37). That is a lot of permanent real estate for a
# thing you open a few times a day, so the proposal is one 26px dot. The label
# moves to hover/tooltip and to the panel header; nothing is removed.
DOCK_FAB = ('<div class="dock fab" title="AI Assistant — trợ lý cách dùng app">'
'<span class="spark">✨</span></div>')
DOCK_BADGE = DOCK_FAB # name kept: 16 screens already reference it
def rail(active: str = "", project: str = "Báo cáo tài chính Q3",
*, empty: bool = False) -> str:
"""The proposed flat sidebar, with `active` highlighted.
`empty=True` renders the no-project state. Running the app with zero
projects shows Cowork and GraphRAG simply *gone* from the menu; here they
stay put but dimmed, and the actions that need a project are disabled with
a reason rather than vanishing.
"""
items = ["Project", "Cowork", "Co4E", "Folder", "GraphRAG", "Schedule Task"]
needs_project = {"Cowork", "GraphRAG"}
rows = "".join(
f'<div class="i{" on" if n == active else ""}">{n}</div>' for n in items)
f'<div class="i{" on" if n == active else ""}'
f'{" off" if empty and n in needs_project else ""}">{n}</div>'
for n in items)
if empty:
return ('<div class="rail">'
'<div class="menutog"><span>MENU</span><span class="chev">‹</span></div>'
'<div class="rpick empty"><span>Chưa có project</span>'
'<span class="cv">▾</span></div>'
'<div class="newbtn off">+ Đoạn chat mới</div>'
'<div class="hint">Tạo project trước</div>'
f'{rows}'
'<div class="sep"></div><div class="hd">RECENTS</div>'
'<div class="i sm off">trống</div>'
'<div class="grow"></div><div class="sep"></div>'
'<div class="i">Dashboard</div><div class="i">Monitoring</div>'
'<div class="i">Cài đặt</div>'
'<div class="acct"><span>👤 local</span>'
'<span class="lang">VN ▾</span><span class="thm">🌙</span></div></div>')
recents = "".join(f'<div class="i sm">{t}</div>' for t in RECENTS)
return (
'<div class="rail">'
@@ -65,7 +114,9 @@ def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str:
'<div class="grow"></div>'
'<div class="sep"></div>'
'<div class="i">Dashboard</div><div class="i">Monitoring</div>'
'<div class="acct">👤 local · Ollama ▾</div>'
'<div class="i">Cài đặt</div>'
'<div class="acct"><span>👤 local</span>'
'<span class="lang">VN ▾</span><span class="thm">🌙</span></div>'
'</div>')
@@ -92,6 +143,19 @@ def projbar(name: str = "") -> str:
return ""
def grp(label: str, action: str = "", *, collapse: str = "") -> str:
"""A list-group heading, optionally with its own create/manage button.
Putting "+" beside WORKFLOWS (and beside AGENTS) is what replaces the "+"
that used to live on the flow tab strip: removing the strip removed its
button too, and the create action has to land somewhere explicit.
"""
chev = {"left": "‹", "right": "›"}.get(collapse, "")
tail = f'<span class="pchev">{chev}</span>' if chev else ""
act = f'<span class="gact">{action}</span>' if action else ""
return f'<div class="hd2 row">{label}<span class="ghd">{act}{tail}</span></div>'
def li(text: str, sub: str = "", *, on: bool = False) -> str:
"""One row in a list pane."""
s = f'<span class="s">{sub}</span>' if sub else ""
@@ -170,8 +234,13 @@ DESCRIPTIONS: dict[str, dict] = {
"dialog-login": {"d": "Màn đăng nhập — <b>đã dựng xong nhưng không nơi nào gọi</b>. "
"App khởi động thẳng với user \“local\”, quyền admin.",
"r": [("3 trang", "Khởi tạo · Đăng nhập · Offline")]},
"overlay-help-panel": {"d": "Robot trợ giúp nổi, có mặt trên mọi màn. Cố tình không có công cụ.",
"r": [("3 trạng thái", "tab mép → huy hiệu → panel chat")]},
"overlay-help-panel": {"d": "Trợ lý dùng app, nổi ở góc phải và có mặt trên mọi màn. "
"Cố tình không có công cụ — chỉ hỏi đáp cách dùng.",
"r": [("3 trạng thái", "tab mép phải → huy hiệu → panel 340×460, "
"<b>luôn ghim góc dưới phải</b>"),
("3 nút", "<b>›</b> ẩn vào cạnh phải · <b>—</b> thu nhỏ về huy hiệu · "
"tab mép để hiện lại"),
("Model", "chọn ở Monitoring ▸ Agents Admin, agent chức năng “help”")]},
}
@@ -281,21 +350,23 @@ ANALYSIS: dict[str, dict] = {
'<div class="btn pri">▷ Chạy</div></div>'
'<div class="r grow">'
'<div class="c w26 pane">'
'<div class="hd2 row">WORKFLOWS<span class="pchev">‹</span></div>'
+ grp("WORKFLOWS", "+ Mới", collapse="left")
+ li("Quy trình phát triển tính năng", "5 bước · đã lưu", on=True)
+ li("Rà soát bảo mật định kỳ", "2 bước · đã lưu")
+ li("Dựng báo cáo từ Excel", "3 bước · đã lưu")
+ '<div class="hd2">AGENTS (5)</div>'
+ grp("AGENTS (5)", "+ Mới")
+ li("Phân tích yêu cầu", "ANALYST") + li("Thiết kế giải pháp", "ARCHITECT")
+ li("Lập trình viên", "CODER") + li("Kiểm thử", "TESTER")
+ li("Soạn tài liệu", "WRITER")
+ '<div class="hd2">SKILLS (5)</div>'
+ grp("SKILLS (5)", "Quản lý…")
+ li("Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …")
+ '<div class="hd2">LẦN CHẠY (6)</div>'
+ grp("LẦN CHẠY (6)")
+ li("✓ Quy trình phát triển", "5/5 · 08-08 15:32")
+ li("✕ Rà soát bảo mật", "3/5 · 08-06 16:32")
+ li("■ Dựng báo cáo từ Excel", "1/5 · 08-04 18:32")
+ '<div class="grow"></div></div>'
+ '<div class="grow"></div>'
'<div class="r tb"><div class="btn">✎</div><div class="btn">⧉</div>'
'<div class="btn">🗑</div><div class="btn grow">▷ Chạy nền</div></div></div>'
'<div class="c grow"><div class="b canvas grow">'
'<div class="node ok">Phân tích yêu cầu</div><div class="arw">→</div>'
'<div class="node ok">Thiết kế</div><div class="arw">→</div>'
@@ -478,7 +549,18 @@ ANALYSIS: dict[str, dict] = {
'<div class="hd2">SANDBOX &amp; QUYỀN</div>'
'<div class="r"><div class="b grow">Tệp: chỉ trong workspace · Mạng: chặn · '
'Tiến trình: giới hạn 4</div></div>'
'<div class="hd2">NHẬT KÝ GẦN ĐÂY</div>'
'<div class="hd2 row">BẢNG GIÁ MODEL'
'<span class="ghd"><span class="gact">Nhập · Xuất · Thêm · Tự dò</span>'
'<span class="pchev">USD ▾</span></span></div>'
'<div class="b tbl">'
'<div class="tr th"><span>Model</span><span>Vào</span><span>Ra</span>'
'<span>Cache</span><span>Đơn vị</span></div>'
'<div class="tr"><span>qwen2.5-coder:7b</span><span>0.00</span><span>0.00</span>'
'<span>0.00</span><span>/Mtok</span></div>'
'<div class="tr"><span>gpt-4o-mini</span><span>0.15</span><span>0.60</span>'
'<span>0.08</span><span>/Mtok</span></div></div>'
'<div class="hd2 row">NHẬT KÝ GẦN ĐÂY'
'<span class="ghd"><span class="gact">Xem tất cả</span></span></div>'
'<div class="b grow"><span class="bad">✕ Chặn đọc personal.xlsx (ngoài sandbox)</span><br>'
'<span class="ok">✓ pytest tests/test_stations.py → 4 passed</span><br>'
'<span class="bad">✕ jira.create_issue — 401 token hết hạn</span></div>'
@@ -509,6 +591,73 @@ ANALYSIS: dict[str, dict] = {
+ li("image_gen", "Sinh ảnh — ☐ tắt")
+ '<div class="grow"></div></div></div>'),
},
"overlay-help-panel": {
"problems": [
"<b>Hai vùng bấm cho một tính năng.</b> Huy hiệu mở, chevron ẩn — nằm sát nhau, "
"dễ bấm nhầm.",
"<b>Vùng bấm quá nhỏ.</b> Chevron rộng <b>18px</b>, tab mép <b>16px</b> "
"(<code>help_agent_widget.py:36-38</code>) — dưới ngưỡng ~24px để bấm thoải mái, "
"nhất là trên màn cảm ứng.",
"<b>Ba trạng thái, thừa một.</b> “Nép mép” và “huy hiệu” đều nghĩa là <i>đang đóng</i>; "
"người dùng phải học hai kiểu đóng và hai đường quay lại.",
"<b>Chiếm 84×64px vĩnh viễn</b> ngay góc dưới phải (huy hiệu 64 + khe 2 + "
"chevron 18 — <code>help_agent_widget.py:34-37</code>) — ở màn Cowork nó nằm đè "
"lên vùng nút Gửi, dù cả ngày chỉ mở vài lần.",
"Huy hiệu dùng <b>chính icon app</b> (<code>help_agent_widget.py:49-53</code>, "
"dự phòng là glyph robot) nên nhìn không khác gì icon cửa sổ; nhãn "
"“Trợ lý App” / “App Assistant” / “アプリアシスタント” nói <i>chỗ dùng</i> "
"chứ không nói <i>nó là gì</i>.",
],
"changes": [
"<b>Một chấm 26px, không chữ.</b> Bỏ luôn chevron rời — chỗ chiếm giảm từ "
"<b>84×64 xuống 26×26</b> (<b>−88% diện tích</b>). Vẫn là một vùng bấm, "
"26px ≥ ngưỡng bấm thoải mái.",
"<b>Tên: “AI Assistant”</b> — giữ nguyên ở cả <b>3 ngôn ngữ</b>, sửa đúng một "
"khoá <code>help_agent.title</code> (<code>i18n.py:470</code>) thay cho "
"“App Assistant / Trợ lý App / アプリアシスタント”. Tên dài không còn là vấn đề "
"vì nó không nằm trên màn lúc bình thường.",
"<b>Chữ chỉ hiện khi rê chuột / focus bàn phím</b> — chấm nở thành pill "
"“✨ AI Assistant”. Lúc bình thường màn hình không có chữ nào thừa.",
"<b>“Ẩn trợ lý” dời vào menu ⋯</b> trong header panel, cạnh “Thu nhỏ”. "
"Không mất chức năng — chỉ chuyển tới lúc người dùng <i>đang</i> tương tác.",
"Thường ngày chỉ còn <b>2 trạng thái</b>: đóng ↔ mở. Ẩn hẳn thành lựa chọn hiếm.",
"Tab mép nới từ <b>16px → 28px</b> cho bấm được.",
"Ở màn có ô nhập dưới đáy (Cowork), chấm <b>nâng lên trên hàng nhập</b>, "
"không đè nút Gửi.",
],
"wf": ('<div class="main dlg">'
'<div class="r tb"><div class="ttl">Trợ lý — thu gọn còn một chấm</div></div>'
'<div class="r grow">'
# 1. at rest — drawn to scale beside the old footprint
'<div class="c w24"><div class="lbl">Bình thường</div>'
'<div class="b grow ctr2" style="gap:14px">'
'<div class="oldbox">cũ 84×64</div>'
'<div class="fab"><span class="spark">✨</span></div></div>'
'<div class="s">26×26 · không chữ, không chevron · −88% diện tích</div></div>'
# 2. hover — the label appears only on demand
'<div class="c w24"><div class="lbl">Rê chuột / focus</div>'
'<div class="b grow ctr2">'
'<span class="fabpill"><span class="fab"><span class="spark">✨</span></span>'
'AI Assistant</span></div>'
'<div class="s">tên chỉ hiện lúc cần</div></div>'
# 3. open — hide lives in the ⋯ menu
'<div class="c grow"><div class="lbl">Mở — “Ẩn” nằm trong menu ⋯</div>'
'<div class="b grow pnl">'
'<div class="r tb phdr"><span class="spark">✨</span><b>AI Assistant</b>'
'<div class="grow"></div><span class="mut">— ⋯</span></div>'
'<div class="mnu"><div class="mi">Thu nhỏ về chấm</div>'
'<div class="mi">Ẩn trợ lý vào cạnh phải</div>'
'<div class="mi">Đổi model…</div></div>'
'<div class="msg a">Xin chào Nam, mình giúp gì khi bạn dùng app?</div>'
'<div class="grow"></div>'
'<div class="r tb"><div class="inp grow">Hỏi về cách dùng app…</div>'
'<div class="btn pri">Gửi</div></div></div></div>'
# 4. hidden — wider edge tab
'<div class="c w18"><div class="lbl">Đã ẩn</div>'
'<div class="b grow ctr2"><div class="edge wide">‹</div></div>'
'<div class="s">tab mép 28px</div></div>'
'</div></div>'),
},
"dialog-settings": {
"problems": [
"Năm group cuộn dọc, không mục lục.",
@@ -635,7 +784,15 @@ MOVES = {
"self.project_list": "→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang",
"self.view_combo": "→ đổi thành cặp tab Kanban | Lịch",
"self.flow_bar": "→ bỏ; chọn workflow từ danh sách trái",
"self.flow_add_btn": "→ nút “+ Mới” cạnh tiêu đề WORKFLOWS "
"(chỗ cũ là dải tab, đã bỏ nên phải có chỗ mới)",
"self.ag_new_btn": "→ nút “+ Mới” cạnh tiêu đề AGENTS",
"self.sk_manage_btn": "→ nút “Quản lý…” cạnh tiêu đề SKILLS",
"self._msg_btn": "→ đổi thành cặp tab Đồ thị | Tin nhắn",
# The chevron beside the launcher is 18px wide and sits next to a 64px
# badge; the action survives, it just moves to where the user already is.
"self.collapse_btn": "→ mục “Ẩn trợ lý vào cạnh phải” trong menu ⋯ ở header panel",
"self.edge_tab": "Giữ — tab mép mở lại trợ lý, nới 16px → 28px",
"self.search_edit": "→ lên sidebar cùng RECENTS",
"self.search_btn": "→ lên sidebar cùng RECENTS",
"self.refresh_btn": "→ lên sidebar cùng RECENTS",
@@ -671,6 +828,45 @@ NEWCHAT = [
"Giữ y nguyên — RECENTS refresh", "<span class='ok'>Không đổi.</span>"),
]
# Surfaced by this audit, deliberately NOT done — each would add or change
# behaviour, which the redesign's scope forbids.
LATER = [
("Xuất log cho Nhật ký gần đây",
"Hiện chỉ có “Xem tất cả” nhảy sang Action Logs (<code>monitoring_tab.py:586</code>). "
"Xuất ra tệp là chức năng mới.",
"chức năng mới"),
("Bỏ auto-refresh, thay bằng nút bấm",
"Monitoring làm mới mỗi <b>3 giây</b> (<code>monitoring_tab.py:43</code>), "
"Schedule <b>10 giây</b> (<code>schedule_task_tab.py:150</code>), "
"Dashboard <b>30 giây</b> (<code>dashboard_tab.py:175</code>). "
"Đề xuất: chỉ làm mới khi vào màn + một nút thủ công.",
"đổi hành vi"),
("Nút tạo skill mới",
"Cả <code>SkillsDialog</code> lẫn <code>SkillManagerTab</code> đều không có. "
"Chỉ tạo được qua AI / template / nhập / nhân bản. "
"<code>SkillEditDialog</code> đã làm được việc này, chỉ thiếu lối vào.",
"chức năng mới"),
("Nút “chat mới” trong pane Lịch sử",
"<code>sidebar.py:68</code> khai báo tín hiệu <code>new_chat</code>, "
"<code>workspace_tab.py:241</code> đã nối — nhưng không nơi nào phát.",
"hoàn thiện thứ đã dựng"),
("Gọi <code>ensure_starter_project()</code>",
"Hàm có docstring “đảm bảo luôn có ít nhất một project” nhưng không ai gọi, "
"trong khi <code>refresh()</code> lại ghi “no auto-seed”. Hai chỗ mâu thuẫn.",
"đổi hành vi"),
("Mật khẩu Sandbox hard-code",
"<code>settings_dialog.py:115</code> để mật khẩu mở khoá ngay trong mã nguồn.",
"bảo mật"),
("Hai lớp trùng tên <code>CustomAgent</code>",
"<code>core/custom_agents.py:23</code> và <code>core/co4e.py:117</code> — "
"khác trường, khác thư mục lưu.",
"dọn mã"),
("Sáu màn không có đường vào",
"AccountsTab · LoginDialog · FlowBuilderDialog · AgentManagerTab · "
"SkillManagerTab · McpServerEditDialog — tổng 64 control.",
"quyết định giữ hay gỡ"),
]
# Old location → new location for EVERY screen, so "nothing was removed" is
# something the reader can check rather than take on trust.
MAPPING = [
@@ -912,7 +1108,7 @@ th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--bd);vertica
th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.05em}
/* ---- wireframe vocabulary ---- */
.wf{display:flex;height:570px;border:1px solid var(--bds);border-radius:var(--r);
overflow:hidden;background:var(--bg);font-size:11px}
overflow:hidden;background:var(--bg);font-size:11px;position:relative}
/* The rail must never crop: its bottom group is real navigation. */
.wf .rail{overflow:visible}
.wf .rail{width:150px;flex:none;background:var(--nav);border-right:1px solid var(--navb);
@@ -925,6 +1121,10 @@ padding:5px 7px;margin-bottom:5px;font-weight:600;display:flex;align-items:cente
justify-content:space-between;gap:4px;line-height:1.3}
.wf .rpick>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wf .rpick .cv{color:var(--mut);font-weight:400;flex:none}
.wf .rpick.empty{color:var(--fnt);font-weight:400;font-style:italic}
.wf .i.off,.wf .newbtn.off{opacity:.45}
.wf .newbtn.off{background:var(--bds);color:var(--tx)}
.wf .hint{font-size:9px;color:var(--fnt);text-align:center;padding:2px 0 6px;font-style:italic}
.wf .newbtn{flex:none}
.wf .i{padding:5px 7px;border-radius:4px;color:var(--tx)}
.wf .i.on{background:var(--navs);border-left:2px solid var(--ac);font-weight:600}
@@ -933,7 +1133,11 @@ justify-content:space-between;gap:4px;line-height:1.3}
.wf .scope{font-size:10px;font-weight:600;color:var(--tx);padding:2px 7px 4px}
.wf .i.allp{color:var(--ac);font-style:italic}
.wf .sep{height:1px;background:var(--navb);margin:5px 0}
.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb);color:var(--mut);font-size:10px}
.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb);
color:var(--mut);font-size:10px;display:flex;align-items:center;gap:5px}
.wf .acct .lang{margin-left:auto;background:var(--rz);border:1px solid var(--bds);
border-radius:3px;padding:1px 5px;color:var(--tx);font-weight:600}
.wf .acct .thm{font-size:11px}
.wf .main,.wf .c{display:flex;flex-direction:column;gap:6px;padding:10px;flex:1;min-width:0}
.wf .main.dlg{border:none}
.wf .ttl{font-weight:700;font-size:13px;white-space:nowrap}
@@ -963,6 +1167,50 @@ font-weight:700}
margin-top:2px}
.wf .hd2.row{display:flex;align-items:center;justify-content:space-between}
.wf .pchev{color:var(--mut);font-size:12px;font-weight:400}
.wf .ghd{display:flex;align-items:center;gap:6px}
.wf .gact{color:var(--ac);font-weight:600;font-size:9.5px;letter-spacing:0}
.wf .b.tbl{padding:0;overflow:hidden}
.wf .tr{display:flex;padding:3px 8px;border-bottom:1px solid var(--bd);gap:6px}
.wf .tr:last-child{border-bottom:none}
.wf .tr span{flex:1}.wf .tr span:first-child{flex:2.4}
.wf .tr.th{color:var(--fnt);font-size:9px;letter-spacing:.05em;font-weight:700}
.wf .ctr2{display:flex;align-items:center;justify-content:center}
.wf .edge{background:var(--sf);border:1px solid var(--bds);border-right:none;
border-radius:5px 0 0 5px;padding:14px 5px;color:var(--mut)}
.wf .edge.wide{padding:14px 10px;font-weight:700;color:var(--tx)}
.wf .mnu{align-self:flex-end;background:var(--rz);border:1px solid var(--bds);
border-radius:5px;padding:3px;min-width:52%;box-shadow:0 3px 10px rgba(16,32,64,.14)}
.wf .mi{padding:4px 8px;border-radius:3px}
.wf .mi:nth-child(2){background:var(--navs);font-weight:600}
/* Sparkle badge — the teal-tinted "AI" chip, same convention as the app's
existing ✨ AI buttons in Folder and Schedule. */
/* The dock is anchored to the window corner — its position is unchanged. */
.wf .main.anchor{position:relative}
.wf .dock{position:absolute;right:10px;bottom:10px}
.wf .dock.badgewrap{display:flex;align-items:center;gap:3px}
.wf .dock .badge,.wf .dock.badge{background:#E6F6F4;border:1px solid #7FD0C4;
border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap}
/* "Ẩn vào cạnh phải" — a real button in the app, next to the launcher. */
.wf .dock .chv{background:var(--sf);border:1px solid var(--bds);border-radius:4px;
padding:6px 3px;color:var(--mut);font-size:11px;line-height:1}
/* The proposed dot: 26px, no label, no neighbouring chevron. Drawn at the same
scale as the wireframe around it so the size claim is visible, not asserted. */
.wf .dock.fab,.wf .fab{width:26px;height:26px;border-radius:50%;background:#E6F6F4;
border:1px solid #7FD0C4;display:flex;align-items:center;justify-content:center;
font-size:12px;box-shadow:0 2px 6px rgba(16,32,64,.14)}
/* Hover / keyboard focus only — the label is never on screen at rest. */
.wf .fabpill{display:inline-flex;align-items:center;gap:5px;background:#E6F6F4;
border:1px solid #7FD0C4;border-radius:13px;padding:4px 11px 4px 5px;
font-weight:700;color:#0F6E62;white-space:nowrap;box-shadow:0 2px 6px rgba(16,32,64,.14)}
.wf .fabpill .fab{box-shadow:none;width:18px;height:18px;font-size:10px}
/* Old vs new footprint, drawn to scale beside each other. */
.wf .oldbox{width:42px;height:32px;border:1px dashed var(--bds);border-radius:4px;
display:flex;align-items:center;justify-content:center;color:var(--fnt);font-size:9px}
.wf .dock.pnl{width:74%;height:76%;background:var(--sf);border:1px solid var(--bds);
border-radius:6px;box-shadow:0 3px 10px rgba(16,32,64,.13)}
.wf .phdr{border-bottom:1px solid var(--bd);padding-bottom:5px;gap:5px}
.wf .spark{color:#0F9B8A}
.wf .pnl{display:flex;flex-direction:column;gap:5px;padding:8px}
/* The rail's own MENU collapse control (150px <-> 54px in the app). */
.wf .menutog{display:flex;align-items:center;justify-content:space-between;
color:var(--fnt);font-size:9px;letter-spacing:.1em;font-weight:700;padding:2px 6px 6px}
@@ -1017,6 +1265,7 @@ line-height:1.7;background:var(--rz);border-radius:4px;padding:5px 7px}
.wf .add{color:var(--ok)}.wf .del{color:var(--bad)}
.wf .ok{color:var(--ok)}.wf .bad{color:var(--bad)}
.wf .cm{color:var(--fnt)}.wf .kw{color:var(--ac)}.wf .fn{color:var(--warn)}
.wf .w18{flex:none;width:18%}.wf .w24{flex:none;width:24%}
.wf .w26{flex:none;width:26%}.wf .w28{flex:none;width:28%}.wf .w32{flex:none;width:32%}
@media(max-width:760px){.wf{height:auto;flex-direction:column}.wf .rail{width:auto}}
.tgl{position:fixed;top:16px;right:16px;z-index:9;background:var(--sf);color:var(--tx);
@@ -1145,7 +1394,12 @@ def main() -> int:
shot = f'<div class="miss">Không chụp được màn này<br><code>{err}</code></div>'
sw = ""
wf = (f'<div class="cap">Đề xuất — bố cục mới</div><div class="wf">{a["wf"]}</div>'
# The dock floats over the MAIN WINDOW. Modal dialogs cover it, so they
# get none; section 27 draws its own (badge + open panel).
dock = "" if (slug.startswith("dialog-")
or slug == "overlay-help-panel") else DOCK_BADGE
wf = (f'<div class="cap">Đề xuất — bố cục mới</div>'
f'<div class="wf">{a["wf"]}{dock}</div>'
if a["wf"] else
'<div class="cap">Đề xuất — bố cục mới</div>'
'<p class="mut">Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.</p>')
@@ -1159,9 +1413,15 @@ def main() -> int:
f'<b>{lab}</b>{": " + txt if txt else ""}' for lab, txt in de["r"])
legend = f'<p class="rg">{bits}</p>'
secs.append(f"""<section class="sec" id="{slug}">
<div class="hd"><b>{n}. {esc(info['title'])}</b><span class="tag">{esc(info['note'])}</span>{sw}</div>
<div class="bd">
# Eight sections were written by hand (tools/audit_handwritten.py). Use
# that markup verbatim, but keep the screenshot and the AST control
# inventory generated so neither goes stale.
hand = HAND_SECTIONS.get(slug)
if hand:
body = (hand.replace("{{SHOT}}", shot)
.replace("{{CONTROLS}}", controls_table(slug, cidx)))
else:
body = f"""<div class="bd">
{intro}
<div class="cap">Hiện tại</div>{shot}
{legend}
@@ -1170,7 +1430,11 @@ def main() -> int:
<div class="cols pc">
<div><div class="cap">Vấn đề</div><ul class="pr">{''.join(f'<li>{p}</li>' for p in a['problems'])}</ul></div>
<div><div class="cap">Thay đổi</div><ul class="pr">{''.join(f'<li>{c}</li>' for c in a['changes'])}</ul></div>
</div></div></section>""")
</div></div>"""
secs.append(f"""<section class="sec" id="{slug}">
<div class="hd"><b>{n}. {esc(info['title'])}</b><span class="tag">{esc(info['note'])}</span>{sw}</div>
{body}</section>""")
flows = "".join(
f'<tr><td><b>{t}</b></td><td class="mut">{b}</td><td>{af}</td></tr>'
@@ -1183,6 +1447,14 @@ def main() -> int:
newchat = "".join(
f'<tr><td><b>{a}</b></td><td class="mut">{o}</td><td>{n}</td><td>{v}</td></tr>'
for a, o, n, v in NEWCHAT)
later = "".join(f'<tr><td><b>{n}</b></td><td>{d}</td>'
f'<td class="mut">{k}</td></tr>' for n, d, k in LATER)
rail_has = rail("Cowork") + ('<div class="main"><div class="ttl">Cowork</div>'
'<div class="b grow"></div></div>')
rail_none = rail("Project", empty=True) + (
'<div class="main"><div class="ttl">Quản lý project</div>'
'<div class="b grow"><span class="mut">Chưa có project — bấm “+ Project mới”</span>'
'</div></div>')
shell_ctl = controls_table("__shell__", cidx)
colls = "".join(
f'<tr><td><b>{n}</b></td><td>{how}</td><td class="mut">{src}</td>'
@@ -1201,7 +1473,8 @@ def main() -> int:
html = f"""<!doctype html><html lang="vi"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CoworkLocal — Audit UI/UX</title><style>{CSS}</style></head><body>
<title>CoworkLocal — Audit UI/UX</title><style>{CSS}
{HAND_CSS}</style></head><body>
<button class="tgl" id="tgl">🌙 Tối</button>
<div class="wrap">
<header>
@@ -1267,6 +1540,20 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư
<h3>Truy vết chi tiết: “Đoạn chat mới”</h3>
<table><tr><th style="width:16%">Khía cạnh</th><th style="width:28%">Giao diện cũ</th>
<th style="width:24%">Giao diện mới</th><th>Có đồng bộ không</th></tr>{newchat}</table>
<div class="cols" style="align-items:start">
<div><div class="cap">Có project</div><div class="wf demo">{rail_has}</div></div>
<div><div class="cap">Chưa có project nào</div><div class="wf demo">{rail_none}</div></div>
</div>
<div class="note"><b>Khi chưa có project</b> (đã chạy thử app với 0 project):
hiện nay <b>Cowork và GraphRAG biến mất</b> khỏi menu nên không chat được, mà không nói vì sao.
Thiết kế mới <b>giữ nguyên cổng chặn đó</b> — vẫn không tạo chat được — nhưng hai mục vẫn nằm
đúng chỗ, chỉ mờ đi; droplist ghi “Chưa có project”; nút chat mới bị khoá kèm lý do
“Tạo project trước”.<br>
<span class="mut">Ghi nhận thêm: lúc đó <code>ctx.active_project_id</code> vẫn giữ
<code>'default'</code> — trỏ vào một project không tồn tại. Và
<code>projects.ensure_starter_project()</code> (“đảm bảo luôn có ít nhất một project”)
<b>không nơi nào gọi</b>.</span></div>
<div class="note bad"><b>Phát hiện:</b> <code>sidebar.py:68</code> khai báo tín hiệu
<code>new_chat</code> và <code>workspace_tab.py:241</code> đã nối nó vào
<code>_on_sidebar_new</code> — nhưng <b>không nơi nào phát tín hiệu này</b>
@@ -1277,7 +1564,13 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư
<h2>Phần 3 — Từng màn hình</h2>
{''.join(secs)}
<h2>Phần 4 — Màn chết (chỉ ghi nhận)</h2>
<h2>Phần 4 — Phát triển lần sau</h2>
<p class="mut">Những việc audit này phát hiện nhưng <b>cố ý không làm</b>, vì đều thêm
hoặc đổi chức năng — ngoài phạm vi “chỉ sắp xếp lại”.</p>
<table><tr><th style="width:26%">Việc</th><th>Chi tiết</th><th style="width:16%">Loại</th></tr>
{later}</table>
<h2>Phần 5 — Màn chết (chỉ ghi nhận)</h2>
<p class="mut">Sáu màn có trong code nhưng không tới được — tổng <b>64 control</b>
(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4).
Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.</p>
@@ -1285,7 +1578,9 @@ Không nằm trong kiểm kê phía trên vì không có đường nào tới; c
<div class="note warn">Ngoài phạm vi: <code>settings_dialog.py:115</code> hard-code mật khẩu
Sandbox; hai lớp cùng tên <code>CustomAgent</code>
(<code>custom_agents.py:23</code> · <code>co4e.py:117</code>).</div>
</div><script>{JS}</script></body></html>"""
</div><script>{JS}</script>
{"".join(f"<script>{j}</script>" for j in HAND_JS)}
</body></html>"""
dest = OUT
dest.write_text(html, encoding="utf-8")
+11 -39
View File
@@ -132,48 +132,20 @@ def main() -> int:
nav_state = {"label": "", "expected": ""}
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
"""Navigate the way a user does: SELECT the nav-rail row.
"""Navigate the way a user does, and record where the rail ends up.
Calling ``win._goto()`` directly swaps the content but leaves the rail
highlighting whatever was selected before — so a Co4E screenshot showed
the content of Co4E with "Workspace" still lit. Setting the current item
fires currentItemChanged → _navigate → _goto, i.e. both halves.
Since the rail became a flat list, ``_goto`` moves the highlight itself
(``_select_nav_row``), so this no longer needs the two-step workaround
that existed while selecting a Workspace child destroyed the row being
selected.
"""
win._ensure_page(page) # build lazy page + its children
item = win._nav_items[page]
if sub is None:
win.nav.setCurrentItem(item)
app.processEvents()
else:
# Two steps, because selecting the row alone does not survive.
#
# Navigating to Workspace runs refresh(), which emits
# subtabs_changed → _reload_nav_children → takeChildren(); that
# DESTROYS the row just selected and the highlight falls back to the
# parent. Retrying only re-triggers the same cascade. (Real app bug,
# reproduced in test_nav_bug.py: 5/5 Workspace rows lose the
# highlight, 0/8 Monitoring rows do.)
#
# So: drive the content first, let the rebuild settle, then set the
# highlight with signals blocked so it cannot cascade again. The
# screenshot then shows what the user *should* see; the underlying
# bug is reported separately in the audit page.
win._goto(page, sub)
app.processEvents()
app.processEvents()
if item.childCount() == 0:
win._reload_nav_children(page)
item.setExpanded(True)
target = next(
(item.child(i) for i in range(item.childCount())
if (item.child(i).data(0, Qt.UserRole) or {}).get("sub") == sub),
None)
assert target is not None, f"nav child page={page} sub={sub} not found"
blocked = win.nav.blockSignals(True)
win.nav.setCurrentItem(target)
win.nav.blockSignals(blocked)
win._ensure_page(page)
win._goto(page, sub)
app.processEvents()
cur = win.nav.currentItem()
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)
nav_state["label"] = cur.text(0) if cur is not None else ""
nav_state["expected"] = expect or nav_state["label"]
+194
View File
@@ -0,0 +1,194 @@
"""Check the Co4E sidebar rearrangement, on the real widget, offscreen.
Phase D only moved things and added a second door to "new flow". So the test
that matters is a subtraction test: every control that existed before must still
exist, the flow tab strip (which carries the pinned Runs tab and lets several
flows stay open) must be untouched, and the section headings must actually name
the list you are looking at — in all three languages.
Run: python tools/check_co4e.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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
# Every control the sidebar and the flow area had before these changes.
EXPECTED = [
"wf_list", "wf_edit_btn", "wf_dup_btn", "wf_del_btn", "wf_runbg_btn",
"agent_list", "ag_new_btn", "ag_edit_btn", "ag_del_btn",
"skill_list", "sk_manage_btn",
# Runs moved off the strip onto a toggle + a back button.
"runs_btn", "runs_back_btn", "runs_table", "runs_side_list", "runs_more_btn",
"name_edit", "add_step_btn", "save_btn", "save_tpl_btn", "mode_combo", "run_btn",
"run_stop_btn", "run_rename_btn", "run_del_btn", "run_clear_btn", "ws_folder_btn",
]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
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.i18n import set_language, tr
from cowork_local.state import AppContext
from cowork_local.ui.co4e_tab import Co4ETab
set_language("vi")
tab = Co4ETab(AppContext(AppConfig.load()))
app.processEvents()
fails: list[str] = []
missing = [n for n in EXPECTED if getattr(tab, n, None) is None]
print(f"control cu con nguyen : {len(EXPECTED) - len(missing)}/{len(EXPECTED)}")
if missing:
fails.append(f"mat control: {missing}")
# The strip is gone from the screen, as the drawing asks.
strip_shown = tab.flow_scroll.isVisible() or tab.flow_add_btn.isVisible()
print(f"dai tab flow tren man : {strip_shown} (phai la False)")
if strip_shown:
fails.append("dai tab flow van con hien")
# What the strip carried must still work. 1) Flow Status, both directions.
tab.runs_btn.setChecked(True)
app.processEvents()
on_runs = tab.center_stack.currentIndex() == 0
tab.runs_back_btn.click()
app.processEvents()
back = tab.center_stack.currentIndex() == 1
print(f"Flow Status: mo = {on_runs} · quay ve flow = {back} "
f"· nut gat dang bat = {tab.runs_btn.isChecked()}")
if not (on_runs and back):
fails.append("khong di/ve duoc trang Flow Status")
if tab.runs_btn.isChecked():
fails.append("nut gat Flow Status khong tra ve trang thai tat")
# 2) Opening a flow from the list REPLACES the one on the canvas — one at a
# time now, which is the part of the old strip that genuinely goes away.
from cowork_local.core import co4e as _co4e
tab._open_flow(_co4e.new_workflow("Flow A"))
app.processEvents()
tab._open_flow(_co4e.new_workflow("Flow B"))
app.processEvents()
print(f"mo 2 flow lien tiep : con {len(tab._flows)} flow tren canvas "
f"({tab._wf.name!r})")
if len(tab._flows) != 1:
fails.append(f"cho 1 flow mo cung luc, thay {len(tab._flows)}")
# One column, four named sections — no icon tabs left.
from PySide6.QtWidgets import QTabWidget
heads = [h.text() for h, _b, _s in tab._sections.values()]
print(f"cot sidebar : {heads}")
if len(heads) != 4:
fails.append(f"cho 4 muc trong cot sidebar, thay {len(heads)}")
if tab.sidebar.findChildren(QTabWidget):
fails.append("van con tab icon trong sidebar")
# Every list visible at once — that is the point of dropping the tabs.
tab.show()
app.processEvents()
shown = [n for n in ("wf_list", "agent_list", "skill_list", "runs_side_list")
if not getattr(tab, n).isHidden()]
print(f"danh sach hien cung luc: {shown}")
if len(shown) != 4:
fails.append(f"chi {len(shown)}/4 danh sach hien cung luc")
# Headings fold their section, so a short window can still reach everything.
head, body, _s = tab._sections["co4e.tab_agents"]
head.setChecked(False)
app.processEvents()
folded = body.isHidden()
head.setChecked(True)
app.processEvents()
print(f"gap/mo muc AGENTS : gap = {folded} · mo lai = {not body.isHidden()}")
if not folded:
fails.append("bam tieu de khong gap duoc muc")
# Both new-flow doors must land on the same slot.
print(f"'Moi' canh WORKFLOWS : {tab.wf_new_btn.text()!r}")
before = tab._wf.name
tab.wf_new_btn.click()
app.processEvents()
print(f"bam 'Moi' -> flow tren canvas {before!r} -> {tab._wf.name!r}")
if tab._wf.name == before:
fails.append("nut 'Moi' canh WORKFLOWS khong tao flow moi")
# The action buttons that act on a selection stayed with the list.
print(f"nut duoi danh sach : agents = "
f"{[b.toolTip() for b in (tab.ag_edit_btn, tab.ag_del_btn)]}")
# --- small screens ------------------------------------------------------
# The complaint that started this: on a laptop the four lists squeezed down
# to one row each. Check real geometry at a few window heights.
print()
for w, h in ((1920, 1080), (1366, 768), (1280, 720)):
tab.resize(w, h)
app.processEvents()
app.processEvents()
heights = {n: getattr(tab, n).height()
for n in ("wf_list", "agent_list", "skill_list", "runs_side_list")}
rows = {n: (getattr(tab, n).height() // max(1, getattr(tab, n).sizeHintForRow(0) or 18))
for n in heights}
print(f"{w}x{h}: cao = {heights} · so dong thay duoc = {rows}")
thin = [n for n, v in heights.items() if v < 50]
if thin:
fails.append(f"o {w}x{h}, danh sach qua thap: {thin}")
# Folding must hand its height to the others, not just hide the body.
tab.resize(1280, 720)
app.processEvents()
before = tab.wf_list.height()
for key in ("co4e.tab_skills", "co4e.runs_tab"):
tab._sections[key][0].setChecked(False)
app.processEvents(); app.processEvents()
after = tab.wf_list.height()
print(f"gap SKILLS + FLOW STATUS -> WORKFLOWS cao {before} -> {after}px")
if after <= before:
fails.append("gap muc khac ma WORKFLOWS khong duoc them cho")
for key in ("co4e.tab_skills", "co4e.runs_tab"):
tab._sections[key][0].setChecked(True)
app.processEvents()
print()
for lang in ("vi", "en", "ja"):
set_language(lang)
tab._retranslate()
app.processEvents()
texts = [h.text() for h, _b, _s in tab._sections.values()]
print(f" {lang}: {texts}")
print(f" nut moi = {tab.wf_new_btn.text()!r}"
f" · runs = {tab.runs_btn.text()!r} / {tab.runs_back_btn.text()!r}")
if any(not t or "CO4E." in t for t in texts):
fails.append(f"thieu ban dich tieu de muc cho {lang}")
set_language("vi")
print()
if fails:
print("*** LOI ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA: Co4E sap xep lai, khong mat control nao")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+92
View File
@@ -0,0 +1,92 @@
"""Check the Dashboard header regroup, on the real widget, offscreen.
Nine controls were on one row. They are now on two, grouped by what they do —
so this asserts that all nine are still present, still wired, and that the
header really is two rows now (row 1 = title + Refresh, row 2 = the selectors).
Run: python tools/check_dashboard.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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn",
"gran_combo", "metric_combo", "currency_lbl", "currency_combo",
"refresh_btn"]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
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.i18n import set_language
from cowork_local.state import AppContext
from cowork_local.ui.dashboard_tab import DashboardTab
set_language("vi")
tab = DashboardTab(AppContext(AppConfig.load()))
tab.resize(1100, 800)
tab.show()
app.processEvents()
tab.refresh()
app.processEvents()
fails: list[str] = []
missing = [n for n in HEADER if getattr(tab, n, None) is None]
print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}")
if missing:
fails.append(f"mat control: {missing}")
# Two rows: everything in the header must sit at one of exactly two y bands.
tops = {}
for n in HEADER:
w = getattr(tab, n)
tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n)
print(f"so hang cua header : {len(tops)}")
for band, names in sorted(tops.items()):
print(f" y~{band * 10:>4}px : {names}")
if len(tops) != 2:
fails.append(f"header co {len(tops)} hang, cho 2")
# Still wired: changing the metric must not throw and must stick.
before = tab.metric_combo.currentData()
tab.metric_combo.setCurrentIndex(1 - tab.metric_combo.currentIndex())
app.processEvents()
after = tab.metric_combo.currentData()
print(f"doi chi so bieu do : {before} -> {after}")
if after == before:
fails.append("combo chi so khong doi duoc")
tab.refresh_btn.click()
app.processEvents()
print("bam Lam moi : khong loi")
print()
if fails:
print("*** LOI ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA: header Dashboard chia 2 hang, du 9 control")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+244
View File
@@ -0,0 +1,244 @@
"""Compare the running app against every proposal on the audit page.
The checklist is not written here — it is read from build_audit_page.ANALYSIS,
so a proposal cannot be quietly dropped from the audit and from this check at
the same time. Each item has a probe against a real MainWindow built offscreen.
Verdicts:
OK the probe passes
CHUA not implemented
KHAC implemented differently on purpose (reason printed)
TAY cannot be probed mechanically — inspect by eye
Run: python tools/check_design_parity.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))
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
def build():
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or 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
set_language("vi")
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
win.resize(1600, 900)
win.show()
for _ in range(8):
app.processEvents()
return app, win
def main() -> int:
app, win = build()
ws = win.workspace
def goto(sub):
win._goto(win._ROW_WORKSPACE, sub)
for _ in range(6):
app.processEvents()
def page(row):
win._goto(row, None)
for _ in range(6):
app.processEvents()
return win._page_widgets[row]
import cowork_local.ui.co4e_tab as co4e_mod
co4e = win.findChildren(co4e_mod.Co4ETab)[0]
dash = page(win._ROW_DASHBOARD)
mon = page(win._ROW_MONITORING)
sched = page(win._ROW_SCHEDULE)
goto(ws._cowork_tab_idx)
chat = ws._cowork
dock = win.help_agent
def rows_of(widget, names):
"""How many distinct y-bands the named widgets occupy."""
bands = set()
for n in names:
w = getattr(widget, n, None)
if w is not None:
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
return len(bands)
# (slug, proposal, verdict, evidence)
R: list[tuple[str, str, str, str]] = []
def add(slug, text, ok, ev, other=None):
R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
# --- 1 Dashboard ---
n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
"currency_combo"])
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
# Taller than the small tiles AND a bigger number = it reads as the headline.
taller = dash.card_cost.height() > dash.card_total.height() * 1.5
bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
f"cỡ số {'34px' if bigger else 'như cũ'}")
# --- 2 Schedule Kanban ---
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
if not lanes:
from cowork_local.core.tasks import STATUSES
lanes = len(STATUSES)
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
has_combo = getattr(sched, "view_combo", None) is not None
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
"vẫn là combo" if has_combo else "đã thành tab")
add("schedule-kanban", "Lane Running có viền cảnh báo", False, "chưa làm")
# --- 4/5 Workspace ---
add("workspace-project", "History lên sidebar thành RECENTS",
win.nav_recents.topLevelItemCount() > 0,
f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
add("workspace-project", "Thanh chọn project dùng chung mọi màn",
win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
goto(ws._co4e_tab_idx)
hdr_off = ws._header.isHidden()
goto(ws._project_tab_idx)
hdr_on = not ws._header.isHidden()
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
"chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
add("workspace-project", "Pane trái cố định, không đổi danh tính", True,
"rail giữ project + RECENTS; pane trong trang vẫn theo màn", "KHAC")
add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
for i in range(win.nav_recents.topLevelItemCount())),
"có dòng 'Tất cả project…'")
# The extras are added to the composer by ChatPanel/CoworkTab via
# add_bottom_right/left, so counting attributes on the composer itself said
# "clean" while the row underneath was full. Count the row instead.
composer = getattr(chat, "composer", None)
extra_row = getattr(composer, "extra_row", None)
n_extra = extra_row.count() if extra_row is not None else -1
add("workspace-cowork", "Usage/cost xuống thanh trạng thái, composer chỉ nhập·đính kèm·gửi",
n_extra == 0, f"hàng dưới ô nhập còn {n_extra} mục")
# --- 6 Co4E ---
add("workspace-co4e", "Bỏ dải tab flow",
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
# --- 7 Folder / 8 GraphRAG ---
folder = ws.tabs.widget(ws._folder_tab_idx)
add("workspace-folder", "Path bar gộp vào tiêu đề",
getattr(folder, "path_edit", None) is None, "path bar vẫn là hàng riêng")
add("workspace-folder", "Panel AI thành lớp phủ phải; terminal thanh mỏng đáy",
False, "chưa làm")
graph = ws.tabs.widget(ws._graphrag_tab_idx)
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", False, "chưa làm")
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
# _msg_btn — a stale name); while it exists, this is still one button whose
# label flips, not a pair of tabs.
toggle = getattr(graph, "_msgs_toggle_btn", None)
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
"vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
# --- 9/15 Monitoring ---
from PySide6.QtWidgets import QHBoxLayout, QScrollArea
ov = mon.findChildren(QScrollArea)[0].widget()
one_col = not isinstance(ov.layout(), QHBoxLayout)
add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
"cột dọc" if one_col else "vẫn 2 cột")
# Its own section = it is a direct child of the single column, not sharing a
# row with the resource meters as it used to.
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
strip = not mon.tabs.tabBar().isHidden()
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
# --- 17/18 dialogs ---
from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
s = SettingsDialog(win.ctx)
add("dialog-settings", "Thêm cột mục lục bên trái",
s.section_list.count() == 5, f"{s.section_list.count()} mục")
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", True,
"đưa xuống hàng tài khoản ở rail thay vì dồn vào Settings", "KHAC")
s.close()
t = TaskEditorDialog(ctx=win.ctx)
add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết",
True, f"dùng mục lục {t.section_list.count()} mục thay vì 3 tab", "KHAC")
t.close()
# --- 27 help dock ---
add("overlay-help-panel", "Một chấm 26px, không chữ",
dock.width() <= 30 and not dock.launcher.text().strip(), f"{dock.width()}px")
from cowork_local.i18n import tr
dock.launcher._set_open(True)
app.processEvents()
add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
dock.launcher._set_open(False)
items = [a.text() for a in dock.more_btn.menu().actions()]
add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯",
tr("help_agent.hide_tooltip") in items, str(items))
add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
dock._hide_to_edge()
app.processEvents()
add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
dock._show_launcher()
app.processEvents()
goto(ws._cowork_tab_idx)
comp = chat.composer
dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
dock_top + dock.height() <= comp_top,
f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
# --- report ---
order = ["OK", "KHAC", "CHUA", "TAY"]
counts = {k: 0 for k in order}
cur = None
for slug, text, verdict, ev in R:
counts[verdict] = counts.get(verdict, 0) + 1
if slug != cur:
print(f"\n{slug}")
cur = slug
print(f" [{verdict:4}] {text}")
print(f" {ev}")
print()
print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
print(f" KHAC = co y lam khac, da ghi ly do")
print(f" CHUA = chua lam")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+117
View File
@@ -0,0 +1,117 @@
"""Check the section index added to Settings and the Task editor, offscreen.
The index is navigation only, so the test is again a subtraction test: every
input control must still be there, and every index row must actually scroll to
its section. Both dialogs are built with .show(), never .exec() — exec() blocks.
Run: python tools/check_dialogs.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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
def controls(dlg):
from PySide6.QtWidgets import (QCheckBox, QComboBox, QLineEdit, QListWidget,
QPlainTextEdit, QPushButton, QSpinBox)
n = 0
for cls in (QComboBox, QLineEdit, QCheckBox, QSpinBox, QPlainTextEdit,
QPushButton, QListWidget):
n += len(dlg.findChildren(cls))
return n
def check(name, dlg, app, expect_rows):
fails = []
print(f"--- {name} ---")
n_ctl = controls(dlg)
idx = dlg.section_list
rows = [idx.item(i).text() for i in range(idx.count())]
print(f"muc luc : {rows}")
print(f"tong control trong hop thoai: {n_ctl}")
if len(rows) != expect_rows:
fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}")
if any(not r or r.endswith(".g_basic") or r.startswith("settings.") for r in rows):
fails.append(f"{name}: co muc chua dich")
# Each row must scroll somewhere different (and the last one furthest down).
from PySide6.QtWidgets import QScrollArea
scroll = dlg.findChildren(QScrollArea)[0]
positions = []
for i in range(idx.count()):
idx.itemClicked.emit(idx.item(i))
app.processEvents()
positions.append(scroll.verticalScrollBar().value())
print(f"vi tri cuon theo tung muc : {positions}")
if positions != sorted(positions):
fails.append(f"{name}: muc luc nhay khong theo thu tu tren xuong")
if len(set(positions)) < 2:
fails.append(f"{name}: bam muc nao cung dung mot cho — muc luc khong chay")
return n_ctl, fails
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from cowork_local.i18n import set_language, tr
from cowork_local.state import AppContext
from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
set_language("vi")
ctx = AppContext(AppConfig.load())
fails = []
s = SettingsDialog(ctx)
s.resize(900, 600)
s.show()
app.processEvents()
n_s, f = check("Cai dat", s, app, 5)
fails += f
t = TaskEditorDialog(ctx=ctx) # task=None → a new task, all fields present
t.resize(900, 600)
t.show()
app.processEvents()
n_t, f = check("Task editor", t, app, 5)
fails += f
# Translations for the two names that had to be invented for the index.
print()
for lang in ("vi", "en", "ja"):
set_language(lang)
print(f" {lang}: general={tr('settings.group.general')!r} "
f"basic={tr('schedtask.g_basic')!r}")
for key in ("settings.group.general", "schedtask.g_basic"):
if tr(key) == key:
fails.append(f"thieu ban dich {key} cho {lang}")
set_language("vi")
print()
if fails:
print("*** LOI ***")
for x in fails:
print(" " + x)
return 1
print("KET QUA: hai hop thoai co muc luc, khong mat control nao")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+131
View File
@@ -0,0 +1,131 @@
"""Check the redesigned help dock on the real widget, offscreen.
The claim being made is a size claim ("84×64 → 26×26"), so this measures the
widget instead of trusting the constants, and confirms that nothing the old
three-button layout could do has gone missing — hiding to the edge just moved
into the panel's ⋯ menu.
Run: python tools/check_help_dock.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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication, QWidget
app = QApplication([])
_load_fonts()
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from cowork_local.i18n import set_language, tr
from cowork_local.state import AppContext
from cowork_local.ui.help_agent_widget import HelpAgentWidget
set_language("vi")
host = QWidget()
host.resize(1200, 800)
dock = HelpAgentWidget(AppContext(AppConfig.load()), host, user_name="local")
app.processEvents()
fails: list[str] = []
OLD_W, OLD_H = 84, 64 # 64px badge + 2px gap + 18px chevron
closed = dock.size()
print(f"dong : {closed.width()}x{closed.height()}px "
f"(cu {OLD_W}x{OLD_H})")
area_new, area_old = closed.width() * closed.height(), OLD_W * OLD_H
print(f"dien tich : {area_new} vs {area_old}px2 "
f"({100 - round(area_new / area_old * 100)}% nho hon)")
if closed.width() > 30 or closed.height() > 30:
fails.append(f"nut dong van {closed.width()}x{closed.height()}, cho <=30")
# 26px clears the ~24px comfortable-tap floor the old 18px chevron missed.
if min(closed.width(), closed.height()) < 24:
fails.append("vung bam nho hon 24px")
# Hover: the name appears, and only then.
print(f"chu luc dong : {dock.launcher.text()!r} (phai rong)")
if dock.launcher.text().strip():
fails.append("nut dong ma van hien chu")
dock.launcher._set_open(True)
app.processEvents()
hovered = dock.size()
print(f"re chuot : {hovered.width()}x{hovered.height()}px · "
f"chu = {dock.launcher.text().strip()!r}")
if tr("help_agent.badge") not in dock.launcher.text():
fails.append("re chuot khong hien 'AI Assistant'")
if hovered.width() <= closed.width():
fails.append("re chuot ma nut khong no ra")
dock.launcher._set_open(False)
app.processEvents()
if dock.size().width() != closed.width():
fails.append("roi chuot ma nut khong thu lai")
# Every state still reachable, and the corner anchor still holds.
for state, call in (("panel", dock._expand), ("launcher", dock._collapse),
("hidden", dock._hide_to_edge), ("launcher", dock._show_launcher)):
call()
app.processEvents()
got = dock._state
inside = (dock.x() + dock.width() <= host.width()
and dock.y() + dock.height() <= host.height())
print(f"trang thai {state:9}: {got:9} {dock.width():3}x{dock.height():3} "
f"goc phai duoi = {inside}")
if got != state:
fails.append(f"khong vao duoc trang thai {state}")
if not inside:
fails.append(f"trang thai {state} tran ra ngoai cua so")
# The edge tab was 16px — below anything comfortable to hit.
dock._hide_to_edge()
app.processEvents()
print(f"tab mep : {dock.width()}px (cu 16px)")
if dock.width() < 24:
fails.append(f"tab mep {dock.width()}px, van duoi 24px")
dock._show_launcher()
# Nothing removed: "hide to the edge" is in the ⋯ menu now.
items = [a.text() for a in dock.more_btn.menu().actions()]
print(f"menu ⋯ : {items}")
for key in ("help_agent.collapse_tooltip", "help_agent.hide_tooltip"):
if tr(key) not in items:
fails.append(f"menu thieu muc {tr(key)}")
print(f"nut thu nho : {dock.min_btn.toolTip()!r}")
print(f"nut gui / o nhap: {dock.send_btn is not None} / {dock.input is not None}")
# All three languages must have the new strings.
for lang in ("vi", "en", "ja"):
set_language(lang)
dock.retranslate()
vals = [dock.launcher.toolTip(), tr("help_agent.badge"),
tr("help_agent.more_tooltip")]
print(f" {lang}: badge={vals[1]!r} more={vals[2]!r}")
if any(not v or v.startswith("help_agent.") for v in vals):
fails.append(f"thieu ban dich cho {lang}")
set_language("vi")
print()
if fails:
print("*** LOI ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA: nut tro ly gon lai, khong mat chuc nang nao")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+301
View File
@@ -0,0 +1,301 @@
"""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())
+94
View File
@@ -0,0 +1,94 @@
"""Prove the long dialogs never scroll sideways — including at large fonts.
The report that started this came from a display at 125–150% scaling, where
every label is wider than on a 100% screen. Rather than trusting one font size,
this runs each dialog at several point sizes and several widths and fails if any
horizontal scrollbar turns up, in the scroll area or in the section index.
Run: python tools/check_no_hscroll.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))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _isolate_home, _load_fonts # noqa: E402
WIDTHS = (1100, 964, 820, 700)
POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
def hscroll(dlg):
"""(scroll-area overflow, index overflow) — each True means a bar appears."""
from PySide6.QtWidgets import QListWidget, QScrollArea
sa = dlg.findChildren(QScrollArea)[0]
over_area = sa.widget().sizeHint().width() > sa.viewport().width()
idx = dlg.findChild(QListWidget, "sectionIndex")
over_idx = False
if idx is not None:
over_idx = idx.sizeHintForColumn(0) > idx.viewport().width()
return over_area, over_idx
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from cowork_local.i18n import set_language
from cowork_local.state import AppContext
from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
set_language("vi")
ctx = AppContext(AppConfig.load())
fails: list[str] = []
for pt in POINTS:
f = QFont(app.font())
f.setPointSize(pt)
app.setFont(f)
for name, make in (("Cai dat", lambda: SettingsDialog(ctx)),
("Task editor", lambda: TaskEditorDialog(ctx=ctx))):
dlg = make()
dlg.show()
row = []
for w in WIDTHS:
dlg.resize(w, 900)
for _ in range(4):
app.processEvents()
over_area, over_idx = hscroll(dlg)
row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}")
if over_area:
fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang")
if over_idx:
fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang")
print(f" {pt:>2}pt {name:12} {' '.join(row)}")
dlg.close()
print()
print("A = vung cuon tran · I = muc luc tran · . = khong tran")
print()
if fails:
print("*** LOI ***")
for x in fails:
print(" " + x)
return 1
print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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:]))
+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:]))
+157
View File
@@ -0,0 +1,157 @@
"""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:]))