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>
390 lines
17 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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;"> </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"))
|