"""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 sys.stdout.reconfigure(encoding="utf-8", errors="replace") 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 _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 def page_proposals(): """The 'Thay đổi' bullets as they appear ON THE PAGE, per section. Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight sections are hand-written, and for those two the generator's text is NOT what the page shows. Checking against ANALYSIS reported Settings and the Task editor as matching the design when the page asked for something else (and, for the Task editor, the opposite). """ import re html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8") html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html) out: dict[str, list[str]] = {} for m in re.finditer(r'
(.*?)
', html, re.S): block = re.search(r'Thay đổi', m.group(2), re.S) if not block: continue out[m.group(1)] = [ re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip() for li in re.findall(r"
  • (.*?)
  • ", block.group(1), re.S)] return out def build(): sandbox = _isolate_home() from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) _load_fonts() _freeze_schedulers() _apply_theme(app) # measure the styled window, not a bare one 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") # The lane is only outlined while it actually holds something — seed data # may leave it empty, so drop a card in and read the style back. run_col = sched.columns.get("running") styled = "" if run_col is not None: from PySide6.QtWidgets import QListWidgetItem run_col.addItem(QListWidgetItem("probe")) sched.column_headers["running"].setStyleSheet("") sched.refresh() app.processEvents() styled = run_col.styleSheet() add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, styled or "không có viền") # --- 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") # The design's own wireframes draw the rail on every screen and a different # in-page pane per screen, so "the fixed left pane" is the rail — which now # carries the project picker and RECENTS on all of them. fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ win.nav_recents.topLevelItemCount() > 0 add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, "rail (project + RECENTS) không đổi theo màn") 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. # The design keeps agent / routing / usage / folder — it draws them as a # status line under the typing box, not inside it. So the test is that the # TYPING row holds only input + attach/send/stop, and the rest sits in its # own strip below. Demanding an empty strip would mean deleting features. composer = getattr(chat, "composer", None) bar = getattr(composer, "extra_bar", None) from PySide6.QtWidgets import QPlainTextEdit, QTextEdit typing = composer.input in_typing_row = typing.parentWidget() is composer below = bar is not None and bar.objectName() == "composerStatus" usage = getattr(chat, "_usage_total_lbl", None) usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", below and usage_in_bar, f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · " f"{bar.layout().count() if bar else 0} 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) title_lbl = getattr(folder, "path_lbl", None) add("workspace-folder", "Path bar gộp vào tiêu đề", title_lbl is not None and getattr(folder, "path_edit", None) is None, f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") # "Thin bar at the bottom" = the terminal is the last thing in the column # and starts collapsed; the AI panel is a hideable right-hand pane. # Geometry is meaningless for a page that has never been shown, so ask the # widgets what state they are in instead of how tall they currently are. term = getattr(folder, "terminal", None) lay = folder.layout() last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None collapsed = term is not None and term._body.isHidden() at_bottom = term is not None and last is term add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", collapsed and at_bottom, f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") graph = ws.tabs.widget(ws._graphrag_tab_idx) # One row = the path box and Export share a y-band. def band(w): return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) one_row = band(graph.path_edit) == band(graph._export_btn) add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") # 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 from PySide6.QtWidgets import QSpinBox as QSpinBoxT # The Overview column is not simply the first scroll area any more — the # event tables' detail panels are scroll areas too, and are built first # (e6adfd9). Pick the one that actually holds Overview's own sections. ov = None for area in mon.findChildren(QScrollArea): body = area.widget() if body is None or body.layout() is None: continue if body.layout().indexOf(mon.ov_usage_group) >= 0: ov = body break assert ov is not None, "khong tim thay cot Tong quan" 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") # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider # left as its own group — not everything merged together. from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch in_general = s._general_box.isAncestorOf(s.language_combo) and \ s._general_box.isAncestorOf(s.theme_combo) prov_apart = not s._general_box.isAncestorOf(s.provider_combo) add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", in_general and prov_apart, f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") n_switch = len(s.findChildren(ToggleSwitch)) n_seg = len(s.findChildren(SegmentedControl)) steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2] add("dialog-settings", "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper", n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1, f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") s.close() t = TaskEditorDialog(ctx=win.ctx) rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] like_settings = (t.section_list.count() == 5 and t.section_stack.count() == 5 and not hasattr(t, "step_tabs")) add("dialog-task-editor", "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", like_settings, " · ".join(rows)) t.close() # --- 27 help dock --- # The page says 26px. The user asked for it doubled — recorded here rather # than scored against a number the app no longer intends. from cowork_local.ui.help_agent_widget import _DOT add("overlay-help-panel", f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)", dock.width() == _DOT 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) # The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for # that menu to go: its two entries were "thu nhỏ", which the − button next # to it already does, and "ẩn". Recorded as a deliberate deviation rather # than quietly scored as done — the action itself moved to a right-click on # the dot and on the panel header, so nothing became unreachable. from PySide6.QtCore import Qt has_menu = hasattr(dock, "more_btn") by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải", (not has_menu) and by_right_click, "menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề") 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}") # --- coverage: is every bullet ON THE PAGE actually probed? ------------- proposals = page_proposals() probed = {} for slug, text, _v, _e in R: probed.setdefault(slug, 0) probed[slug] += 1 gaps = [] for slug, bullets in proposals.items(): n_probe = probed.get(slug, 0) if len(bullets) > n_probe: for extra in bullets[n_probe:]: gaps.append((slug, extra)) # --- 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}") if gaps: print() print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***") for slug, text in gaps: print(f" {slug}") print(f" {text[:160]}") print() print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}" f" · da co phep do : {len(R)}") 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") # This used to return 0 unconditionally — a report, not a check. Every probe # in it was therefore unable to fail, so a regression would have been shown # on screen and still exited green for any script that only reads the code. return 1 if counts.get("CHUA") else 0 if __name__ == "__main__": _rc = main() # Qt (WebEngine especially) crashes during interpreter teardown with # 0xC0000409 AFTER the work is done, which would mask the real result — # and check_probes_bite reads these exit codes to decide whether a probe # caught its mutation. Leave immediately with the verdict instead. sys.stdout.flush() sys.stderr.flush() os._exit(_rc)