Files
cowork-local/ui/help_agent_widget.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
The Gitea repo was initialised from an earlier snapshot, so main and the
machine this runs on had drifted apart in 153 files before any UI work
started. This commit brings the branch up to the local tree as it stood
on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows
shows up as its own reviewable diff instead of being mixed in with the
pre-existing divergence.

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

390 lines
17 KiB
Python

"""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, QPixmap
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QLineEdit, 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
_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2)
_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it
_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it
_GAP = 2
_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge
_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")
def _app_pixmap(size: int) -> QPixmap:
"""icon.png scaled to ``size`` (smooth), for the launcher badge label."""
p = _ASSETS / "icon.png"
if p.exists():
return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return icon("robot").pixmap(size, size)
class _IconTap(QLabel):
"""A QLabel that behaves like a button (click → signal) — used for the
launcher badge so it carries NO QPushButton chrome/box, just the icon on a
clean rounded card."""
clicked = Signal()
def mousePressEvent(self, e): # noqa: N802 - Qt override
if e.button() == Qt.LeftButton:
self.clicked.emit()
e.accept()
return
super().mousePressEvent(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.collapse_btn.setIcon(icon("chevron-right", color=muted))
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"""
/* The app-icon badge that opens the dock: a plain card, no button box. */
#helpLauncher {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; }}
#helpLauncher:hover {{ background: {p.hover}; }}
#helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none;
border-radius: {r}px; }}
#helpCollapseBtn:hover, #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:
# A left-side chevron collapses the assistant to the edge…
self.collapse_btn = QPushButton(self)
self.collapse_btn.setObjectName("helpCollapseBtn")
self.collapse_btn.setIcon(icon("chevron-right", color=self._pal.text_muted))
self.collapse_btn.setCursor(Qt.PointingHandCursor)
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.collapse_btn.clicked.connect(self._hide_to_edge)
# …and the app icon itself opens the chat — a clean rounded badge (like
# image 2), NOT a QPushButton (which added a pale box around the icon).
self.launcher = _IconTap(self)
self.launcher.setObjectName("helpLauncher")
self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER)
self.launcher.setAlignment(Qt.AlignCenter)
self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON))
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)
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 _apply_state(self) -> None:
st = self._state
self.edge_tab.setVisible(st == _HIDDEN)
self.collapse_btn.setVisible(st == _LAUNCHER_ST)
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:
w = _LAUNCHER + _GAP + _COLLAPSE_W
self.resize(w, _LAUNCHER)
# Icon on the left, the collapse chevron on the RIGHT (toward the
# screen edge it tucks into).
self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER)
self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2,
_COLLAPSE_W, _COLLAPSE_H)
else: # hidden
self.resize(_TAB_W, _TAB_H)
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
self.reposition()
self.raise_()
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.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"))
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))