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: