diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py
index c961320..9979d26 100644
--- a/tools/check_controls_alive.py
+++ b/tools/check_controls_alive.py
@@ -67,6 +67,13 @@ MOVED = {
"ui\\help_agent_widget.py": {
"self.collapse_btn": "→ mục 'Ẩn trợ lý' trong menu ⋯ của panel",
},
+ "ui\\monitoring_tab.py": {
+ # e6adfd9 turned one Refresh in the Monitoring header into one per
+ # table (page.title_refresh_btn on Bảo mật / MCP / Hành động / Agent).
+ # Tổng quan gets none because it auto-refreshes every 3s (_REFRESH_MS);
+ # Icon has nothing to refresh.
+ "self.refresh_btn": "→ nút 'Làm mới' riêng trên từng bảng (title_refresh_btn)",
+ },
"ui\\schedule_task_tab.py": {
"self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)",
},
diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py
index bce3943..30148bd 100644
--- a/tools/check_design_parity.py
+++ b/tools/check_design_parity.py
@@ -1,381 +1,392 @@
-"""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
- 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")
- # 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)
+"""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)