Restore the two gating rules the redesign dropped, and put the dot back in its corner

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) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-18 12:30:50 +09:00
co-authored by Claude Opus 5
parent 16406f863f
commit a0cef768c9
6 changed files with 249 additions and 22 deletions
+10 -2
View File
@@ -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:
+9 -5
View File
@@ -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": {
+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())
+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())
+20 -2
View File
@@ -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()
+14 -13
View File
@@ -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: