From a0cef768c904120e1bece1d22e43b521f117b50d Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Tue, 18 Aug 2026 12:30:50 +0900 Subject: [PATCH] Restore the two gating rules the redesign dropped, and put the dot back in its corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 — hiding the assistant from the dot. The old build kept a chevron button beside the dot wired to _hide_to_edge; the redesign moved that action into the panel's ⋯ menu, so hiding went from one click to open-panel → ⋯ → Ẩn. The drawing has no button there (26px, no text, no chevron), so the reach comes back as a right-click on the dot, with the tooltip saying so. A2 — per-part cost. Folding Input/Output/Cache into the total tile's sub-line carried their token counts but not their prices, which live only on those three cards, so the cost breakdown left the screen entirely. The wireframe asks for the tiles anyway — "$0.31 Tổng chi phí · 57 lượt / 395.4K Tổng token / 292.8K Input / 102.7K Output / 108.9K Cache" — so they are back as tiles, each with its price, and the turn count rides on the cost tile's label as drawn. The dot on Cowork. _update_dock_guard lifted it whenever a composer existed, testing only the vertical axis. On a wide window the composer ends at the chat column's right edge — x=1688 against a dot at x=1892 — so the dot rose 156px to clear something that was never under it, and Cowork became the one screen where it was not in the corner. It now lifts only when the two actually overlap: 18px from the bottom at 1936px wide, still lifted at 1200 and 900 where the composer does reach under it. Also: open_tooltip and show_tooltip still said "App Assistant" after the rename. check_dock_corner covers the corner rule at three widths and fails if the lift comes back unconditionally. 18/18 checkers pass. Co-Authored-By: Claude Opus 5 (1M context) --- app.py | 12 ++++- i18n.py | 14 +++-- tools/audit_gating.py | 102 +++++++++++++++++++++++++++++++++++++ tools/check_dock_corner.py | 94 ++++++++++++++++++++++++++++++++++ ui/help_agent_widget.py | 22 +++++++- ui/monitoring_tab.py | 27 +++++----- 6 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 tools/audit_gating.py create mode 100644 tools/check_dock_corner.py diff --git a/app.py b/app.py index 8d268c7..b29bb58 100644 --- a/app.py +++ b/app.py @@ -1108,8 +1108,16 @@ class MainWindow(QMainWindow): # Measured from the composer's TOP edge in window coordinates: # its own height misses the extra row of controls laid out under # it, which left the dot still overlapping by ~25px. - top = comp.mapTo(self, comp.rect().topLeft()).y() - guard = max(0, self.height() - top + 8) + origin = comp.mapTo(self, comp.rect().topLeft()) + # ...but only lift the dot if the composer is actually beneath + # it. The composer stops at the chat column's right edge, well + # short of the dot, so lifting it there raised the dot 156px for + # nothing — on Cowork alone it sat off the corner every other + # screen keeps it in. + dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x() + dock_right = dock_left + dock.width() + if dock_right > origin.x() and dock_left < origin.x() + comp.width(): + guard = max(0, self.height() - origin.y() + 8) dock.set_bottom_guard(guard) def _on_projects_changed(self) -> None: diff --git a/i18n.py b/i18n.py index 788b5f3..c4112cc 100644 --- a/i18n.py +++ b/i18n.py @@ -498,13 +498,17 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Ask how to use the app…", "ja": "アプリの使い方を質問…", "vi": "Hỏi cách sử dụng app…"}, "help_agent.open_tooltip": { - "en": "App Assistant — help using the app", - "ja": "アプリアシスタント — アプリの使い方をサポート", - "vi": "Trợ lý App — hỗ trợ sử dụng app"}, + "en": "AI Assistant — help using the app", + "ja": "AI Assistant — アプリの使い方をサポート", + "vi": "AI Assistant — hỗ trợ sử dụng app"}, "help_agent.collapse_tooltip": { "en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"}, "help_agent.hide_tooltip": { "en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"}, + "help_agent.dot_hint": { + "en": "right-click to hide", + "ja": "右クリックで非表示", + "vi": "chuột phải để ẩn"}, # The name on the launcher pill. Deliberately the same in every language — # it is a product name, and it only shows on hover, so length is not a # constraint the way it was on a permanently visible badge. @@ -513,8 +517,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "help_agent.more_tooltip": { "en": "More", "ja": "その他", "vi": "Thêm"}, "help_agent.show_tooltip": { - "en": "Show the App Assistant", "ja": "アプリアシスタントを表示", - "vi": "Hiện App Assistant"}, + "en": "Show the AI Assistant", "ja": "AI Assistant を表示", + "vi": "Hiện AI Assistant"}, "help_agent.empty_reply": { "en": "(no answer)", "ja": "(回答なし)", "vi": "(không có phản hồi)"}, "help_agent.error": { diff --git a/tools/audit_gating.py b/tools/audit_gating.py new file mode 100644 index 0000000..e550b10 --- /dev/null +++ b/tools/audit_gating.py @@ -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[\w\.\[\]\(\)_]*?)\.?(?PsetVisible|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()) diff --git a/tools/check_dock_corner.py b/tools/check_dock_corner.py new file mode 100644 index 0000000..3b01e15 --- /dev/null +++ b/tools/check_dock_corner.py @@ -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()) diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index b90b28c..d250c27 100644 --- a/ui/help_agent_widget.py +++ b/ui/help_agent_widget.py @@ -223,7 +223,8 @@ class HelpAgentWidget(QWidget): self.launcher.setObjectName("helpLauncher") self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) self.launcher.setCursor(Qt.PointingHandCursor) - self.launcher.setToolTip(tr("help_agent.open_tooltip")) + self.launcher.setToolTip( + f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}') self.launcher.clicked.connect(self._expand) def _build_panel(self) -> None: @@ -267,6 +268,12 @@ class HelpAgentWidget(QWidget): self.act_collapse.triggered.connect(self._collapse) self.act_hide = menu.addAction(tr("help_agent.hide_tooltip")) self.act_hide.triggered.connect(self._hide_to_edge) + # The old build put a chevron button beside the dot that hid the + # assistant in one click. The drawing has no such button — the dot is + # 26px, no text, no chevron — so the same reach comes back as a right- + # click on the dot rather than as pixels next to it. + self.launcher.setContextMenuPolicy(Qt.CustomContextMenu) + self.launcher.customContextMenuRequested.connect(self._dot_menu) self.more_btn.setMenu(menu) hb.addWidget(self.more_btn) v.addWidget(header) @@ -309,6 +316,16 @@ class HelpAgentWidget(QWidget): self._state = _LAUNCHER_ST self._apply_state() + def _dot_menu(self, pos) -> None: + """Right-click on the dot: hide to the edge, the one action that means + anything while the panel is shut ("collapse to dot" already happened).""" + from PySide6.QtWidgets import QMenu + + menu = QMenu(self.launcher) + act = menu.addAction(tr("help_agent.hide_tooltip")) + act.triggered.connect(self._hide_to_edge) + menu.exec(self.launcher.mapToGlobal(pos)) + def _hide_to_edge(self) -> None: self._state = _HIDDEN self._apply_state() @@ -460,7 +477,8 @@ class HelpAgentWidget(QWidget): self._render() self.title.setText(tr("help_agent.title")) self.input.setPlaceholderText(tr("help_agent.placeholder")) - self.launcher.setToolTip(tr("help_agent.open_tooltip")) + self.launcher.setToolTip( + f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}') if self.launcher.open: self.launcher.setText(f" {tr('help_agent.badge')}") self._layout_launcher() diff --git a/ui/monitoring_tab.py b/ui/monitoring_tab.py index e9a037e..b07c8fe 100644 --- a/ui/monitoring_tab.py +++ b/ui/monitoring_tab.py @@ -459,13 +459,16 @@ class MonitoringTab(QWidget): self.ov_usage_cache = StatCard() self.ov_usage_cost = StatCard() self.ov_usage_calls = StatCard() - for i, card in enumerate((self.ov_usage_total, self.ov_usage_cost, - self.ov_usage_calls)): + # The wireframe's usage block is five figures: cost (with the turn + # count on its label), then Tổng token / Input / Output / Cache. Folding + # the last three into a sub-line took their PER-PART COST off screen — + # the total tile has room for the token counts but not for three more + # prices — and the drawing asks for the tiles anyway. + for i, card in enumerate((self.ov_usage_cost, self.ov_usage_total, + self.ov_usage_in, self.ov_usage_out, + self.ov_usage_cache)): usage_lay.addWidget(card, 0, i) - # Kept alive and updated, but off the KPI row: their figures ride on the - # token tile's sub-line instead of taking three tiles of their own. - for hidden in (self.ov_usage_in, self.ov_usage_out, self.ov_usage_cache): - hidden.setVisible(False) + self.ov_usage_calls.setVisible(False) # rides on the cost tile's label # Budget: remaining/budget, direct entry, auto-warns red past 85% used — # same box (and same usage.budget_* config) as the Dashboard's. self.ov_budget_card = BudgetCard() @@ -916,11 +919,7 @@ class MonitoringTab(QWidget): events = ut.load_events() s = ut.summarize(events) costs = ut.cost_usd_events(events, pricing) - self.ov_usage_total.set( - tr("dashboard.card_total"), fmt_tokens(s["total"]), - f'{tr("dashboard.card_in")} {fmt_tokens(s["in"])} · ' - f'{tr("dashboard.card_out")} {fmt_tokens(s["out"])} · ' - f'{tr("dashboard.card_cache")} {fmt_tokens(s["cache"])}') + self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"])) self.ov_usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "") self.ov_usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]), ut.format_cost(costs["in"], pricing)) @@ -928,8 +927,10 @@ class MonitoringTab(QWidget): ut.format_cost(costs["out"], pricing)) self.ov_usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]), ut.format_cost(costs["cache"], pricing)) - self.ov_usage_cost.set(tr("dashboard.card_cost"), - ut.format_cost(sum(costs.values()), pricing, digits=2)) + # "Tổng chi phí · 57 lượt", exactly as the wireframe labels it. + self.ov_usage_cost.set( + f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}', + ut.format_cost(sum(costs.values()), pricing, digits=2)) self._refresh_budget() def _sync_sbx_more_label(self, *_a) -> None: