Files
cowork-local/ui/chat_panel.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

1740 lines
86 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.
"""Base chat panel shared by the Cowork and Code tabs.
Provides: streaming transcript, a message queue, and history autosave.
Several messages can run **at the same time** inside one tab: each turn owns its
own worker thread and its own turn-context (assistant bubble, transcript record,
message list, output folder), so their streaming output and files never collide.
The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5);
extra messages wait in the composer queue and start automatically as slots free
up. Graph events are still forwarded per session.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtCore import QFileSystemWatcher
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter,
QVBoxLayout, QWidget,
)
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView, ThinkingIndicator
from .composer import Composer
from .icons import collapse_right_icon, icon as app_icon
from .osutil import is_image, open_path
from .widgets import CollapsibleSection, CollapseStrip, PlanSection
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
# Friendly "what the agent is doing now" translation keys for the working
# indicator, so a long file/document build reads as "Creating…" rather than a
# generic "Running".
_TOOL_STATUS = {
"save_file": "chat.creating",
"write_file": "chat.creating",
"run_command": "chat.creating",
"edit_file": "chat.editing",
"install_package": "chat.installing",
"read_file": "chat.reading",
}
def _format_plan_steps(steps) -> str:
"""Render plan steps ``[{title, status}]`` as an icon checklist for the chat."""
lines = []
for s in steps or []:
title = str((s or {}).get("title", "")).strip()
if not title:
continue
icon = _PLAN_ICONS.get(str((s or {}).get("status", "pending")).lower(), "○")
lines.append(f"{icon} {title}")
return "\n".join(lines)
def _is_scratch(path: str) -> bool:
"""True for helper/intermediate files (kept out of the Output list)."""
try:
return ".scratch" in Path(path).parts
except Exception: # noqa: BLE001
return False
class ChatPanel(QWidget):
graph_event = Signal(str, dict) # (session_name, event)
turn_finished = Signal(dict)
status_message = Signal(str)
output_changed = Signal(str) # workspace dir; emitted when a file is written
history_changed = Signal() # a session was created/updated → refresh History
def __init__(self, ctx: AppContext, kind: str, session_name: str,
placeholder_key: str = "composer.placeholder_default"):
super().__init__()
from ..core.history import new_session_id
self.ctx = ctx
self.kind = kind
self.session_name = session_name
self.session_id = new_session_id()
self.title = ""
# Which project (workspace) this conversation belongs to — every new
# thread inherits the currently selected project (Claude-Projects style).
self.project_id = "default"
self.messages: List[Dict[str, Any]] = []
# self.worker points at the most-recently-started worker (kept for
# back-compat); every running turn is tracked in self._active so several
# can run concurrently. Each value is a turn-context dict — see _start_turn.
self.worker: AgentWorker | None = None
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
self._turn_seq: int = 0
# session_id -> its live messages list, for every conversation that still has
# a turn running. Lets you start a new chat / reopen an old one WHILE work
# runs: the running turn keeps writing to its own conversation in the
# background, and reopening it attaches to the SAME list (never a stale disk
# copy), so the two never race on save.
self._sessions_live: Dict[str, List[Dict[str, Any]]] = {}
self._teams_worker: AgentWorker | None = None
self.turns: List[Dict[str, Any]] = []
# File system watcher — watches the workspace/output folder for new files
# and auto-loads them into the agent's context on the next turn.
self._file_watcher = QFileSystemWatcher(self)
self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed)
self._known_files: set = set() # set of known file paths in the watched dir
self._watch_debounce = QTimer(self)
self._watch_debounce.setSingleShot(True)
self._watch_debounce.setInterval(800) # debounce rapid file changes
self._watch_debounce.timeout.connect(self._process_new_watched_files)
self._watched_dir: Optional[Path] = None
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self._toolbar = QWidget()
self.toolbar_v = QVBoxLayout(self._toolbar)
self.toolbar_v.setContentsMargins(10, 8, 10, 4)
self.toolbar_v.setSpacing(4)
self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows
self.toolbar_layout.setSpacing(8)
self.toolbar_v.addLayout(self.toolbar_layout)
root.addWidget(self._toolbar)
self.chat_view = ChatView()
self.composer = Composer(placeholder_key)
self.composer.submitted.connect(self.submit)
self.composer.stop_requested.connect(self.stop)
self.composer.attachments_added.connect(self._on_attachments_added)
self.composer.attachment_removed.connect(self._on_attachment_removed)
self.composer.attach_limit_note.connect(self.status_message)
self.composer.manage_skills.connect(self._open_skills_manager)
self.composer.set_max_attachments(
int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0))
# Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left,
# updated after each turn; cost uses the Monitoring model-price table.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
self.composer.add_bottom_left(self._usage_total_lbl)
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
# qwen for the local provider). Cowork and Code pick independently and
# run in parallel. The list is fetched from the active provider.
# The per-tab Agent defaults to the Settings model on startup; a manual
# pick (override) is remembered only until the active provider changes.
self._model = ctx.config.provider_conf().get("model", "")
self._agent_provider = ctx.config.active_provider
self._agent_user_override = False
self._admin_agent = None # selected Admin-defined agent preset, if any
# Auto Model Routing override for the NEXT turn (set by _apply_routing when
# the router picks a different model). None → use the tab's own selection.
self._routed_provider: Optional[str] = None
self._routed_model: Optional[str] = None
self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch)
self._pending_agent_switch_review = False
self._agent_worker: AgentWorker | None = None
self._agent_lbl = QLabel(tr("chatpanel.agent_label"))
self._agent_lbl.setObjectName("hint")
self.agent_combo = QComboBox()
self.agent_combo.setMinimumWidth(150)
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
self.composer.add_bottom_right(self._agent_lbl)
self.composer.add_bottom_right(self.agent_combo)
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
# model per message (see core/routing + _apply_routing).
from .routing_toggle import RoutingToggle
self.routing_toggle = RoutingToggle(ctx, self.kind)
self.composer.add_bottom_right(self.routing_toggle)
# Manual "compress conversation" — trim old history to cut tokens.
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
self.compress_btn.setIcon(app_icon("compress"))
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
self.compress_btn.clicked.connect(self._compress_messages)
self.composer.add_bottom_right(self.compress_btn)
self.refresh_agents()
# Chat column: transcript expands, the chat box is pinned at the bottom.
chat_col = QWidget()
cc = QVBoxLayout(chat_col)
cc.setContentsMargins(0, 0, 0, 0)
cc.setSpacing(0)
cc.addWidget(self.chat_view, 1)
self.thinking = ThinkingIndicator() # animated "working…" line while we wait
cc.addWidget(self.thinking)
composer_wrap = QWidget()
cwl = QVBoxLayout(composer_wrap)
cwl.setContentsMargins(8, 4, 8, 8)
cwl.addWidget(self.composer)
cc.addWidget(composer_wrap)
self.center_split = QSplitter(Qt.Horizontal)
self.center_split.addWidget(chat_col)
root.addWidget(self.center_split, 1)
# Right sidebar: Output files only (see below — Input is tracked but
# not shown).
self.input_section = CollapsibleSection(tr("widgets.input_files"))
self.output_section = CollapsibleSection(tr("widgets.output_files"))
# Input files are NOT shown in Cowork's UI anymore — but they're still
# fully tracked (add/remove/paths()) exactly as before, since that list
# is what gets written into the conversation's own "inputs" field on
# save (kept alongside the conversation; nothing here deletes the
# user's actual files — the conversation JSON itself only disappears
# when the conversation is deleted, same as always). Give input_section
# a real, permanently-hidden PARENT (not just "never added to a layout")
# so its own internal auto-show-on-add() call can never pop it up as a
# stray floating window.
self._input_hidden_host = QWidget(self)
self._input_hidden_host.setVisible(False)
_hh_lay = QVBoxLayout(self._input_hidden_host)
_hh_lay.setContentsMargins(0, 0, 0, 0)
_hh_lay.addWidget(self.input_section)
self.plan_section = PlanSection(tr("widgets.plan_title")) # live step checklist, above the Files panel
# The plan is shown INLINE in the conversation now (see add_plan), so this
# legacy right-panel checklist is parked inside the permanently-hidden
# host. Without a parent it would pop as a stray top-level "Plan (N)"
# window the moment set_steps() made it visible — parenting it here keeps
# its set_steps/clear calls truly inert (a hidden ancestor never renders).
_hh_lay.addWidget(self.plan_section)
self.input_section.activated.connect(self._open_io_item)
self.output_section.activated.connect(self._open_io_item)
# Right-click a file → Open / "View & AI Edit" (in-app viewer+editor).
for section in (self.input_section, self.output_section):
section.list.setContextMenuPolicy(Qt.CustomContextMenu)
section.list.customContextMenuRequested.connect(
lambda pos, s=section: self._io_context_menu(s, pos))
self._io_widget = QWidget()
iol = QVBoxLayout(self._io_widget)
iol.setContentsMargins(6, 6, 6, 6)
iol.setSpacing(4)
io_hdr = QHBoxLayout()
self._io_collapse_btn = QPushButton()
self._io_collapse_btn.setIcon(collapse_right_icon())
self._io_collapse_btn.setFixedWidth(28)
self._io_collapse_btn.clicked.connect(lambda: self._set_io_collapsed(True))
self._files_header = QLabel()
self._files_header.setStyleSheet("font-weight:600;")
io_hdr.addWidget(self._io_collapse_btn)
io_hdr.addWidget(self._files_header, 1)
# The plan now shows INLINE in the conversation (an expandable block whose
# steps tick off as they complete), not in this right panel — so it's kept
# out of the layout here. The object stays (its set_steps/clear calls are
# harmless no-ops on a hidden widget).
self.plan_section.setVisible(False)
iol.addLayout(io_hdr)
bl_host = QWidget()
bl = QVBoxLayout(bl_host)
bl.setContentsMargins(0, 0, 0, 0)
bl.setSpacing(4)
bl.addWidget(self.output_section) # Output only — Input is tracked but hidden
bl.addStretch(1)
iol.addWidget(bl_host, 1)
# Collapsing shrinks the panel to a thin clickable line (not hidden).
# The collapse button lives in the panel header; the strip re-expands.
self._io_strip = CollapseStrip(tr("chatpanel.expand_files_tooltip"), expand_dir="left")
self._io_strip.clicked.connect(lambda: self._set_io_collapsed(False))
self._io_strip.setVisible(False)
self._io_pane = QWidget()
pl = QHBoxLayout(self._io_pane)
pl.setContentsMargins(0, 0, 0, 0)
pl.setSpacing(0)
pl.addWidget(self._io_strip)
pl.addWidget(self._io_widget, 1)
self.center_split.addWidget(self._io_pane)
self.center_split.setStretchFactor(0, 1)
self.center_split.setStretchFactor(1, 0)
self.center_split.setChildrenCollapsible(False)
self.center_split.setSizes([820, 220])
on_language_changed(self._retranslate_base)
def _retranslate_base(self) -> None:
"""Re-apply the current language to the chrome shared by every tab
(Cowork/Code toolbars call their own retranslate on top of this)."""
self._agent_lbl.setText(tr("chatpanel.agent_label"))
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
self.compress_btn.setText(tr("chatpanel.compress_btn"))
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
self.input_section.set_title(tr("widgets.input_files"))
self.output_section.set_title(tr("widgets.output_files"))
self.plan_section.set_title(tr("widgets.plan_title"))
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
self._files_header.setText(tr("chatpanel.files_header"))
self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip"))
def apply_theme(self) -> None:
"""Re-apply theme styles to the chat view so all existing message bubbles
adapt when the app switches between light and dark modes."""
self.chat_view.apply_theme()
# ---- hooks for subclasses ---------------------------------------
def build_job(self, text: str, messages: List[Dict[str, Any]],
out_dir: Optional[Path]):
"""Return the agent job for this turn.
``messages`` is the turn's OWN message list (a snapshot of the history so
far plus the new user message) — the job must read/append to it, never to
``self.messages``, so parallel turns don't race. ``out_dir`` is the turn's
isolated output folder (or None when the tab produces no files)."""
raise NotImplementedError
def _turn_output_dir(self, turn_id: str) -> Optional[Path]:
"""Isolated output folder for one turn (None = share/no files). Overridden
by tabs that write files, so concurrent turns never clobber each other."""
return None
def assistant_title(self) -> str:
return tr("chat.assistant")
def workspace_dir(self) -> Optional[Path]:
"""Folder shown via the 'open folder' link on messages (None = no link)."""
return None
def register_output(self, path: str) -> None:
"""Add a finished file to the Output list — skips intermediate/helper
files (.scratch/ and, for Cowork, generator scripts) so only real
deliverables show up. Auto-expands the Files panel if it was collapsed."""
if _is_scratch(path) or self._is_intermediate_output(path):
return
# Auto-expand the Files panel if collapsed so the new file is visible.
if not self._io_widget.isVisible():
self._set_io_collapsed(False)
self.output_section.add(path)
wd = self.workspace_dir()
if wd:
# Let the Structure (RAG) graph auto-refresh from this workspace.
self.output_changed.emit(str(wd))
# ---- file system watcher for auto-loading new files --------------
def _start_watching(self, directory: Path) -> None:
"""Start watching ``directory`` for new files. When new supported files
appear, they are automatically loaded into the agent's context on the
next turn (via ``_augment``)."""
if self._watched_dir == directory:
return
self._stop_watching()
try:
directory = directory.resolve()
if not directory.is_dir():
return
self._watched_dir = directory
self._file_watcher.addPath(str(directory))
# Snapshot the current set of files so we can detect NEW ones.
self._known_files = set(
str(p) for p in directory.iterdir()
if p.is_file() and not p.name.startswith(".")
and p.suffix.lower() in self._INPUT_EXTS
)
except OSError:
self._watched_dir = None
self._known_files = set()
def _stop_watching(self) -> None:
"""Stop watching the current directory."""
if self._watched_dir is not None:
try:
self._file_watcher.removePath(str(self._watched_dir))
except OSError:
pass
self._watched_dir = None
self._known_files = set()
def _on_watched_dir_changed(self, path: str) -> None:
"""Called when the watched directory changes. Debounces rapid changes."""
if path == str(self._watched_dir):
self._watch_debounce.start()
def _process_new_watched_files(self) -> None:
"""Compare current files against the known set and notify about new ones."""
if self._watched_dir is None:
return
try:
current = set(
str(p) for p in self._watched_dir.iterdir()
if p.is_file() and not p.name.startswith(".")
and p.suffix.lower() in self._INPUT_EXTS
)
except OSError:
return
new_files = current - self._known_files
if not new_files:
self._known_files = current
return
self._known_files = current
# Add new files to the Input section so the user can see them.
for fp in sorted(new_files):
self.input_section.add(fp)
# Emit a status message so the user knows new files were detected.
names = ", ".join(Path(p).name for p in sorted(new_files))
self.status_message.emit(
tr("chatpanel.new_files_detected", names=names, n=len(new_files))
)
def _is_intermediate_output(self, path: str) -> bool:
"""Override hook: hide helper/generator files from the Output list."""
return False
def on_file_written(self, path: str) -> None:
"""Hook: the agent created/edited a file (shown in the Output box)."""
self.register_output(path)
def on_inputs_added(self, paths: List[str]) -> None:
for p in paths:
self.input_section.add(p)
def _on_attachments_added(self, paths: List[str]) -> None:
# Push attachments into the Input box as soon as they're attached.
for p in paths:
self.input_section.add(p)
def _on_attachment_removed(self, path: str) -> None:
# A file added by mistake was removed in the composer — drop it from the
# Input panel too (only matters before the message is sent).
self.input_section.remove(path)
def _open_io_item(self, path: str) -> None:
open_path(path)
def _io_context_menu(self, section, pos) -> None:
"""Right-click menu on a file in the Input/Output lists: Open with the
OS app, or view + AI-edit it inside the app (FileEditDialog)."""
item = section.list.itemAt(pos)
if item is None:
return
path = item.data(Qt.UserRole)
if not path:
return
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
act_open = menu.addAction(app_icon("link"), tr("chatpanel.menu_open"))
act_edit = menu.addAction(app_icon("edit"), tr("chatpanel.menu_ai_edit"))
chosen = menu.exec(section.list.mapToGlobal(pos))
if chosen is act_open:
open_path(path)
elif chosen is act_edit:
from .file_edit_dialog import FileEditDialog
FileEditDialog(self.ctx, path, self).exec()
# ---- skills management (shared by Cowork and Code) ---------------
def _open_skills_manager(self) -> None:
"""Open the Skills manager (add / edit / delete / enable skills)."""
from .skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._skills_changed()
self.status_message.emit(tr("chatpanel.skills_updated"))
def _skills_changed(self) -> None:
"""Hook after skills were edited (Code tab refreshes its Skills button)."""
# ---- per-tab agent (model / admin-agent preset) selection --------
_ADMIN_AGENT_PREFIX = "admin:"
# Sent (invisibly — folded into the outgoing content, never the visible
# chat bubble) as a one-shot prefix on the FIRST turn run under a newly
# picked model/agent, when the conversation already has prior turns: asks
# the new model to check over the most recent step before doing anything
# new, so a mid-conversation switch doesn't silently drop continuity.
_MODEL_SWITCH_REVIEW_NOTE = (
"[Note: the AI model/agent for this conversation was just switched.] Before "
"addressing the request below, briefly re-check the most recent step above — "
"if anything there looks incomplete, inconsistent, or wrong, redo or fix it "
"first, then continue."
)
def _agent_signature(self) -> str:
"""Identifies WHAT will run the next turn (admin agent id, or plain
provider:model) — comparing this across turns is how a genuine
mid-conversation switch is detected."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
return f"{self._ADMIN_AGENT_PREFIX}{agent.agent_id}"
return f"{self.ctx.config.active_provider}:{self._model}"
def _current_agent_label(self) -> str:
"""Human-friendly name of what will run the next turn — for the visible
'auto-switched model' notice in the transcript."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
return agent.name
return self._model or tr("chat.provider_default_short")
def _on_agent_changed(self, _i: int) -> None:
data = self.agent_combo.currentData() or ""
if isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX):
# An Admin-defined agent preset (Monitoring → Agents Admin): runs
# on its pinned model (or the Settings default when unpinned) and
# injects its instructions into every turn of this tab.
from ..core import admin_agents
agent_id = data[len(self._ADMIN_AGENT_PREFIX):]
self._admin_agent = admin_agents.load_agent(
agent_id, admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
self._agent_user_override = True
self._agent_provider = self.ctx.config.active_provider
self._model = (self._admin_agent.model if self._admin_agent else "") or ""
if self._admin_agent is not None:
self.status_message.emit(f"{self.session_name} agent: {self._admin_agent.name}")
self._note_agent_switch()
return
self._admin_agent = None
new = data or "" # "" → provider default
if new != self._model:
# A deliberate pick by the user — remember it until the provider changes.
self._agent_user_override = True
self._agent_provider = self.ctx.config.active_provider
self._model = new
if self._model:
self.status_message.emit(f"{self.session_name} agent: {self._model}")
self._note_agent_switch()
def _note_agent_switch(self) -> None:
"""Flag a pending review note for the NEXT turn when the selection
genuinely changed mid-conversation (there's already history AND this
isn't just the initial default being applied)."""
sig = self._agent_signature()
last = getattr(self, "_last_turn_agent_signature", None)
if last is not None and sig != last and self.messages:
self._pending_agent_switch_review = True
def admin_agent_prompt(self) -> str:
"""The selected admin agent's instructions ('' when a plain model is
selected) — appended to the project context of every turn."""
agent = getattr(self, "_admin_agent", None)
return agent.effective_prompt() if agent is not None else ""
def refresh_agents(self) -> None:
"""Fetch the model list from the active provider (in the background) and
fill the per-tab Agent combo — called at start and on provider change.
The default follows Settings; see state.resolve_agent_default."""
from ..state import resolve_agent_default
name = self.ctx.config.active_provider
setting_model = self.ctx.config.provider_conf(name).get("model", "")
keep, self._agent_user_override = resolve_agent_default(
name, setting_model, self._model, self._agent_provider, self._agent_user_override)
self._model = keep
self._agent_provider = name
def job(worker: AgentWorker):
error = ""
try:
prov = self.ctx.build_provider_for(name)
models = list(getattr(prov, "list_models", lambda: [])() or [])
if not models:
error = getattr(prov, "last_error", "")
except Exception as exc: # noqa: BLE001 - never break the UI over a model list
models, error = [], str(exc)
return {"models": models, "keep": keep, "error": error}
def done(result) -> None:
self._populate_agents(result.get("models", []), result.get("keep", ""))
# Surface the REAL reason models didn't load (network/auth/config)
# instead of silently falling back to "(provider default)".
err = result.get("error", "")
if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
def _populate_agents(self, models, keep: str) -> None:
self.agent_combo.blockSignals(True)
self.agent_combo.clear()
# The Agent picker is a MODEL picker — the raw model list of the active
# provider. Admin-defined agents (Monitoring → Agents Admin) are NOT
# listed here: they are system-management presets, not a model/agent to
# pick for a Cowork conversation. To apply a work agent's persona, use
# the /agent command (built-in + custom Flow agents).
items = list(dict.fromkeys([m for m in models if m])) # dedupe, keep order
if keep and keep not in items:
items.insert(0, keep)
for m in items:
self.agent_combo.addItem(m, m)
if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so
# we fall back to the provider's default model (never a fake name).
self.agent_combo.addItem("(provider default)", None)
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1
if idx >= 0:
self.agent_combo.setCurrentIndex(idx)
self.agent_combo.blockSignals(False)
data = self.agent_combo.currentData() or ""
if not (isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX)):
self._model = data or ""
def build_provider(self):
"""Provider for THIS tab: the selected admin agent's pinned
provider/model when one is selected, else the tab's selected model
(or the provider's configured default when none is chosen)."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
from ..core.admin_agents import build_agent_provider
return build_agent_provider(self.ctx, agent)
# An Auto/Manual routing override (set by _apply_routing for this turn)
# wins over the tab's own provider/model selection.
provider = self._routed_provider or self.ctx.config.active_provider
model = self._routed_model or self._model or None
return self.ctx.build_provider_for(provider, model)
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
"""Auto Model Routing hook — run once per outgoing message.
Off → no-op. Auto → silently switch to the best-fit model. Manual → ask
the user (modal, with the configured confirm timeout) before switching.
Sets ``self._routed_provider``/``self._routed_model`` for THIS turn;
:meth:`build_provider` honours them. Never raises — a routing failure
must never block sending a message; it just falls back to the tab's
own model.
"""
# Recompute fresh each message; clear any previous turn's override.
self._routed_provider = None
self._routed_model = None
# An explicitly-pinned Admin agent takes precedence over routing.
if getattr(self, "_admin_agent", None) is not None:
return
if not (text or "").strip():
return
try:
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
if mode == "off":
return
service = self.ctx.routing()
cur_provider = self.ctx.config.active_provider
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route(self.kind, text, cur_provider, cur_model, mode_override=mode)
if not result.should_switch:
return
target = result.target()
if target is None:
return
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return # declined / timed out → keep current model
self._routed_provider = to_provider
self._routed_model = to_model
notice = self.chat_view.add_status(tr(
"routing.switched_notice",
model=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
turn["bubbles"].append(notice)
except Exception: # noqa: BLE001 — routing must never block a chat turn
self._routed_provider = None
self._routed_model = None
def _compress_messages(self) -> None:
"""Manual compress: keep the system prompt + the last 2 turns verbatim and
DIGEST all older messages into one compact summary, shrinking it until the
whole conversation is under 25% of its original token size."""
if self._view_busy():
self.status_message.emit(tr("chatpanel.compress_busy"))
return
from ..core.usage_tracker import estimate_tokens
msgs = list(self.messages)
def _tok(ms):
return sum(estimate_tokens(str(m.get("content", ""))) for m in ms)
orig = _tok(msgs)
systems = [m for m in msgs if m.get("role") == "system"]
rest = [m for m in msgs if m.get("role") != "system"]
starts = [i for i, m in enumerate(rest) if m.get("role") == "user"]
if len(starts) <= 2 or orig <= 0:
self.status_message.emit(tr("chatpanel.compress_short"))
return
cut = starts[-2] # keep the last 2 turns verbatim
old, recent = rest[:cut], rest[cut:]
old_tok = _tok(old) or 1 # target: digest < 25% of the OLD part
def _digest(per_msg: int):
parts = []
for m in old:
c = str(m.get("content", "")).strip().replace("\n", " ")
if c:
parts.append(f"- {m.get('role', '')}: {c[:per_msg]}")
body = "\n".join(parts)
return {"role": "user",
"content": f"[{tr('chatpanel.compress_digest_header', n=len(old))}]\n{body}"}
per_msg = 240
digest = _digest(per_msg)
# shrink the digest until the OLD conversation is under 25% of its size
while _tok([digest]) > 0.25 * old_tok and per_msg > 20:
per_msg = max(20, per_msg // 2)
digest = _digest(per_msg)
self.messages = systems + [digest] + recent
pct = int(_tok([digest]) * 100 / old_tok)
self.status_message.emit(tr("chatpanel.compress_reduced", pct=pct, n=len(old)))
def _set_io_collapsed(self, collapsed: bool) -> None:
self._io_widget.setVisible(not collapsed)
self._io_strip.setVisible(collapsed)
strip_w = CollapseStrip.WIDTH + 2
if collapsed:
self._io_pane.setMaximumWidth(strip_w)
self._collapse_split_pane(self._io_pane, strip_w)
else:
self._io_pane.setMaximumWidth(16777215) # QWIDGETSIZE_MAX
self._restore_split_sizes()
# ---- shared split-pane collapse helpers (used by subclasses too) ----
def _collapse_split_pane(self, pane: QWidget, strip_w: int) -> None:
"""Shrink one splitter pane to ``strip_w`` and hand the freed width to
the widest remaining pane. Works for any number of panes."""
sizes = self.center_split.sizes()
idx = self.center_split.indexOf(pane)
if not (0 <= idx < len(sizes)):
return
diff = sizes[idx] - strip_w
sizes[idx] = strip_w
others = [i for i in range(len(sizes)) if i != idx and sizes[i] > 0]
if others and diff != 0:
big = max(others, key=lambda i: sizes[i])
sizes[big] = max(strip_w, sizes[big] + diff)
self.center_split.setSizes(sizes)
def _restore_split_sizes(self) -> None:
"""Default expanded layout; panes still collapsed stay thin (max-width)."""
self.center_split.setSizes([820, 220])
# ---- delete a turn (message + its input/output files) ------------
def _delete_turn(self, turn: Dict[str, Any]) -> None:
files = [p for p in (turn.get("inputs", []) + turn.get("outputs", [])) if p]
if files:
preview = "\n".join("• " + str(p) for p in files[:12])
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
else:
prompt = tr("chatpanel.delete_confirm_plain")
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes:
return
for bubble in turn.get("bubbles", []):
bubble.setParent(None)
bubble.deleteLater()
ids = {id(m) for m in turn.get("messages", [])}
if ids:
self.messages = [m for m in self.messages if id(m) not in ids]
for p in files:
try:
fp = Path(p)
if fp.is_file():
fp.unlink()
except OSError:
pass
if turn in self.turns:
self.turns.remove(turn)
self._rebuild_io()
self._autosave()
self.status_message.emit(tr("chatpanel.delete_done"))
def _rebuild_io(self) -> None:
self.input_section.clear()
self.output_section.clear()
for t in self.turns:
for p in t.get("inputs", []):
self.input_section.add(p)
for p in t.get("outputs", []):
self.output_section.add(p)
# ---- turn lifecycle ---------------------------------------------
def submit(self, text: str, attachments: Optional[List[str]] = None) -> None:
# Composer only emits 'submitted' when not busy; queued items are
# drained from here after each turn completes.
self._start_turn(text, attachments or [])
def _attach_char_limit(self) -> int:
"""Per-file content cap (characters) from the Settings token limit
(~4 chars/token)."""
try:
tokens = int(self.ctx.config.data.get("attachments", {}).get("max_tokens", 500000))
except (TypeError, ValueError):
tokens = 500000
return max(1000, tokens) * 4
# File types considered valid input data in the workspace/output folder
_INPUT_EXTS = {
".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml",
".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp",
".rtf", ".tsv",
}
def _augment(self, text: str, attachments: List[str], notify=None) -> str:
"""Embed attachment paths AND their extracted contents into the prompt so
the agent actually reads and analyses each attached file.
Additionally, scans the workspace/output folder for existing files and
loads them as input data so the agent can read/process them automatically.
``notify``, if given, is called with UI-visible events (a live "reading
page X/Y" progress notice, and a warning when a file's content could not
be read) instead of failures being silently handed to the model as an
opaque inline note."""
has_attachments = bool(attachments)
limit = self._attach_char_limit()
lines = [text] if text else []
# --- User-attached files ---
if has_attachments:
lines.append("\n[Attachments] — read and use these files to answer the request:")
for p in attachments:
lines.extend(self._read_one_attachment(p, limit, notify))
# --- Auto-load existing workspace/output folder files as input data ---
# This is what makes "📁 Chọn thư mục khác" useful as an INPUT folder
# too: every file already in the chosen folder is read and embedded so
# the agent can act on their contents without manual attaching.
workspace = self.workspace_dir()
max_files = int(self.ctx.config.data.get("attachments", {})
.get("max_files", 10) or 0)
if workspace is not None:
lines.extend(self._folder_input_lines(
workspace,
"[Workspace files] — existing files in output folder, "
"read and use as input data. The user expects you to "
"process these files automatically:",
limit, max_files, notify))
# --- Project knowledge (Claude-Projects style) ---
# Only scanned separately when it's a DIFFERENT folder from the
# session's own workspace — for Cowork the two are now the same
# folder (a project has one shared workspace, no per-thread
# sub-folder), so this never double-scans the same directory.
knowledge = self.project_knowledge_dir()
if knowledge is not None and knowledge != workspace:
lines.extend(self._folder_input_lines(
knowledge,
"[Project files] — shared knowledge files of this project, "
"available to every conversation in it. Read and use them "
"as context for the request:",
limit, max_files, notify))
return "\n".join(lines)
def project_knowledge_dir(self):
"""Folder of project-level shared knowledge files (None = no project
knowledge). Overridden by the Cowork tab for non-default projects."""
return None
def _folder_input_lines(self, folder: Path, header: str, limit: int,
max_files: int, notify=None) -> list:
"""Embed a folder's readable files into the prompt — recursing into
every sub-folder, any depth, not just the top level, so files placed
in nested folders are read and processed too (same per-message file
cap as manual attachments — Settings → Attachments → max files;
0 = unlimited — so a folder with dozens of files can't blow the
context window)."""
from ..core.doc_extract import find_input_files
out: list = []
shown, total = find_input_files(folder, self._INPUT_EXTS, max_files)
if shown:
out.append("\n" + header)
for f in shown:
out.extend(self._read_one_attachment(str(f), limit, notify))
if total > len(shown):
skipped = total - len(shown)
out.append(f"…({skipped} more files in the folder were not "
"loaded — per-message attachment limit; mention a "
"file by name if the user asks about it)")
if notify is not None:
notify({"type": "notice", "level": "warning",
"text": tr("chat.workspace_files_capped",
shown=len(shown), total=total)})
return out
def _read_one_attachment(self, path: str, limit: int, notify=None) -> list:
"""Read and format one attachment/workspace file. Returns list of lines.
Handles every file type: images (noted with path), MS Office / PDF /
OpenDocument / text (extracted), and ZIP archives — which are auto-
extracted into the workspace and their contents read + processed."""
name = Path(path).name
result = []
if is_image(path):
result.append(f"- {name} (image at {path})")
return result
from ..core.doc_extract import is_zip
if is_zip(path):
result.extend(self._read_zip_attachment(path, name, limit, notify))
return result
def progress(page: int, total: int, _name=name) -> None:
if notify is not None and total > 1:
notify({"type": "notice", "level": "progress",
"text": tr("chat.reading_progress", name=_name, page=page, total=total)})
content, note = self._read_attachment_text(path, progress=progress)
if content is None:
result.append(f"- {name} ({note}; located at {path})")
if notify is not None:
notify({"type": "notice", "level": "warning",
"text": tr("chat.attachment_failed", name=name, note=note)})
return result
self._enforce_attachment_security(name, content) # raises SecurityBlocked on a violation
extra = ""
if len(content) > limit:
content = content[:limit]
extra = f"\n…(truncated to ~{limit // 4} tokens)…"
result.append(f"- {name} ({path})")
result.append(f"\n--- Content of {name} ---\n{content}{extra}\n--- end of {name} ---")
return result
def _read_zip_attachment(self, path: str, name: str, limit: int, notify=None) -> list:
"""Auto-extract a .zip into the workspace and read+process its files, so
an attached archive is unpacked and its contents used automatically."""
from ..core.doc_extract import extract_archive
ws = self.workspace_dir()
dest = (Path(ws) if ws is not None else Path(path).parent) / Path(name).stem
files = extract_archive(path, dest)
result = [f"- {name} (archive) — extracted {len(files)} file(s) into the workspace at "
f"{dest}. Read/edit them there as needed."]
if self.workspace_dir() is not None:
self.output_changed.emit(str(self.workspace_dir())) # let the graph/folder refresh
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
shown = files[:max_files] if max_files else files
for f in shown:
result.extend(self._read_one_attachment(str(f), limit, notify))
if max_files and len(files) > max_files:
result.append(f"- …and {len(files) - max_files} more file(s) in {dest} "
"(not inlined; open/read them from the workspace as needed).")
return result
def _enforce_attachment_security(self, filename: str, content: str) -> None:
"""Agent Security's attachment layer (Settings → 🛡 Agent Security) —
scans extracted file content for malicious payloads BEFORE it enters
the model's context. No-op when disabled. Raises SecurityBlocked
(propagates out of _augment → the worker job → AgentWorker.failed,
which the panel shows as a chat error) on a violation."""
sec = self.ctx.config.data.get("agent_security", {})
if not sec.get("enabled") or not sec.get("validate_attachments", True):
return
from ..core.agent_security import SecurityBlocked, combined_rules_text, validate_attachment
from ..core.agent_security_alert import notify_admin
rules_text = combined_rules_text(self.ctx.config)
verdict = validate_attachment(self.build_provider(), filename, content, rules_text)
if verdict.allowed:
return
notify_admin(self.ctx.config, verdict, detail=f"file: {filename}")
raise SecurityBlocked(verdict)
@staticmethod
def _read_attachment_text(path: str, progress=None):
"""Best-effort text extraction so the agent can read the attachment.
Returns (text, note); text is None when nothing readable was found.
Delegates to core.doc_extract, which parses docx/xlsx/pptx/odf directly
(stdlib, no extra packages), uses pypdf for PDFs (reporting per-page
``progress`` for multi-page files), and falls back to a headless
LibreOffice conversion for anything else."""
from ..core.doc_extract import extract_text
return extract_text(path, progress=progress)
def _apply_skill_command(self, text: str):
"""Parse a leading ``/skill`` command typed in the chat box.
Returns ``(prefix, request, info)`` — see ``core.skills.parse_skill_command``."""
try:
from ..core.skills import parse_skill_command
return parse_skill_command(text)
except Exception:
return "", text, "Could not read skills from the Skills manager."
def _apply_agent_command(self, text: str):
"""Parse a ``/agent`` command typed in the chat box (Cowork parity with
Co4E): apply a named agent PERSONA to the turn. Returns
``(prefix, request, info)`` — see ``core.agent_command.parse_agent_command``."""
try:
from ..core.agent_command import parse_agent_command
return parse_agent_command(text, self.ctx.config.shared_dir)
except Exception: # noqa: BLE001
return "", text, "Could not read the agent catalog."
def run_prompts(self, prompts: List[str]) -> None:
"""Enqueue several prompts and run them (used by flows). They start up to
the parallel limit; the rest stay queued and start as slots free up."""
prompts = [p for p in prompts if p and p.strip()]
if not prompts:
return
for p in prompts:
self.composer.enqueue(p)
self._drain_queue()
def _start_turn(self, text: str, attachments: Optional[List[str]] = None) -> None:
attachments = attachments or []
typed = text
prefix, request, info = self._apply_skill_command(text)
if info is not None:
# A local /skill command (list / select / error) — answer inline.
self.chat_view.add_user(typed)
self.chat_view.add_assistant(self.assistant_title()).set_markdown(info)
self._drain_queue()
return
text = request
# /agent directive → apply a named agent persona to this turn (parity with
# the Co4E chat). Combined with any /skill prefix already parsed above.
agent_prefix, text, agent_info = self._apply_agent_command(text)
if agent_info is not None:
self.chat_view.add_user(typed)
self.chat_view.add_assistant(self.assistant_title()).set_markdown(agent_info)
self._drain_queue()
return
if agent_prefix:
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
if not self.title:
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
self.title = (base[:60] + "…") if len(base) > 60 else base
# Reset the Plan panel so each message starts from a clean checklist (the
# previous message's plan never lingers/flickers into this one).
self.plan_section.clear()
# Each turn works on its OWN message list: a snapshot of the history so far
# plus the new user message, merged back into self.messages when the turn
# finishes (see _finalize_turn). This keeps concurrent turns from racing on
# the shared list. The user content is filled in by the worker (below) —
# reading attachment text can pip-install a parser or call LibreOffice,
# which must not run on the UI thread.
snapshot = list(self.messages)
user_msg: Dict[str, Any] = {"role": "user", "content": prefix or text}
local_messages = snapshot + [user_msg]
# Consume the pending switch-review flag exactly once, for THIS turn —
# and record what's running it so the next genuine switch is detected
# against this, not against the selection that was current mid-turn.
review_switch = self._pending_agent_switch_review
self._pending_agent_switch_review = False
self._last_turn_agent_signature = self._agent_signature()
bubble = self.chat_view.add_user(text or "(attachment)")
turn: Dict[str, Any] = {"bubbles": [bubble], "messages": [],
"inputs": list(attachments), "outputs": []}
if review_switch:
# Make the mid-conversation model switch VISIBLE (it was silent
# before): a one-line notice so the user sees the run continued
# smoothly on the newly-picked model rather than wondering.
notice = self.chat_view.add_status(
tr("chat.model_switched", model=self._current_agent_label()))
turn["bubbles"].append(notice)
self.turns.append(turn)
bubble.add_delete_link(lambda t=turn: self._delete_turn(t))
if attachments:
bubble.add_attachments(attachments)
self.on_inputs_added(attachments)
folder = self.workspace_dir()
if folder:
bubble.add_folder_link(str(folder))
self.graph_event.emit(self.session_name, {"type": "user", "content": text})
# Auto Model Routing: may switch this turn's provider/model (Auto), or
# ask first (Manual). Runs before build_job so build_provider() sees the
# routed choice. No-op when the toggle is Off.
self._apply_routing(text, turn)
self._turn_seq += 1
out_dir = self._turn_output_dir(f"t{self._turn_seq}")
base_job = self.build_job(text, local_messages, out_dir)
def job(worker, _m=user_msg, _t=text, _a=attachments, _p=prefix, _j=base_job,
_review=review_switch):
# Worker thread: do the (possibly slow) attachment extraction here so
# the UI stays responsive, then run the real agent job.
from ..core import usage_tracker
usage_tracker.set_context(self.kind, self.title or self.session_id)
body = self._augment(_t, _a, notify=worker.emit_event)
notes = self._session_notes()
if notes:
body = f"{body}\n\n{notes}" if body else notes
_m["content"] = (_p + "\n\n---\n\n" + body) if _p else body
if _review:
# Invisible to the chat bubble (that already shows the plain
# typed text) — only the payload actually sent to the model
# carries the note.
_m["content"] = f"{self._MODEL_SWITCH_REVIEW_NOTE}\n\n{_m['content']}"
return _j(worker)
worker = AgentWorker(job)
# A self-contained context for THIS turn, so its streaming events and files
# never touch another running turn's state. Signals bind the context via a
# default-arg so the right ctx is delivered on the UI thread. The "home_*"
# fields pin the turn to the conversation it started in, so it keeps saving
# there even if the user switches to another chat while it runs.
ctx: Dict[str, Any] = {
"worker": worker, "user_msg": user_msg, "assistant": None,
"record": turn, "messages": local_messages,
"snapshot_len": len(snapshot), "out_dir": out_dir,
"home_id": self.session_id, "home_messages": self.messages,
"home_title": self.title, "home_out_root": self.workspace_dir(),
"detached": False,
# For re-rendering the in-progress turn if the user reopens this chat:
"display_text": text, "partial": "", "plan_steps": [],
# token/cost accounting: cumulative session usage BEFORE this turn, so
# the turn's own tokens are (after − before).
"usage_base": self._usage_snapshot(),
}
self._sessions_live[self.session_id] = self.messages
self._active[worker] = ctx
self.worker = worker
# Record the conversation in History right away (with the new user message,
# so it has a title) — it shows up and can be selected while it's running.
self._save_snapshot(self.session_id, local_messages, self.title)
self.history_changed.emit()
worker.event.connect(lambda ev, c=ctx: self._on_event(c, ev))
worker.permission_requested.connect(lambda a, c=ctx: self._on_permission(c, a))
worker.finished_ok.connect(lambda r, c=ctx: self._on_finished(c, r))
worker.failed.connect(lambda e, c=ctx: self._on_failed(c, e))
self.composer.set_running(True)
# One turn at a time PER conversation: this conversation now has a running
# turn, so further sends here go to the Queue (in order, no interleaving).
# Other conversations can still run in parallel up to the global cap.
if self._view_busy() or len(self._active) >= self._max_parallel():
self.composer.set_busy(True)
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
self.thinking.start("chat.running")
worker.start()
def _on_event(self, ctx: Dict[str, Any], ev: Dict[str, Any]) -> None:
etype = ev.get("type")
# Track the in-progress state even while this turn is a detached background
# job, so reopening its conversation can re-render the CURRENT task (partial
# answer + live plan) — see _reattach_running_turn.
if etype == "text":
ctx["partial"] = ctx.get("partial", "") + ev.get("delta", "")
elif etype == "assistant_done":
ctx["partial"] = ""
elif etype == "plan_set":
ctx["plan_steps"] = ev.get("steps") or []
# A turn only RENDERS into the transcript/sidebar of the conversation it was
# started in. If the user navigated away, skip live rendering (the data is
# tracked above and shown when the conversation is reopened).
if ctx.get("detached") or ctx.get("home_id") != self.session_id:
return
record = ctx["record"]
if etype == "text":
self.thinking.stop() # real output is streaming now
if ctx["assistant"] is None:
ctx["assistant"] = self.chat_view.add_assistant(self.assistant_title())
ctx["last_assistant"] = ctx["assistant"] # for the per-turn usage footer
record["bubbles"].append(ctx["assistant"])
folder = self.workspace_dir()
if folder:
ctx["assistant"].add_folder_link(str(folder))
ctx["assistant"].append_delta(ev.get("delta", ""))
elif etype == "assistant_done":
self.graph_event.emit(self.session_name, ev)
ctx["assistant"] = None
ctx["reasoning"] = None # next step starts a fresh Thinking box
self._autosave() # persist latest result (crash-safe, mid-turn)
elif etype == "tool_proposed":
# Show WHAT it's doing (e.g. "Creating…" while a document is generated).
self.thinking.start(_TOOL_STATUS.get(ev.get("name"), "chat.running"))
if ev.get("name") == "update_plan":
return # the plan tool drives the Plan view, not a chat bubble
# Show the step in the transcript (the code being written / diff /
# command being run) so the whole process is visible, CLI-style.
preview = ev.get("preview") or {}
body = preview.get("text", "")
if body:
icons = {"diff": "✎", "command": "▶"}
title = preview.get("title") or ev.get("name", "tool")
label = f"{icons.get(preview.get('kind'), '⚙')} {title}"
# A diff/create/edit preview renders as a colored before/after
# (additions/deletions), not a flat text block.
if preview.get("kind") == "diff":
step = self.chat_view.add_diff(label, body, True)
else:
step = self.chat_view.add_tool(label, body, True)
record["bubbles"].append(step)
# Remember this step's bubble so live stdout/stderr ("tool_output")
# can be appended to it in real time while the command runs.
ctx.setdefault("step_bubbles", {})[ev.get("id")] = step
self.graph_event.emit(self.session_name, ev)
elif etype == "tool_output":
# Live output from a running command/install (see run_cancellable) —
# append to its step bubble so progress is visible before it finishes.
step = ctx.get("step_bubbles", {}).get(ev.get("id"))
if step is not None:
step.append_plain(ev.get("delta", ""))
elif etype == "notice":
# A UI-visible aside outside the model's own turn: either a live
# "reading page X/Y" progress line, or a warning that something
# (e.g. an attachment) could not be processed.
if ev.get("level") == "progress":
self.thinking.set_progress_text(ev.get("text", ""))
else:
bubble = self.chat_view.add_tool(
tr("chat.attachment_warning_title"), ev.get("text", ""), False)
record["bubbles"].append(bubble)
elif etype == "tool_result":
ctx.get("step_bubbles", {}).pop(ev.get("id"), None)
self.thinking.start("chat.running") # back to the model for the next step
if ev.get("name") == "update_plan":
return # plan tool: no chat bubble (Plan view already updated)
mark = "✓" if ev.get("ok") else "✗"
tool_bubble = self.chat_view.add_tool(
f"{ev.get('name')} {mark}", ev.get("output", ""), ev.get("ok", True))
record["bubbles"].append(tool_bubble)
folder = ev.get("path") or self.workspace_dir()
if folder:
tool_bubble.add_folder_link(str(folder), tr("chat.open_folder"))
if ev.get("path"):
record["outputs"].append(ev["path"])
self.on_file_written(ev["path"])
# Files produced by a command (e.g. a script that builds a .pptx) —
# surface the real deliverable, not the generator script.
for pr in ev.get("produced", []) or []:
record["outputs"].append(pr)
self.register_output(pr)
self.graph_event.emit(self.session_name, ev)
self._autosave() # persist after each tool result (crash-safe)
elif etype == "outputs_removed":
# Intermediate/generator files were cleaned up — drop them from Output.
for p in ev.get("paths", []) or []:
self.output_section.remove(p)
if p in record.get("outputs", []):
record["outputs"].remove(p)
elif etype == "outputs_added":
# Deliverables flattened out of a sub-folder into the Output root.
for p in ev.get("paths", []) or []:
if p not in record.get("outputs", []):
record["outputs"].append(p)
self.register_output(p)
elif etype == "reasoning":
# A reasoning model is "thinking" (Qwen3/DeepSeek-R1 etc.). Relabel the
# indicator AND stream the reasoning into a collapsed "🧠 Thinking" box
# so the process is visible without flooding the chat.
self.thinking.set_label("chat.thinking")
piece = ev.get("delta", "")
if piece:
if ctx.get("reasoning") is None:
ctx["reasoning"] = self.chat_view.add_reasoning()
record["bubbles"].append(ctx["reasoning"])
ctx["reasoning"].append_delta(piece)
elif etype == "plan_set":
steps = ev.get("steps") or []
self.on_plan(steps) # Plan panel (right sidebar)
# Also show the checklist inline in the chat, updated in place.
body = _format_plan_steps(steps)
if ctx.get("plan_bubble") is None:
ctx["plan_bubble"] = self.chat_view.add_plan(body)
record["bubbles"].append(ctx["plan_bubble"])
else:
ctx["plan_bubble"].set_plain(body)
def on_plan(self, steps) -> None:
"""Render the current message's step checklist in the Plan panel above the
Output list. The agent sends the full list on each ``update_plan`` call."""
self.plan_section.set_steps(steps)
def _cleanup_turn(self, ctx: Dict[str, Any], ok: bool) -> None:
"""Hook: a turn just ended (``ok`` = finished vs failed). Given the turn
context, so a tab can promote/discard that turn's isolated output folder.
No-op in the base."""
def _session_notes(self) -> str:
"""Extra context folded into the outgoing user message (same layer as
attachment content) — e.g. Cowork lists files already produced earlier
in this conversation so the agent can reference/revise them by name
without the user re-uploading. No-op in the base."""
return ""
def _on_permission(self, ctx: Dict[str, Any], action: Dict[str, Any]) -> None:
# Auto-approves UNLESS this workspace requires confirming commands —
# a per-workspace Auto-run override (see AppContext.project_confirm_commands),
# falling back to the global "confirm before running commands" setting.
# Resolve on THIS turn's worker, never the latest — several turns may
# be awaiting approval at once.
if self.ctx.project_confirm_commands():
from .permission_dialog import PermissionDialog
approved, _remember = PermissionDialog.ask(action, parent=self)
ctx["worker"].resolve_permission(approved)
return
ctx["worker"].resolve_permission(True)
def _finalize_turn(self, ctx: Dict[str, Any]) -> None:
"""Merge one turn's new messages into its OWN conversation's history.
"New" = everything the job appended after this turn's snapshot. Drop any
system prompt the agent inserted when the history already carries one, so
two turns started from an empty history don't leave a duplicate system
message. Merges into ``home_messages`` (the list of the conversation the
turn started in) so a background turn saves to the right chat even after the
user switched away. Same object refs are reused, so _delete_turn's id-based
removal still finds them."""
home = ctx["home_messages"]
local = ctx["messages"]
new = local[ctx["snapshot_len"]:]
if any(m.get("role") == "system" for m in home):
new = [m for m in new if m.get("role") != "system"]
home.extend(new)
ctx["record"]["messages"] = new
def _end_turn(self, ctx: Dict[str, Any]) -> None:
"""Shared teardown for a finished/failed turn: merge history, drop the
worker, release the conversation once nothing else is running for it, and
refresh the (global) running/capacity indicators."""
self._finalize_turn(ctx)
self._active.pop(ctx["worker"], None)
home_id = ctx.get("home_id")
if home_id and not any(c.get("home_id") == home_id for c in self._active.values()):
self._sessions_live.pop(home_id, None)
# Update the chat-box indicator for the CURRENT view: stop it once the viewed
# conversation is idle (a live turn's own streaming manages it otherwise, so
# we don't restart it here and disturb streaming).
if not self._view_busy():
self.thinking.stop()
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
# Re-evaluate the per-conversation gate: sends dispatch again only when THIS
# conversation is idle and the global cap allows.
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
def _turn_is_live(self, ctx: Dict[str, Any]) -> bool:
"""True when the turn belongs to the currently-viewed conversation."""
return ctx.get("home_id") == self.session_id and not ctx.get("detached")
def _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]],
title: str, inputs: Optional[List[str]] = None) -> None:
"""Persist a conversation by id (used both to register it in History the
moment it starts and to save a finished background turn). No-op until it has
a user message. Never raises into the UI."""
if not self.ctx.config.history.get("autosave", True):
return
if not any(m.get("role") == "user" for m in messages):
return
try:
from ..core.history import save_conversation
save_conversation(
self.ctx.config.history_dir(), self.kind, session_id,
messages, title, inputs=list(inputs or []), outputs=[],
# Only the CURRENT view knows its project for sure; a background
# turn's save must not overwrite another conversation's project
# with whatever the user is viewing now (save_conversation keeps
# the stored value when '' is passed).
project_id=self.project_id if session_id == self.session_id else "",
)
except Exception:
pass # persistence must never disrupt the UI
def _persist_session(self, ctx: Dict[str, Any]) -> None:
"""Save a BACKGROUND turn's conversation (it isn't the current view, so the
view-based _autosave can't). Outputs are rebuilt from disk on reopen."""
self._save_snapshot(ctx["home_id"], ctx["home_messages"],
ctx.get("home_title", ""),
inputs=ctx.get("record", {}).get("inputs", []))
self.history_changed.emit()
def running_session_ids(self):
"""Set of conversation ids that currently have a turn running (for the
History status markers)."""
return set(self._sessions_live)
def _finalize_plan(self, ctx: Dict[str, Any]) -> None:
"""On a successful finish, keep the plan visible with every step ticked
'done' (so a completed plan can be reviewed) — it is cleared only when the
NEXT message starts a fresh plan (see _start_turn)."""
steps = ctx.get("plan_steps")
if not steps:
return
changed = False
for s in steps:
if s.get("status") != "done":
s["status"] = "done"
changed = True
if changed:
self.on_plan(steps) # re-render (Plan panel for Cowork / preview for Code)
pb = ctx.get("plan_bubble")
if pb is not None:
pb.set_plain(_format_plan_steps(steps))
# ---- token / cost accounting (shown in the chat, Claude-style) ----------
def _usage_label(self) -> str:
return self.title or self.session_id
def _session_events(self):
from ..core import usage_tracker as ut
label = self._usage_label()
return [e for e in ut.load_events()
if e.get("source") == self.kind and e.get("label") == label]
def _usage_snapshot(self) -> Dict[str, int]:
"""Cumulative in/out/cache tokens for THIS conversation so far."""
snap = {"in": 0, "out": 0, "cache": 0}
for e in self._session_events():
snap["in"] += int(e.get("in", 0) or 0)
snap["out"] += int(e.get("out", 0) or 0)
snap["cache"] += int(e.get("cache", 0) or 0)
return snap
def _session_cost_usd(self) -> float:
from ..core import model_pricing as mp
return sum(mp.turn_cost_usd(e.get("model", ""), e.get("in", 0), e.get("out", 0),
self.ctx.config) for e in self._session_events())
def _show_usage(self, ctx: Dict[str, Any]) -> None:
"""Per-turn footer under the assistant message + the running conversation
total (bottom-left). Cost uses the Monitoring model-price table and the
display currency, and auto-updates when the model is switched."""
from ..core import model_pricing as mp, usage_tracker as ut
cur = self._usage_snapshot()
base = ctx.get("usage_base") or {"in": 0, "out": 0, "cache": 0}
d_in = max(0, cur["in"] - base.get("in", 0))
d_out = max(0, cur["out"] - base.get("out", 0))
d_cache = max(0, cur["cache"] - base.get("cache", 0))
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
# Condensed format (tight icon+value, single-space separators) — the
# old 4-space-wide separators made this label wide enough that it got
# crowded out of the composer's bottom row by the Local-folder button
# sharing the same row.
bub = ctx.get("last_assistant")
if bub is not None and (d_in or d_out):
turn_usd = mp.turn_cost_usd(self._model, d_in, d_out, self.ctx.config)
bub.add_usage(f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
f"{ut.format_cost(turn_usd, pricing)}")
self._usage_total_lbl.setText(
f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} "
f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} "
f"{ut.format_cost(self._session_cost_usd(), pricing)}")
def _on_finished(self, ctx: Dict[str, Any], result: Dict[str, Any]) -> None:
live = self._turn_is_live(ctx)
self._end_turn(ctx)
self._cleanup_turn(ctx, True) # promote this turn's output folder, if any
self.status_message.emit(tr("chatpanel.done", name=tr(f"app.tab.{self.kind}")))
if live:
self._finalize_plan(ctx) # keep the completed plan shown
try:
self._show_usage(ctx) # per-turn + conversation token/cost
except Exception: # noqa: BLE001 — usage display must never break a turn
pass
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
folder = self.workspace_dir()
if folder:
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
ctx["record"]["bubbles"].append(done)
self._autosave()
else:
self._persist_session(ctx) # save the background conversation by id
self.turn_finished.emit(result)
# Notify only once EVERYTHING is done (no running turns, empty queue).
if not self._active and not self.composer.has_queue():
self._maybe_notify_teams(result)
self._drain_queue()
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
live = self._turn_is_live(ctx)
self._end_turn(ctx)
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
if live:
self.chat_view.add_error(err)
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
from ..providers.base import is_model_not_found_error
if is_model_not_found_error(err) and ctx.get("display_text"):
# A "soft" failure, not a crash: the selected model itself is
# invalid/unavailable. Put the message back in the composer so
# the user can just pick a different model in Settings and hit
# Send again, instead of having to retype the whole prompt.
self.composer.set_text(ctx["display_text"])
else:
self._persist_session(ctx)
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
self.turn_finished.emit({"error": err})
self._drain_queue()
def _drain_queue(self) -> None:
# Start the NEXT queued message only while THIS conversation is idle (one
# turn at a time here) and the global cap allows. Starting one flips
# _view_busy() to True, so exactly one runs — the queue drains in order.
while (not self._view_busy() and len(self._active) < self._max_parallel()
and self.composer.has_queue()):
nxt = self.composer.pop_next()
if not nxt:
break
self._start_turn(nxt.get("text", ""), nxt.get("attachments", []))
def stop(self) -> None:
if not self._active:
return
for w in list(self._active):
if w.isRunning():
w.request_stop()
self.composer.clear_queue() # don't start anything still waiting
self.status_message.emit(tr("chatpanel.stopping", name=tr(f"app.tab.{self.kind}")))
# ---- Teams auto-notify ------------------------------------------
def _last_assistant_text(self) -> str:
for m in reversed(self.messages):
if m.get("role") == "assistant" and m.get("content"):
return m["content"]
return ""
def _maybe_notify_teams(self, result: Dict[str, Any]) -> None:
teams = self.ctx.config.teams
notifier = self.ctx.teams_notifier()
if not (teams.get("notify_on_complete") and notifier.configured):
return
summary = self._last_assistant_text() or "Task completed."
facts = {"Session": self.session_name, "Model": self.ctx.config.model_label()}
wd = self.workspace_dir()
if wd:
facts["Folder"] = str(wd)
if result.get("error"):
facts["Status"] = "Error"
def job(worker: AgentWorker):
ok, detail = notifier.send(f"Cowork {self.session_name} — task done", summary[:1200], facts)
return {"ok": ok, "detail": detail}
w = AgentWorker(job)
w.finished_ok.connect(lambda r: self.status_message.emit(r.get("detail", "")))
self._teams_worker = w
w.start()
# ---- persistence -------------------------------------------------
def _autosave(self) -> None:
if not self.ctx.config.history.get("autosave", True):
return
if not any(m.get("role") == "user" for m in self.messages):
return
try:
from ..core.history import save_conversation
path = save_conversation(
self.ctx.config.history_dir(), self.kind, self.session_id,
self.messages, self.title,
inputs=self.input_section.paths(),
outputs=self.output_section.paths(),
project_id=self.project_id,
)
# Remember this as the session to restore next launch (crash-safe).
last = self.ctx.config.data.setdefault("last_session", {})
if last.get(self.kind) != str(path):
last[self.kind] = str(path)
self.ctx.save()
except Exception:
pass # autosave must never disrupt the UI
def _busy(self) -> bool:
"""True while any turn is still running in this tab (any conversation)."""
return bool(self._active)
def _view_busy(self) -> bool:
"""True while the CURRENTLY-VIEWED conversation has a turn running."""
return any(c.get("home_id") == self.session_id for c in self._active.values())
def _sync_indicators(self) -> None:
"""Reflect the CURRENT conversation's agent status in the chat box + composer.
Switching chats, or hitting History → Refresh, shows whether THIS chat is
still processing (a background turn) or idle."""
if self._view_busy():
self.thinking.start("chat.running") # this conversation is still working
else:
self.thinking.stop()
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
def refresh_status(self) -> None:
"""Public: re-sync the on-screen agent status for the current conversation
(used by the History Refresh button)."""
self._sync_indicators()
def _max_parallel(self) -> int:
"""Unlimited concurrent turns — no cap (the old Settings limit was removed).
A large sentinel keeps the queue logic intact without ever gating."""
return 100000
def active_workers(self) -> List[AgentWorker]:
"""Workers for turns still running (used to stop them all on quit)."""
return list(self._active)
def _detach_live_turns(self) -> None:
"""Before switching away from the current conversation, turn its running
turns into background jobs: they stop rendering into the (about-to-be-
cleared) transcript but keep running and save to their own conversation."""
for c in self._active.values():
if c.get("home_id") == self.session_id:
c["detached"] = True
c["assistant"] = None # its bubbles are about to be cleared
def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]:
"""The in-progress turn's context for a conversation (one at a time), or None."""
for c in self._active.values():
if c.get("home_id") == session_id:
return c
return None
def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None:
"""Re-render an in-progress turn into the current transcript and re-attach it
so it keeps streaming live — used when reopening a running conversation, so
the user sees the CURRENT task (message + steps so far + live plan), not just
the last saved state."""
record = ctx["record"]
record["bubbles"] = [] # the old bubbles were cleared on the view switch
# 1) the user's message that is being processed
ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)")
record["bubbles"].append(ub)
# 2) steps already completed this turn (assistant text / tool results); found
# by identity after the user message (a system prompt may sit before it).
# Snapshot the list — the worker thread may still be appending to it.
msgs = list(ctx.get("messages", []))
ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1)
for m in (msgs[ui + 1:] if ui >= 0 else []):
role = m.get("role")
if role == "assistant" and (m.get("content") or "").strip():
b = self.chat_view.add_assistant(self.assistant_title())
b.set_markdown(m["content"])
record["bubbles"].append(b)
elif role == "tool":
b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True)
record["bubbles"].append(b)
# 3) the live plan checklist (if any) — inline, expandable
steps = ctx.get("plan_steps") or []
if steps:
self.on_plan(steps)
pb = self.chat_view.add_plan(_format_plan_steps(steps))
record["bubbles"].append(pb)
ctx["plan_bubble"] = pb
# 4) the partial answer of the step currently streaming — re-attach so new
# deltas keep appending to this bubble.
ctx["assistant"] = None
ctx["reasoning"] = None
if (ctx.get("partial") or "").strip():
ab = self.chat_view.add_assistant(self.assistant_title())
ab.set_markdown(ctx["partial"])
record["bubbles"].append(ab)
ctx["assistant"] = ab
# 5) live again → future events render here
ctx["detached"] = False
self.chat_view.scroll_to_bottom()
def new_session(self) -> None:
from ..core.history import new_session_id
# Allowed while work is running: current turns keep going in the background.
self._detach_live_turns()
self.messages = []
self.session_id = new_session_id()
self.title = ""
self.turns = []
self.chat_view.clear()
self.composer.clear_queue()
self.composer.reset_input() # clear leftover text / "Attached: …" hint
self.plan_section.clear()
self.input_section.clear()
self.output_section.clear()
self.graph_event.emit(self.session_name, {"type": "reset"})
self._sync_indicators()
self.history_changed.emit() # current view changed → refresh History highlight
def load_conversation(self, conv: Dict[str, Any]) -> None:
"""Switch the view to a stored conversation. Allowed while work is running —
the current turns keep going in the background."""
sid = conv.get("session_id") or self.session_id
# Clicking the conversation you're already viewing while it has a running
# turn must NOT tear down its live rendering — just no-op.
if sid == self.session_id and self._view_busy():
return
self._detach_live_turns()
self.session_id = sid
self.title = conv.get("title", "")
self.project_id = conv.get("project_id", "") or "default"
# If this conversation still has a turn running in the background, attach to
# its LIVE message list (not a stale disk copy) so the two never race on save.
if sid in self._sessions_live:
self.messages = self._sessions_live[sid]
else:
self.messages = list(conv.get("messages", []))
self.turns = []
self.chat_view.clear()
self.composer.clear_queue()
self.composer.reset_input() # clear leftover text / "Attached: …" hint
self.plan_section.clear()
self.input_section.clear()
self.output_section.clear()
self.graph_event.emit(self.session_name, {"type": "reset"})
for m in self.messages:
role = m.get("role")
if role == "user":
self.chat_view.add_user(m.get("content", ""))
self.graph_event.emit(self.session_name, {"type": "user", "content": m.get("content", "")})
elif role == "assistant":
if m.get("content"):
self.chat_view.add_assistant(self.assistant_title()).set_markdown(m["content"])
self.graph_event.emit(self.session_name, {"type": "assistant_done", "content": m["content"]})
for tc in m.get("tool_calls", []) or []:
self.graph_event.emit(self.session_name, {
"type": "tool_proposed", "name": tc.get("name", ""),
"args": tc.get("arguments", {}),
"preview": {"text": str(tc.get("arguments", {}))},
})
elif role == "tool":
self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True)
self.graph_event.emit(self.session_name, {
"type": "tool_result", "name": m.get("name", ""),
"ok": True, "output": m.get("content", ""),
})
# Restore the Input/Output file lists too.
for p in conv.get("inputs", []):
self.input_section.add(p)
for p in conv.get("outputs", []):
self.output_section.add(p)
# If this conversation has a turn running in the background, re-render the
# in-progress task and re-attach it so it keeps streaming live here.
running = self._running_ctx_for(sid)
if running is not None:
self._reattach_running_turn(running)
elif self.messages:
# A past (already finished) session — surface a link to its output
# folder even though the live "done" marker isn't replayed.
folder = self.workspace_dir()
if folder:
marker = self.chat_view.add_status(tr("chat.session_folder_marker"))
marker.add_folder_link(str(folder), tr("chat.open_folder_short"))
# Jump to the newest message after the transcript is rebuilt.
self.chat_view.scroll_to_bottom()
self._sync_indicators()
self.history_changed.emit() # current view changed → refresh History highlight