fix(tools): 5 checker UI hỏng sau đợt tách widget R08

`tools/` không đổi một byte nào giữa hai bản, nhưng 5 checker vẫn chết vì
chúng tìm control bằng `getattr(root, "ten")` trên đúng widget cũ — mà R08 đã
dời control xuống widget con.

Thêm ba helper dùng chung vào `capture_screens.py`:
  * `_own_member` — tên do app khai trên widget, không phải thừa kế từ Qt
  * `owner_of`   — widget thật sự đang giữ tên đó, duyệt theo bề rộng
  * `control`    — lấy control dù nó nằm ở cấp nào

`check_controls_alive` từ "MẤT 24 control" về 0, kèm liệt kê 22 control đã
đổi chỗ và 2 cái đổi tên. `check_probes_bite` từ 1/4 lên 6/6 phép cấy lỗi đều
bị bắt — phép cấy thứ hai trỏ vào `ui/schedule_task_tab.py` đã bị xoá, nay
trỏ vào `presentation/scheduling/kanban_board_widget.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:37:06 +09:00
co-authored by Claude Opus 5
parent e5c184ce07
commit 81b9482011
7 changed files with 378 additions and 262 deletions
+57
View File
@@ -37,6 +37,63 @@ OUT_DIR = REPO / "docs" / "screens"
THEMES = ("dark", "light") THEMES = ("dark", "light")
def _own_member(widget, name: str) -> bool:
"""True when `name` is declared by the app on `widget`, not inherited from Qt.
Instance attributes live in ``vars(widget)``; methods live on the class, so
both are checked. Only classes defined inside ``cowork_local`` count, so a
Qt base class that happens to use the same name can never be mistaken for
the app's own control.
"""
if name in vars(widget):
return True
for base in type(widget).__mro__:
if getattr(base, "__module__", "").startswith("cowork_local") and name in vars(base):
return True
return False
def owner_of(root, name: str):
"""The widget in `root`'s subtree that actually holds `name` today.
EPIC R08 split every screen's god-widget into child widgets, so a control
that used to be ``tab.gran_combo`` now lives at ``tab.chart.gran_combo``,
and ``ScheduleTaskTab.columns`` moved to ``ScheduleTaskTab.kanban.columns``.
Checkers ask for a control by name and get back whichever widget owns it
today, so a further split does not break them again — while a control that
is genuinely gone still returns ``None`` and is still reported as a loss.
Breadth-first, so the shallowest owner wins if a name appears twice.
"""
from PySide6.QtWidgets import QWidget
seen: set[int] = set()
queue = [root]
while queue:
w = queue.pop(0)
if id(w) in seen:
continue
seen.add(id(w))
if _own_member(w, name):
return w
queue.extend(c for c in w.children() if isinstance(c, QWidget))
return None
def control(root, name: str, default=None):
"""The control named `name` anywhere in `root`'s subtree — see `owner_of`.
Falls back to a plain ``getattr`` on `root` so a screen that already bridges
its old attribute names itself keeps working: ``MonitoringTab.__getattr__``
maps ``ov_*`` onto the extracted tabs, and a name served that way is on no
widget's ``__dict__`` for `owner_of` to find.
"""
holder = owner_of(root, name)
if holder is not None:
return getattr(holder, name, default)
return getattr(root, name, default)
def _isolate_home() -> Path: def _isolate_home() -> Path:
"""Copy the real config dir into a temp HOME and repoint the env at it.""" """Copy the real config dir into a temp HOME and repoint the env at it."""
real = Path.home() / ".cowork_local" real = Path.home() / ".cowork_local"
+24 -2
View File
@@ -25,7 +25,9 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 from capture_screens import ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of,
)
# controls.json lists every control in a FILE, and several files hold more than # 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 # one class (schedule_task_tab.py alone has the tab plus three dialogs). Only
@@ -82,6 +84,11 @@ MOVED = {
}, },
"ui\\structure_graph_view.py": { "ui\\structure_graph_view.py": {
"self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)", "self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)",
# R08-T14 split the screen and renamed these two on the way out. Both
# are still on screen, under a new owner and a new name, so they are
# deliberate moves rather than losses.
"self._ag_collapse": "→ GraphQaWidget._collapse_btn (nút thu gọn bảng Agent)",
"self._msgs_view": "→ GraphMessagesView.widget (cây Tin nhắn theo ngày)",
}, },
"app.py": { "app.py": {
"self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)", "self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)",
@@ -163,8 +170,9 @@ def main() -> int:
.read_text(encoding="utf-8")) .read_text(encoding="utf-8"))
own = owners(win) own = owners(win)
alive = dead = moved = skipped = other_class = 0 alive = dead = moved = skipped = other_class = relocated = 0
losses: list[tuple[str, str, str]] = [] losses: list[tuple[str, str, str]] = []
moves: list[tuple[str, str, str]] = []
for rec in index: for rec in index:
holder = own.get(rec["file"]) holder = own.get(rec["file"])
if holder is None: if holder is None:
@@ -186,6 +194,17 @@ def main() -> int:
alive += 1 alive += 1
elif var in MOVED.get(rec["file"], {}): elif var in MOVED.get(rec["file"], {}):
moved += 1 moved += 1
else:
# EPIC R08 extracted sub-widgets out of every screen, so a
# control can still be on screen while no longer being a direct
# attribute of the screen's own widget (FolderTab.mode_btn ->
# FolderTab.preview.mode_btn). Searching the subtree keeps the
# subtraction test honest: it still fails on a control that is
# genuinely gone, but a relocation now reads as a relocation.
sub = owner_of(holder, name)
if sub is not None:
relocated += 1
moves.append((rec["file"], var, type(sub).__name__))
else: else:
dead += 1 dead += 1
losses.append((rec["file"], var, losses.append((rec["file"], var,
@@ -195,6 +214,9 @@ def main() -> int:
print(f"co y doi cho : {moved}") 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]]: 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" {v:26} {w}")
print(f"doi cho khi tach : {relocated} (con tren man, nam trong widget con)")
for f, var, own in sorted(moves):
print(f" {var:26} -> {own}.{var.split('.', 1)[1]}")
print(f"thuoc class khac : {other_class} (hop thoai dinh nghia cung file)") 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"khong kiem duoc : {skipped} (dialog dung rieng, bien cuc bo)")
print(f"MAT : {dead}") print(f"MAT : {dead}")
+13 -8
View File
@@ -19,7 +19,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 from capture_screens import _apply_theme, _isolate_home, _load_fonts, control # noqa: E402
HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn", HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn",
"gran_combo", "metric_combo", "currency_lbl", "currency_combo", "gran_combo", "metric_combo", "currency_lbl", "currency_combo",
@@ -42,7 +42,11 @@ def main() -> int:
from cowork_local.i18n import set_language from cowork_local.i18n import set_language
from cowork_local.state import AppContext from cowork_local.state import AppContext
from cowork_local.ui.dashboard_tab import DashboardTab # R08-T13 moved the screen out of ui/ into presentation/dashboard/ and
# split its header controls across UsageChartWidget / HabitsWidget, so
# every control below is looked up through `control()` rather than as a
# direct attribute of the tab.
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
set_language("vi") set_language("vi")
tab = DashboardTab(AppContext(AppConfig.load())) tab = DashboardTab(AppContext(AppConfig.load()))
@@ -53,7 +57,7 @@ def main() -> int:
app.processEvents() app.processEvents()
fails: list[str] = [] fails: list[str] = []
missing = [n for n in HEADER if getattr(tab, n, None) is None] missing = [n for n in HEADER if control(tab, n) is None]
print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}") print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}")
if missing: if missing:
fails.append(f"mat control: {missing}") fails.append(f"mat control: {missing}")
@@ -61,7 +65,7 @@ def main() -> int:
# Two rows: everything in the header must sit at one of exactly two y bands. # Two rows: everything in the header must sit at one of exactly two y bands.
tops = {} tops = {}
for n in HEADER: for n in HEADER:
w = getattr(tab, n) w = control(tab, n)
tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n) tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n)
print(f"so hang cua header : {len(tops)}") print(f"so hang cua header : {len(tops)}")
for band, names in sorted(tops.items()): for band, names in sorted(tops.items()):
@@ -70,14 +74,15 @@ def main() -> int:
fails.append(f"header co {len(tops)} hang, cho 2") fails.append(f"header co {len(tops)} hang, cho 2")
# Still wired: changing the metric must not throw and must stick. # Still wired: changing the metric must not throw and must stick.
before = tab.metric_combo.currentData() metric = control(tab, "metric_combo")
tab.metric_combo.setCurrentIndex(1 - tab.metric_combo.currentIndex()) before = metric.currentData()
metric.setCurrentIndex(1 - metric.currentIndex())
app.processEvents() app.processEvents()
after = tab.metric_combo.currentData() after = metric.currentData()
print(f"doi chi so bieu do : {before} -> {after}") print(f"doi chi so bieu do : {before} -> {after}")
if after == before: if after == before:
fails.append("combo chi so khong doi duoc") fails.append("combo chi so khong doi duoc")
tab.refresh_btn.click() control(tab, "refresh_btn").click()
app.processEvents() app.processEvents()
print("bam Lam moi : khong loi") print("bam Lam moi : khong loi")
+53 -34
View File
@@ -25,7 +25,16 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 from capture_screens import ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control,
)
# EPIC R08 split every screen's god-widget into child widgets, so controls
# this file used to read straight off the screen object now live one level
# down (DashboardTab.chart.gran_combo, ScheduleTaskTab.kanban.columns, the
# Monitoring Overview sections, the Settings sub-pages...). `control()`
# looks a name up anywhere in the screen's subtree, so this checker keeps
# measuring the real control and still returns None when one is truly gone.
def page_proposals(): def page_proposals():
@@ -107,7 +116,7 @@ def main() -> int:
"""How many distinct y-bands the named widgets occupy.""" """How many distinct y-bands the named widgets occupy."""
bands = set() bands = set()
for n in names: for n in names:
w = getattr(widget, n, None) w = control(widget, n)
if w is not None: if w is not None:
bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
return len(bands) return len(bands)
@@ -123,29 +132,31 @@ def main() -> int:
"currency_combo"]) "currency_combo"])
add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") 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 than the small tiles AND a bigger number = it reads as the headline.
taller = dash.card_cost.height() > dash.card_total.height() * 1.5 card_cost = control(dash, "card_cost")
bigger = "34px" in dash.card_cost.value_lbl.styleSheet() card_total = control(dash, "card_total")
taller = card_cost.height() > card_total.height() * 1.5
bigger = "34px" in card_cost.value_lbl.styleSheet()
add("dashboard", "Chi phí làm thẻ chính", taller and bigger, 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"cao {card_cost.height()}px vs thẻ phụ {card_total.height()}px · "
f"cỡ số {'34px' if bigger else 'như cũ'}") f"cỡ số {'34px' if bigger else 'như cũ'}")
# --- 2 Schedule Kanban --- # --- 2 Schedule Kanban ---
lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) lanes = len(control(sched, "columns") or {})
if not lanes: if not lanes:
from cowork_local.core.tasks import STATUSES from cowork_local.core.tasks import STATUSES
lanes = len(STATUSES) lanes = len(STATUSES)
add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
has_combo = getattr(sched, "view_combo", None) is not None has_combo = control(sched, "view_combo") is not None
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
"vẫn là combo" if has_combo else "đã thành tab") "vẫn là combo" if has_combo else "đã thành tab")
# The lane is only outlined while it actually holds something — seed data # 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. # may leave it empty, so drop a card in and read the style back.
run_col = sched.columns.get("running") run_col = (control(sched, "columns") or {}).get("running")
styled = "" styled = ""
if run_col is not None: if run_col is not None:
from PySide6.QtWidgets import QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
run_col.addItem(QListWidgetItem("probe")) run_col.addItem(QListWidgetItem("probe"))
sched.column_headers["running"].setStyleSheet("") control(sched, "column_headers")["running"].setStyleSheet("")
sched.refresh() sched.refresh()
app.processEvents() app.processEvents()
styled = run_col.styleSheet() styled = run_col.styleSheet()
@@ -184,13 +195,13 @@ def main() -> int:
# status line under the typing box, not inside it. So the test is that the # 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 # 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. # own strip below. Demanding an empty strip would mean deleting features.
composer = getattr(chat, "composer", None) composer = control(chat, "composer")
bar = getattr(composer, "extra_bar", None) bar = control(composer, "extra_bar")
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
typing = composer.input typing = composer.input
in_typing_row = typing.parentWidget() is composer in_typing_row = typing.parentWidget() is composer
below = bar is not None and bar.objectName() == "composerStatus" below = bar is not None and bar.objectName() == "composerStatus"
usage = getattr(chat, "_usage_total_lbl", None) usage = control(chat, "_usage_total_lbl")
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) 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", 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, below and usage_in_bar,
@@ -199,23 +210,25 @@ def main() -> int:
# --- 6 Co4E --- # --- 6 Co4E ---
add("workspace-co4e", "Bỏ dải tab flow", add("workspace-co4e", "Bỏ dải tab flow",
not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") not (control(co4e, "flow_scroll").isVisible()
or control(co4e, "flow_add_btn").isVisible()), "đã ẩn")
sections = control(co4e, "_sections") or []
add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách", 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") len(sections) >= 3, f"{len(sections)} mục xếp chồng")
add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải", 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") not control(co4e, "flow_scroll").isVisible(), "nav → cột trái → panel phải")
# --- 7 Folder / 8 GraphRAG --- # --- 7 Folder / 8 GraphRAG ---
folder = ws.tabs.widget(ws._folder_tab_idx) folder = ws.tabs.widget(ws._folder_tab_idx)
title_lbl = getattr(folder, "path_lbl", None) title_lbl = control(folder, "path_lbl")
add("workspace-folder", "Path bar gộp vào tiêu đề", add("workspace-folder", "Path bar gộp vào tiêu đề",
title_lbl is not None and getattr(folder, "path_edit", None) is None, title_lbl is not None and control(folder, "path_edit") is None,
f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") 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 # "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. # 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 # 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. # widgets what state they are in instead of how tall they currently are.
term = getattr(folder, "terminal", None) term = control(folder, "terminal")
lay = folder.layout() lay = folder.layout()
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
collapsed = term is not None and term._body.isHidden() collapsed = term is not None and term._body.isHidden()
@@ -227,13 +240,14 @@ def main() -> int:
# One row = the path box and Export share a y-band. # One row = the path box and Export share a y-band.
def band(w): def band(w):
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
one_row = band(graph.path_edit) == band(graph._export_btn) g_path, g_export = control(graph, "path_edit"), control(graph, "_export_btn")
one_row = band(g_path) == band(g_export)
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, 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}") f"path y≈{band(g_path) * 10} · Export y≈{band(g_export) * 10}")
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it # 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 # _msg_btn — a stale name); while it exists, this is still one button whose
# label flips, not a pair of tabs. # label flips, not a pair of tabs.
toggle = getattr(graph, "_msgs_toggle_btn", None) toggle = control(graph, "_msgs_toggle_btn")
add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is 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") "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
@@ -248,7 +262,7 @@ def main() -> int:
body = area.widget() body = area.widget()
if body is None or body.layout() is None: if body is None or body.layout() is None:
continue continue
if body.layout().indexOf(mon.ov_usage_group) >= 0: if body.layout().indexOf(control(mon, "ov_usage_group")) >= 0:
ov = body ov = body
break break
assert ov is not None, "khong tim thay cot Tong quan" assert ov is not None, "khong tim thay cot Tong quan"
@@ -257,25 +271,29 @@ def main() -> int:
"cột dọc" if one_col else "vẫn 2 cột") "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 # Its own section = it is a direct child of the single column, not sharing a
# row with the resource meters as it used to. # row with the resource meters as it used to.
own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 pricing = control(mon, "ov_pricing_group")
own = ov.layout().indexOf(pricing) >= 0
add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, 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") f"là mục riêng trong cột, rộng {pricing.width()}px")
strip = not mon.tabs.tabBar().isHidden() mon_tabs = control(mon, "tabs")
strip = not mon_tabs.tabBar().isHidden()
add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", 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") strip and mon_tabs.count() == 8, f"dải tab hiện={strip}, {mon_tabs.count()} tab")
# --- 17/18 dialogs --- # --- 17/18 dialogs ---
from cowork_local.ui.settings_dialog import SettingsDialog from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog from cowork_local.ui.task_editor_dialog import TaskEditorDialog
s = SettingsDialog(win.ctx) s = SettingsDialog(win.ctx)
s_list = control(s, "section_list")
add("dialog-settings", "Thêm cột mục lục bên trái", 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") s_list.count() == 5, f"{s_list.count()} mục")
# The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider # 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. # left as its own group — not everything merged together.
from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
in_general = s._general_box.isAncestorOf(s.language_combo) and \ gen_box = control(s, "_general_box")
s._general_box.isAncestorOf(s.theme_combo) in_general = gen_box.isAncestorOf(control(s, "language_combo")) and \
prov_apart = not s._general_box.isAncestorOf(s.provider_combo) gen_box.isAncestorOf(control(s, "theme_combo"))
prov_apart = not gen_box.isAncestorOf(control(s, "provider_combo"))
add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", 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, in_general and prov_apart,
f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
@@ -288,10 +306,11 @@ def main() -> int:
f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
s.close() s.close()
t = TaskEditorDialog(ctx=win.ctx) t = TaskEditorDialog(ctx=win.ctx)
rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] t_list, t_stack = control(t, "section_list"), control(t, "section_stack")
like_settings = (t.section_list.count() == 5 rows = [t_list.item(i).text() for i in range(t_list.count())]
and t.section_stack.count() == 5 like_settings = (t_list.count() == 5
and not hasattr(t, "step_tabs")) and t_stack.count() == 5
and control(t, "step_tabs") is None)
add("dialog-task-editor", 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", "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)) like_settings, " · ".join(rows))
+5 -1
View File
@@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import ( # noqa: E402 from capture_screens import ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of)
def main() -> int: def main() -> int:
@@ -50,6 +50,10 @@ def main() -> int:
win.show() win.show()
app.processEvents() app.processEvents()
st, w = win.structure, win.workspace st, w = win.structure, win.workspace
# R08-T14 split StructureGraphView: the browser view, the cached graph
# and the scan/render steps all moved onto GraphRenderer, while the shell
# only forwards the public methods. Probe the widget that owns them.
st = owner_of(st, "web") or st
fails: list[str] = [] fails: list[str] = []
# startup itself must not build any of it # startup itself must not build any of it
+15 -8
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import os import os
import sys import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path from pathlib import Path
@@ -21,7 +22,9 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 from capture_screens import ( # noqa: E402
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control,
)
# The rail, top to bottom, as the audit page's rail() helper draws it. # 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_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"]
@@ -122,7 +125,9 @@ def main() -> int:
app.processEvents() app.processEvents()
sched = win._page_widgets[win._ROW_SCHEDULE] sched = win._page_widgets[win._ROW_SCHEDULE]
from PySide6.QtWidgets import QScrollArea from PySide6.QtWidgets import QScrollArea
lanes = list(sched.columns.values()) # R08-T11 moved the Kanban lanes onto KanbanBoardWidget; the shell only
# holds the header and the view switch.
lanes = list(control(sched, "columns").values())
# The page holds more than one scroll area — take the one the lanes live in. # The page holds more than one scroll area — take the one the lanes live in.
board = next(sa for sa in sched.findChildren(QScrollArea) board = next(sa for sa in sched.findChildren(QScrollArea)
if sa.isAncestorOf(lanes[0])) if sa.isAncestorOf(lanes[0]))
@@ -141,15 +146,16 @@ def main() -> int:
for _ in range(8): for _ in range(8):
app.processEvents() app.processEvents()
dash = win._page_widgets[win._ROW_DASHBOARD] dash = win._page_widgets[win._ROW_DASHBOARD]
hero, small = dash.card_cost, dash.card_total # R08-T13 moved the stat cards onto TokenUsageCardWidget.
hero, small = control(dash, "card_cost"), control(dash, "card_total")
print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · "
f"the phu x={left_of(small, dash)} cao={small.height()}") f"the phu x={left_of(small, dash)} cao={small.height()}")
if left_of(hero, dash) >= left_of(small, dash): if left_of(hero, dash) >= left_of(small, dash):
fails.append("the Chi phi khong nam ben trai cac the phu") fails.append("the Chi phi khong nam ben trai cac the phu")
if hero.height() < small.height() * 1.5: if hero.height() < small.height() * 1.5:
fails.append("the Chi phi khong cao gap ruoi the phu") fails.append("the Chi phi khong cao gap ruoi the phu")
row1 = top_of(dash.card_total, dash) row1 = top_of(control(dash, "card_total"), dash)
row2 = top_of(dash.card_out, dash) row2 = top_of(control(dash, "card_out"), dash)
print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)")
if row2 <= row1: if row2 <= row1:
fails.append("4 the phu khong xep 2x2") fails.append("4 the phu khong xep 2x2")
@@ -159,7 +165,7 @@ def main() -> int:
for _ in range(8): for _ in range(8):
app.processEvents() app.processEvents()
dock = win.help_agent dock = win.help_agent
comp = ws._cowork.composer comp = control(ws._cowork, "composer")
dock_bottom = top_of(dock, win) + dock.height() dock_bottom = top_of(dock, win) + dock.height()
comp_top = top_of(comp, win) comp_top = top_of(comp, win)
print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}")
@@ -183,11 +189,12 @@ def main() -> int:
app.processEvents() app.processEvents()
import cowork_local.ui.co4e_tab as co4e_mod import cowork_local.ui.co4e_tab as co4e_mod
c4 = win.findChildren(co4e_mod.Co4ETab)[0] c4 = win.findChildren(co4e_mod.Co4ETab)[0]
xs = [c4._split.widget(i).x() for i in range(c4._split.count())] c4_split = control(c4, "_split")
xs = [c4_split.widget(i).x() for i in range(c4_split.count())]
print(f"Co4E 3 pane x = {xs}") print(f"Co4E 3 pane x = {xs}")
if xs != sorted(xs): if xs != sorted(xs):
fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)")
heads = [h.text() for h, _b, _s in c4._sections.values()] heads = [h.text() for h, _b, _s in (control(c4, "_sections") or {}).values()]
print(f"cot sidebar: {heads}") print(f"cot sidebar: {heads}")
if len(heads) != 4: if len(heads) != 4:
fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4")
+3 -1
View File
@@ -33,7 +33,9 @@ MUTATIONS = [
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
"check_layout_geometry.py"), "check_layout_geometry.py"),
("tra lane Running ve khong vien", ("tra lane Running ve khong vien",
"ui/schedule_task_tab.py", # R08-T11 doi cho: ScheduleTaskTab tach ra, phan ve Kanban (ke ca vien
# canh bao cua lane Running) nam o presentation/scheduling/.
"presentation/scheduling/kanban_board_widget.py",
'if status == "running" and counts[status]:', 'if status == "running" and counts[status]:',
'if False:', 'if False:',
"check_design_parity.py"), "check_design_parity.py"),