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>
451 lines
20 KiB
Python
451 lines
20 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
|
||
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("&", "&").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"))
|
||
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"))
|