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:
File diff suppressed because one or more lines are too long
+317
-22
@@ -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 & 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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
Reference in New Issue
Block a user