Feature/fsg gamma team ui fix (#3)
CI / test (push) Canceled after 0s

## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-20 12:12:56 +00:00
co-authored by Hiep Ha Van Nam Pham Dinh Thanh lamhv7 NamPDT
parent 414eaddca3
commit 1419587401
137 changed files with 23356 additions and 3722 deletions
+102
View File
@@ -0,0 +1,102 @@
"""List every show/hide/enable/disable rule, baseline vs now.
The redesign was allowed to change the flow. It was NOT allowed to change what
is hidden or greyed out — those rules encode real preconditions, and dropping
one turns a guarded action into a broken one.
Reports rules that disappeared, appeared, or changed target between the
pre-redesign commit and HEAD.
"""
from __future__ import annotations
import re
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
BASE = "291a611"
CALL = re.compile(
r"(?P<target>[\w\.\[\]\(\)_]*?)\.?(?P<verb>setVisible|setHidden|setEnabled|"
r"setDisabled|setTabVisible|setTabEnabled|hide|show)\s*\(")
def files():
out = subprocess.run(["git", "diff", "--name-only", f"{BASE}..HEAD", "--", "*.py"],
capture_output=True, text=True, encoding="utf-8").stdout
return [f for f in out.split() if f.endswith(".py") and not f.startswith("tools/")]
def _arg(s, start):
"""Text inside the call's parentheses — the CONDITION, which matters as
much as the call being there at all."""
depth, out = 0, []
for ch in s[start:]:
if ch == "(":
depth += 1
if depth == 1:
continue
elif ch == ")":
depth -= 1
if depth == 0:
break
if depth >= 1:
out.append(ch)
return "".join(out).strip()
def rules(rev, path):
"""{(target, verb): set(conditions)} for one revision of one file."""
src = subprocess.run(["git", "show", f"{rev}:{path}"],
capture_output=True, text=True, encoding="utf-8",
errors="replace").stdout or ""
found = {}
for n, line in enumerate(src.splitlines(), 1):
s = line.strip()
if s.startswith("#") or s.startswith('"'):
continue
for m in CALL.finditer(s):
target, verb = m.group("target"), m.group("verb")
if not target or (verb in ("hide", "show") and not target):
continue
cond = _arg(s, m.end() - 1) or "-"
found.setdefault((target, verb), {}).setdefault(cond, n)
return found
def main() -> int:
gone, added, changed = [], [], []
for path in files():
old, new = rules(BASE, path), rules("HEAD", path)
for key in sorted(set(old) - set(new)):
gone.append((path, key, old[key]))
for key in sorted(set(new) - set(old)):
added.append((path, key, new[key]))
for key in sorted(set(new) & set(old)):
if set(old[key]) != set(new[key]):
changed.append((path, key, old[key], new[key]))
print(f"=== A. LUAT BI BO ({len(gone)}) ===")
for path, (target, verb), conds in gone:
for cond, line in conds.items():
print(f" {path}:{line:<5} {target}.{verb}({cond})")
print()
print(f"=== B. DIEU KIEN DOI ({len(changed)}) ===")
for path, (target, verb), oldc, newc in changed:
print(f" {path} {target}.{verb}()")
for c in sorted(set(oldc) - set(newc)):
print(f" cu : ({c})")
for c in sorted(set(newc) - set(oldc)):
print(f" moi : ({c}) dong {newc[c]}")
print()
print(f"=== C. LUAT MOI THEM ({len(added)}) ===")
for path, (target, verb), conds in added:
for cond, line in conds.items():
print(f" {path}:{line:<5} {target}.{verb}({cond})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page.
Run: python tools/capture_screens.py
Two safety measures, both mandatory — this script drives the REAL application:
1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a
module-level constant resolved at import time. We copy that folder to a temp
directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing
``cowork_local``, so every write the app makes lands in the copy. The user's
real data is never opened for writing.
2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and
``RoutingScheduler``, which would *execute the user's scheduled tasks* — real
agent turns writing real files. Both ``start`` methods are patched to no-ops
before the window is built.
We also construct ``MainWindow`` directly rather than calling ``app.run()``:
``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``.
Screens that fail to render (QtWebEngine generally cannot initialise offscreen)
are recorded in the manifest with their error. They are never silently skipped —
the audit page renders an explicit "could not capture" placeholder for them.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import traceback
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent # …/cowork_local
OUT_DIR = REPO / "docs" / "screens"
THEMES = ("dark", "light")
def _isolate_home() -> Path:
"""Copy the real config dir into a temp HOME and repoint the env at it."""
real = Path.home() / ".cowork_local"
sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-"))
if real.exists():
shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True)
else:
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
for var in ("USERPROFILE", "HOME"):
os.environ[var] = str(sandbox)
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
return sandbox
def _load_fonts() -> int:
"""Register system fonts with the offscreen platform.
The offscreen plugin ships with NO font database (``QFontDatabase.families()``
returns an empty list), so every glyph renders as a tofu box — unusable when
the screenshots are the deliverable. Loading the real Windows faces fixes
both Latin and Vietnamese diacritics, and Consolas covers the code views.
"""
from PySide6.QtGui import QFontDatabase
wanted = [
"SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf",
"seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf",
]
root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts"
loaded = 0
for name in wanted:
path = root / name
if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1:
loaded += 1
return loaded
def _apply_theme(app, name: str | None = None) -> str:
"""Load the app's real stylesheet onto `app`.
`MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a
checker that builds the window directly measures a window with no padding,
no margins and no borders. Every QSS-driven layout bug is invisible there.
"""
from cowork_local import theme
from cowork_local.config import AppConfig
name = name or AppConfig.load().theme
theme.set_active_theme(name)
app.setStyleSheet(theme.stylesheet(name))
return name
def _freeze_schedulers() -> None:
"""No-op the background engines so nothing is executed while we capture."""
from cowork_local.core.task_scheduler import TaskScheduler
TaskScheduler.start = lambda self: None # type: ignore[assignment]
try:
from cowork_local.core.routing.scheduler import RoutingScheduler
RoutingScheduler.start = lambda self: None # type: ignore[assignment]
except Exception:
pass
def main() -> int:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sandbox = _isolate_home()
sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data
OUT_DIR.mkdir(parents=True, exist_ok=True)
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
app = QApplication([])
n_fonts = _load_fonts()
print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform")
if not n_fonts:
print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes")
_freeze_schedulers()
import cowork_local.theme as theme
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.i18n import set_language, tr
from cowork_local.state import AppContext
assert str(sandbox) in str(CONFIG_DIR), (
f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}")
print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}")
# Fill the sandbox with demo data so the screenshots show a working app.
# Safe by construction: seed() re-asserts it is inside a capture sandbox.
from seed_demo_data import seed
counts = seed()
print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items()))
from cowork_local.app import MainWindow
cfg = AppConfig.load()
set_language("vi")
ctx = AppContext(cfg)
manifest: list[dict] = []
# Label of the nav row selected right now, recorded into every shot so the
# "is the rail pointing at the right thing?" question is machine-checked
# instead of eyeballed across 54 images.
nav_state = {"label": "", "expected": ""}
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
"""Navigate the way a user does, and record where the rail ends up.
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)
win._goto(page, sub)
app.processEvents()
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"]
def shot(widget, slug: str, title: str, note: str = "") -> None:
"""Grab `widget` for the active theme; record success or the error."""
rec = {"slug": slug, "title": title, "theme": theme.current_theme(),
"note": note, "file": "", "error": "",
"nav": nav_state["label"], "nav_expected": nav_state["expected"]}
try:
app.processEvents()
app.processEvents()
pm = widget.grab()
if pm.isNull() or pm.width() < 2:
raise RuntimeError("grab() returned an empty pixmap")
name = f"{slug}-{theme.current_theme()}.png"
pm.save(str(OUT_DIR / name))
rec["file"] = f"screens/{name}"
print(f" ok {name} ({pm.width()}x{pm.height()})")
except Exception as exc: # noqa: BLE001
rec["error"] = f"{type(exc).__name__}: {exc}"
print(f" FAIL {slug}: {rec['error']}")
manifest.append(rec)
for th in THEMES:
print(f"\n=== theme: {th} ===")
ctx.config.theme = th
theme.set_active_theme(th)
app.setStyleSheet(theme.stylesheet(th))
win = MainWindow(ctx, user_name="local")
win.resize(1600, 1000)
win.show()
app.processEvents()
# ---- main screens, driven through the app's own navigation API ------
ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3
nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard"))
shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35")
nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule"))
sched = win._page_widgets[ROW_SCHED]
shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70")
try: # combo index 1 == Calendar view
sched.view_combo.setCurrentIndex(1)
app.processEvents()
shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88")
sched.view_combo.setCurrentIndex(0)
except Exception as exc: # noqa: BLE001
manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar",
"theme": th, "note": "ui/calendar_view.py:88", "file": "",
"error": f"{type(exc).__name__}: {exc}"})
print(f" FAIL schedule-calendar: {exc}")
# Workspace: capture with no project selected, then with one selected so
# the project-gated sub-tabs (Cowork, GraphRAG) actually exist.
nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace"))
ws = win.workspace
shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188")
try:
if ws.project_list.count():
ws.project_list.setCurrentRow(0)
app.processEvents()
except Exception: # noqa: BLE001
pass
for attr, slug, title, note in (
("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"),
("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"),
("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"),
("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"),
):
idx = getattr(ws, attr, None)
if idx is None:
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
"file": "", "error": "sub-tab index not present"})
continue
nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx))
shot(win, slug, title, note)
# Monitoring: enumerate its sub-tabs from the app itself.
nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring"))
mon = win._page_widgets[ROW_MON]
try:
subs = mon.nav_subtabs()
except Exception as exc: # noqa: BLE001
subs = []
print(f" FAIL monitoring subtabs: {exc}")
for label, sub, _icon in subs:
nav_to(win, ROW_MON, sub, expect=label)
slug = "monitoring-" + "".join(
c.lower() if c.isalnum() else "-" for c in label).strip("-")
shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132")
# Dialogs/overlays below are not nav destinations.
nav_state["label"] = nav_state["expected"] = ""
# ---- dialogs: built directly and shown (never exec(), it blocks) -----
for slug, title, note, build in _dialog_specs(ctx, win):
try:
dlg = build()
dlg.show()
app.processEvents()
shot(dlg, slug, title, note)
dlg.close()
except Exception as exc: # noqa: BLE001
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
"file": "", "error": f"{type(exc).__name__}: {exc}"})
print(f" FAIL {slug}: {type(exc).__name__}: {exc}")
# ---- overlays --------------------------------------------------------
try:
help_dock = win.help_agent
help_dock._expand()
app.processEvents()
shot(help_dock, "overlay-help-panel", "Help dock — expanded panel",
"ui/help_agent_widget.py:79")
except Exception as exc: # noqa: BLE001
print(f" FAIL overlay-help-panel: {exc}")
win.close()
(OUT_DIR / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
bad_nav = [r for r in manifest
if r["nav_expected"] and r["nav"] != r["nav_expected"]]
checked = sum(1 for r in manifest if r["nav_expected"])
print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}")
for r in bad_nav:
print(f" MISMATCH {r['slug']} [{r['theme']}]: "
f"rail says '{r['nav']}', screen is '{r['nav_expected']}'")
ok = sum(1 for r in manifest if r["file"])
bad = [r for r in manifest if not r["file"]]
print(f"\ncaptured {ok}/{len(manifest)}")
if bad:
print("could NOT capture (recorded in manifest, shown as placeholders):")
for r in bad:
print(f" - {r['slug']} [{r['theme']}]: {r['error']}")
print(f"sandbox (safe to delete): {sandbox}")
return 0
def _dialog_specs(ctx, win):
"""(slug, title, note, factory) for each dialog we can build headlessly.
Signatures differ per dialog (some take ctx first, some take parent first,
some require a real model object) — each factory below matches the actual
``__init__`` it calls, not a guessed one.
"""
# NOTE: two different classes share the name `CustomAgent` —
# core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the
# co4e one (it has `.role`); importing the other raises AttributeError.
from cowork_local.core.co4e import CustomAgent
from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog
from cowork_local.ui.file_edit_dialog import FileEditDialog
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog
from cowork_local.ui.permission_dialog import PermissionDialog
from cowork_local.ui.agents_admin_tab import AgentEditDialog
from cowork_local.ui.login_dialog import LoginDialog
return [
# SettingsDialog(ctx, parent)
("dialog-settings", "Settings", "ui/settings_dialog.py:26",
lambda: SettingsDialog(ctx, win)),
# TaskEditorDialog(task, all_tasks, parent, ctx)
("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55",
lambda: TaskEditorDialog(None, [], win, ctx)),
# SkillsDialog(parent, ctx)
("dialog-skills", "Skills manager", "ui/skills_dialog.py:108",
lambda: SkillsDialog(win, ctx)),
# SkillEditDialog(parent, skill, ctx)
("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23",
lambda: SkillEditDialog(win, None, ctx)),
("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50",
lambda: FileEditDialog(ctx, "", win)),
# Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real
("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23",
lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)),
# ExtConnectorEditDialog(parent, category, connector)
("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23",
lambda: ExtConnectorEditDialog(win, "other", None)),
# PermissionDialog(action, parent) — `preview` is a dict, not a string
("dialog-permission", "Permission request", "ui/permission_dialog.py:13",
lambda: PermissionDialog(
{"name": "run_command",
"preview": {"title": "Run command", "kind": "command",
"text": "npm install --save-dev vitest"}}, win)),
# AgentEditDialog(parent, ctx, agent, default_model_hint)
("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35",
lambda: AgentEditDialog(win, ctx, None, "")),
("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57",
lambda: LoginDialog(ctx, win)),
]
if __name__ == "__main__":
raise SystemExit(main())
+204
View File
@@ -0,0 +1,204 @@
"""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
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, _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()
_apply_theme(app) # measure the styled widget, 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.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__":
_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)
+94
View File
@@ -0,0 +1,94 @@
"""A drop-list must have room for its text and for the tick beside it.
macOS marks the current row with a checkmark; Windows does not. The language
combo is only as wide as "VN", and the popup inherits that width, so on macOS
the tick landed on top of the two letters. Reported from a Mac, so this checks
the property that made it possible rather than the platform.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication, QStyle
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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")
win.resize(1400, 900)
win.show()
app.processEvents()
fails = []
from PySide6.QtWidgets import QStyledItemDelegate
for name in ("language_combo", "nav_project", "provider_combo"):
combo = getattr(win, name, None)
if combo is None or not combo.count():
continue
view = combo.view()
fm = view.fontMetrics()
longest = max(fm.horizontalAdvance(combo.itemText(i))
for i in range(combo.count()))
tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo)
need = longest + tick
have = max(view.minimumWidth(), combo.width())
print(f"{name:15}: chu dai nhat {longest:>4}px + dau tick {tick:>3}px "
f"= can {need:>4}px | popup rong {have:>4}px")
if have < need:
fails.append(f"{name}: popup {have}px, khong du {need}px cho chu + dau tick")
# No tick: the row is already tinted, and the menu-style delegate that
# draws one on macOS covered the two letters it was marking.
deleg = combo.itemDelegate()
plain = type(deleg) is QStyledItemDelegate
print(f"{'':15} delegate={type(deleg).__name__} (khong ve dau tick={plain})")
if not plain:
fails.append(f"{name}: dung delegate kieu menu — macOS se ve dau tick")
# ...and the current row must still be obvious without one.
view = combo.view()
sheet = app.styleSheet()
if "selection-background-color" not in sheet:
fails.append("popup khong to mau muc dang chon")
print()
for f in fails:
print("FAIL " + f)
print("PASS moi drop-list du cho chu va dau tick" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+219
View File
@@ -0,0 +1,219 @@
"""Round 3: is every control in the inventory still in the built app?
docs/screens/controls.json was extracted from the source by AST before the
redesign started. Rounds 1 and 2 ask whether the new shape is right; this one
asks the opposite question — whether rearranging dropped anything.
A control counts as alive if the attribute still exists on its screen's widget
AND is a real QWidget. Ones that were deliberately moved or replaced are listed
in MOVED with where they went, so an intentional change reads differently from
an accidental loss.
Run: python tools/check_controls_alive.py
"""
from __future__ import annotations
import json
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
# controls.json lists every control in a FILE, and several files hold more than
# one class (schedule_task_tab.py alone has the tab plus three dialogs). Only
# the screen's own class lives on the widget we can inspect, so each control is
# attributed to its class first, using the file as it was when the inventory
# was taken — that is the baseline commit, not today's line numbers.
BASELINE = "291a611"
def class_ranges(path: str) -> list[tuple[str, int, int]]:
"""(class name, first line, last line) from the file at BASELINE."""
import ast
import subprocess
try:
src = subprocess.run(["git", "show", f"{BASELINE}:{path}"],
cwd=REPO, capture_output=True, text=True,
encoding="utf-8", check=True).stdout
except Exception: # noqa: BLE001
return []
try:
tree = ast.parse(src)
except SyntaxError:
return []
return [(n.name, n.lineno, max(getattr(x, "lineno", n.lineno)
for x in ast.walk(n)))
for n in tree.body if isinstance(n, ast.ClassDef)]
def owning_class(ranges, line: int) -> str:
for name, start, end in ranges:
if start <= line <= end:
return name
return ""
# Controls that are gone ON PURPOSE, with what replaced them. Anything missing
# and NOT listed here is a regression.
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)",
},
"ui\\folder_tab.py": {
"self.path_edit": "→ tiêu đề màn (path_lbl)",
},
"ui\\structure_graph_view.py": {
"self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)",
},
"app.py": {
"self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)",
},
}
# The class whose controls each owner widget actually holds.
MAIN_CLASS = {
"app.py": "MainWindow",
"ui\\workspace_tab.py": "WorkspaceTab",
"ui\\cowork_tab.py": "CoworkTab",
"ui\\chat_panel.py": "ChatPanel",
"ui\\composer.py": "Composer",
"ui\\sidebar.py": "HistorySidebar",
"ui\\co4e_tab.py": "Co4ETab",
"ui\\folder_tab.py": "FolderTab",
"ui\\structure_graph_view.py": "StructureGraphView",
"ui\\schedule_task_tab.py": "ScheduleTaskTab",
"ui\\dashboard_tab.py": "DashboardTab",
"ui\\monitoring_tab.py": "MonitoringTab",
"ui\\help_agent_widget.py": "HelpAgentWidget",
}
# Which built widget owns each source file's controls.
def owners(win):
ws = win.workspace
import cowork_local.ui.co4e_tab as co4e_mod
return {
"app.py": win,
"ui\\workspace_tab.py": ws,
"ui\\cowork_tab.py": ws._cowork,
"ui\\chat_panel.py": ws._cowork,
"ui\\composer.py": ws._cowork.composer,
"ui\\sidebar.py": win.sidebar,
"ui\\co4e_tab.py": win.findChildren(co4e_mod.Co4ETab)[0],
"ui\\folder_tab.py": ws.tabs.widget(ws._folder_tab_idx),
"ui\\structure_graph_view.py": ws.tabs.widget(ws._graphrag_tab_idx),
"ui\\schedule_task_tab.py": win._page_widgets[win._ROW_SCHEDULE],
"ui\\dashboard_tab.py": win._page_widgets[win._ROW_DASHBOARD],
"ui\\monitoring_tab.py": win._page_widgets[win._ROW_MONITORING],
"ui\\help_agent_widget.py": win.help_agent,
}
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = 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, 950)
win.show()
# Build every lazy page before looking for its controls.
for row in (win._ROW_DASHBOARD, win._ROW_SCHEDULE, win._ROW_MONITORING):
win._goto(row, None)
for _ in range(6):
app.processEvents()
for sub in range(win.workspace.tabs.count()):
win._goto(win._ROW_WORKSPACE, sub)
for _ in range(6):
app.processEvents()
index = json.loads((REPO / "docs" / "screens" / "controls.json")
.read_text(encoding="utf-8"))
own = owners(win)
alive = dead = moved = skipped = other_class = 0
losses: list[tuple[str, str, str]] = []
for rec in index:
holder = own.get(rec["file"])
if holder is None:
skipped += len(rec["controls"])
continue
ranges = class_ranges(rec["file"].replace("\\", "/"))
want = MAIN_CLASS.get(rec["file"], "")
for c in rec["controls"]:
var = c["var"]
if not var.startswith("self."):
skipped += 1
continue
# Belongs to a dialog defined in the same file → not on this widget.
if ranges and want and owning_class(ranges, c["line"]) != want:
other_class += 1
continue
name = var.split(".", 1)[1]
if getattr(holder, name, None) is not None:
alive += 1
elif var in MOVED.get(rec["file"], {}):
moved += 1
else:
dead += 1
losses.append((rec["file"], var,
c.get("label_vi") or c.get("label") or "?"))
print(f"control con song : {alive}")
print(f"co y doi cho : {moved}")
for f, v, w in [(f, v, MOVED[f][v]) for f in MOVED for v in MOVED[f]]:
print(f" {v:26} {w}")
print(f"thuoc class khac : {other_class} (hop thoai dinh nghia cung file)")
print(f"khong kiem duoc : {skipped} (dialog dung rieng, bien cuc bo)")
print(f"MAT : {dead}")
for f, var, label in losses:
print(f" ! {f}: {var} ({label})")
print()
if dead:
print("*** VONG 3 THAT BAI: co control bien mat ***")
return 1
print("KET QUA VONG 3: khong control nao bien mat ngoai y muon")
return 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)
+177
View File
@@ -0,0 +1,177 @@
"""Workspace ▸ Cowork against its wireframe (section 5).
The drawing heads the screen with the THREAD's title — "Gom số liệu doanh thu",
not the word "Cowork" — with the model beside it, Skills and Cuộc trò chuyện mới
on the right, and a caps TỆP ĐẦU RA (n) panel down the side.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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.core.history import list_conversations, load_conversation
from cowork_local.i18n import set_language, tr
from cowork_local.state import AppContext
set_language("vi")
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
win.resize(1920, 1000)
win.show()
app.processEvents()
win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx)
app.processEvents()
c = win.cowork
fails = []
# a new thread has no title yet, so the screen name stands in
print(f"chua mo thread: tieu de={c._title_lbl.text()!r}")
if not c._title_lbl.text().strip():
fails.append("tieu de trong khi chua mo thread")
convs = list_conversations()
if not convs:
fails.append("khong co hoi thoai de kiem")
else:
conv = load_conversation(Path(convs[0]["path"]))
c.load_conversation(conv)
app.processEvents()
want = conv.get("title", "")
print(f"sau khi mo thread: tieu de={c._title_lbl.text()!r} (thread={want!r})")
if want and c._title_lbl.text() != want:
fails.append(f"tieu de khong theo thread: {c._title_lbl.text()!r} != {want!r}")
if c._title_lbl.text() == tr("cowork.title") and want:
fails.append("tieu de van la ten man hinh")
# the files panel is a caps section carrying its own count
head = c.output_section.header.text()
print(f"pane tep dau ra: {head!r}")
body = head.lstrip("▾▸ ").split(" (")[0]
if body != body.upper():
fails.append(f"tieu de pane chua viet hoa: {head!r}")
if "(" not in head:
fails.append("tieu de pane khong kem so luong")
# toolbar keeps both actions the drawing shows
for name, btn in (("Skills", c.skills_btn), ("chat moi", c._new_btn)):
if not btn.isVisible():
fails.append(f"thieu nut {name} tren thanh cong cu")
print(f"nut tren thanh cong cu: {c.skills_btn.text()!r}, {c._new_btn.text()!r}")
# the status strip, read left to right, is
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
from PySide6.QtCore import QPoint as _P
bar = c._usage_total_lbl.parentWidget()
def _x(widget):
return widget.mapTo(bar, _P(0, 0)).x()
usage_text = c._usage_total_lbl.text()
print(f"usage tren dai: {usage_text!r}")
if not usage_text.strip():
fails.append("dai duoi khong hien token/chi phi cua thread da mo")
order = [("Agent", _x(c._agent_lbl)), ("Dinh tuyen", _x(c.routing_toggle)),
("usage", _x(c._usage_total_lbl))]
folder = getattr(c, "folder_lbl", None)
if folder is not None:
order.append(("thu muc", _x(folder)))
print("thu tu: " + " < ".join(f"{n}({x})" for n, x in order))
for (n1, x1), (n2, x2) in zip(order, order[1:]):
if x1 >= x2:
fails.append(f"dai duoi sai thu tu: {n1} khong dung truoc {n2}")
# the drawing gives Cowork two columns; History lives in the rail's RECENTS
# and its pane arrives folded to the strip the drawing keeps
w = win.workspace
sb = w._sidebar
print(f"vao Cowork: History hien={sb.isVisible()}")
if sb.isVisible():
fails.append("pane Lich su van o tren man Cowork — ban ve chi co 2 cot")
w.show_history_pane()
app.processEvents()
print(f"sau 'Tat ca project…': History hien={sb.isVisible()} rong={sb.width()}px")
if not sb.isVisible():
fails.append("'Tat ca project…' khong mo duoc pane Lich su")
w._on_history_fold(True)
app.processEvents()
# one heading on the files panel, with the chevron at its right — the
# drawing has "TỆP ĐẦU RA (3) ›" and nothing above it
from PySide6.QtCore import QPoint as _QP
hdr_w = c.output_section.header
chev = c._io_collapse_btn
same_row = abs(hdr_w.mapTo(c, _QP(0, 0)).y() - chev.mapTo(c, _QP(0, 0)).y()) <= 8
chev_right = chev.mapTo(c, _QP(0, 0)).x() > hdr_w.mapTo(c, _QP(0, 0)).x()
# Count what the panel actually shows as headings. Asking whether the old
# label is visible proved nothing: unparented, it reports invisible whether
# or not the code hides it.
from PySide6.QtWidgets import QLabel
heads = [l.text() for l in c._io_widget.findChildren(QLabel)
if l.isVisible() and l.text().strip()]
print(f"tieu de hien trong pane: {heads} | "
f"chevron cung hang={same_row} ben phai={chev_right}")
if heads:
fails.append(f"pane co tieu de thua ngoai '{hdr_w.text()}': {heads}")
if not (same_row and chev_right):
fails.append("chevron thu gon khong nam cuoi hang tieu de pane")
# the composer spans the screen, under BOTH columns — inside the chat
# column it stopped at the files panel's edge and shrank when files arrived
from PySide6.QtCore import QPoint
c._set_io_collapsed(False)
app.processEvents()
split = c.center_split
files_pane = split.widget(1)
comp_right = c.composer.mapTo(c, QPoint(c.composer.width(), 0)).x()
files_left = files_pane.mapTo(c, QPoint(0, 0)).x()
below = c.composer.mapTo(c, QPoint(0, 0)).y() > split.mapTo(c, QPoint(0, 0)).y()
spans = comp_right > files_left
print(f"o nhap: rong {c.composer.width()} / man {c.width()} | "
f"duoi splitter={below} | trai qua duoi pane tep={spans}")
if not below:
fails.append("o nhap khong nam duoi hang than")
if not spans:
fails.append("o nhap dung lai o mep pane tep, khong trai het man")
print()
for f in fails:
print("FAIL " + f)
print("PASS man Cowork khop ban ve" if not fails else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
"""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
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, _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()
_apply_theme(app) # measure the styled widget, 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.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())
+392
View File
@@ -0,0 +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'<section class="sec" id="([^"]+)">(.*?)</section>', html, re.S):
block = re.search(r'Thay đổi</div><ul class="pr">(.*?)</ul>', 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"<li>(.*?)</li>", 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)
+140
View File
@@ -0,0 +1,140 @@
"""Check the left-list + right-panel navigation in Settings and Task editor.
Both dialogs are navigated the same way, as the audit page asks: a list of the
real group boxes on the left, one panel shown at a time on the right. So the
test is that picking a row swaps the panel, that the rows match the groups, and
— since this is a rearrangement — that no input control went missing.
Run: python tools/check_dialogs.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, _isolate_home, _load_fonts # noqa: E402
# Every field each dialog must still offer after the move.
SETTINGS_FIELDS = [
"language_combo", "theme_combo", "tray_chk", "notify_chk",
"provider_combo", "prov_base", "prov_key", "prov_model",
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
"sandbox_block_network", "sec_enabled", "ai_check",
]
TASK_FIELDS = [
"title_edit", "desc_edit", "gen_desc_btn", "priority_combo", "status_combo",
"workspace_combo", "provider_combo", "model_combo", "skill_combo",
"sched_enabled", "run_at_edit", "files_list", "files_add_btn", "links_list",
"links_add_btn", "next_combo", "run_next_combo", "pass_output_chk",
"depends_list", "retry_spin", "timeout_spin", "approval_chk",
]
def check(name, dlg, app, expect_rows, fields):
fails = []
print(f"--- {name} ---")
idx, stack = dlg.section_list, dlg.section_stack
rows = [idx.item(i).text() for i in range(idx.count())]
print(f"muc : {rows}")
if len(rows) != expect_rows:
fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}")
if idx.count() != stack.count():
fails.append(f"{name}: {idx.count()} muc nhung {stack.count()} panel")
# Picking a row must swap the panel — and each panel must hold something.
swapped, empty = 0, []
for i in range(idx.count()):
idx.setCurrentRow(i)
for _ in range(3):
app.processEvents()
if stack.currentIndex() == i:
swapped += 1
page = stack.widget(i).widget()
if not page.findChildren(type(page)):
empty.append(rows[i])
print(f"chon muc -> doi panel : {swapped}/{idx.count()}")
if swapped != idx.count():
fails.append(f"{name}: chon muc khong doi panel")
if empty:
fails.append(f"{name}: panel rong {empty}")
missing = [f for f in fields if getattr(dlg, f, None) is None]
print(f"field con nguyen : {len(fields) - len(missing)}/{len(fields)}")
if missing:
fails.append(f"{name}: mat field {missing}")
return fails
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_apply_theme(app) # measure the styled widget, 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 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, 640)
s.show()
app.processEvents()
fails += check("Cai dat", s, app, 5, SETTINGS_FIELDS)
t = TaskEditorDialog(ctx=ctx)
t.resize(900, 640)
t.show()
app.processEvents()
fails += check("Task editor", t, app, 5, TASK_FIELDS)
# Both dialogs must be navigated the SAME way — that is the stated point.
same = (type(s.section_list) is type(t.section_list)
and type(s.section_stack) is type(t.section_stack))
print()
print(f"hai hop thoai cung kieu dieu huong: {same}")
if not same:
fails.append("hai hop thoai dieu huong khac kieu")
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}")
set_language("vi")
print()
if fails:
print("*** LOI ***")
for x in fails:
print(" " + x)
return 1
print("KET QUA: hai hop thoai dung danh sach trai + panel phai, khong mat field")
return 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)
+94
View File
@@ -0,0 +1,94 @@
"""The assistant dot sits in the bottom-right corner — unless the composer is
genuinely underneath it.
It used to lift on Cowork whenever a composer existed, measured only on the
vertical axis. On a wide window the composer stops at the chat column's right
edge, far short of the dot, so the dot rose 156px for nothing and Cowork was
the one screen where it was not in the corner.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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")
win.show()
dock = win.help_agent
fails = []
def gap_and_overlap(width, height):
win.resize(width, height)
app.processEvents()
win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx)
app.processEvents()
comp = win.cowork.composer
origin = comp.mapTo(win, comp.rect().topLeft())
dleft = dock.x() - win.mapToGlobal(win.rect().topLeft()).x()
overlaps = (dleft + dock.width() > origin.x()
and dleft < origin.x() + comp.width())
gap = win.height() - (dock.y() + dock.height())
return gap, overlaps, origin.x() + comp.width(), dleft
# baseline: every other screen
win.resize(1936, 1048)
app.processEvents()
win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx)
app.processEvents()
corner = win.height() - (dock.y() + dock.height())
print(f"man thuong : cach day {corner}px")
for w, h in ((1936, 1048), (1200, 800), (900, 700)):
gap, over, comp_right, dleft = gap_and_overlap(w, h)
print(f"Cowork {w}x{h:<5}: cach day {gap:>4}px | composer het o x={comp_right} "
f"| cham o x={dleft} | chong nhau={over}")
if over and gap <= corner:
fails.append(f"{w}x{h}: composer nam duoi cham ma cham khong duoc nang")
if not over and gap != corner:
fails.append(f"{w}x{h}: khong chong nhau ma cham van lech "
f"({gap}px thay vi {corner}px)")
print()
for f in fails:
print("FAIL " + f)
print("PASS cham o goc, chi nang khi that su bi che" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+127
View File
@@ -0,0 +1,127 @@
"""GraphRAG rebuilds when it is opened, not every time a project is picked.
Switching project used to rescan the folder, redo the force layout and setHtml
the whole D3 page immediately — for a tab usually not on screen. With the rail's
picker one click away from anywhere, that fired constantly, and the rebuild you
did see on opening GraphRAG read as the page reloading itself.
"""
from __future__ import annotations
import os
import sys
import time
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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")
win.resize(1600, 950)
win.show()
app.processEvents()
st, w = win.structure, win.workspace
fails: list[str] = []
# startup itself must not build any of it
if st.web is not None or st._graph is not None:
fails.append("dung san do thi ngay luc khoi dong — startup phai nhe")
# the idle warm-up builds the browser view and the first graph off the
# click path (MainWindow fires this on a 3s timer; call it directly here)
win._prewarm_graph()
deadline = time.monotonic() + 30
while st._graph is None and time.monotonic() < deadline:
app.processEvents()
time.sleep(0.02)
if st._graph is None:
print("FAIL lam nong khong dung duoc do thi")
sys.stdout.flush()
os._exit(1)
print(f"sau khi lam nong: web={'co' if st.web else 'chua'} do thi={'co' if st._graph else 'chua'}")
calls = {"scan": 0, "html": 0}
real_scan, real_html = st._scan, st._render_d3
st._scan = lambda: (calls.__setitem__("scan", calls["scan"] + 1), real_scan())[1]
st._render_d3 = lambda: (calls.__setitem__("html", calls["html"] + 1), real_html())[1]
# ...so the click itself does nothing but show it — this is the flash
t0 = time.monotonic()
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
app.processEvents()
print(f"bam vao GraphRAG: {(time.monotonic() - t0) * 1000:.0f}ms "
f"_scan={calls['scan']} setHtml={calls['html']}")
if calls["scan"] or calls["html"]:
fails.append("bam vao GraphRAG van phai quet/nap lai trang — con chop trang")
# re-entering an unchanged graph must not rebuild anything
for _ in range(3):
win._goto(win._ROW_WORKSPACE, w._project_tab_idx)
app.processEvents()
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
app.processEvents()
print(f"vao lai 3 lan (khong doi gi): _scan={calls['scan']} setHtml={calls['html']}")
if calls["scan"] or calls["html"]:
fails.append("vao lai GraphRAG van quet/reload du khong co gi doi")
# switching project off-screen defers the work
win._goto(win._ROW_WORKSPACE, w._cowork_tab_idx)
app.processEvents()
ids = [pid for _n, pid in w.project_choices()]
for pid in ids[1:3]:
w.choose_project(pid)
app.processEvents()
print(f"doi {len(ids[1:3])} project khi dang o Cowork: _scan={calls['scan']} "
f"setHtml={calls['html']} | can quet lan toi={st._needs_scan}")
if calls["scan"]:
fails.append("doi project van quet ngay du GraphRAG khong tren man")
if len(ids) > 1 and not st._needs_scan:
fails.append("doi project ma khong danh dau can quet lai")
# ...and opening it does the work once
win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx)
app.processEvents()
print(f"mo GraphRAG: _scan={calls['scan']}")
if len(ids) > 1 and calls["scan"] != 1:
fails.append(f"mo GraphRAG phai quet dung 1 lan, dang {calls['scan']}")
print()
for f in fails:
print("FAIL " + f)
print("PASS GraphRAG chi dung lai do thi khi can" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
"""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
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, _isolate_home, _load_fonts # noqa: E402
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication, QWidget
app = QApplication([])
_load_fonts()
_apply_theme(app) # measure the styled widget, 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 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)")
# The page draws 26px; the user asked for double. Read the size the module
# declares rather than a number written here, so the two cannot drift, and
# keep the two things that actually matter: it is a square chip, and it is
# still far smaller than the button it replaced.
if closed.width() != closed.height():
fails.append(f"nut dong khong vuong: {closed.width()}x{closed.height()}")
if area_new >= area_old:
fails.append(f"khong con nho hon nut cu: {area_new} vs {area_old}px2")
# 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()
# The ⋯ menu is gone (user's call — its two entries were "collapse", which
# the − button beside it already did, and "hide"). Nothing was lost with it:
# collapse is the − button and the dot, hide is a right-click on either the
# dot or the open panel's header. Check the routes, not the menu.
from PySide6.QtCore import Qt as _Qt
if hasattr(dock, "more_btn"):
fails.append("menu ⋯ van con tren panel")
dock._collapse()
app.processEvents()
if dock._state != "launcher":
fails.append("nut − khong thu nho duoc ve cham")
if dock.launcher.contextMenuPolicy() != _Qt.CustomContextMenu:
fails.append("cham khong co menu chuot phai de an")
dock._hide_to_edge()
app.processEvents()
if dock._state != "hidden":
fails.append("khong an duoc vao canh phai")
print(f"thu nho + an : ca hai duong deu chay (trang thai cuoi={dock._state!r})")
dock._show_launcher()
app.processEvents()
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()
# more_tooltip went with the ⋯ menu; dot_hint replaces it as the
# string that tells you the right-click is there.
vals = [dock.launcher.toolTip(), tr("help_agent.badge"),
tr("help_agent.dot_hint")]
print(f" {lang}: badge={vals[1]!r} goi y chuot phai={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())
+80
View File
@@ -0,0 +1,80 @@
"""The help panel must follow a language switch — transcript included.
retranslate() re-labelled the chrome but not the rendered HTML transcript, so
the greeting and the "AI Assistant" speaker label stayed in the language the
panel happened to be built in.
"""
from __future__ import annotations
import os
import re
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
sys.stdout.reconfigure(encoding="utf-8") # ja/vi text on a cp932 console
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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")
win.resize(1400, 900)
win.show()
app.processEvents()
panel = win.help_agent
fails = []
for lang in ("en", "ja", "vi"):
win._set_language(lang) if hasattr(win, "_set_language") else set_language(lang)
if not hasattr(win, "_set_language"):
panel.retranslate()
app.processEvents()
# The panel greets by the signed-in name, not the placeholder.
want = panel._greeting()
shown = re.sub("<[^>]+>", " ", panel.log.toHtml())
shown = " ".join(shown.split())
# compare on the stable half of the sentence, the name is substituted
probe = " ".join(want.split())[:28]
state = "ok" if probe and probe in shown else "MISSING"
print(f"{lang}: title={panel.title.text()!r} greeting={state}")
if state != "ok":
print(f" muon: {probe!r}")
print(f" thay: {shown[:160]!r}")
if state != "ok":
fails.append(f"{lang}: transcript still shows another language")
print()
print("PASS panel follows the language" if not fails
else "\n".join(f"FAIL {f}" for f in fails))
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
"""The Icon library screen against its wireframe.
The drawing (section 16) puts the three actions on the title row, a magnifier
in the search box, caps section headings, and — its stated complaint — a
visible edge on an icon cell when you hover or select it, so you can tell what
you are about to pick.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtCore import QPoint
from PySide6.QtWidgets import QApplication, QLineEdit
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
from cowork_local.config import CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
from cowork_local.config import AppConfig
from cowork_local.i18n import set_language
from cowork_local.state import AppContext
from cowork_local.ui.icons_admin_tab import IconsAdminTab
set_language("vi")
tab = IconsAdminTab(AppContext(AppConfig.load()))
tab.resize(1100, 700)
tab.show()
app.processEvents()
fails = []
# 1. actions on the title row, to its right — not in a strip below the
# grids. Comparing y alone was not enough: a button left out of the
# layout sits at (0,0), which is "the same row" by accident.
title = tab._title
t_pos = title.mapTo(tab, QPoint(0, 0))
t_mid = t_pos.y() + title.height() // 2
t_right = t_pos.x() + title.width()
for name, btn in (("Thêm", tab.add_btn), ("Dán", tab.paste_btn),
("Xóa", tab.del_btn)):
pos = btn.mapTo(tab, QPoint(0, 0))
mid = pos.y() + btn.height() // 2
aligned = abs(mid - t_mid) <= 6
after = pos.x() >= t_right
print(f"nut {name:5}: tam y={mid} (tieu de {t_mid}) thang hang={aligned} "
f"| x={pos.x()} (sau tieu de {t_right})={after}")
if not (aligned and after):
fails.append(f"nut {name} khong nam cung hang, ben phai tieu de")
# 2. magnifier in the search box
lead = tab.search.actions()
print(f"o tim co icon kinh lup: {bool(lead)}")
if not lead:
fails.append("o tim thieu icon kinh lup")
# 3. caps headings
for name, lbl in (("tich hop", tab._builtin_lbl), ("tuy chinh", tab._custom_lbl)):
text = lbl.text()
print(f"tieu de {name}: {text!r}")
if text != text.upper():
fails.append(f"tieu de {name} chua viet hoa: {text!r}")
# 4. the cell has an edge to see — compare the painted cell hovered vs not
grid = tab.builtin_grid
if grid.count():
rect = grid.visualItemRect(grid.item(0))
plain = grid.grab(rect).toImage()
grid.setCurrentRow(0)
app.processEvents()
picked = grid.grab(rect).toImage()
diff = sum(1 for x in range(plain.width()) for y in range(plain.height())
if plain.pixelColor(x, y) != picked.pixelColor(x, y))
print(f"o icon doi {diff} diem anh khi duoc chon")
if diff == 0:
fails.append("o icon khong doi gi khi duoc chon — khong thay minh dang chon cai nao")
else:
fails.append("luoi icon rong")
print()
for f in fails:
print("FAIL " + f)
print("PASS man Icon khop ban ve" if not fails else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+94
View File
@@ -0,0 +1,94 @@
"""Schedule Task must not scroll sideways on a screen the app supports.
Two separate causes, both reported as "some screens scroll, some don't":
· each lane is a QListWidget whose column hint runs a few px past its own
viewport, so individual lanes grew a scrollbar at most window widths;
· the seven lanes together wanted 1242px where a 1280 window leaves 1091.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
# Smallest screen the app is expected to run on, and the rail at both extremes.
SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (1936, 1048)]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QAbstractScrollArea, QApplication, QScrollArea
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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")
win.show()
app.processEvents()
fails = []
for w, h in SIZES:
# 150 is the default and 240 a realistic widening. 360 (the maximum)
# on a 1280 screen leaves 920px for seven lanes that need 1067 — that
# scroll is the user's own trade, so it is reported, not failed.
for rail in (150, 240, 360):
win.resize(w, h)
app.processEvents()
win.split.setSizes([rail, max(1, w - rail)])
win._goto(win._ROW_SCHEDULE, None)
app.processEvents()
page = [p for p in win._page_widgets
if p is not None and hasattr(p, "counts_lbl")][0]
outer = page.findChild(QScrollArea)
lanes = [s for s in win.findChildren(QAbstractScrollArea)
if s.isVisible() and s.__class__.__name__ == "_KanbanColumn"]
spill = outer.horizontalScrollBar().maximum()
lane_spill = [s.horizontalScrollBar().maximum() for s in lanes]
worst = max(lane_spill) if lane_spill else 0
print(f"{w}x{h} rail={rail:<4}: {len(lanes)} lan rong "
f"{lanes[0].width() if lanes else 0:>4} | vung ngoai thua {spill:>4}px "
f"| lan thua toi da {worst}px")
if spill and rail < 360:
fails.append(f"{w}x{h} rail={rail}: 7 lan tran {spill}px")
elif spill:
print(f" (rail keo het co: nguoi dung tu chon, thua {spill}px)")
if worst:
fails.append(f"{w}x{h} rail={rail}: mot lan tu tran {worst}px")
print()
for f in fails:
print("FAIL " + f)
print("PASS khong cuon ngang o moi co man hinh da thu" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+213
View File
@@ -0,0 +1,213 @@
"""Round 2: does the built layout have the SHAPE the wireframes draw?
Round 1 asks "does the feature exist". A screen can pass that and still be laid
out wrongly — right widgets, wrong order, wrong side, wrong proportions. This
round measures real geometry against what the audit page's wireframes depict:
reading order of the rail, section order down Monitoring, which side each pane
is on, and the size relationships the design calls out (hero card, the dot).
Run: python tools/check_layout_geometry.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
# The rail, top to bottom, as the audit page's rail() helper draws it.
RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"]
RAIL_BOTTOM = ["Dashboard", "Giám sát"]
# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost →
# what the machine is doing → what the agent may touch → per-model prices →
# what actually happened.
MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group",
"ov_pricing_group", "ov_activity_group", "ov_audit_group"]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = 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, 950)
win.show()
for _ in range(8):
app.processEvents()
ws = win.workspace
fails: list[str] = []
def top_of(w, ref):
return w.mapTo(ref, w.rect().topLeft()).y()
def left_of(w, ref):
return w.mapTo(ref, w.rect().topLeft()).x()
# --- 1. rail: reading order, and the rail is on the LEFT ---------------
rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
bottom = [win.nav_bottom.topLevelItem(i).text(0)
for i in range(win.nav_bottom.topLevelItemCount())]
print(f"thanh menu : {rows}")
print(f"nhom day : {bottom}")
if rows != RAIL_ORDER:
fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}")
if bottom != RAIL_BOTTOM:
fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}")
rail_x = left_of(win._nav_wrap, win)
content_x = left_of(win.pages, win)
print(f"rail x={rail_x} · noi dung x={content_x}")
if rail_x >= content_x:
fails.append("rail khong nam ben trai noi dung")
# --- 2. rail header order: picker ABOVE the new-chat button ------------
py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win)
ry = top_of(win.nav_recents, win)
ay = top_of(win._account_row, win)
print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}")
if not (py < by < ry < ay):
fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)")
# --- 3. Monitoring: one column, sections in the drawn order ------------
win._goto(win._ROW_MONITORING, None)
for _ in range(8):
app.processEvents()
mon = win._page_widgets[win._ROW_MONITORING]
tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)]
lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops}
print("Monitoring, tu tren xuong:")
for n, y in tops:
print(f" {n:28} y={y:5} x={lefts[n]}")
if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]:
fails.append("thu tu muc trong Monitoring khong khop ban ve")
# Sandbox and Permissions share a row; everything else is full width.
perm_y = top_of(mon.ov_permissions_group, mon)
sbx_y = top_of(mon.ov_sandbox_details_group, mon)
same_row = abs(perm_y - sbx_y) < 20
print(f"Sandbox | Quyen cung hang: {same_row}")
if not same_row:
fails.append("Sandbox va Quyen khong cung mot hang")
price_w = mon.ov_pricing_group.width()
res_w = mon.ov_resource_group.width()
print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)")
if price_w < res_w * 0.95:
fails.append("bang gia model khong chiem tron be ngang")
# --- 3b. Schedule: all seven lanes on screen, no horizontal scroll -----
win._goto(win._ROW_SCHEDULE, None)
for _ in range(8):
app.processEvents()
sched = win._page_widgets[win._ROW_SCHEDULE]
from PySide6.QtWidgets import QScrollArea
lanes = list(sched.columns.values())
# The page holds more than one scroll area — take the one the lanes live in.
board = next(sa for sa in sched.findChildren(QScrollArea)
if sa.isAncestorOf(lanes[0]))
rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes)
fits = rightmost <= board.viewport().width() + 2
print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · "
f"khung rong {board.viewport().width()} · vua mot man = {fits}")
if len(lanes) != 7:
fails.append(f"chi co {len(lanes)} lane, thiet ke la 7")
if not fits:
fails.append(f"lane thu 7 nam ngoai man ({rightmost} > "
f"{board.viewport().width()}) — phai cuon ngang")
# --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 --------
win._goto(win._ROW_DASHBOARD, None)
for _ in range(8):
app.processEvents()
dash = win._page_widgets[win._ROW_DASHBOARD]
hero, small = dash.card_cost, dash.card_total
print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · "
f"the phu x={left_of(small, dash)} cao={small.height()}")
if left_of(hero, dash) >= left_of(small, dash):
fails.append("the Chi phi khong nam ben trai cac the phu")
if hero.height() < small.height() * 1.5:
fails.append("the Chi phi khong cao gap ruoi the phu")
row1 = top_of(dash.card_total, dash)
row2 = top_of(dash.card_out, dash)
print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)")
if row2 <= row1:
fails.append("4 the phu khong xep 2x2")
# --- 5. Cowork: the dot clears the composer, and is the declared size --
win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx)
for _ in range(8):
app.processEvents()
dock = win.help_agent
comp = ws._cowork.composer
dock_bottom = top_of(dock, win) + dock.height()
comp_top = top_of(comp, win)
print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}")
# Reading _DOT and comparing against it makes this unfailable — change the
# constant and the expectation moves with it (check_probes_bite caught
# exactly that). Bound what the design actually claims instead: a square
# chip, big enough to hit, far smaller than the 84x64 button it replaced.
# 26px was drawn, 52px is what the user asked for; 64 is the ceiling past
# which "gọn" stops being true.
if not 24 <= dock.width() <= 64 or dock.width() != dock.height():
fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, "
f"cho o khoang 24..64 va phai vuong")
if dock_bottom > comp_top:
fails.append("cham tro ly de len o nhap")
if left_of(dock, win) + dock.width() > win.width():
fails.append("cham tro ly tran ra ngoai cua so")
# --- 6. Co4E: sidebar left, canvas middle, config right ---------------
win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx)
for _ in range(8):
app.processEvents()
import cowork_local.ui.co4e_tab as co4e_mod
c4 = win.findChildren(co4e_mod.Co4ETab)[0]
xs = [c4._split.widget(i).x() for i in range(c4._split.count())]
print(f"Co4E 3 pane x = {xs}")
if xs != sorted(xs):
fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)")
heads = [h.text() for h, _b, _s in c4._sections.values()]
print(f"cot sidebar: {heads}")
if len(heads) != 4:
fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4")
print()
if fails:
print("*** LECH BO CUC ***")
for f in fails:
print(" " + f)
return 1
print("KET QUA VONG 2: hinh hoc khop ban ve")
return 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)
+133
View File
@@ -0,0 +1,133 @@
"""Does the layout adapt across screen sizes AND display scalings?
Two things change between machines, and only one of them is width:
* the screen is bigger or smaller — more or fewer pixels to lay out in;
* the display scale is 100/125/150% — the SAME number of logical pixels
holds less, because every label and margin is taller.
A breakpoint written as a raw pixel number only holds on the machine it was
tuned on. This walks a grid of (window size × font scale) and, for each cell,
checks that no screen is clipped and that the panes folded when they had to.
Run: python tools/check_multi_screen.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
# Real-world panels, from a small laptop up to 4K-at-150%-effective.
SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)]
# 9pt ≈ 100%, 11pt ≈ 125%, 14pt ≈ 150% of the design baseline.
POINTS = [9, 11, 14]
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication
app = 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
from cowork_local.ui.widgets import ui_scale
set_language("vi")
fails: list[str] = []
print(f"{'co chu':>7} {'cua so':>11} {'thang do':>9} {'man bi bo':>10} panel da gap")
print("-" * 86)
for pt in POINTS:
f = QFont(app.font())
f.setPointSize(pt)
app.setFont(f)
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
win.show()
for _ in range(6):
app.processEvents()
ws = win.workspace
dests = [("Project", win._ROW_WORKSPACE, ws._project_tab_idx),
("Cowork", win._ROW_WORKSPACE, ws._cowork_tab_idx),
("Co4E", win._ROW_WORKSPACE, ws._co4e_tab_idx),
("Folder", win._ROW_WORKSPACE, ws._folder_tab_idx),
("GraphRAG", win._ROW_WORKSPACE, ws._graphrag_tab_idx),
("Schedule", win._ROW_SCHEDULE, None),
("Dashboard", win._ROW_DASHBOARD, None),
("Monitoring", win._ROW_MONITORING, None)]
for w, h in SIZES:
win.resize(w, h)
for _ in range(6):
app.processEvents()
clipped = []
for name, page, sub in dests:
win._goto(page, sub)
for _ in range(4):
app.processEvents()
widget = win._page_widgets[page]
if widget.minimumSizeHint().width() > widget.width() + 1:
clipped.append(name)
folded = []
if getattr(ws, "_is_narrow", False):
folded.append("pane Project/History")
import cowork_local.ui.co4e_tab as co4e_mod
c4 = win.findChildren(co4e_mod.Co4ETab)[0]
if c4._config_collapsed:
folded.append("panel cau hinh Co4E")
scale = ui_scale(win)
print(f"{pt:>5}pt {w:>5}x{h:<5} {scale:>8.2f} "
f"{(', '.join(clipped) or 'khong'):>10} {', '.join(folded) or '-'}")
if clipped:
fails.append(f"{pt}pt {w}x{h}: bi bo — {clipped}")
# The window must never demand more than the smallest panel we support.
need = win.minimumSizeHint().width()
if need > SIZES[0][0]:
fails.append(f"{pt}pt: cua so doi toi thieu {need}px, "
f"rong hon man nho nhat ({SIZES[0][0]}px)")
print(f"{'':>7} {'':>11} {'':>9} cua so doi toi thieu: {need}px")
win.close()
del win
for _ in range(3):
app.processEvents()
print()
if fails:
print("*** KHONG THICH UNG DUOC ***")
for x in fails:
print(" " + x)
return 1
print("KET QUA: bo cuc thich ung o moi co man hinh va muc phong chu da thu")
return 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)
+342
View File
@@ -0,0 +1,342 @@
"""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
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)) # `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 _apply_theme, _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()
_apply_theme(app) # measure the styled window, not a bare one
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}")
# Collapsing the rail must not take the project picker away with it: at
# 54px the combo cannot show a name, so a folder button stands in for it.
if not win._nav_collapsed:
win._toggle_nav()
app.processEvents()
mini = getattr(win, "nav_project_btn", None)
if mini is None or mini.isHidden():
fails.append("thu gon rail xong khong con cach nao doi project")
else:
mini.menu().aboutToShow.emit()
app.processEvents()
menu_items = [a.text() for a in mini.menu().actions()]
combo_items = [win.nav_project.itemText(i)
for i in range(win.nav_project.count())]
if menu_items != combo_items:
fails.append(f"menu project khi thu gon lech voi combo: "
f"{menu_items} vs {combo_items}")
elif len(menu_items) > 1:
before = win.workspace.selected_project_id()
# Any row but the one already selected, or nothing would change.
row = (win.nav_project.currentIndex() + 1) % len(menu_items)
mini.menu().actions()[row].trigger()
app.processEvents()
after = win.workspace.selected_project_id()
if after == before:
fails.append("chon project tu menu thu gon khong doi project")
else:
print(f"doi project khi thu gon: {before} -> {after}")
win._toggle_nav()
app.processEvents()
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__":
_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)
+156
View File
@@ -0,0 +1,156 @@
"""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
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, _isolate_home, _load_fonts # noqa: E402
WIDTHS = (1100, 964, 820, 700)
POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
def hscroll(dlg, app):
"""(scroll-area overflow, index overflow) — each True means content is
wider than the space it is given.
A dialog built from step tabs has one scroll area per page, and a page that
is not current has stale geometry — so each tab is brought to the front
before its page is measured.
"""
from PySide6.QtWidgets import QListWidget, QScrollArea
over_area = False
stack = getattr(dlg, "section_stack", None)
if stack is not None:
# One scroll area per section; a page that is not current has stale
# geometry, so bring each to the front before measuring it.
idx = dlg.section_list
keep = idx.currentRow()
for i in range(stack.count()):
idx.setCurrentRow(i)
for _ in range(3):
app.processEvents()
sa = stack.widget(i)
if sa.widget().sizeHint().width() > sa.viewport().width():
over_area = True
idx.setCurrentRow(keep)
else:
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 = _index_elides(idx)
return over_area, over_idx
def _index_elides(idx) -> bool:
"""True when a section name does not fit the visible width of the list.
Two earlier attempts got this wrong:
· `sizeHintForColumn(0) > viewport().width()` returns 182px at 9pt, 11pt
and 14pt alike — it does not track the font, so it called the 9pt
dialog broken while nothing on screen was clipped.
· Asking the delegate whether it elides. It does not: the view lays each
row out at its natural width and the viewport simply clips what runs
past it, so a list squeezed to 90px still reported "no elision".
So compare the painted text against the width that is actually on screen.
"""
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
for i in range(idx.count()):
row = idx.indexFromItem(idx.item(i))
opt = QStyleOptionViewItem()
idx.initViewItemOption(opt)
opt.rect = idx.visualRect(row)
idx.itemDelegate().initStyleOption(opt, row)
box = idx.style().subElementRect(QStyle.SE_ItemViewItemText, opt, idx)
label = idx.item(i).text()
visible = idx.viewport().width() - box.left()
if QFontMetrics(opt.font).horizontalAdvance(label) > visible:
return True
return False
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_apply_theme(app) # measure the styled widget, 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 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, app)
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__":
_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)
+114
View File
@@ -0,0 +1,114 @@
"""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
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
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:]))
+132
View File
@@ -0,0 +1,132 @@
"""Round 5: do the checks actually bite?
Rounds 1–4 all report green. That is only worth something if the checks would
have turned red had the work not been done. So this round breaks the app on
purpose, one feature at a time, and fails if the corresponding check still
passes — a check that cannot fail is not evidence.
Each mutation is applied by monkey-patching the module BEFORE the checker
builds its own window, then undone.
Run: python tools/check_probes_bite.py
"""
from __future__ import annotations
import io
import os
import runpy
import subprocess
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")
# (name, file, find, replace, checker that must FAIL because of it)
MUTATIONS = [
("phong to cham tro ly gap doi khai bao",
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
"check_layout_geometry.py"),
("tra lane Running ve khong vien",
"ui/schedule_task_tab.py",
'if status == "running" and counts[status]:',
'if False:',
"check_design_parity.py"),
("bo cot muc luc cua Cai dat",
"ui/settings_dialog.py",
"self.section_list, self.section_stack = section_panels(pages)",
"self.section_list, self.section_stack = section_panels(pages[:1])",
"check_dialogs.py"),
("noi lai dai tab flow Co4E",
"ui/co4e_tab.py",
"self.flow_scroll.setVisible(False)",
"self.flow_scroll.setVisible(True)",
"check_co4e.py"),
("bo dong 'Tat ca project...' khoi GAN DAY",
"app.py",
'more.setData(0, Qt.UserRole, {"all": True})',
'more.setData(0, Qt.UserRole, {})',
"check_design_parity.py"),
("tra thanh menu ve accordion (bo nhom day)",
"app.py",
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
"check_layout_geometry.py"),
]
def run_checker(script: str) -> int:
"""Run a checker in a fresh process; return its exit code."""
proc = subprocess.run(
[sys.executable, str(REPO / "tools" / script)],
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
"PYTHONIOENCODING": "utf-8"})
return proc.returncode
def tree_state() -> str:
return subprocess.run(["git", "status", "--short"], cwd=REPO,
capture_output=True, text=True).stdout.strip()
def main() -> int:
fails: list[str] = []
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
# progress is legitimately uncommitted, and demanding a clean tree made this
# round fail for a reason that has nothing to do with the mutations.
before = tree_state()
print(f"{'hong gi':44} {'phep do':26} ket qua")
print("-" * 88)
for name, rel, find, repl, checker in MUTATIONS:
path = REPO / rel
# newline="" both ways: the default translates on read AND write, so a
# LF file came back as CRLF and every mutated file was left "modified"
# even after being restored.
with io.open(path, "r", encoding="utf-8", newline="") as fh:
original = fh.read()
if find not in original:
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
continue
def write(text: str) -> None:
with io.open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(text)
write(original.replace(find, repl, 1))
try:
code = run_checker(checker)
finally:
write(original) # always restore
bit = code != 0
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
if not bit:
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
# Everything must be back exactly as it was before this run.
after = tree_state()
same = after == before
print()
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
if not same:
print(" truoc:", before.replace("\n", " | ") or "(sach)")
print(" sau :", after.replace("\n", " | ") or "(sach)")
fails.append("file chua duoc khoi phuc sau khi thu")
print()
if fails:
print("*** VONG 5 THAT BAI ***")
for f in fails:
print(" " + f)
return 1
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+116
View File
@@ -0,0 +1,116 @@
"""With no project chosen, the gated screens must stay shut on every route.
The redesign shows Cowork and GraphRAG at all times instead of making them
appear and disappear. That is a presentation change only — the gate is the same
`isTabVisible` state as before — so this asserts the gate still actually holds,
and holds for programmatic jumps too, not just for the greyed rail rows.
Runs against an EMPTY home, not a copy of the real one: with any project on
disk the gate is open and the test proves nothing.
"""
from __future__ import annotations
import os
import sys
import tempfile
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")
sandbox = Path(tempfile.mkdtemp(prefix="cowork-gate-"))
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
for _var in ("USERPROFILE", "HOME"):
os.environ[_var] = str(sandbox)
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
from capture_screens import _apply_theme, _freeze_schedulers, _load_fonts # noqa: E402
def main() -> int:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
from cowork_local.config import CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
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")
win.resize(1400, 900)
win.show()
app.processEvents()
fails = []
n_projects = len(win.workspace.project_choices())
print(f"project tren dia: {n_projects}")
if n_projects:
print("FAIL home khong rong — phep thu vo nghia")
sys.stdout.flush()
os._exit(1)
tree = win.nav
rows = [(i, tree.topLevelItem(i)) for i in range(tree.topLevelItemCount())]
locked = [(i, it) for i, it in rows if it.isDisabled()]
print("dong bi khoa :", [it.text(0) for _i, it in locked])
print("dong mo binh thuong:", [it.text(0) for _i, it in rows if not it.isDisabled()])
if not locked:
fails.append("khong project nao ma khong dong nao bi khoa")
for _i, it in locked:
if not it.toolTip(0):
fails.append(f"dong khoa '{it.text(0)}' khong noi ly do")
for i, it in locked:
label = it.text(0)
data = it.data(0, Qt.UserRole) or {}
before = win.workspace.current_subtab()
tree.setCurrentItem(it)
app.processEvents()
if win.workspace.current_subtab() != before:
fails.append(f"bam duoc vao '{label}' du dang khoa")
# the route a greyed row does not guard: a jump from code
win._goto(data.get("page", 0), data.get("sub"))
app.processEvents()
landed = win.workspace.current_subtab()
if landed == data.get("sub"):
fails.append(f"_goto mo duoc '{label}' trong khi cong dang dong")
print(f" '{label}': bam -> {before}, _goto -> {landed} "
f"(tab hien = {win.workspace.tabs.isTabVisible(data.get('sub'))})")
# controls that would act on a project must be off too
for name, widget in (("+ chat moi", win.nav_new_chat),
("chon project", win.nav_project_btn)):
if widget.isEnabled():
fails.append(f"'{name}' van bam duoc khi chua co project")
print(f"+ chat moi bat={win.nav_new_chat.isEnabled()} "
f"tooltip={win.nav_new_chat.toolTip()!r}")
print()
for f in fails:
print("FAIL " + f)
print("PASS cong project van giu, ca khi bam lan khi goi tu code" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+170
View File
@@ -0,0 +1,170 @@
"""Workspace ▸ Project against its wireframe (section 4).
The drawing: a "Quản lý project" title with + Project mới on its right, a caps
PROJECT heading over the list, every row carrying "N đoạn chat · M task", and
the form reading Tên / Mô tả / Instructions / Thư mục làm việc.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtCore import QPoint
from PySide6.QtWidgets import QApplication, QLabel
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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, tr
from cowork_local.state import AppContext
set_language("vi")
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
win.resize(1400, 900)
win.show()
app.processEvents()
win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx)
app.processEvents()
w = win.workspace
fails = []
# 1. title, and the create button on its row, to its right
print(f"tieu de: {w._header.text()!r}")
if w._header.text() != tr("workspace.header"):
fails.append("tieu de khong phai workspace.header")
h_pos = w._header.mapTo(w, QPoint(0, 0))
h_mid = h_pos.y() + w._header.height() // 2
b_pos = w._new_btn.mapTo(w, QPoint(0, 0))
b_mid = b_pos.y() + w._new_btn.height() // 2
aligned = abs(b_mid - h_mid) <= 8
after = b_pos.x() >= h_pos.x() + w._header.width()
print(f"nut '+ Project mới': tam y={b_mid} (tieu de {h_mid}) thang hang={aligned} "
f"| x={b_pos.x()} sau tieu de={after}")
if not (aligned and after):
fails.append("nut tao project khong nam cung hang, ben phai tieu de")
# 2. caps heading over the list
hdr = w._projects_hdr.text()
print(f"tieu de pane trai: {hdr!r}")
if not hdr or hdr != hdr.upper():
fails.append(f"tieu de pane trai chua viet hoa: {hdr!r}")
# 3. every row says how much is in the project
lst = w.project_list
if not lst.count():
fails.append("khong co project nao de kiem")
shown = 0
for i in range(lst.count()):
row = lst.itemWidget(lst.item(i))
labels = [l.text() for l in row.findChildren(QLabel)] if row else []
if len(labels) < 2:
fails.append(f"hang {i} khong co dong dem chat/task")
continue
shown += 1
if i < 2:
print(f" hang {i}: {labels[0]!r} / {labels[1]!r}")
# the sub-line must be the counts string, not the name repeated
if labels[1] == labels[0] or not any(ch.isdigit() for ch in labels[1]):
fails.append(f"hang {i}: dong phu khong phai so dem: {labels[1]!r}")
print(f"so hang co dong dem: {shown}/{lst.count()}")
# 4. the form reads as the drawing labels it
want = [tr("workspace.name"), tr("workspace.description"),
tr("workspace.instructions"), tr("workspace.folder_label")]
seen = [l.text() for l in w.findChildren(QLabel) if l.isVisible() and l.text()]
for label in want:
if label not in seen:
fails.append(f"thieu nhan {label!r}")
print(f"nhan form: {want}")
# 5. the drawing heads a populated screen with the title alone; the
# explanation belongs to an empty one.
print(f"hint hien voi {lst.count()} project: {w._hint.isVisible()}")
if lst.count() and w._hint.isVisible():
fails.append("doan giai thich van hien du da co project")
# 6. the path is a field in the drawing, not caption text
from PySide6.QtWidgets import QLineEdit
is_field = isinstance(w.folder_lbl, QLineEdit) and w.folder_lbl.isReadOnly()
print(f"o thu muc: {type(w.folder_lbl).__name__} (o nhap chi doc={is_field})")
if not is_field:
fails.append("duong dan thu muc khong phai o nhap chi doc")
# 7. Lưu project floats at the foot of the panel, not right under the form
save_y = w._save_btn.mapTo(w, QPoint(0, 0)).y()
folder_y = w.folder_lbl.mapTo(w, QPoint(0, 0)).y()
print(f"nut Luu y={save_y}, o thu muc y={folder_y}, cach {save_y - folder_y}px")
if save_y - folder_y < 80:
fails.append("nut Luu khong bi day xuong day panel")
# 8. the rail's picker must name the projects. It reads project_choices(),
# which used to read item.text() — and when rows became widgets the item
# text went empty, so every entry showed as a bare folder glyph. Creating
# a project is when a user notices, so create one here.
before = [n for n, _pid in w.project_choices()]
w._create()
app.processEvents()
choices = w.project_choices()
picker = [win.nav_project.itemText(i) for i in range(win.nav_project.count())]
print(f"project_choices: {[n for n, _p in choices][:4]}")
print(f"picker hien thi: {picker[:4]}")
if any(not name.strip() for name, _pid in choices):
fails.append("project_choices tra ve ten rong")
if len(choices) <= len(before):
fails.append("tao project moi khong vao danh sach")
for name, _pid in choices:
if not any(name in text for text in picker):
fails.append(f"picker khong hien ten {name!r}")
break
# 9. the title row sits above the sub-tabs, so what is on it must follow
# the title — + Project mới turned up in the corner of every other one.
for idx, name in ((w._cowork_tab_idx, "Cowork"), (w._co4e_tab_idx, "Co4E"),
(w._folder_tab_idx, "Thu muc"),
(w._graphrag_tab_idx, "GraphRAG")):
if idx < 0:
continue
win._goto(win._ROW_WORKSPACE, idx)
app.processEvents()
if w._new_btn.isVisible():
fails.append(f"nut '+ Project moi' hien ca o man {name}")
win._goto(win._ROW_WORKSPACE, w._project_tab_idx)
app.processEvents()
print(f"nut tao chi hien o Project: {not any('Project moi' in f for f in fails)}")
print()
for f in fails:
print("FAIL " + f)
print("PASS man Project khop ban ve" if not fails else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+268
View File
@@ -0,0 +1,268 @@
"""Rail icons must sit on one vertical line, and stay there when it collapses.
Two bugs this catches:
· "Cài đặt" sat 42px right of "Dashboard"/"Giám sát" — its QSS margin pushed
the button in while the tree rows above start at the rail edge.
· Collapsing re-placed the icon of every label-less button, sliding + to the
middle of the 54px rail.
Runs with the app's real stylesheet loaded. Without it the window has no
padding, margins or borders and neither bug is visible — see _apply_theme.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
TOL = 2 # px; anti-aliasing on an icon edge
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtCore import QPoint
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
theme_name = _apply_theme(app)
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")
win.resize(1400, 900)
win.show()
app.processEvents()
rail = win._nav_wrap
def ink_x(w):
"""Leftmost painted pixel of a widget, in rail coordinates."""
img = w.grab().toImage()
bg = img.pixelColor(w.width() - 3, 2)
for x in range(img.width()):
for y in range(2, img.height() - 2):
c = img.pixelColor(x, y)
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
+ abs(c.blue() - bg.blue())) > 60:
return w.mapTo(rail, QPoint(x, 0)).x()
return None
def tree_text_x(tree):
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
opt.rect = tree.visualRect(index)
tree.itemDelegate().initStyleOption(opt, index)
txt = tree.style().subElementRect(QStyle.SE_ItemViewItemText, opt, tree)
return tree.mapTo(rail, QPoint(txt.left(), 0)).x()
def btn_text_x(w):
"""Left edge of the label: first ink past the icon's gap."""
# Settings lays its own row out now, so read the label widget directly
# rather than hunting for a gap in the painted pixels.
lbl = getattr(win, "_nav_settings_text", None)
if w is getattr(win, "_nav_settings_btn", None) and lbl is not None:
return lbl.mapTo(rail, QPoint(0, 0)).x() if lbl.isVisible() else None
if not w.text():
return None
img = w.grab().toImage()
bg = img.pixelColor(w.width() - 3, 2)
ink = []
for x in range(img.width()):
for y in range(2, img.height() - 2):
c = img.pixelColor(x, y)
if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
+ abs(c.blue() - bg.blue())) > 60:
ink.append(x)
break
if not ink:
return None
for a, b in zip(ink, ink[1:]): # first gap = icon/label spacing
if b - a > 2:
return w.mapTo(rail, QPoint(b, 0)).x()
return None
def tree_icon_x(tree):
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
opt.rect = tree.visualRect(index)
tree.itemDelegate().initStyleOption(opt, index)
deco = tree.style().subElementRect(
QStyle.SE_ItemViewItemDecoration, opt, tree)
return tree.mapTo(rail, QPoint(deco.left(), 0)).x()
def snapshot():
app.processEvents()
out = {"nav rows": tree_icon_x(win.nav),
"bottom rows": tree_icon_x(win.nav_bottom),
"bottom rows text": tree_text_x(win.nav_bottom)}
for label, attr in (("MENU", "_nav_toggle_btn"),
("new chat", "nav_new_chat"),
("settings", "_nav_settings_btn")):
w = getattr(win, attr, None)
if w is not None and w.isVisible():
out[label] = ink_x(w)
if label == "settings":
out["settings text"] = btn_text_x(w)
return out
fails = []
for theme_name in ("dark", "light"):
_apply_theme(app, theme_name)
app.processEvents()
if win._nav_collapsed:
win._toggle_nav()
opened = snapshot()
win._toggle_nav()
app.processEvents()
closed = snapshot()
win._toggle_nav()
app.processEvents()
fails += compare(theme_name, rail, opened, closed)
fails += column_widths_do_not_move_icons(win, app, theme_name)
fails += rows_stay_under_the_header(win, app, theme_name)
print()
for f in fails:
print(f"FAIL {f}")
print("PASS every rail icon holds its line" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
def rows_stay_under_the_header(win, app, theme_name):
"""The destinations start just below the + button in both states.
Collapsing hides RECENTS, the one item in the scroll body with a stretch
factor. With nothing left to expand, a box layout centres what remains, and
the whole group slid ~300px down the rail.
"""
from PySide6.QtCore import QPoint
rail = win._nav_wrap
gaps = {}
for state in ("open", "collapsed"):
if (state == "collapsed") != win._nav_collapsed:
win._toggle_nav()
app.processEvents()
btn = win.nav_new_chat
below = btn.mapTo(rail, QPoint(0, btn.height())).y()
top = win.nav.mapTo(rail, QPoint(0, 0)).y() + win.nav.visualItemRect(win.nav.topLevelItem(0)).top()
gaps[state] = top - below
if win._nav_collapsed:
win._toggle_nav()
app.processEvents()
print(f" gap under +: open={gaps['open']}px collapsed={gaps['collapsed']}px")
if abs(gaps["collapsed"] - gaps["open"]) > 4:
return [f"{theme_name}: the destinations sit {gaps['collapsed']}px below "
f"the + button when collapsed but {gaps['open']}px when open"]
return []
def column_widths_do_not_move_icons(win, app, theme_name):
"""The icon must not care how wide the column is.
On the machine that reported this the column matched the rail and the icons
sat in the middle; in a test render the column stayed wider than the view
and the same code drew them at the left. So sweep the width and require the
icon to hold still.
"""
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
if not win._nav_collapsed:
win._toggle_nav()
app.processEvents()
fails, seen = [], {}
# The invariant that matters. A centred decoration is the only way Qt can
# put a label-less row's icon anywhere but the left edge, and how far it
# travels depends on the box the column hands it — which is why this
# reproduces on one machine and not another. Require the instruction
# itself, not just the pixel it happens to produce here.
from PySide6.QtCore import Qt as _Qt
for tree_name in ("nav", "nav_bottom"):
tree = getattr(win, tree_name)
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
tree.itemDelegate().initStyleOption(opt, index)
align = int(opt.decorationAlignment)
if align & int(_Qt.AlignHCenter) or not align & int(_Qt.AlignLeft):
fails.append(f"{theme_name} {tree_name}: decorationAlignment={align}"
f" — icon is free to drift off the left edge")
for tree_name in ("nav", "nav_bottom"):
tree = getattr(win, tree_name)
keep = tree.columnWidth(0)
for width in (tree.viewport().width(), 70, 100, 140):
tree.setColumnWidth(0, width)
app.processEvents()
index = tree.indexFromItem(tree.topLevelItem(0), 0)
opt = QStyleOptionViewItem()
tree.initViewItemOption(opt)
opt.rect = tree.visualRect(index)
tree.itemDelegate().initStyleOption(opt, index)
x = tree.style().subElementRect(
QStyle.SE_ItemViewItemDecoration, opt, tree).left()
seen.setdefault(tree_name, []).append((width, x))
tree.setColumnWidth(0, keep)
app.processEvents()
xs = {x for _w, x in seen[tree_name]}
if len(xs) > 1:
fails.append(f"{theme_name} {tree_name}: icon x changes with the "
f"column width — {seen[tree_name]}")
print(f" column sweep: " + " ".join(
f"{n}={[x for _w, x in v]}" for n, v in seen.items()))
win._toggle_nav()
app.processEvents()
return fails
def compare(theme_name, rail, opened, closed):
print()
print(f"theme={theme_name}")
print(f"{'element':<12}{'open':>7}{'collapsed':>11}{'drift':>8}")
fails = []
for key in opened:
a, b = opened[key], closed.get(key)
drift = "-" if a is None or b is None else f"{b - a:+d}"
print(f"{key:<12}{str(a):>7}{str(b):>11}{drift:>8}")
if a is not None and b is not None and abs(b - a) > TOL:
fails.append(f"{key}: icon moves {b - a:+d}px when the rail collapses")
# Settings is a button but reads as one more row in the bottom list, so
# both its icon and its label have to start where theirs do.
for state, snap in (("open", opened), ("collapsed", closed)):
for what in ("", " text"):
ref, got = snap.get("bottom rows" + what), snap.get("settings" + what)
if ref is not None and got is not None and abs(got - ref) > TOL:
fails.append(f"{theme_name} {state}: settings{what} x={got} but "
f"the rows above it start at x={ref}")
return fails
if __name__ == "__main__":
raise SystemExit(main())
+128
View File
@@ -0,0 +1,128 @@
"""The splitter handle beside the rail has to actually move the rail.
setFixedWidth left it drawn but inert: it looked draggable and did nothing.
Also checks that a width the user drags to survives a collapse/expand, and
that collapsing still pins the rail at 54px.
"""
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 ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
def main() -> int:
sandbox = _isolate_home()
from PySide6.QtWidgets import QApplication
app = QApplication([])
_load_fonts()
_freeze_schedulers()
_apply_theme(app)
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 (
_NAV_COLLAPSED_WIDTH, _NAV_MIN_WIDTH, 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")
win.resize(1400, 900)
win.show()
app.processEvents()
fails = []
rail, split = win._nav_wrap, win.split
def drag_to(px):
"""What the splitter does when the handle is dragged."""
total = sum(split.sizes())
split.setSizes([px, max(1, total - px)])
app.processEvents()
win._on_split_moved(px, 1)
app.processEvents()
return rail.width()
start = rail.width()
wide = drag_to(300)
print(f"keo rong : {start} -> {wide}px")
if wide <= start:
fails.append(f"keo tay nam ra 300px ma rail van {wide}px")
narrow = drag_to(_NAV_MIN_WIDTH)
print(f"keo hep : {wide} -> {narrow}px")
if narrow >= wide:
fails.append(f"keo hep lai khong an: {narrow}px")
# The ceiling is a share of the window now, so ask the window for it.
ceiling = win._nav_max_width()
over = drag_to(ceiling + 200)
print(f"keo qua max: {over}px (tran {ceiling} = {ceiling * 100 // win.width()}% cua so)")
if over > ceiling:
fails.append(f"rail vuot tran: {over} > {ceiling}")
under = drag_to(20)
print(f"keo duoi min: {under}px (san {_NAV_MIN_WIDTH})")
if under < _NAV_MIN_WIDTH:
fails.append(f"rail thap hon san: {under} < {_NAV_MIN_WIDTH}")
# a dragged width has to come back after a fold
chosen = drag_to(min(280, win._nav_max_width()))
win._toggle_nav()
app.processEvents()
folded = rail.width()
print(f"thu gon : {folded}px")
if folded != _NAV_COLLAPSED_WIDTH:
fails.append(f"thu gon phai la {_NAV_COLLAPSED_WIDTH}px, dang {folded}px")
win._toggle_nav()
app.processEvents()
back = rail.width()
print(f"mo lai : {back}px (da chon {chosen}px)")
if abs(back - chosen) > 4:
fails.append(f"mo lai quen be rong da keo: {back} thay vi {chosen}")
# The ceiling is a share, so it has to move with the window — it was read
# once at construction and stuck at 162px on every monitor.
seen = {}
for w in (1280, 1600, 1936):
win.resize(w, 900)
app.processEvents()
split.setSizes([2000, 1]) # drag the handle as far right as it goes
app.processEvents()
seen[w] = (rail.width(), win._nav_max_width())
print(f"cua so {w}: keo het co -> {seen[w][0]}px (tran {seen[w][1]}px)")
for w, (got, ceiling) in seen.items():
if abs(got - ceiling) > 4:
fails.append(f"cua so {w}: keo het chi duoc {got}px, tran la {ceiling}px")
if len({c for _g, c in seen.values()}) == 1:
fails.append("tran khong doi theo be rong cua so — dang la px co dinh")
print()
for f in fails:
print("FAIL " + f)
print("PASS tay nam keo duoc, nho be rong qua lan gap" if not fails
else f"{len(fails)} problem(s)")
sys.stdout.flush()
os._exit(1 if fails else 0)
if __name__ == "__main__":
raise SystemExit(main())
+119
View File
@@ -0,0 +1,119 @@
"""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
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 _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:]))
+137
View File
@@ -0,0 +1,137 @@
"""Extract every interactive control from the UI source, mechanically.
Reading the files by hand and listing what I notice is exactly how functionality
gets dropped from a redesign. This walks the AST instead, so the inventory is
exhaustive by construction: if a widget is constructed in the file, it appears.
For each control it reports the variable it is bound to, its widget type, the
label expression (usually a ``tr("...")`` key), the signal handlers wired to it,
and the source line — enough to check "did the new design keep this?".
Run: python tools/extract_controls.py [ui/file.py ...]
"""
from __future__ import annotations
import ast
import io
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
UI = REPO / "ui"
# Widget types that represent something the user can click, type in or toggle.
WIDGETS = {
"QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist",
"QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập",
"QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng",
"QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ",
"QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt",
"QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng",
"QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại",
}
# Signals worth recording — these are the "it does something" wires.
SIGNALS = {
"clicked", "toggled", "currentIndexChanged", "currentTextChanged",
"textChanged", "returnPressed", "valueChanged", "itemClicked",
"itemDoubleClicked", "currentItemChanged", "currentChanged",
"customContextMenuRequested", "tabCloseRequested", "linkActivated",
"stateChanged", "activated", "triggered", "editingFinished",
}
def _txt(node) -> str:
"""Best-effort source text for a label expression."""
try:
return ast.unparse(node)
except Exception: # noqa: BLE001
return "?"
class Visitor(ast.NodeVisitor):
def __init__(self, path: Path):
self.path = path
self.controls: dict[str, dict] = {} # var name -> record
self.menu_actions: list[dict] = []
# ---- self.btn = QPushButton(...) / btn = QComboBox() -------------------
def visit_Assign(self, node: ast.Assign) -> None:
if isinstance(node.value, ast.Call):
fn = node.value.func
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
if name in WIDGETS:
for tgt in node.targets:
var = _txt(tgt)
args = [_txt(a) for a in node.value.args]
self.controls.setdefault(var, {
"var": var, "type": name, "kind": WIDGETS[name],
"label": args[0] if args else "",
"line": node.lineno, "signals": [], "object_name": "",
})
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
# ---- x.clicked.connect(handler) ----------------------------------
if isinstance(fn, ast.Attribute) and fn.attr == "connect":
sig = fn.value
if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS:
var = _txt(sig.value)
rec = self.controls.get(var)
if rec is not None and node.args:
rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}")
# ---- x.setText(tr("...")) / setObjectName / setToolTip -----------
if isinstance(fn, ast.Attribute) and node.args:
var = _txt(fn.value)
rec = self.controls.get(var)
if rec is not None:
if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]:
rec["label"] = _txt(node.args[0])
elif fn.attr == "setObjectName":
rec["object_name"] = _txt(node.args[0]).strip("'\"")
elif fn.attr == "setToolTip" and not rec["label"]:
rec["label"] = _txt(node.args[0])
# ---- menu.addAction("Xoá") — context menus are real features -----
if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args:
self.menu_actions.append({
"menu": _txt(fn.value), "label": _txt(node.args[0]),
"line": node.lineno,
})
self.generic_visit(node)
def scan(path: Path) -> dict:
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
v = Visitor(path)
v.visit(tree)
# Drop pure containers with no wiring and no label — they are layout, not
# controls the user acts on directly.
controls = [c for c in v.controls.values()
if c["signals"] or c["label"] or c["object_name"]]
controls.sort(key=lambda c: c["line"])
return {"file": str(path.relative_to(REPO)),
"controls": controls, "menu_actions": v.menu_actions}
def main(argv: list[str]) -> int:
targets = [Path(a) for a in argv] or sorted(UI.glob("*.py"))
out = []
for t in targets:
if t.name == "__init__.py":
continue
p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t)
try:
out.append(scan(p))
except SyntaxError as exc: # noqa: PERF203
print(f" SKIP {p.name}: {exc}", file=sys.stderr)
dest = REPO / "docs" / "screens" / "controls.json"
dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8")
n_ctl = sum(len(f["controls"]) for f in out)
n_act = sum(len(f["menu_actions"]) for f in out)
print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+157
View File
@@ -0,0 +1,157 @@
"""Lift the hand-written audit sections out of 10-18.ui-audit.html.
That file was edited by hand: eight sections carry richer wireframes, prose and
interactive tables than the generator produces, plus the CSS and scripts they
need. Keeping two HTML files around means they drift, so this pulls the
hand-written parts into ``tools/audit_handwritten.py`` — a data module the
builder merges back in, making ``docs/ui-audit.html`` the single output again.
Two things are tokenised out before storing, so they stay generated rather than
frozen at extraction time:
{{SHOT}} the screenshot block (keeps ~8 MB of base64 out of the module)
{{CONTROLS}} the AST-derived control inventory (must track controls.json)
Workflow when you hand-edit one of those sections directly in the page:
1. edit docs/ui-audit.html
2. python tools/extract_handwritten.py (reads it back into the module)
3. python tools/build_audit_page.py (regenerates, edits preserved)
Pass another filename to import sections from a different copy.
"""
from __future__ import annotations
import contextlib
import io
import re
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DOCS = REPO / "docs"
OUT = REPO / "tools" / "audit_handwritten.py"
# Hand-written sections are DETECTED, not listed: every section whose text
# differs from what the generator alone would emit is stored.
#
# There used to be a fixed list here, and it cost a section — "Monitoring ▸ Công
# cụ" was hand-written but missing from the list, so each rebuild quietly put
# the generated version back. Diffing against the live output cannot work (it
# already contains the merged result and would find nothing), so the reference
# is a generator-only render produced in-process, with the merge disabled.
#
# These are the ones known so far; anything else detected is added on top.
KNOWN = [
"monitoring-sự-kiện-bảo-mật", "monitoring-lịch-sử-gọi-mcp",
"monitoring-nhật-ký-hành-động", "monitoring-trạng-thái-agent",
"monitoring-agents-admin", "monitoring-icon", "monitoring-công-cụ",
"dialog-settings", "dialog-task-editor",
]
SECTION = re.compile(r'<section class="sec" id="([^"]+)">(.*?)</section>', re.S)
BODY = re.compile(r'(<div class="bd">.*)', re.S)
SHOT = re.compile(r'<div class="shot">.*?</div>', re.S)
CONTROLS = re.compile(r'<details class="ctl">.*?</details>', re.S)
STYLE = re.compile(r"<style>(.*?)</style>", re.S)
SCRIPT = re.compile(r"<script>(.*?)</script>", re.S)
def bodies(html: str) -> dict[str, str]:
"""slug -> the section's <div class="bd"> … </div>, header excluded."""
out = {}
for m in SECTION.finditer(html):
b = BODY.search(m.group(2))
if b:
out[m.group(1)] = b.group(1).strip()
return out
def norm(s: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip()
def main(argv: list[str]) -> int:
src_path = DOCS / (argv[0] if argv else "ui-audit.html")
if not src_path.exists():
print(f"khong thay {src_path}")
return 1
src = src_path.read_text(encoding="utf-8")
# Screenshots are re-embedded by the builder; keep the base64 out of here.
hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>", src))
sys.path.insert(0, str(REPO / "tools"))
import build_audit_page as B
# Render what the generator ALONE would produce, into a temp file, and treat
# every section that differs from it as hand-written.
with tempfile.TemporaryDirectory() as tmp:
keep_out, keep_hand = B.OUT, B.HAND_SECTIONS
B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {}
try:
with contextlib.redirect_stdout(io.StringIO()):
B.main()
made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>",
B.OUT.read_text(encoding="utf-8")))
finally:
B.OUT, B.HAND_SECTIONS = keep_out, keep_hand
detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, "")))
slugs = [s for s in hand if s in set(detected) | set(KNOWN)]
new = [s for s in detected if s not in KNOWN]
gone = [s for s in KNOWN if s in hand and s not in detected]
if new:
print(f"phat hien them section viet tay: {new}")
if gone:
# Not an error: a hand section can be edited back to match the generator.
print(f"section trong KNOWN nay giong ban sinh: {gone}")
stored = {}
for slug in slugs:
body = hand[slug]
body = SHOT.sub("{{SHOT}}", body, count=1)
body = CONTROLS.sub("{{CONTROLS}}", body, count=1)
stored[slug] = body
# CSS rules and scripts the hand edits added. Compared against the builder's
# OWN constants, not its output — the output already carries the merge.
extra_css = "\n".join(
ln for ln in STYLE.search(src).group(1).splitlines()
if ln.strip() and ln not in B.CSS)
# Scripts already sitting INSIDE a stored section travel with it — collecting
# them again would bind every listener twice (the +/- steppers would then
# count by two). Only page-level scripts belong in EXTRA_JS.
gen_js = {norm(B.JS)}
in_section = "".join(stored.values())
extra_js = [j for j in SCRIPT.findall(src)
if norm(j) not in gen_js and j not in in_section]
parts = [
'"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n'
"GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n"
"source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n"
'{{CONTROLS}} so those stay generated.\n"""\n',
"SECTIONS = {",
]
for slug, body in stored.items():
parts.append(f" {slug!r}: {body!r},")
parts.append("}\n")
parts.append(f"EXTRA_CSS = {extra_css!r}\n")
parts.append("EXTRA_JS = [")
for j in extra_js:
parts.append(f" {j!r},")
parts.append("]\n")
OUT.write_text("\n".join(parts), encoding="utf-8")
print(f"section viet tay : {len(stored)}")
for slug, body in stored.items():
print(f" {slug:32} {len(body):>7,} ky tu"
f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}")
print(f"CSS them : {len(extra_css.splitlines())} dong")
print(f"script them : {len(extra_js)}")
print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+352
View File
@@ -0,0 +1,352 @@
"""Populate a CoworkLocal config dir with realistic demo data, so the audit
screenshots show a working app instead of empty lists.
MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a
sandbox — every store below resolves its path from ``CONFIG_DIR``, which is
``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this.
Where the app exposes a write API we call it (projects, history, tasks, skills,
workflows, agents). Two stores are written as raw files on purpose:
* **usage** and **audit** — their ``record()`` helpers always stamp
``datetime.now()``, so they cannot backdate. A one-day spike makes a useless
chart, so the day files are written directly.
* **co4e/run_history.json** — the manager only persists from a Qt signal
handler; there is no public save.
"""
from __future__ import annotations
import json
import os
import random
from datetime import datetime, timedelta
from pathlib import Path
rnd = random.Random(20260808) # fixed seed → identical screenshots every run
PROJECTS = [
("Trạm sạc EV — Cổng vận hành",
"Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.",
"Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n"
"Luôn viết test trước khi sửa logic thanh toán."),
("Báo cáo tài chính Q3",
"Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.",
"Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n"
"Mọi con số phải truy được về file nguồn."),
("Cổng tra cứu tài liệu ISO",
"Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.",
"Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."),
]
CONVERSATIONS = [
(0, "Dựng API trạng thái trạm sạc", True, [
("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."),
("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n"
"Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n"
"Mình tạo `app/api/stations.py` và test đi kèm nhé?"),
("user", "Ừ làm đi."),
("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n"
"- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n"
"Chạy `pytest tests/test_stations.py` → 4 passed."),
]),
(0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [
("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."),
("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, "
"`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n"
"Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"),
]),
(1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [
("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."),
("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác "
"(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n"
"Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."),
]),
(1, "Dựng slide trình bày kết quả Q3", False, [
("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."),
("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → "
"top 5 tăng trưởng → rủi ro → kế hoạch Q4."),
]),
(2, "Đánh chỉ mục kho tài liệu ISO", False, [
("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."),
("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. "
"Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."),
]),
(0, "Rà soát bảo mật trước khi lên production", False, [
("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."),
("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n"
"2. Endpoint `/admin/export` không kiểm tra quyền\n"
"3. CORS đang để `allow_origins=[\"*\"]`"),
]),
]
TASKS = [
# (title, status, priority, ai, enabled, offset_days, last_status)
("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"),
("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"),
("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"),
("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"),
("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"),
("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None),
("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"),
("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None),
("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None),
("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"),
]
SKILLS = [
("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.",
"Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n"
"2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n"
"4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."),
("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.",
"Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n"
"Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."),
("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.",
"Trước khi sửa logic, viết test mô tả hành vi đúng.\n"
"Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."),
("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.",
"Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n"
"Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."),
("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.",
"Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n"
"Slide cuối luôn là hành động tiếp theo."),
]
CO4E_AGENTS = [
("Phân tích yêu cầu", "ANALYST", "search",
"Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.",
["Rà soát bảo mật"]),
("Thiết kế giải pháp", "ARCHITECT", "flow",
"Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []),
("Lập trình viên", "CODER", "code",
"Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]),
("Kiểm thử", "TESTER", "shield",
"Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]),
("Soạn tài liệu", "WRITER", "book",
"Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]),
]
WORKFLOWS = [
("Quy trình phát triển tính năng",
["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]),
("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]),
("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]),
]
AUDIT_EVENTS = [
("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"),
("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"),
("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"),
("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"),
("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"),
("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"),
("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"),
("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"),
("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"),
("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"),
("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"),
("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"),
("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"),
("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"),
("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"),
]
MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")]
LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu",
"Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"]
def _iso(dt: datetime) -> str:
return dt.isoformat(timespec="seconds")
def seed(days: int = 45) -> dict:
"""Fill the (sandboxed) config dir. Returns a per-store count summary."""
from cowork_local.config import CONFIG_DIR
home = str(Path.home())
assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME"
assert "cowork-capture-" in home or "cowork-seed-" in home, (
f"HOME ({home}) does not look like a capture sandbox — refusing to seed")
from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks
out: dict[str, int] = {}
now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0)
# ---- projects ---------------------------------------------------------
made = []
for name, desc, instr in PROJECTS:
p = projects.new_project(name, description=desc, instructions=instr)
p.workspace_dir().mkdir(parents=True, exist_ok=True)
# a few files so the Folder tab's tree isn't bare
for rel in ("README.md", "src/main.py", "src/billing/session.py",
"tests/test_stations.py", "docs/ghi-chu.md"):
f = p.workspace_dir() / rel
f.parent.mkdir(parents=True, exist_ok=True)
if not f.exists():
f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8")
made.append(p)
out["projects"] = len(made)
# ---- conversations ----------------------------------------------------
hist_root = CONFIG_DIR / "history"
hist_root.mkdir(parents=True, exist_ok=True)
n_conv = 0
for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS):
proj = made[pi]
created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3))
sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}"
payload = [{"role": r, "content": c} for r, c in msgs]
for directory in (hist_root, proj.workspace_dir() / ".cowork_history"):
directory.mkdir(parents=True, exist_ok=True)
path = history.save_conversation(
directory, "cowork", sid, payload, title=title,
created=created, project_id=proj.project_id)
if pinned:
history.set_pinned(path, True)
# stagger mtime so the sidebar's newest-first order looks real
ts = (now - timedelta(days=i * 2 + 1)).timestamp()
os.utime(path, (ts, ts))
n_conv += 1
out["conversations"] = n_conv
# ---- scheduled tasks --------------------------------------------------
for title, status, prio, ai, enabled, off, last in TASKS:
t = tasks.new_task(
title=title, status=status, priority=prio, is_ai_generated=ai,
project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b",
description=f"Tác vụ tự động: {title.lower()}.",
schedule={"enabled": enabled,
"run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"),
"repeat_type": "daily" if enabled else "none"},
logs={"last_status": last or "", "last_run_id": "run-demo" if last else "",
"last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""},
)
if last:
t["runs"] = [{"run_id": f"r{n}", "status": last,
"finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"),
"error": "Chứng chỉ hết hạn" if last == "failed" else None}
for n in range(1, 4)]
tasks.save_task(t)
out["tasks"] = len(TASKS)
# ---- skills -----------------------------------------------------------
for name, desc, instr in SKILLS:
skills.save_skill(skills.Skill(name=name, description=desc,
instructions=instr, enabled=True))
out["skills"] = len(SKILLS)
# ---- Co4E agents ------------------------------------------------------
for name, role, icon, instr, sk in CO4E_AGENTS:
a = co4e.new_custom_agent(name)
a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk
a.model = "qwen2.5-coder:7b"
co4e.save_custom_agent(a)
out["co4e_agents"] = len(CO4E_AGENTS)
# ---- Co4E workflows ---------------------------------------------------
wfs = []
for name, steps in WORKFLOWS:
wf = co4e.new_workflow(name)
prev = None
for j, label in enumerate(steps):
node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120,
data=co4e.Step(label=label, role="AGENT",
instructions=f"{label}: thực hiện phần việc của mình "
f"rồi chuyển kết quả cho bước sau.",
model="qwen2.5-coder:7b"))
wf.nodes.append(node)
if prev:
wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id),
source=prev, target=node.id))
prev = node.id
co4e.save_workflow(wf)
wfs.append(wf)
out["workflows"] = len(wfs)
# ---- Co4E run history (no public save — written directly) -------------
runs = []
specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2),
("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)]
for k, (status, done, total, ago) in enumerate(specs, 1):
wf = wfs[k % len(wfs)]
runs.append({
"id": f"run{k}", "wf_id": wf.id, "name": wf.name,
"total": total, "done": done, "status": status,
"plan_mode": False, "manual": False, "created_by": "local",
"created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"),
"error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "",
"node_status": {n.id: ("done" if i < done else
("error" if status == "error" and i == done else "idle"))
for i, n in enumerate(wf.nodes)},
"wf": co4e.workflow_to_dict(wf),
"out_dir": str(made[0].workspace_dir()),
"project_id": made[0].project_id,
})
hp = CONFIG_DIR / "co4e" / "run_history.json"
hp.parent.mkdir(parents=True, exist_ok=True)
hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8")
out["co4e_runs"] = len(runs)
# ---- usage day files (record() cannot backdate) -----------------------
usage_dir = CONFIG_DIR / "usage"
usage_dir.mkdir(parents=True, exist_ok=True)
n_usage = 0
for d in range(days):
day = now - timedelta(days=days - 1 - d)
# a workday rhythm: quiet weekends, a gentle upward trend
weekend = day.weekday() >= 5
turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12
lines = []
for _ in range(turns):
prov, model = rnd.choice(MODELS)
lines.append(json.dumps({
"ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))),
"source": rnd.choice(["cowork", "cowork", "task", "co4e"]),
"label": rnd.choice(LABELS), "provider": prov, "model": model,
"in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200),
"cache": rnd.randint(0, 4200), "estimated": False,
"account": "local", "machine": "DESKTOP-DEMO",
}, ensure_ascii=False))
n_usage += 1
(usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
out["usage_events"] = n_usage
# ---- audit day files (drives Security / MCP / Action tables) ----------
audit_dir = CONFIG_DIR / "audit"
audit_dir.mkdir(parents=True, exist_ok=True)
n_audit = 0
roles = ["cowork", "code", "schedule", "graphrag", "security"]
for d in range(14):
day = now - timedelta(days=13 - d)
lines = []
for _ in range(rnd.randint(4, 9)):
kind, name, ok, detail = rnd.choice(AUDIT_EVENTS)
lines.append(json.dumps({
"ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))),
"kind": kind, "agent_role": rnd.choice(roles), "name": name,
"ok": ok, "detail": detail,
"account": "local", "role": "admin", "machine": "DESKTOP-DEMO",
}, ensure_ascii=False))
n_audit += 1
(audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
out["audit_events"] = n_audit
# ---- admin agents -----------------------------------------------------
admin_dir = admin_agents.agents_admin_dir("")
admin_dir.mkdir(parents=True, exist_ok=True)
for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"),
("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"),
("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"),
("Kiểm tra lệnh nguy hiểm", "security")]:
a = admin_agents.new_agent(name, task_kind=kind, provider="ollama",
model="qwen2.5-coder:7b", updated_by="local",
prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.")
admin_agents.save_agent(a, admin_dir)
out["admin_agents"] = 7
return out
if __name__ == "__main__":
raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.")