Files
cowork-local/ui/help_agent_widget.py
T
NamPDTandClaude Opus 5 cb68f87b35 feat(ui): close the last gaps against the audit design (31/31)
check_design_parity.py reads its checklist from the audit page's own
proposals; it now reports every one of the 31 as implemented, with no
deliberate divergences left.

  * Schedule: the Kanban/Calendar drop-list became a pair of tabs, and the
    Running lane is outlined while it holds anything — dropping a card there
    starts the task for real, so it should not look like the other six.
  * Cowork: agent / routing / usage / folder moved out of the typing box into
    their own status strip beneath it, styled as status rather than a second
    toolbar. All of them stay interactive; the design's read-only strip would
    have cost features.
  * Folder: the path is written as the screen's title instead of sitting in a
    read-only text box that looked editable and cost a row.
  * GraphRAG: the second toolbar row is gone (Export joined the first), and
    the one button that relabelled itself became Đồ thị | Tin nhắn tabs, so
    the view you are NOT in is named too.
  * Settings gained the theme picker, so language / provider / theme are all
    reachable there as well as on the rail's account row.
  * Task editor: the five group boxes are grouped into three step tabs
    (Nội dung → Lịch chạy → Liên kết). All 22 fields verified present after
    the move; only the old section index is gone, replaced by the tabs.
  * The assistant dot now clears a screen's own bottom bar (Cowork's
    composer), measured from the composer's top edge in window coordinates.

Also adds .gitattributes: without it a Windows checkout records CRLF and
every file reads as fully rewritten to a Linux CI runner.

Verification: 7 check_*.py suites green, no screen clipped at 1920/1366/1280,
and no dialog scrolls sideways at 9/11/14pt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:25:25 +09:00

451 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Floating in-app Help assistant — the app icon pinned to the bottom-right of
the main window, on every screen. Click it to expand a compact chat panel that
greets the user (in the display language) and answers how-to-use-the-app
questions only. A chevron on its left collapses it to a thin tab at the screen
edge when the user doesn't want it visible.
It is deliberately minimal: no tools, no file access, no agent loop — just a
single ``provider.chat`` per message (same pattern as the AI-draft helpers),
scoped by the built-in "help" admin agent's system prompt (see
``core.admin_agents``: task_kind "help"). The agent is managed in Monitoring →
Agents Admin, so the Admin can pick which provider/model answers.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QTextBrowser,
QVBoxLayout, QWidget,
)
from ..core import admin_agents
from ..core.worker import AgentWorker
from ..i18n import tr
from .icons import icon
_ASSETS = Path(__file__).resolve().parent.parent / "assets"
_MARGIN = 18 # gap from the window's bottom-right corner
# Closed, the assistant is a single 26px dot. It used to be an 84×64 block (a
# 64px badge plus an 18px "hide" chevron beside it) sitting permanently over the
# bottom-right of every screen — on Cowork, right on top of the Send button —
# for something opened a few times a day. The name now appears on hover only,
# and "hide to the edge" moved into the panel's ⋯ menu.
_DOT = 26 # closed launcher (a round chip)
_DOT_ICON = 14 # the sparkle inside it
_PILL_PAD = 12 # extra width for the label when hovered
_TAB_W, _TAB_H = 28, 48 # the "show" tab when hidden at the edge (was 16 wide)
_PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
# The three states the floating assistant cycles through.
_HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
def _current_user() -> str:
return (os.environ.get("USERNAME") or os.environ.get("USER") or "").strip()
def _app_icon() -> QIcon:
"""The app's own icon.png (falls back to the generic robot glyph if the
asset is somehow missing)."""
p = _ASSETS / "icon.png"
return QIcon(str(p)) if p.exists() else icon("robot")
# _app_pixmap()/_IconTap were the 52px icon and its click-through QLabel for the
# old 64px badge. The launcher is a real button now, so both are gone.
class _HoverPill(QPushButton):
"""The closed launcher: a dot at rest, a labelled pill under the pointer.
Keyboard focus counts as hover, so the name is reachable without a mouse.
Resizing is delegated to the owner because this widget is inside an overlay
that has to re-pin itself to the window corner whenever its size changes.
"""
def __init__(self, owner):
super().__init__(owner)
self._owner = owner
self.open = False
def _set_open(self, value: bool) -> None:
if value == self.open:
return
self.open = value
self.setText(f" {tr('help_agent.badge')}" if value else "")
self._owner._layout_launcher()
def enterEvent(self, e): # noqa: N802 - Qt override
self._set_open(True)
super().enterEvent(e)
def leaveEvent(self, e): # noqa: N802 - Qt override
if not self.hasFocus():
self._set_open(False)
super().leaveEvent(e)
def focusInEvent(self, e): # noqa: N802 - Qt override
self._set_open(True)
super().focusInEvent(e)
def focusOutEvent(self, e): # noqa: N802 - Qt override
self._set_open(False)
super().focusOutEvent(e)
class HelpAgentWidget(QWidget):
"""Overlay child of the main window; anchors itself bottom-right and cycles
hidden-tab → launcher icon → expanded chat panel."""
status_message = Signal(str)
def __init__(self, ctx, parent=None, user_name: str = ""):
super().__init__(parent)
self.ctx = ctx
self._user_name = user_name or _current_user()
self._state = _LAUNCHER_ST
self._busy = False
self._worker: Optional[AgentWorker] = None
# Conversation history (excludes the system prompt, prepended per call).
# Seeded with the greeting so the panel always opens on a friendly hello.
self._history: List[Dict[str, str]] = [
{"role": "assistant", "content": self._greeting()}
]
self.setAttribute(Qt.WA_StyledBackground, True)
self._pal = self._compute_palette()
self._build_edge_tab()
self._build_launcher()
self._build_panel()
self._apply_style()
self._apply_state()
# ---- theming ----------------------------------------------------------
def _compute_palette(self):
"""The app's design tokens for the theme in effect. The whole dock —
header included — follows the app theme; a header locked to a light
strip stranded a bright bar in the middle of the dark UI."""
from ..theme import palette
return palette(getattr(self.ctx.config, "theme", "system"))
def apply_theme(self) -> None:
"""Re-style + re-render when the app theme switches (called from
MainWindow._apply_theme). The whole dock re-colours, icons included —
icons are painted bitmaps, so they must be rebuilt, not restyled."""
self._pal = self._compute_palette()
self._apply_style()
muted = self._pal.text_muted
self.edge_tab.setIcon(icon("chevron-left", color=muted))
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=self._pal.accent))
self.min_btn.setIcon(icon("minus", color=muted))
self._render()
def _apply_style(self) -> None:
"""The dock owns its own style sheet (it floats above the window, so the
app-wide sheet does not reach it cleanly) but draws every value from the
shared tokens — see theme.py."""
p = self._pal
r, rl = p.radius, p.radius_lg
self.setStyleSheet(f"""
/* Closed launcher: a {_DOT}px dot. `pill` flips to true on hover, when
the label comes out and the shape stretches to a rounded bar. */
#helpLauncher {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {_DOT // 2}px; color: {p.text}; font-weight: 600;
font-size: 12px; padding: 0; text-align: center; }}
#helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }}
#helpLauncher:hover {{ background: {p.hover}; border-color: {p.border_strong}; }}
#helpLauncher:focus {{ border: 1px solid {p.focus_ring}; }}
#helpEdgeTab {{ background: {p.surface}; border: 1px solid {p.border};
border-right: none; border-top-left-radius: {r}px;
border-bottom-left-radius: {r}px; }}
#helpEdgeTab:hover {{ background: {p.hover}; }}
#helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; color: {p.text}; }}
#helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }}
#helpHeader QLabel {{ background: transparent; color: {p.text}; }}
#helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px;
background: transparent; }}
#helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }}
#helpMinBtn:hover {{ background: {p.hover}; }}
#helpLog {{ background: {p.sunken}; border: none; color: {p.text};
padding: 4px 6px; }}
#helpInputRow {{ background: {p.surface};
border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }}
#helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px;
background: {p.surface_raised}; color: {p.text}; }}
#helpInput:focus {{ border: 1px solid {p.focus_ring}; }}
#helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }}
#helpSendBtn:hover {{ background: {p.accent_solid_hover}; }}
#helpSendBtn:disabled {{ background: {p.border_strong}; }}
""")
# ---- greeting / labels ------------------------------------------------
def _greeting(self) -> str:
name = self._user_name or tr("help_agent.default_user")
return tr("help_agent.greeting", name=name)
# ---- construction -----------------------------------------------------
def _build_edge_tab(self) -> None:
# Shown only while hidden: a thin tab at the right edge to bring the
# assistant back (chevron points left = "slide out").
self.edge_tab = QPushButton(self)
self.edge_tab.setObjectName("helpEdgeTab")
self.edge_tab.setIcon(icon("chevron-left", color=self._pal.text_muted))
self.edge_tab.setCursor(Qt.PointingHandCursor)
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
self.edge_tab.clicked.connect(self._show_launcher)
def _build_launcher(self) -> None:
# One control, one job: this opens the chat. The chevron that used to sit
# beside it (a second 18px hit target for a second meaning of "closed")
# is gone — hiding to the edge is now a line in the panel's ⋯ menu.
self.launcher = _HoverPill(self)
self.launcher.setObjectName("helpLauncher")
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=self._pal.accent))
self.launcher.setCursor(Qt.PointingHandCursor)
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
self.launcher.clicked.connect(self._expand)
def _build_panel(self) -> None:
self.panel = QFrame(self)
self.panel.setObjectName("helpPanel")
v = QVBoxLayout(self.panel)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
# Header: app icon + title + minimize (plain white bar, no colour fill)
header = QFrame(self.panel)
header.setObjectName("helpHeader")
hb = QHBoxLayout(header)
hb.setContentsMargins(12, 8, 8, 8)
self.title_icon = QLabel(header)
self.title_icon.setPixmap(_app_icon().pixmap(20, 20))
hb.addWidget(self.title_icon)
self.title = QLabel(tr("help_agent.title"), header)
self.title.setObjectName("helpTitle")
hb.addWidget(self.title, 1)
self.min_btn = QPushButton(header)
self.min_btn.setObjectName("helpMinBtn")
self.min_btn.setIcon(icon("minus", color=self._pal.text_muted))
self.min_btn.setFixedSize(24, 24)
self.min_btn.setCursor(Qt.PointingHandCursor)
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.min_btn.clicked.connect(self._collapse)
hb.addWidget(self.min_btn)
# "Hide to the right edge" lives here now, next to "minimise", instead of
# as a permanent 18px chevron on every screen. Same action, offered where
# the user is already interacting with the assistant.
self.more_btn = QPushButton("⋯", header)
self.more_btn.setObjectName("helpMinBtn")
self.more_btn.setFixedSize(24, 24)
self.more_btn.setCursor(Qt.PointingHandCursor)
self.more_btn.setToolTip(tr("help_agent.more_tooltip"))
menu = QMenu(self.more_btn)
self.act_collapse = menu.addAction(tr("help_agent.collapse_tooltip"))
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)
self.more_btn.setMenu(menu)
hb.addWidget(self.more_btn)
v.addWidget(header)
# Conversation log
self.log = QTextBrowser(self.panel)
self.log.setObjectName("helpLog")
self.log.setOpenExternalLinks(False)
v.addWidget(self.log, 1)
# Input row
row = QFrame(self.panel)
row.setObjectName("helpInputRow")
rb = QHBoxLayout(row)
rb.setContentsMargins(8, 8, 8, 8)
rb.setSpacing(6)
self.input = QLineEdit(row)
self.input.setObjectName("helpInput")
self.input.setPlaceholderText(tr("help_agent.placeholder"))
self.input.returnPressed.connect(self._send)
rb.addWidget(self.input, 1)
self.send_btn = QPushButton(row)
self.send_btn.setObjectName("helpSendBtn")
self.send_btn.setIcon(icon("send"))
self.send_btn.setFixedSize(32, 30)
self.send_btn.setCursor(Qt.PointingHandCursor)
self.send_btn.clicked.connect(self._send)
rb.addWidget(self.send_btn)
v.addWidget(row)
self._render()
# ---- state transitions ------------------------------------------------
def _expand(self) -> None:
self._state = _PANEL
self._apply_state()
self.input.setFocus()
def _collapse(self) -> None:
self._state = _LAUNCHER_ST
self._apply_state()
def _hide_to_edge(self) -> None:
self._state = _HIDDEN
self._apply_state()
def _show_launcher(self) -> None:
self._state = _LAUNCHER_ST
self._apply_state()
def _layout_launcher(self) -> None:
"""Size the overlay to the dot, or to the pill while it is hovered."""
w = _DOT
if self.launcher.open:
w = max(_DOT, self.launcher.fontMetrics()
.horizontalAdvance(self.launcher.text()) + _DOT + _PILL_PAD)
self.resize(w, _DOT)
self.launcher.setGeometry(0, 0, w, _DOT)
# Round while it is a dot, pill-shaped once the label is out.
self.launcher.setProperty("pill", bool(self.launcher.open))
self.launcher.style().unpolish(self.launcher)
self.launcher.style().polish(self.launcher)
self.reposition()
self.raise_()
def _apply_state(self) -> None:
st = self._state
self.edge_tab.setVisible(st == _HIDDEN)
self.launcher.setVisible(st == _LAUNCHER_ST)
self.panel.setVisible(st == _PANEL)
if st == _PANEL:
self.resize(_PANEL_W, _PANEL_H)
self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H)
elif st == _LAUNCHER_ST:
self._layout_launcher()
return # _layout_launcher repositions and raises
else: # hidden
self.resize(_TAB_W, _TAB_H)
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
self.reposition()
self.raise_()
# A screen whose bottom edge is an input row (Cowork's composer) must not
# have the dock sitting on top of it — set by MainWindow when the page
# changes, in window coordinates.
_bottom_guard = 0
def set_bottom_guard(self, height: int) -> None:
"""Reserve `height` px at the foot of the window for the page's own
controls; the dock floats above it instead of over the Send button."""
if height != self._bottom_guard:
self._bottom_guard = max(0, height)
self.reposition()
def reposition(self) -> None:
"""Pin to the parent's bottom-right corner (called on parent resize)."""
p = self.parentWidget()
if p is None:
return
x = max(0, p.width() - self.width() - _MARGIN)
y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard)
self.move(x, y)
# ---- rendering --------------------------------------------------------
def _bubble_html(self, who: str, content: str) -> str:
"""One message as a clearly-separated, labelled bubble: the user's turns
sit right-aligned with an accent tint, the assistant's left-aligned on a
neutral fill, each headed by its speaker name — so who said what is never
ambiguous. (QTextDocument has no border-radius, so filled table cells do
the bubble work.)"""
p = self._pal
text = (content or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = text.replace("\n", "<br>")
# bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended)
# rather than the translucent accent_soft used in style sheets.
if who == "user":
align, bg, label_color = "right", p.accent_wash, p.accent
label = tr("chat.you")
else:
align, bg, label_color = "left", p.surface_raised, p.success
label = tr("help_agent.title")
return (
f'<table width="100%" cellspacing="0" cellpadding="0"><tr>'
f'<td align="{align}">'
f'<table width="80%" cellspacing="0" cellpadding="7" bgcolor="{bg}"><tr>'
f'<td style="color:{p.text};">'
f'<b style="color:{label_color};">{label}</b><br>{text}'
f'</td></tr></table></td></tr></table>'
'<div style="line-height:6px;">&nbsp;</div>' # gap between turns
)
def _render(self, pending: bool = False) -> None:
parts = [self._bubble_html(m["role"], m["content"]) for m in self._history]
if pending:
parts.append(self._bubble_html("assistant", "…"))
self.log.setHtml("".join(parts))
self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
# ---- send a message ---------------------------------------------------
def _send(self) -> None:
if self._busy:
return
text = self.input.text().strip()
if not text:
return
self.input.clear()
self._history.append({"role": "user", "content": text})
self._set_busy(True)
self._render(pending=True)
agent = admin_agents.ensure_help_agent(
admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
history = list(self._history)
def job(worker):
provider = admin_agents.build_agent_provider(self.ctx, agent)
messages = [{"role": "system", "content": agent.effective_prompt()}] + history
result = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
content = result.get("content", "") if isinstance(result, dict) else str(result)
return {"content": provider.strip_think(content) or ""}
worker = AgentWorker(job)
worker.finished_ok.connect(self._on_reply)
worker.failed.connect(self._on_failed)
self._worker = worker
worker.start()
def _on_reply(self, result: Dict[str, Any]) -> None:
content = (result or {}).get("content", "").strip() or tr("help_agent.empty_reply")
self._history.append({"role": "assistant", "content": content})
self._set_busy(False)
self._render()
def _on_failed(self, err: str) -> None:
self._history.append({"role": "assistant",
"content": tr("help_agent.error", error=err)})
self._set_busy(False)
self._render()
def _set_busy(self, busy: bool) -> None:
self._busy = busy
self.input.setEnabled(not busy)
self.send_btn.setEnabled(not busy)
def retranslate(self) -> None:
self.title.setText(tr("help_agent.title"))
self.input.setPlaceholderText(tr("help_agent.placeholder"))
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
if self.launcher.open:
self.launcher.setText(f" {tr('help_agent.badge')}")
self._layout_launcher()
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.more_btn.setToolTip(tr("help_agent.more_tooltip"))
self.act_collapse.setText(tr("help_agent.collapse_tooltip"))
self.act_hide.setText(tr("help_agent.hide_tooltip"))
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))