Files
cowork-local/tools/check_design_parity.py
T
NamPDTandClaude Opus 5 cb68f87b35 feat(ui): close the last gaps against the audit design (31/31)
check_design_parity.py reads its checklist from the audit page's own
proposals; it now reports every one of the 31 as implemented, with no
deliberate divergences left.

  * Schedule: the Kanban/Calendar drop-list became a pair of tabs, and the
    Running lane is outlined while it holds anything — dropping a card there
    starts the task for real, so it should not look like the other six.
  * Cowork: agent / routing / usage / folder moved out of the typing box into
    their own status strip beneath it, styled as status rather than a second
    toolbar. All of them stay interactive; the design's read-only strip would
    have cost features.
  * Folder: the path is written as the screen's title instead of sitting in a
    read-only text box that looked editable and cost a row.
  * GraphRAG: the second toolbar row is gone (Export joined the first), and
    the one button that relabelled itself became Đồ thị | Tin nhắn tabs, so
    the view you are NOT in is named too.
  * Settings gained the theme picker, so language / provider / theme are all
    reachable there as well as on the rail's account row.
  * Task editor: the five group boxes are grouped into three step tabs
    (Nội dung → Lịch chạy → Liên kết). All 22 fields verified present after
    the move; only the old section index is gone, replaced by the tabs.
  * The assistant dot now clears a screen's own bottom bar (Cowork's
    composer), measured from the composer's top edge in window coordinates.

Also adds .gitattributes: without it a Windows checkout records CRLF and
every file reads as fully rewritten to a Linux CI runner.

Verification: 7 check_*.py suites green, no screen clipped at 1920/1366/1280,
and no dialog scrolls sideways at 9/11/14pt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:25:25 +09:00

293 lines
13 KiB
Python

"""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")
# 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
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")
have = [n for n in ("provider_combo", "language_combo", "theme_combo")
if getattr(s, n, None) is not None]
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings",
len(have) == 3, f"{have} (cũng có ở hàng tài khoản trên rail)")
s.close()
t = TaskEditorDialog(ctx=win.ctx)
steps = [t.step_tabs.tabText(i) for i in range(t.step_tabs.count())]
add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết",
len(steps) == 3, " · ".join(steps))
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())