"""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. # The audit page draws this at 26px ("26×26 · không chữ, không chevron"). # Doubled at the user's request: 26 read as too small to notice on a 1920 # screen. Still half the area of the 84×64 button it replaced. _DOT = 52 # closed launcher (a round chip) _DOT_ICON = 28 # 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 # Straight from docs/ui-audit.html (.wf .fab / .fabpill / .spark): the # assistant is teal, not the app accent, and the same in both themes — # it is one recognisable object floating over every screen. _TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4" _TEAL_TEXT = "#0F6E62" # The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a # colour emoji ignores CSS colour — so what the page actually renders is the # gold star. Sampled from the page's own render of section 27 (1055 pixels of # the star, averaged): #FDBE59. _SPARK_GOLD = "#FDBE59" # 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. # Kept by identity so retranslate() can rewrite it without having to # guess which language the visible text is in — and without touching a # real reply that happens to look like a greeting. self._greet_msg: Dict[str, str] = { "role": "assistant", "content": self._greeting()} self._history: List[Dict[str, str]] = [self._greet_msg] 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=_TEAL_TEXT)) self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) 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: {_TEAL_BG}; border: 1px solid {_TEAL_LINE}; border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700; font-size: 12px; padding: 0; text-align: center; }} #helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }} #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }} #helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }} #helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE}; border-right: none; border-top-left-radius: {r}px; border-bottom-left-radius: {r}px; }} #helpEdgeTab:hover {{ background: #D5EFEA; }} #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=_TEAL_TEXT)) 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=_SPARK_GOLD)) self.launcher.setCursor(Qt.PointingHandCursor) 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: 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( icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16)) 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) # No ⋯ menu. The audit page put "Ẩn trợ lý" in one, but its two entries # were "thu nhỏ" — which the − button beside it already does — and # "ẩn vào cạnh phải". A drop-list to reach one action that duplicates # its neighbour is chrome; removed at the user's request. # # Hiding stays reachable by right-click, on the header while the panel # is open and on the dot while it is shut, so no route is lost. for target in (header, self.launcher): target.setContextMenuPolicy(Qt.CustomContextMenu) target.customContextMenuRequested.connect( lambda pos, w=target: self._hide_menu(w, pos)) 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_menu(self, widget, pos) -> None: """Right-click, on the dot or the open panel's header: hide to the edge. The only action worth offering here — collapsing is what the − button and the dot itself already are. """ from PySide6.QtWidgets import QMenu menu = QMenu(widget) act = menu.addAction(tr("help_agent.hide_tooltip")) act.triggered.connect(self._hide_to_edge) menu.exec(widget.mapToGlobal(pos)) 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("&", "&").replace("<", "<").replace(">", ">") text = text.replace("\n", "
") # 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'' f'
' f'' f'
' f'{label}
{text}' f'
' '
 
' # 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: # The transcript is rendered HTML, so switching language left the # greeting — and every "AI Assistant" speaker label — in the language # the panel was built in. if self._history and self._history[0] is self._greet_msg: self._greet_msg["content"] = self._greeting() self._render() self.title.setText(tr("help_agent.title")) self.input.setPlaceholderText(tr("help_agent.placeholder")) 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() self.min_btn.setToolTip(tr("help_agent.collapse_tooltip")) self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))