diff --git a/presentation/chat/attachment_picker.py b/presentation/chat/attachment_picker.py
new file mode 100644
index 0000000..f04dc25
--- /dev/null
+++ b/presentation/chat/attachment_picker.py
@@ -0,0 +1,215 @@
+"""Tệp đính kèm của một lượt chat — R08-T03.
+
+Đọc nội dung tệp người dùng kèm vào rồi ghép vào câu hỏi. Ba thứ đáng
+chú ý:
+
+* ``_enforce_attachment_security`` chạy TRƯỚC khi nội dung vào ngữ cảnh
+ model — đây là một trong ba tầng kiểm của R09.
+* ``_attach_char_limit`` cắt bớt tệp quá dài; không cắt thì một tệp log
+ vài chục MB đủ làm hỏng cả lượt.
+* Kèm cả thư mục thì chỉ lấy DANH SÁCH tệp, không đọc nội dung từng cái.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import List
+from PySide6.QtCore import Qt
+from ...core.worker import AgentWorker
+from ...i18n import tr
+from ...ui.osutil import is_image
+
+
+class AttachmentMixin:
+ """Trộn vào ChatPanel."""
+
+ 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 _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
+
+ 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 _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 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
diff --git a/presentation/chat/chat_agents.py b/presentation/chat/chat_agents.py
new file mode 100644
index 0000000..256afc2
--- /dev/null
+++ b/presentation/chat/chat_agents.py
@@ -0,0 +1,246 @@
+"""Chọn agent, skill và định tuyến model cho khung chat — R08-T06.
+
+``_apply_routing`` quyết định lượt này chạy bằng model nào: người dùng
+chọn tay, hay để bộ định tuyến tự chọn theo chính sách.
+
+``_note_agent_switch`` ghi lại việc đổi agent giữa chừng vào chính mạch
+hội thoại — không ghi thì đọc lại transcript sẽ thấy giọng đổi đột ngột
+mà không hiểu vì sao.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict
+from PySide6.QtCore import Qt, Signal
+from ...core.worker import AgentWorker
+from ...i18n import tr
+
+
+class ChatAgentsMixin:
+ """Trộn vào ChatPanel."""
+
+ 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.
+
+ Since R03-T04 the Off/Auto/Manual/Fallback rules live in
+ ``application/model_routing/routing_application_service.py``; the copy
+ that used to sit here (and again in Co4E and AI-Edit) is gone. What
+ remains is the widget's own job: snapshot the tab's provider/model into
+ a request, host the Manual-mode modal, and render the outcome by setting
+ ``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
+ by :meth:`build_provider`) plus a status bubble.
+
+ 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
+ try:
+ from ...application.model_routing import (
+ RoutingRequest,
+ build_routing_application_service,
+ )
+ from ...ui.routing_toggle import confirm_switch
+
+ # The model the tab WOULD use without routing — the picker's choice,
+ # or the provider's configured default when nothing is picked.
+ cur_provider = self.ctx.config.active_provider
+ cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
+ outcome = build_routing_application_service(self.ctx).resolve(
+ RoutingRequest(
+ surface=self.kind, # per-workspace mode key ("cowork"/…)
+ prompt=text,
+ current_provider=cur_provider,
+ current_model=cur_model,
+ ),
+ # Manual mode only: the modal stays in the presentation layer so
+ # the application service never imports Qt.
+ confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
+ )
+ if not outcome.switched:
+ return # off / nothing better / declined → keep the tab's model
+ self._routed_provider = outcome.provider
+ self._routed_model = outcome.model
+ notice = self.chat_view.add_status(tr(
+ "routing.switched_notice",
+ model=outcome.model, task=outcome.task_type,
+ gain=f"{outcome.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 _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 _open_skills_manager(self) -> None:
+ """Open the Skills manager (add / edit / delete / enable skills)."""
+ from ...ui.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)."""
diff --git a/presentation/chat/chat_bubble_style.py b/presentation/chat/chat_bubble_style.py
new file mode 100644
index 0000000..05f85bb
--- /dev/null
+++ b/presentation/chat/chat_bubble_style.py
@@ -0,0 +1,202 @@
+"""Cách vẽ một bong bóng chat: màu, đường thời gian, diff, trạng thái — R08-T01.
+
+Tách khỏi ``chat_history_widget.py``: đây là phần quyết định TRÔNG THẾ NÀO,
+còn file kia quyết định HIỆN CÁI GÌ.
+
+``diff_to_html`` tô màu phần thêm/bớt khi agent sửa file; ``_TimelineGutter``
+vẽ đường dọc nối các lượt, ``ThinkingIndicator`` là ba chấm lúc chờ.
+"""
+from __future__ import annotations
+
+import html
+from pathlib import Path
+from PySide6.QtCore import QPointF, Qt, QTimer, Signal
+from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
+from PySide6.QtWidgets import (
+ QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
+ QVBoxLayout, QWidget,
+)
+from ...i18n import on_language_changed, tr
+from ...theme import palette, resolve_theme
+from ...config import CONFIG_DIR
+from ...ui.osutil import is_image, open_folder, open_path
+
+
+def _app_theme() -> str:
+ """Resolve the current app theme (light or dark) from config."""
+ try:
+ import json
+ with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f:
+ data = json.load(f)
+ return resolve_theme(data.get("theme", "dark"))
+ except Exception: # noqa: BLE001
+ return "dark"
+
+
+def _p():
+ """Design tokens for the theme in effect right now."""
+ return palette(_app_theme())
+
+
+def _dot_color(role: str) -> str:
+ """Timeline dot colour for a message role."""
+ p = _p()
+ return {
+ "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
+ "error": p.role_error, "success": p.role_result,
+ }.get(role, p.text_faint)
+
+
+class _TimelineGutter(QWidget):
+ """The left rail of the point-conversation: a vertical connector line with a
+ role-colored dot near the top, so stacked messages read as a timeline
+ (Claude-Code style) instead of separate boxes."""
+
+ def __init__(self, role: str):
+ super().__init__()
+ self._role = role
+ self.setFixedWidth(22)
+
+ def set_role(self, role: str) -> None:
+ self._role = role
+ self.update()
+
+ def paintEvent(self, _e): # noqa: N802
+ p = QPainter(self)
+ p.setRenderHint(QPainter.Antialiasing)
+ tok = _p()
+ x = 11.0
+ cy = 15.0
+ # connector line (faint) running the full height → continuous rail
+ p.setPen(QPen(QColor(tok.border), 2))
+ p.drawLine(int(x), 0, int(x), self.height())
+ # a background ring lifts the dot off the line
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(tok.bg))
+ p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
+ p.setBrush(QColor(_dot_color(self._role)))
+ p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
+
+
+def _diff_legend(diff_text: str) -> str:
+ """A small badge pair labeling what the colors mean: 'Before → After' for
+ an edit, or a single 'Added'/'Removed' badge for a pure create/delete —
+ so the before/after distinction is explicit, not just implied by color."""
+ has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
+ has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
+ p = _p()
+
+ def pill(bg: str, fg: str, key: str) -> str:
+ return (f'{html.escape(tr(key))}')
+
+ if has_add and has_del:
+ badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
+ + f' → '
+ + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
+ elif has_add:
+ badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
+ elif has_del:
+ badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
+ else:
+ return ""
+ return f'
{badge}
'
+
+
+def diff_to_html(diff_text: str) -> str:
+ """Render a unified diff with GitHub/Claude-Code-style line coloring —
+ additions green, deletions red, hunk headers highlighted — plus an
+ explicit Before/After (or Added/Removed) legend, instead of a flat text
+ block, so a before/after edit reads at a glance. A brand-new file (an
+ empty 'before') naturally renders as all-green, which is exactly what
+ ``difflib.unified_diff`` already produces for it."""
+ legend = _diff_legend(diff_text)
+ p = _p()
+ rows = []
+ for ln in diff_text.splitlines():
+ esc = html.escape(ln) if ln else " "
+ if ln.startswith(("+++", "---")):
+ rows.append(f'{esc}
')
+ elif ln.startswith("@@"):
+ rows.append(f'{esc}
')
+ elif ln.startswith("+"):
+ rows.append(f'{esc}
')
+ elif ln.startswith("-"):
+ rows.append(f'{esc}
')
+ else:
+ rows.append(f"{esc}
")
+ body = "".join(rows) or "(no textual change)"
+ return (f'{legend}{body}
')
+
+
+def format_status_line(base: str, ticks: int) -> str:
+ """Animated status line for the working indicator, e.g. ``🤖 Running..`` and,
+ once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow
+ synthesis clearly reads as still running. ``ticks`` advances every 500 ms."""
+ dots = "." * (ticks % 4)
+ secs = ticks // 2
+ suffix = f" · {secs}s" if secs >= 3 else ""
+ return f"{base}{dots}{suffix}"
+
+
+class ThinkingIndicator(QWidget):
+ """A small animated 'the agent is working' line shown while waiting for a
+ result, so a long wait never looks like a frozen / empty screen.
+
+ Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes
+ a few seconds, the elapsed time — so a long synthesis clearly reads as still
+ running rather than stuck."""
+
+ def __init__(self):
+ super().__init__()
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(14, 2, 14, 4)
+ lay.setSpacing(0)
+ self._label = QLabel("")
+ self._label.setObjectName("hint")
+ lay.addWidget(self._label)
+ lay.addStretch(1)
+ self._base_key = "chat.running"
+ self._override: str | None = None
+ self._ticks = 0
+ self._timer = QTimer(self)
+ self._timer.setInterval(500)
+ self._timer.timeout.connect(self._tick)
+ self.setVisible(False)
+ on_language_changed(self._render)
+
+ def start(self, label_key: str = "chat.running") -> None:
+ self._base_key = label_key
+ self._override = None
+ self._ticks = 0
+ self._render()
+ self.setVisible(True)
+ if not self._timer.isActive():
+ self._timer.start()
+
+ def set_label(self, label_key: str) -> None:
+ if label_key != self._base_key:
+ self._base_key = label_key
+ self._override = None
+ self._render()
+
+ def set_progress_text(self, text: str) -> None:
+ """Show an already-formatted, literal status line (e.g. a live "reading
+ page 12/40" or streamed command-output detail) instead of a translated
+ key — used for fine-grained progress within a single step."""
+ self._override = text
+ self._render()
+
+ def stop(self) -> None:
+ self._timer.stop()
+ self._override = None
+ self.setVisible(False)
+
+ def _tick(self) -> None:
+ self._ticks += 1
+ self._render()
+
+ def _render(self) -> None:
+ base = self._override if self._override is not None else tr(self._base_key)
+ self._label.setText(format_status_line(base, self._ticks))
diff --git a/presentation/chat/chat_event_stream.py b/presentation/chat/chat_event_stream.py
new file mode 100644
index 0000000..8ac56d1
--- /dev/null
+++ b/presentation/chat/chat_event_stream.py
@@ -0,0 +1,228 @@
+"""Nhận sự kiện phát về từ luồng chạy nền — R08-T06.
+
+Agent chạy ở luồng khác và bắn sự kiện dần: chữ, lời gọi tool, kế hoạch, xin
+quyền. ``_on_event`` phân nhánh theo loại rồi cập nhật đúng bong bóng.
+
+``_on_permission`` là chỗ giao diện hỏi người dùng — cổng chính sách chỉ trả
+lời ALLOW/DENY/ASK, còn hỏi thế nào là việc của tầng này (xem
+``docs/architecture/security-policy.md`` mục 5).
+
+Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+from PySide6.QtCore import Qt, Signal
+from ...core.worker import AgentWorker
+from ...i18n import tr
+from ...state import AppContext
+from ...ui.composer import Composer
+
+
+class ChatEventStreamMixin:
+ """Xử lý sự kiện của một lượt. Trộn vào ChatPanel."""
+
+ 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).
+ from ...ui.chat_panel import _TOOL_STATUS
+ 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.
+ from ...ui.chat_panel import _format_plan_steps
+ 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 _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 ...ui.permission_dialog import PermissionDialog
+
+ approved, _remember = PermissionDialog.ask(action, parent=self)
+ ctx["worker"].resolve_permission(approved)
+ return
+ ctx["worker"].resolve_permission(True)
+
+ 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))
+
+ 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())
diff --git a/presentation/chat/chat_helpers.py b/presentation/chat/chat_helpers.py
new file mode 100644
index 0000000..613a03a
--- /dev/null
+++ b/presentation/chat/chat_helpers.py
@@ -0,0 +1,53 @@
+"""Hàm và bảng tra dùng chung trong khung chat — R08-T06.
+
+Thuần hàm, không widget. Gom về đây vì cả năm file trong gói đều hỏi tới.
+"""
+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 ...ui.chat_view import ChatView, ThinkingIndicator
+from ...ui.composer import Composer
+from ...ui.icons import collapse_right_icon, icon as app_icon
+from ...ui.osutil import is_image, open_path
+from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
+
+
+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
+
+
+_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",
+}
diff --git a/presentation/chat/chat_history_widget.py b/presentation/chat/chat_history_widget.py
new file mode 100644
index 0000000..905d864
--- /dev/null
+++ b/presentation/chat/chat_history_widget.py
@@ -0,0 +1,348 @@
+"""Scrollable chat transcript built from message bubbles."""
+from __future__ import annotations
+
+from .chat_bubble_style import ( # noqa: F401 — giữ đường vào cũ
+ ThinkingIndicator, _TimelineGutter, _app_theme, _diff_legend, _dot_color, _p,
+ diff_to_html, format_status_line,
+)
+
+import html
+from pathlib import Path
+
+from PySide6.QtCore import QPointF, Qt, QTimer, Signal
+from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
+from PySide6.QtWidgets import (
+ QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
+ QVBoxLayout, QWidget,
+)
+
+from ...i18n import on_language_changed, tr
+from ...theme import palette, resolve_theme
+from ...config import CONFIG_DIR
+from ...ui.osutil import is_image, open_folder, open_path
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+class MessageBubble(QFrame):
+ """One message; assistant/tool bubbles render markdown via QTextBrowser."""
+
+ def __init__(self, role: str, title: str = "", collapsible: bool = False,
+ collapsed: bool = True):
+ super().__init__()
+ self.role = role
+ self._text = ""
+ self._collapsible = collapsible
+ self._title = title
+ self._head = None
+ # Point-conversation layout: [dot rail][content column].
+ outer = QHBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(6)
+ self._gutter = _TimelineGutter(role)
+ outer.addWidget(self._gutter)
+ content = QWidget()
+ lay = QVBoxLayout(content)
+ lay.setContentsMargins(2, 4, 8, 8)
+ lay.setSpacing(4)
+ self._content_layout = lay
+ outer.addWidget(content, 1)
+
+ if title:
+ if collapsible:
+ # Clickable header that folds long tool output away to keep the
+ # transcript short. Collapsed by default; click to expand.
+ self._head = QPushButton(title)
+ self._head.setCursor(Qt.PointingHandCursor)
+ self._head.setStyleSheet(
+ "QPushButton { text-align:left; border:none; background:transparent;"
+ f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
+ self._head.clicked.connect(self._toggle_body)
+ lay.addWidget(self._head)
+ else:
+ head = QLabel(title)
+ head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
+ lay.addWidget(head)
+
+ self.body = QTextBrowser()
+ self.body.setOpenExternalLinks(True)
+ self.body.setFrameShape(QFrame.NoFrame)
+ # Text color adapts to theme.
+ self._apply_theme_styles(role)
+ lay.addWidget(self.body)
+
+ self._apply_style(role)
+ if collapsible and collapsed:
+ self.body.setVisible(False)
+ if collapsible:
+ self._update_head()
+
+ def _toggle_body(self) -> None:
+ self.body.setVisible(not self.body.isVisible())
+ if self.body.isVisible():
+ self._autosize()
+ self._update_head()
+
+ def _update_head(self) -> None:
+ if not self._head:
+ return
+ expanded = self.body.isVisible()
+ arrow = "▾" if expanded else "▸"
+ preview = ""
+ if not expanded and self._text.strip():
+ first = self._text.strip().splitlines()[0]
+ if len(first) > 70:
+ first = first[:70] + "…"
+ preview = f" {first}"
+ self._head.setText(f"{arrow} {self._title}{preview}")
+
+ def _current_theme(self) -> str:
+ """Resolve the current app theme (light or dark)."""
+ return _app_theme()
+
+ def _apply_theme_styles(self, role: str) -> None:
+ """Apply text color to the body QTextBrowser based on current theme + role."""
+ p = _p()
+ text_color = {
+ "success": p.success,
+ "error": p.danger,
+ "tool": p.text_muted, # secondary, like Claude's steps
+ }.get(role, p.text)
+ self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
+
+ def _apply_style(self, role: str) -> None:
+ """Flat timeline row — no bubble box; the left dot/rail conveys role and
+ structure (Claude-Code style). The user's own message gets a faint tint
+ so questions are easy to pick out when scanning."""
+ p = _p()
+ if role == "user":
+ self.setStyleSheet(
+ f"QFrame {{ background: {p.surface}; border: none; "
+ f"border-radius: {p.radius}px; }}")
+ else:
+ self.setStyleSheet("QFrame { background: transparent; border: none; }")
+
+ def apply_theme(self) -> None:
+ """Re-apply theme-dependent styles so existing rows adapt when the app
+ theme switches (light ↔ dark)."""
+ self._apply_theme_styles(self.role)
+ self._apply_style(self.role)
+ self._gutter.set_role(self.role)
+
+ def chat_view(self):
+ """Walk up the parent chain to find the enclosing ChatView, if any."""
+ p = self.parent()
+ while p is not None:
+ if isinstance(p, ChatView):
+ return p
+ p = p.parent()
+ return None
+
+ def append_delta(self, delta: str) -> None:
+ self._text += delta
+ self.set_markdown(self._text)
+
+ def set_markdown(self, text: str) -> None:
+ self._text = text
+ self.body.setMarkdown(text)
+ self._autosize()
+ if self._collapsible:
+ self._update_head()
+
+ def set_plain(self, text: str) -> None:
+ self._text = text
+ self.body.setPlainText(text)
+ self._autosize()
+ if self._collapsible:
+ self._update_head()
+
+ def append_plain(self, delta: str) -> None:
+ self._text += delta
+ self.set_plain(self._text)
+
+ def set_diff(self, diff_text: str) -> None:
+ """Render a unified diff (see :func:`diff_to_html`) with colored
+ before/after lines instead of a flat text block."""
+ self._text = diff_text
+ self.body.setHtml(diff_to_html(diff_text))
+ self._autosize()
+ if self._collapsible:
+ self._update_head()
+
+ def add_usage(self, text: str) -> None:
+ """A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost),
+ like Claude Code. Replaces any previous usage line on this bubble."""
+ existing = getattr(self, "_usage_lbl", None)
+ if existing is not None:
+ existing.setText(text)
+ return
+ lbl = QLabel(text)
+ lbl.setObjectName("faint")
+ lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
+ self._usage_lbl = lbl
+ self._content_layout.addWidget(lbl)
+
+ def add_delete_link(self, callback) -> None:
+ link = QLabel(f'{tr("chat.delete_link")}')
+ link.setToolTip(tr("chat.delete_tooltip"))
+ link.linkActivated.connect(lambda *_: callback())
+ self._content_layout.addWidget(link)
+
+ def add_folder_link(self, folder: str, label: str | None = None) -> None:
+ label = label or tr("chat.open_workspace")
+ link = QLabel(f'{label}')
+ link.setToolTip(str(folder))
+ link.linkActivated.connect(lambda *_: open_folder(folder))
+ self._content_layout.addWidget(link)
+
+ def add_attachments(self, paths) -> None:
+ """Show attached files: images as thumbnails, others as clickable links."""
+ for p in paths:
+ path = str(p)
+ name = Path(path).name
+ if is_image(path):
+ pix = QPixmap(path)
+ if not pix.isNull():
+ thumb = QLabel()
+ thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation))
+ thumb.setToolTip(name)
+ thumb.setCursor(Qt.PointingHandCursor)
+ self._content_layout.addWidget(thumb)
+ continue
+ file_link = QLabel(f'{name}')
+ file_link.setToolTip(path)
+ file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
+ self._content_layout.addWidget(file_link)
+
+ def _autosize(self) -> None:
+ width = self.body.viewport().width()
+ if width <= 0:
+ width = 560 # sensible default before the widget is laid out
+ self.body.document().setTextWidth(width)
+ height = int(self.body.document().size().height()) + 12
+ self.body.setFixedHeight(max(28, min(height, 1200)))
+
+ def resizeEvent(self, event): # noqa: N802 - re-flow on width change
+ super().resizeEvent(event)
+ self._autosize()
+
+
+class ChatView(QScrollArea):
+ """Scrollable chat transcript.
+
+ Emits ``theme_changed`` (via the apply_theme method) so every child
+ ``MessageBubble`` can re-apply its theme-aware inline styles when the
+ app switches between light and dark modes."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWidgetResizable(True)
+ self._container = QWidget()
+ self._lay = QVBoxLayout(self._container)
+ self._lay.setContentsMargins(12, 12, 12, 12)
+ self._lay.setSpacing(10)
+ self._lay.addStretch(1)
+ self.setWidget(self._container)
+
+ def apply_theme(self) -> None:
+ """Ask every MessageBubble inside this view to re-apply theme styles.
+
+ Called from ``ChatPanel.apply_theme`` whenever the app theme changes."""
+ for i in range(self._lay.count()):
+ item = self._lay.itemAt(i)
+ w = item.widget() if item else None
+ if isinstance(w, MessageBubble):
+ w.apply_theme()
+
+ def _add(self, bubble: MessageBubble) -> MessageBubble:
+ # insert before the trailing stretch
+ self._lay.insertWidget(self._lay.count() - 1, bubble)
+ self._scroll_to_bottom()
+ return bubble
+
+ def add_user(self, text: str) -> MessageBubble:
+ b = MessageBubble("user", tr("chat.you"))
+ b.set_plain(text)
+ return self._add(b)
+
+ def add_assistant(self, title: str | None = None) -> MessageBubble:
+ b = MessageBubble("assistant", title or tr("chat.assistant"))
+ return self._add(b)
+
+ def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble:
+ # Tool steps (run command, generated code/diff, output) are collapsible to
+ # keep the transcript short — collapsed when OK, expanded on error.
+ b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
+ b.set_plain(body)
+ return self._add(b)
+
+ def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble:
+ """Like :meth:`add_tool`, but renders ``diff_text`` as a colored
+ before/after diff (see :func:`diff_to_html`) instead of flat text."""
+ b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
+ b.set_diff(diff_text)
+ return self._add(b)
+
+ def add_plan(self, body: str) -> MessageBubble:
+ """The task plan shown INLINE in the timeline (never a pop-up or side
+ panel) — a permanent, always-expanded row whose steps tick off as they
+ complete. The agent re-sends the full list on each update; the caller
+ updates this same row in place via ``set_plain``."""
+ b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False)
+ b.set_plain(body)
+ return self._add(b)
+
+ def add_reasoning(self, title: str | None = None) -> MessageBubble:
+ # The model's private reasoning — a collapsed, collapsible box so the user
+ # can see it's thinking (and expand to read) without it flooding the chat.
+ b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True)
+ return self._add(b)
+
+ def add_error(self, text: str) -> MessageBubble:
+ b = MessageBubble("error", tr("chat.error"))
+ b.set_plain(text)
+ return self._add(b)
+
+ def add_status(self, text: str) -> MessageBubble:
+ """A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành')."""
+ b = MessageBubble("tool", "")
+ b.set_plain(text)
+ return self._add(b)
+
+ def add_success(self, text: str) -> MessageBubble:
+ """Like :meth:`add_status`, but styled green — used for the "turn done"
+ marker so completion reads as an unmistakable success signal."""
+ b = MessageBubble("success", "")
+ b.set_plain(text)
+ return self._add(b)
+
+ def clear(self) -> None:
+ while self._lay.count() > 1:
+ item = self._lay.takeAt(0)
+ w = item.widget()
+ if w:
+ w.deleteLater()
+
+ def scroll_to_bottom(self) -> None:
+ """Scroll to the newest message, deferred so freshly-added bubbles have
+ finished sizing (their height is computed after layout)."""
+ QTimer.singleShot(0, self._scroll_to_bottom)
+ QTimer.singleShot(80, self._scroll_to_bottom)
+
+ def _scroll_to_bottom(self) -> None:
+ bar = self.verticalScrollBar()
+ bar.setValue(bar.maximum())
diff --git a/presentation/chat/chat_input_box.py b/presentation/chat/chat_input_box.py
new file mode 100644
index 0000000..9bf3875
--- /dev/null
+++ b/presentation/chat/chat_input_box.py
@@ -0,0 +1,328 @@
+"""Ô nhập của khung chat — R08-T02.
+
+Tự giãn cao theo nội dung, Ctrl+Enter để gửi, dán ảnh từ clipboard thành tệp
+đính kèm, và popup gợi ý khi gõ ``/skill`` hoặc ``/agent``.
+
+Tách khỏi ``composer_widget.py`` vì đây là phần bắt phím và chuột; phần kia
+là thanh công cụ quanh nó.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QImage, QKeyEvent
+from PySide6.QtWidgets import (
+ QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
+ QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
+)
+from ...config import CONFIG_DIR
+from ...i18n import on_language_changed, tr
+from ...theme import current_palette
+from ...ui.icons import icon, IconLabel
+
+
+class _SkillPopup(QListWidget):
+ """The ``/skill`` picker.
+
+ Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it
+ does NOT grab the keyboard, so the input keeps focus and the user can keep
+ typing their request after ``/skill``. Navigation / accept / Esc are handled by
+ the parent ``_Input``'s key handler (which still receives every key); clicking
+ an item selects it; the popup auto-hides when the input loses focus."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
+ | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
+ self.setAttribute(Qt.WA_ShowWithoutActivating, True)
+ self.setFocusPolicy(Qt.NoFocus)
+
+
+class _Input(QPlainTextEdit):
+ """Plain text edit: submits on Enter, accepts pasted/dropped images & files."""
+
+ submit = Signal()
+ media_added = Signal(list)
+ manage_skills = Signal() # user picked "Manage skills…" in the /skill popup
+
+ MIN_HEIGHT = 64 # ~2 lines
+ MAX_HEIGHT = 220 # ~8 lines, then it scrolls
+
+ def __init__(self):
+ super().__init__()
+ self.setAcceptDrops(True)
+ # Use a clean Latin/Vietnamese-friendly UI font for the input (the global
+ # '*' rule falls back to Japanese faces, which mis-render some glyphs).
+ self.setStyleSheet(
+ "font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;"
+ )
+ # Grow with the text (up to MAX_HEIGHT), then scroll instead.
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.textChanged.connect(self._adjust_height)
+ # "/skill" + "/agent" command popup — lists skills / agents inline.
+ self._skill_popup = _SkillPopup(self)
+ self._popup_kind = "skill" # which command the popup is showing
+ self._skill_popup.itemClicked.connect(self._accept_item)
+ self.textChanged.connect(self._maybe_show_skills)
+ self._adjust_height()
+
+ # ---- /skill autocomplete ----------------------------------------
+ def _skill_token(self):
+ """Locate a ``/skill[:partial]`` command the cursor is currently typing —
+ ANYWHERE in the message, not just at the start (so "dùng /skill:foo …"
+ with text typed before it still triggers the picker). Mirrors
+ ``core.skills.parse_skill_command``'s whitespace-boundary rule.
+
+ Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the
+ ``/skill`` token begins in the document, ``partial_filter`` is the text
+ typed after ``:`` (``''`` while still typing the command word itself) — or
+ ``None`` when the cursor isn't inside a ``/skill`` token."""
+ import re
+ pos = self.textCursor().position()
+ before = self.toPlainText()[:pos]
+ # The token is the whitespace-delimited word ending at the cursor; its
+ # start must be the document start or follow whitespace (same boundary
+ # parse_skill_command enforces with its (?= 2 and "/skill".startswith(token):
+ return start, "" # typing "/s", "/sk", … "/skill" → show the whole list
+ m = re.match(r"^/skill:?([\w\-.]*)$", token)
+ return (start, m.group(1)) if m else None
+
+ def _skill_filter(self):
+ """Return the partial filter while a '/skill' command is being typed
+ (anywhere in the message), or None."""
+ tok = self._skill_token()
+ return tok[1] if tok else None
+
+ def _agent_token(self):
+ """Locate a ``/agent[:partial]`` command the cursor is typing (mirror of
+ ``_skill_token``). Returns ``(start_offset, partial)`` or None."""
+ import re
+ pos = self.textCursor().position()
+ before = self.toPlainText()[:pos]
+ start = re.search(r"\S*$", before).start()
+ token = before[start:]
+ if len(token) >= 2 and "/agent".startswith(token):
+ return start, ""
+ m = re.match(r"^/agent:?([\w\-.]*)$", token)
+ return (start, m.group(1)) if m else None
+
+ def _maybe_show_skills(self) -> None:
+ # One popup serves both commands: show skills while typing /skill, agents
+ # while typing /agent (Cowork parity with the Co4E chat).
+ stok = self._skill_token()
+ if stok is not None:
+ self._popup_kind = "skill"
+ self._populate_skill_popup(stok[1])
+ self._show_cmd_popup()
+ return
+ atok = self._agent_token()
+ if atok is not None:
+ self._popup_kind = "agent"
+ self._populate_agent_popup(atok[1])
+ self._show_cmd_popup()
+ return
+ self._skill_popup.hide()
+
+ def _populate_skill_popup(self, filt: str) -> None:
+ try:
+ from ..core.skills import builtin_skills, list_skills
+ # Include always-on built-ins so the picker is usable before the user
+ # has created any custom skill.
+ skills = list_skills() + builtin_skills()
+ except Exception:
+ skills = []
+ f = (filt or "").lower()
+ matches = [s for s in skills
+ if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()]
+ self._skill_popup.clear()
+ for s in matches:
+ text = ("✓ " if s.enabled else " ") + s.name
+ if s.description:
+ text += f" — {s.description}"
+ item = QListWidgetItem(text)
+ item.setData(Qt.UserRole, s.slug)
+ self._skill_popup.addItem(item)
+ if not matches:
+ empty = QListWidgetItem(tr("composer.no_skills"))
+ empty.setFlags(Qt.NoItemFlags)
+ self._skill_popup.addItem(empty)
+ manage = QListWidgetItem(tr("composer.manage_skills"))
+ manage.setData(Qt.UserRole, "__manage__")
+ self._skill_popup.addItem(manage)
+ self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
+
+ def _populate_agent_popup(self, filt: str) -> None:
+ try:
+ from ..core.agent_command import collect_agents
+ agents = collect_agents("") # built-ins + local admin + custom agents
+ except Exception:
+ agents = []
+ f = (filt or "").lower()
+ matches = [a for a in agents
+ if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()]
+ self._skill_popup.clear()
+ for a in matches:
+ text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "")
+ item = QListWidgetItem(text)
+ item.setData(Qt.UserRole, a["slug"])
+ self._skill_popup.addItem(item)
+ if not matches:
+ empty = QListWidgetItem(tr("composer.no_agents"))
+ empty.setFlags(Qt.NoItemFlags)
+ self._skill_popup.addItem(empty)
+ self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
+
+ def _show_cmd_popup(self) -> None:
+ rows = min(7, self._skill_popup.count())
+ h = 10 + rows * 22
+ self._skill_popup.resize(max(300, self.width()), h)
+ top_left = self.mapToGlobal(self.rect().topLeft())
+ self._skill_popup.move(top_left.x(), top_left.y() - h - 2)
+ self._skill_popup.show()
+
+ def _dismiss_skill_popup(self) -> None:
+ """Hide the /skill picker (Esc)."""
+ self._skill_popup.hide()
+
+ def focusOutEvent(self, e) -> None: # noqa: N802
+ # The popup never grabs focus, so a click away lands here → dismiss it
+ # (unless the click is on the popup itself, e.g. picking an item).
+ if not self._skill_popup.underMouse():
+ self._skill_popup.hide()
+ super().focusOutEvent(e)
+
+ def _accept_item(self, item=None) -> None:
+ """Dispatch popup selection to the right handler based on which command
+ (``/skill`` or ``/agent``) the popup is currently showing."""
+ if self._popup_kind == "agent":
+ self._accept_agent(item)
+ else:
+ self._accept_skill(item)
+
+ def _replace_token(self, tok, replacement: str) -> None:
+ pos = self.textCursor().position()
+ start = tok[0] if tok else pos
+ full = self.toPlainText()
+ new_text = full[:start] + replacement + full[pos:]
+ new_pos = start + len(replacement)
+ self.blockSignals(True)
+ self.setPlainText(new_text)
+ self.blockSignals(False)
+ cur = self.textCursor()
+ cur.setPosition(min(new_pos, len(new_text)))
+ self.setTextCursor(cur)
+ self._adjust_height()
+ self.setFocus()
+
+ def _accept_skill(self, item=None) -> None:
+ item = item or self._skill_popup.currentItem()
+ self._skill_popup.hide()
+ if item is None:
+ return
+ slug = item.data(Qt.UserRole)
+ if slug == "__manage__":
+ self.manage_skills.emit() # open the Skills manager
+ return
+ if not slug:
+ return
+ # Replace ONLY the /skill token the cursor is on — text typed before it
+ # ("dùng …") and after it is preserved, so the command can sit mid-sentence.
+ self._replace_token(self._skill_token(), f"/skill:{slug} ")
+
+ def _accept_agent(self, item=None) -> None:
+ item = item or self._skill_popup.currentItem()
+ self._skill_popup.hide()
+ if item is None:
+ return
+ slug = item.data(Qt.UserRole)
+ if not slug:
+ return
+ self._replace_token(self._agent_token(), f"/agent:{slug} ")
+
+ def _adjust_height(self, *_a) -> None:
+ # QPlainTextEdit reports the document height in LINES (not pixels), so
+ # convert via line spacing to get the real pixel height.
+ lines = self.document().size().height() or 1
+ line_px = self.fontMetrics().lineSpacing()
+ h = int(lines * line_px + 2 * self.frameWidth() + 12)
+ h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
+ if h != self.height():
+ self.setFixedHeight(h)
+
+ def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802
+ if self._skill_popup.isVisible():
+ k = e.key()
+ if k in (Qt.Key_Down, Qt.Key_Up):
+ n = self._skill_popup.count()
+ if n:
+ step = 1 if k == Qt.Key_Down else -1
+ self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n)
+ return
+ if k == Qt.Key_Tab:
+ self._accept_item() # Tab = autocomplete the highlighted item
+ return
+ if k == Qt.Key_Escape:
+ self._dismiss_skill_popup()
+ return
+ if k in (Qt.Key_Return, Qt.Key_Enter):
+ item = self._skill_popup.currentItem()
+ slug = item.data(Qt.UserRole) if item else None
+ is_agent = self._popup_kind == "agent"
+ tok = self._agent_token() if is_agent else self._skill_token()
+ prefix = "/agent:" if is_agent else "/skill:"
+ token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else ""
+ exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}"
+ if slug and slug != "__manage__" and not exact:
+ # A suggestion is highlighted but not yet fully typed —
+ # Enter completes it into the box first (same as Tab),
+ # instead of submitting a partial/mistyped slug that
+ # the parser would just reject as "not found".
+ self._accept_item(item)
+ return
+ # Slug already fully typed (or nothing usable is highlighted,
+ # e.g. the "no skills found" placeholder) — Enter RUNS the
+ # /skill command as typed: hide the popup and fall through to
+ # the normal submit below.
+ self._skill_popup.hide()
+ if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
+ self.submit.emit()
+ return
+ super().keyPressEvent(e)
+
+ def insertFromMimeData(self, source) -> None: # noqa: N802 - paste
+ paths = _paths_from_mime(source)
+ if paths:
+ self.media_added.emit(paths)
+ return
+ super().insertFromMimeData(source)
+
+ def canInsertFromMimeData(self, source) -> bool: # noqa: N802
+ if source.hasImage() or source.hasUrls():
+ return True
+ return super().canInsertFromMimeData(source)
+
+ def dragEnterEvent(self, e) -> None: # noqa: N802
+ if e.mimeData().hasUrls() or e.mimeData().hasImage():
+ e.acceptProposedAction()
+ return
+ super().dragEnterEvent(e)
+
+ def dragMoveEvent(self, e) -> None: # noqa: N802
+ if e.mimeData().hasUrls() or e.mimeData().hasImage():
+ e.acceptProposedAction()
+ return
+ super().dragMoveEvent(e)
+
+ def dropEvent(self, e) -> None: # noqa: N802
+ paths = _paths_from_mime(e.mimeData())
+ if paths:
+ self.media_added.emit(paths)
+ e.acceptProposedAction()
+ return
+ super().dropEvent(e)
diff --git a/presentation/chat/chat_output_panel.py b/presentation/chat/chat_output_panel.py
new file mode 100644
index 0000000..eede096
--- /dev/null
+++ b/presentation/chat/chat_output_panel.py
@@ -0,0 +1,187 @@
+"""Khung tệp vào/ra của một lượt chat — R08-T05.
+
+Agent có thể tạo tệp trong lúc chạy. Thay vì bắt người dùng tự đi tìm,
+khung này theo dõi thư mục output và hiện tệp mới ngay khi có.
+
+``_is_intermediate_output`` là chỗ lọc: một lượt chạy đẻ ra nhiều tệp
+trung gian mà người dùng không quan tâm; hiện hết thì khung thành bãi rác.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import List, Optional
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QWidget
+from ...i18n import tr
+from ...ui.icons import icon as app_icon
+from ...ui.osutil import open_path
+from ...ui.widgets import CollapseStrip
+
+
+class OutputPanelMixin:
+ """Trộn vào ChatPanel."""
+
+ 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."""
+ from ...ui.chat_panel import _is_scratch
+ 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))
+
+ 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 _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 ...ui.file_edit_dialog import FileEditDialog
+
+ FileEditDialog(self.ctx, path, self).exec()
+
+ 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)
+
+ 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()
+
+ 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])
+
+ 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 workspace_dir(self) -> Optional[Path]:
+ """Folder shown via the 'open folder' link on messages (None = no link)."""
+ return None
diff --git a/presentation/chat/chat_panel_layout.py b/presentation/chat/chat_panel_layout.py
new file mode 100644
index 0000000..4a098d4
--- /dev/null
+++ b/presentation/chat/chat_panel_layout.py
@@ -0,0 +1,149 @@
+"""Bố cục khung chat — R08-T06.
+
+Hai cột: mạch hội thoại bên trái, khung tệp đầu ra bên phải. Ô nhập nằm dưới
+CẢ HAI cột — đó là lý do khung tệp đứng cạnh mạch hội thoại mà không làm hẹp
+chỗ gõ. Đặt trong cột chat thì ô nhập co lại mỗi lần có tệp xuất hiện.
+
+Vài widget cố ý được gắn vào một cha ẩn vĩnh viễn thay vì bỏ hẳn: khung tệp
+đầu vào và bảng kế hoạch cũ vẫn còn được gọi ``set_steps``/``add`` ở nơi
+khác. Không có cha thì lần gọi đầu tiên sẽ bật lên thành một cửa sổ nổi lạc
+lõng giữa màn hình.
+
+Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
+"""
+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 ...ui.chat_view import ChatView, ThinkingIndicator
+from ...ui.composer import Composer
+from ...ui.icons import collapse_right_icon, icon as app_icon
+from ...ui.osutil import is_image, open_path
+from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
+
+
+class ChatPanelLayoutMixin:
+ """Dựng bố cục. Trộn vào ChatPanel."""
+
+ def _build_layout(self, root) -> None:
+ """``root`` là QVBoxLayout gốc do ``__init__`` dựng."""
+ # 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)
+ self.center_split = QSplitter(Qt.Horizontal)
+ self.center_split.addWidget(chat_col)
+ root.addWidget(self.center_split, 1)
+
+ # The composer spans the whole screen, under BOTH columns — that is how
+ # the drawing lays it out, and it is the reason the files panel can sit
+ # beside the transcript without narrowing what you type into. Inside the
+ # chat column it stopped at the panel's edge and the input shrank
+ # whenever files appeared.
+ composer_wrap = QWidget()
+ cwl = QVBoxLayout(composer_wrap)
+ cwl.setContentsMargins(8, 4, 8, 8)
+ cwl.addWidget(self.composer)
+ root.addWidget(composer_wrap)
+
+ # Right sidebar: Output files only (see below — Input is tracked but
+ # not shown).
+ self.input_section = CollapsibleSection(tr("widgets.input_files"))
+ # No cap: this section owns the whole right panel (its header is
+ # hoisted into io_hdr below), so the list should fill the space down
+ # to the composer instead of stopping at a fixed height with empty
+ # panel below it.
+ self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None)
+ # 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;")
+ # The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and
+ # the section already draws exactly that, count included. A separate
+ # "Files" label above it was the same thing said twice, so the section's
+ # own header moves onto this row and the collapse chevron sits at its
+ # right, where the drawing puts it. _files_header stays for the tabs
+ # that still label their panel, just not in this layout.
+ self._files_header.setVisible(False)
+ io_hdr.addWidget(self.output_section.header, 1)
+ io_hdr.addWidget(self._io_collapse_btn)
+ # 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, 1) # Output only — Input is tracked but hidden; fills down to the composer
+ 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)
diff --git a/presentation/chat/chat_session_store.py b/presentation/chat/chat_session_store.py
new file mode 100644
index 0000000..1578680
--- /dev/null
+++ b/presentation/chat/chat_session_store.py
@@ -0,0 +1,414 @@
+"""Lưu, nạp lại phiên chat và đếm token — R08-T06.
+
+``_reattach_running_turn`` là phần tinh tế nhất: người dùng chuyển sang
+phiên khác rồi quay lại trong khi lượt cũ vẫn đang chạy, thì phải nối
+lại đúng luồng đó chứ không được khởi động lại.
+
+``_compress_messages`` nén ngữ cảnh khi hội thoại dài quá cửa sổ model.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QMessageBox
+from ...core.worker import AgentWorker
+from ...i18n import tr
+
+
+class ChatSessionMixin:
+ """Trộn vào ChatPanel."""
+
+ def _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]],
+ title: str, inputs: Optional[List[str]] = None,
+ history_dir: Optional[Path] = 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.
+
+ ``history_dir``, when given, is used INSTEAD of
+ ``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04):
+ a background turn must save into the project it started in, not
+ whichever project happens to be selected in the Workspace screen by
+ the time the turn finishes.
+ """
+ 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(
+ history_dir if history_dir is not None else 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", []),
+ history_dir=ctx.get("home_history_dir"))
+ 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 _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 refresh_usage(self) -> None:
+ """Show what this conversation has already cost.
+
+ The label was written only at the end of a turn, so opening a thread
+ from History left the strip blank however much it had spent.
+ """
+ from ...core import model_pricing as mp
+ from ...core import usage_tracker as ut
+
+ cur = self._usage_snapshot()
+ if not (cur["in"] or cur["out"] or cur["cache"]):
+ self._usage_total_lbl.setText("")
+ return
+ # same source _show_usage reads, so the two never disagree
+ pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
+ 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 _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 _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 _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()
+
+ 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 _notify_title(self) -> None:
+ """Let a screen that heads itself with the thread title follow along.
+
+ The thread also decides what the usage strip should read, so refresh
+ that here rather than at each of the three places the title changes.
+ """
+ hook = getattr(self, "refresh_title", None)
+ if callable(hook):
+ hook()
+ if getattr(self, "_usage_total_lbl", None) is not None:
+ self.refresh_usage()
+
+ 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._notify_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
+
+ 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 _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 _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)
+ from ...ui.chat_panel import _format_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()
diff --git a/presentation/chat/chat_turn_runner.py b/presentation/chat/chat_turn_runner.py
new file mode 100644
index 0000000..2b3f322
--- /dev/null
+++ b/presentation/chat/chat_turn_runner.py
@@ -0,0 +1,281 @@
+"""Chạy một lượt chat, từ lúc bấm Gửi tới lúc kết thúc — R08-T06.
+
+``_start_turn`` (144 dòng) và ``_on_event`` (127) là hai hàm dài nhất
+trong màn này, và cố ý để nguyên: cái đầu dựng trọn ngữ cảnh một lượt
+rồi giao cho luồng nền, cái sau phân nhánh theo từng loại sự kiện phát
+về. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại.
+
+Mỗi lượt có luồng riêng và ngữ cảnh riêng, nên chạy song song nhiều lượt
+trong cùng một khung chat được.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+from PySide6.QtCore import Qt, Signal
+from ...core.worker import AgentWorker
+from ...i18n import tr
+from ...state import AppContext
+from ...ui.composer import Composer
+
+
+class ChatTurnRunnerMixin:
+ """Trộn vào ChatPanel."""
+
+ 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 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 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 _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
+ self._notify_title()
+
+ # 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(),
+ # R06-T04: captured NOW, at submit time — see _persist_session's
+ # use of this. Without it, a background turn (this session isn't
+ # the one currently displayed) saves into whatever
+ # ctx.config.history_dir() resolves to AT THE TIME IT FINISHES,
+ # which is the *currently viewed* project's history folder if the
+ # user switched projects (ui/workspace_tab.py::_load_current)
+ # while this turn was still running — silently saving one
+ # project's conversation into another project's history folder.
+ "home_history_dir": self.ctx.config.history_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 _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 _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 _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}")))
diff --git a/presentation/chat/composer_widget.py b/presentation/chat/composer_widget.py
new file mode 100644
index 0000000..5384988
--- /dev/null
+++ b/presentation/chat/composer_widget.py
@@ -0,0 +1,364 @@
+"""Message composer: multiline input, attachments, Send/Stop, message queue.
+
+Several turns can run at once (up to the configured parallel limit). Once that
+limit is reached the composer switches to "Queue" mode: extra messages (with
+their attachments) are held in the queue and dispatched automatically as running
+turns finish and free up a slot. Files/images can be attached to a message.
+"""
+from __future__ import annotations
+
+from .chat_input_box import _Input, _SkillPopup
+
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QImage, QKeyEvent
+from PySide6.QtWidgets import (
+ QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
+ QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
+)
+
+from ...config import CONFIG_DIR
+from ...i18n import on_language_changed, tr
+from ...theme import current_palette
+from ...ui.icons import icon, IconLabel
+
+
+def _save_pasted_image(image) -> str | None:
+ """Save a clipboard/drag QImage to the config dir; return its path."""
+ try:
+ if not isinstance(image, QImage) or image.isNull():
+ return None
+ folder = CONFIG_DIR / "pasted"
+ folder.mkdir(parents=True, exist_ok=True)
+ name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png"
+ path = folder / name
+ if image.save(str(path), "PNG"):
+ return str(path)
+ except Exception:
+ return None
+ return None
+
+
+def _is_local_skill_command(text: str) -> bool:
+ """True for a bare ``/skill`` (list) or ``/skill:`` (select) command that
+ is answered inline instantly — these must run even while a turn is busy, so they
+ bypass the message queue (unlike ``/skill: ``, which is a real
+ turn and should queue)."""
+ import re
+ t = (text or "").strip()
+ return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t))
+
+
+def _is_local_agent_command(text: str) -> bool:
+ """Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare
+ ``/agent`` (list) or ``/agent:`` (select) is answered inline instantly."""
+ import re
+ t = (text or "").strip()
+ return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t))
+
+
+def _paths_from_mime(md) -> List[str]:
+ paths: List[str] = []
+ if md.hasUrls():
+ for u in md.urls():
+ if u.isLocalFile():
+ paths.append(u.toLocalFile())
+ if not paths and md.hasImage():
+ p = _save_pasted_image(md.imageData())
+ if p:
+ paths.append(p)
+ return paths
+
+
+
+
+
+
+class Composer(QWidget):
+ submitted = Signal(str, list) # (text, attachment paths)
+ stop_requested = Signal()
+ queue_changed = Signal(int)
+ attachments_added = Signal(list) # current attachment paths (pushed to the Input box)
+ attachment_removed = Signal(str) # a wrongly-added attachment was removed
+ attach_limit_note = Signal(str) # shown when the attachment-count limit is hit
+ manage_skills = Signal() # relayed from the /skill popup "Manage skills…"
+
+ def __init__(self, placeholder_key: str = "composer.placeholder_default"):
+ super().__init__()
+ self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change
+ self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]}
+ self._attachments: List[str] = []
+ self._max_attachments = 0 # 0 = unlimited; set from Settings
+ self._busy = False
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(6)
+
+ # --- queue strip (hidden when empty) ---
+ self.queue_box = QWidget()
+ qlay = QVBoxLayout(self.queue_box)
+ qlay.setContentsMargins(0, 0, 0, 0)
+ self.queue_label = QLabel()
+ self.queue_label.setObjectName("hint")
+ self.queue_list = QListWidget()
+ self.queue_list.setMaximumHeight(78)
+ self.queue_list.itemDoubleClicked.connect(self._remove_queue_item)
+ qlay.addWidget(self.queue_label)
+ qlay.addWidget(self.queue_list)
+ self.queue_box.setVisible(False)
+ root.addWidget(self.queue_box)
+
+ # --- attachments strip (hidden when empty) ---
+ self.attach_box = QWidget()
+ alay = QVBoxLayout(self.attach_box)
+ alay.setContentsMargins(0, 0, 0, 0)
+ self.attach_label = QLabel()
+ self.attach_label.setObjectName("hint")
+ self.attach_list = QListWidget()
+ # Single horizontal row of chips; scroll sideways when there are many.
+ self.attach_list.setFlow(QListView.LeftToRight)
+ self.attach_list.setWrapping(False)
+ self.attach_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.attach_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.attach_list.setFixedHeight(40)
+ self.attach_list.itemDoubleClicked.connect(self._remove_attachment)
+ alay.addWidget(self.attach_label)
+ alay.addWidget(self.attach_list)
+ self.attach_box.setVisible(False)
+ root.addWidget(self.attach_box)
+
+ # --- input row ---
+ row = QHBoxLayout()
+ self.input = _Input()
+ self.input.setPlaceholderText(tr(self._placeholder_key))
+ self.input.submit.connect(self._on_submit)
+ self.input.media_added.connect(self._add_paths)
+ self.input.manage_skills.connect(self.manage_skills.emit)
+ row.addWidget(self.input, 1)
+
+ btns = QVBoxLayout()
+ self.attach_btn = QPushButton("")
+ self.attach_btn.setIcon(icon("attach"))
+ self.attach_btn.clicked.connect(self._pick_attachments)
+ self.send_btn = QPushButton()
+ self.send_btn.setIcon(icon("upload"))
+ self.send_btn.setObjectName("primary")
+ self.send_btn.clicked.connect(self._on_submit)
+ self.stop_btn = QPushButton()
+ self.stop_btn.setIcon(icon("stop"))
+ self.stop_btn.setObjectName("danger")
+ self.stop_btn.setVisible(False)
+ self.stop_btn.clicked.connect(self.stop_requested.emit)
+ # Attach pinned to the input's top edge, Send (and Stop, once a turn
+ # is running) pinned to its bottom edge — the gap between them is
+ # absorbed by this stretch instead of splitting evenly above/below
+ # the whole button column, which is what centering it did before.
+ btns.addWidget(self.attach_btn)
+ btns.addStretch(1)
+ btns.addWidget(self.send_btn)
+ btns.addWidget(self.stop_btn)
+ row.addLayout(btns)
+ root.addLayout(row)
+
+ # bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
+ # right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
+ # Its own strip UNDER the typing box, styled as a status line rather
+ # than a second toolbar: the design asks for the typing area to be just
+ # input · attach · send, with agent / routing / usage / folder reading
+ # as status underneath. They stay interactive — only quieter.
+ self._bottom_left_count = 0
+ self.extra_bar = QWidget()
+ self.extra_bar.setObjectName("composerStatus")
+ self.extra_row = QHBoxLayout(self.extra_bar)
+ self.extra_row.setContentsMargins(2, 2, 2, 0)
+ self.extra_row.setSpacing(6)
+ self.extra_row.addStretch(1)
+ root.addWidget(self.extra_bar)
+
+ on_language_changed(self._retranslate)
+
+ def _retranslate(self) -> None:
+ self.queue_list.setToolTip(tr("composer.queue_tooltip"))
+ self.attach_list.setToolTip(tr("composer.attachments_tooltip"))
+ self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip"))
+ self.send_btn.setText(tr("composer.queue_btn") if self._busy else tr("composer.send"))
+ self.stop_btn.setText(tr("composer.stop"))
+ if self.input.toPlainText().strip() == "" and not self._attachments:
+ self.input.setPlaceholderText(tr(self._placeholder_key))
+ self._refresh_queue()
+ self._refresh_attachments()
+
+ def add_bottom_right(self, widget) -> None:
+ self.extra_row.addWidget(widget)
+
+ def add_bottom_left(self, widget) -> None:
+ """Insert before the stretch, after any previously-added left widget —
+ so repeated calls read left-to-right in call order, same row as
+ whatever add_bottom_right widgets (e.g. the Agent combo) sit on the
+ right of the stretch."""
+ self.extra_row.insertWidget(self._bottom_left_count, widget)
+ self._bottom_left_count += 1
+
+ # ---- public API --------------------------------------------------
+ def set_text(self, text: str) -> None:
+ self.input.setPlainText(text)
+ self.input.setFocus()
+
+ def reset_input(self) -> None:
+ """Clear the input + pending attachments and restore the default placeholder
+ (used on New chat so no stale text or 'Attached: …' hint carries over)."""
+ self.input.clear()
+ self._attachments = []
+ self._refresh_attachments()
+ self.input.setPlaceholderText(tr(self._placeholder_key))
+
+ def set_busy(self, busy: bool) -> None:
+ """Capacity gate: when True, new sends are queued (the Send button reads
+ 'Queue'). Independent of whether any turn is running — see set_running."""
+ self._busy = busy
+ self.send_btn.setText(tr("composer.queue_btn") if busy else tr("composer.send"))
+
+ def set_running(self, running: bool) -> None:
+ """Show the Stop button whenever at least one turn is running (may be True
+ even when not at capacity, so a single in-flight message can be stopped)."""
+ self.stop_btn.setVisible(running)
+
+ def has_queue(self) -> bool:
+ return bool(self._queue)
+
+ def pop_next(self) -> Dict | None:
+ if not self._queue:
+ return None
+ item = self._queue.pop(0)
+ self._refresh_queue()
+ return item
+
+ def clear_queue(self) -> None:
+ self._queue.clear()
+ self._refresh_queue()
+
+ def enqueue(self, text: str, attachments: List[str] | None = None) -> None:
+ self._queue.append({"text": text, "attachments": list(attachments or [])})
+ self._refresh_queue()
+
+ # ---- attachments -------------------------------------------------
+ def set_max_attachments(self, n: int) -> None:
+ self._max_attachments = max(0, int(n or 0))
+
+ def _add_one(self, path: str) -> bool:
+ """Add a file unless it's a duplicate or the count limit is reached.
+ Returns False (and notifies) when the limit blocked it."""
+ if not path or path in self._attachments:
+ return True
+ if self._max_attachments and len(self._attachments) >= self._max_attachments:
+ self.attach_limit_note.emit(tr("chatpanel.attach_limit", n=self._max_attachments))
+ return False
+ self._attachments.append(path)
+ return True
+
+ def _pick_attachments(self) -> None:
+ files, _ = QFileDialog.getOpenFileNames(
+ self, tr("composer.attach_dialog_title"), "",
+ tr("composer.attach_dialog_filter"),
+ )
+ for f in files:
+ if not self._add_one(f):
+ break
+ self._refresh_attachments()
+
+ def _add_paths(self, paths: List[str]) -> None:
+ """Add attachments from paste / drag-drop."""
+ for p in paths:
+ if not self._add_one(p):
+ break
+ self._refresh_attachments()
+ if paths:
+ names = ", ".join(Path(p).name for p in paths)
+ self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names))
+
+ def _remove_attachment(self, item: QListWidgetItem) -> None:
+ idx = self.attach_list.row(item)
+ if 0 <= idx < len(self._attachments):
+ self._remove_attachment_path(self._attachments[idx])
+
+ def _remove_attachment_path(self, path: str) -> None:
+ """Remove one wrongly-added file (✕ button or double-click)."""
+ if path in self._attachments:
+ self._attachments.remove(path)
+ self._refresh_attachments()
+ self.attachment_removed.emit(path) # also drop it from the Input panel
+
+ def _refresh_attachments(self) -> None:
+ self.attach_list.clear()
+ for p in self._attachments:
+ item = QListWidgetItem()
+ row = QWidget()
+ _cp = current_palette()
+ row.setStyleSheet(
+ f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
+ f" border-radius: {_cp.radius_sm}px;")
+ h = QHBoxLayout(row)
+ h.setContentsMargins(8, 2, 4, 2)
+ h.setSpacing(4)
+ short = Path(p).name
+ if len(short) > 22:
+ short = short[:19] + "…"
+ name = IconLabel("attach", short, size=13)
+ name.setToolTip(p)
+ remove = QPushButton()
+ remove.setIcon(icon("close", size=12))
+ remove.setObjectName("danger")
+ remove.setFixedSize(18, 18)
+ remove.setToolTip(tr("composer.remove_tooltip"))
+ remove.setCursor(Qt.PointingHandCursor)
+ remove.clicked.connect(lambda _=False, path=p: self._remove_attachment_path(path))
+ h.addWidget(name) # compact chip (no stretch → many fit in one row)
+ h.addWidget(remove)
+ item.setSizeHint(row.sizeHint())
+ self.attach_list.addItem(item)
+ self.attach_list.setItemWidget(item, row)
+ self.attach_label.setText(tr("composer.attachments_label", n=len(self._attachments)))
+ self.attach_box.setVisible(bool(self._attachments))
+ if self._attachments:
+ self.attachments_added.emit(list(self._attachments))
+
+ # ---- submit / queue ----------------------------------------------
+ def _on_submit(self) -> None:
+ text = self.input.toPlainText().strip()
+ attachments = list(self._attachments)
+ if not text and not attachments:
+ return
+ self.input.clear()
+ self._attachments = []
+ self._refresh_attachments()
+ self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint
+ # A local /skill or /agent list/select command is answered inline instantly
+ # — run it now even while a turn is busy (don't bury it in the queue).
+ if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)):
+ self._queue.append({"text": text, "attachments": attachments})
+ self._refresh_queue()
+ else:
+ self.submitted.emit(text, attachments)
+
+ def _remove_queue_item(self, item: QListWidgetItem) -> None:
+ idx = self.queue_list.row(item)
+ if 0 <= idx < len(self._queue):
+ self._queue.pop(idx)
+ self._refresh_queue()
+
+ def _refresh_queue(self) -> None:
+ self.queue_list.clear()
+ for i, entry in enumerate(self._queue, 1):
+ text = entry.get("text", "")
+ n = len(entry.get("attachments", []))
+ preview = text if len(text) <= 70 else text[:70] + "…"
+ if n:
+ preview += f" (+{n})"
+ self.queue_list.addItem(f"{i}. {preview}")
+ self.queue_label.setText(tr("composer.queue_label", n=len(self._queue)))
+ self.queue_box.setVisible(bool(self._queue))
+ self.queue_changed.emit(len(self._queue))
diff --git a/ui/chat_panel.py b/ui/chat_panel.py
index ac44885..caf32d6 100644
--- a/ui/chat_panel.py
+++ b/ui/chat_panel.py
@@ -11,6 +11,18 @@ up. Graph events are still forwarded per session.
"""
from __future__ import annotations
+from ..presentation.chat.chat_event_stream import ChatEventStreamMixin
+from ..presentation.chat.chat_panel_layout import ChatPanelLayoutMixin
+from ..presentation.chat.chat_helpers import ( # noqa: F401 — giữ đường vào cũ
+ _TOOL_STATUS, _format_plan_steps, _is_scratch,
+)
+
+from ..presentation.chat.attachment_picker import AttachmentMixin
+from ..presentation.chat.chat_output_panel import OutputPanelMixin
+from ..presentation.chat.chat_agents import ChatAgentsMixin
+from ..presentation.chat.chat_turn_runner import ChatTurnRunnerMixin
+from ..presentation.chat.chat_session_store import ChatSessionMixin
+
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -37,37 +49,18 @@ _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):
+class ChatPanel(ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin,
+ OutputPanelMixin,
+ ChatAgentsMixin,
+ ChatTurnRunnerMixin,
+ ChatSessionMixin,
+ QWidget):
graph_event = Signal(str, dict) # (session_name, event)
turn_finished = Signal(dict)
status_message = Signal(str)
@@ -188,116 +181,7 @@ class ChatPanel(QWidget):
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)
- self.center_split = QSplitter(Qt.Horizontal)
- self.center_split.addWidget(chat_col)
- root.addWidget(self.center_split, 1)
-
- # The composer spans the whole screen, under BOTH columns — that is how
- # the drawing lays it out, and it is the reason the files panel can sit
- # beside the transcript without narrowing what you type into. Inside the
- # chat column it stopped at the panel's edge and the input shrank
- # whenever files appeared.
- composer_wrap = QWidget()
- cwl = QVBoxLayout(composer_wrap)
- cwl.setContentsMargins(8, 4, 8, 8)
- cwl.addWidget(self.composer)
- root.addWidget(composer_wrap)
-
- # Right sidebar: Output files only (see below — Input is tracked but
- # not shown).
- self.input_section = CollapsibleSection(tr("widgets.input_files"))
- # No cap: this section owns the whole right panel (its header is
- # hoisted into io_hdr below), so the list should fill the space down
- # to the composer instead of stopping at a fixed height with empty
- # panel below it.
- self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None)
- # 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;")
- # The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and
- # the section already draws exactly that, count included. A separate
- # "Files" label above it was the same thing said twice, so the section's
- # own header moves onto this row and the collapse chevron sits at its
- # right, where the drawing puts it. _files_header stays for the tabs
- # that still label their panel, just not in this layout.
- self._files_header.setVisible(False)
- io_hdr.addWidget(self.output_section.header, 1)
- io_hdr.addWidget(self._io_collapse_btn)
- # 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, 1) # Output only — Input is tracked but hidden; fills down to the composer
- 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)
+ self._build_layout(root)
def _retranslate_base(self) -> None:
"""Re-apply the current language to the chrome shared by every tab
@@ -319,166 +203,27 @@ class ChatPanel(QWidget):
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:"
@@ -494,330 +239,25 @@ class ChatPanel(QWidget):
"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.
-
- Since R03-T04 the Off/Auto/Manual/Fallback rules live in
- ``application/model_routing/routing_application_service.py``; the copy
- that used to sit here (and again in Co4E and AI-Edit) is gone. What
- remains is the widget's own job: snapshot the tab's provider/model into
- a request, host the Manual-mode modal, and render the outcome by setting
- ``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
- by :meth:`build_provider`) plus a status bubble.
-
- 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
- try:
- from ..application.model_routing import (
- RoutingRequest,
- build_routing_application_service,
- )
- from .routing_toggle import confirm_switch
-
- # The model the tab WOULD use without routing — the picker's choice,
- # or the provider's configured default when nothing is picked.
- cur_provider = self.ctx.config.active_provider
- cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
- outcome = build_routing_application_service(self.ctx).resolve(
- RoutingRequest(
- surface=self.kind, # per-workspace mode key ("cowork"/…)
- prompt=text,
- current_provider=cur_provider,
- current_model=cur_model,
- ),
- # Manual mode only: the modal stays in the presentation layer so
- # the application service never imports Qt.
- confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
- )
- if not outcome.switched:
- return # off / nothing better / declined → keep the tab's model
- self._routed_provider = outcome.provider
- self._routed_model = outcome.model
- notice = self.chat_view.add_status(tr(
- "routing.switched_notice",
- model=outcome.model, task=outcome.task_type,
- gain=f"{outcome.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 = {
@@ -826,753 +266,39 @@ class ChatPanel(QWidget):
".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
- self._notify_title()
-
- # 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(),
- # R06-T04: captured NOW, at submit time — see _persist_session's
- # use of this. Without it, a background turn (this session isn't
- # the one currently displayed) saves into whatever
- # ctx.config.history_dir() resolves to AT THE TIME IT FINISHES,
- # which is the *currently viewed* project's history folder if the
- # user switched projects (ui/workspace_tab.py::_load_current)
- # while this turn was still running — silently saving one
- # project's conversation into another project's history folder.
- "home_history_dir": self.ctx.config.history_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,
- history_dir: Optional[Path] = 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.
-
- ``history_dir``, when given, is used INSTEAD of
- ``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04):
- a background turn must save into the project it started in, not
- whichever project happens to be selected in the Workspace screen by
- the time the turn finishes.
- """
- 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(
- history_dir if history_dir is not None else 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", []),
- history_dir=ctx.get("home_history_dir"))
- 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 refresh_usage(self) -> None:
- """Show what this conversation has already cost.
- The label was written only at the end of a turn, so opening a thread
- from History left the strip blank however much it had spent.
- """
- from ..core import model_pricing as mp
- from ..core import usage_tracker as ut
- cur = self._usage_snapshot()
- if not (cur["in"] or cur["out"] or cur["cache"]):
- self._usage_total_lbl.setText("")
- return
- # same source _show_usage reads, so the two never disagree
- pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
- 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 _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:
@@ -1581,50 +307,8 @@ class ChatPanel(QWidget):
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)."""
@@ -1659,163 +343,3 @@ class ChatPanel(QWidget):
"""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 _notify_title(self) -> None:
- """Let a screen that heads itself with the thread title follow along.
-
- The thread also decides what the usage strip should read, so refresh
- that here rather than at each of the three places the title changes.
- """
- hook = getattr(self, "refresh_title", None)
- if callable(hook):
- hook()
- if getattr(self, "_usage_total_lbl", None) is not None:
- self.refresh_usage()
-
- 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._notify_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
diff --git a/ui/chat_view.py b/ui/chat_view.py
index 5e4bc96..8ca2c18 100644
--- a/ui/chat_view.py
+++ b/ui/chat_view.py
@@ -1,507 +1,13 @@
-"""Scrollable chat transcript built from message bubbles."""
+"""Vỏ chuyển tiếp — R08-T01.
+
+Phần thân đã chuyển sang ``presentation/chat/chat_history_widget.py``.
+Giữ đường import cũ cho ``ui/chat_panel.py`` và checker.
+"""
from __future__ import annotations
-import html
-from pathlib import Path
-
-from PySide6.QtCore import QPointF, Qt, QTimer, Signal
-from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
-from PySide6.QtWidgets import (
- QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
- QVBoxLayout, QWidget,
+from ..presentation.chat.chat_bubble_style import ( # noqa: F401
+ ThinkingIndicator, _TimelineGutter, diff_to_html, format_status_line,
+)
+from ..presentation.chat.chat_history_widget import ( # noqa: F401
+ ChatView, MessageBubble,
)
-
-from ..i18n import on_language_changed, tr
-from ..theme import palette, resolve_theme
-from ..config import CONFIG_DIR
-from .osutil import is_image, open_folder, open_path
-
-
-def _app_theme() -> str:
- """Resolve the current app theme (light or dark) from config."""
- try:
- import json
- with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f:
- data = json.load(f)
- return resolve_theme(data.get("theme", "dark"))
- except Exception: # noqa: BLE001
- return "dark"
-
-
-def _p():
- """Design tokens for the theme in effect right now."""
- return palette(_app_theme())
-
-
-def _dot_color(role: str) -> str:
- """Timeline dot colour for a message role."""
- p = _p()
- return {
- "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
- "error": p.role_error, "success": p.role_result,
- }.get(role, p.text_faint)
-
-
-class _TimelineGutter(QWidget):
- """The left rail of the point-conversation: a vertical connector line with a
- role-colored dot near the top, so stacked messages read as a timeline
- (Claude-Code style) instead of separate boxes."""
-
- def __init__(self, role: str):
- super().__init__()
- self._role = role
- self.setFixedWidth(22)
-
- def set_role(self, role: str) -> None:
- self._role = role
- self.update()
-
- def paintEvent(self, _e): # noqa: N802
- p = QPainter(self)
- p.setRenderHint(QPainter.Antialiasing)
- tok = _p()
- x = 11.0
- cy = 15.0
- # connector line (faint) running the full height → continuous rail
- p.setPen(QPen(QColor(tok.border), 2))
- p.drawLine(int(x), 0, int(x), self.height())
- # a background ring lifts the dot off the line
- p.setPen(Qt.NoPen)
- p.setBrush(QColor(tok.bg))
- p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
- p.setBrush(QColor(_dot_color(self._role)))
- p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
-
-
-def _diff_legend(diff_text: str) -> str:
- """A small badge pair labeling what the colors mean: 'Before → After' for
- an edit, or a single 'Added'/'Removed' badge for a pure create/delete —
- so the before/after distinction is explicit, not just implied by color."""
- has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
- has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
- p = _p()
-
- def pill(bg: str, fg: str, key: str) -> str:
- return (f'{html.escape(tr(key))}')
-
- if has_add and has_del:
- badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
- + f' → '
- + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
- elif has_add:
- badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
- elif has_del:
- badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
- else:
- return ""
- return f'{badge}
'
-
-
-def diff_to_html(diff_text: str) -> str:
- """Render a unified diff with GitHub/Claude-Code-style line coloring —
- additions green, deletions red, hunk headers highlighted — plus an
- explicit Before/After (or Added/Removed) legend, instead of a flat text
- block, so a before/after edit reads at a glance. A brand-new file (an
- empty 'before') naturally renders as all-green, which is exactly what
- ``difflib.unified_diff`` already produces for it."""
- legend = _diff_legend(diff_text)
- p = _p()
- rows = []
- for ln in diff_text.splitlines():
- esc = html.escape(ln) if ln else " "
- if ln.startswith(("+++", "---")):
- rows.append(f'{esc}
')
- elif ln.startswith("@@"):
- rows.append(f'{esc}
')
- elif ln.startswith("+"):
- rows.append(f'{esc}
')
- elif ln.startswith("-"):
- rows.append(f'{esc}
')
- else:
- rows.append(f"{esc}
")
- body = "".join(rows) or "(no textual change)"
- return (f'{legend}{body}
')
-
-
-def format_status_line(base: str, ticks: int) -> str:
- """Animated status line for the working indicator, e.g. ``🤖 Running..`` and,
- once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow
- synthesis clearly reads as still running. ``ticks`` advances every 500 ms."""
- dots = "." * (ticks % 4)
- secs = ticks // 2
- suffix = f" · {secs}s" if secs >= 3 else ""
- return f"{base}{dots}{suffix}"
-
-
-class ThinkingIndicator(QWidget):
- """A small animated 'the agent is working' line shown while waiting for a
- result, so a long wait never looks like a frozen / empty screen.
-
- Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes
- a few seconds, the elapsed time — so a long synthesis clearly reads as still
- running rather than stuck."""
-
- def __init__(self):
- super().__init__()
- lay = QHBoxLayout(self)
- lay.setContentsMargins(14, 2, 14, 4)
- lay.setSpacing(0)
- self._label = QLabel("")
- self._label.setObjectName("hint")
- lay.addWidget(self._label)
- lay.addStretch(1)
- self._base_key = "chat.running"
- self._override: str | None = None
- self._ticks = 0
- self._timer = QTimer(self)
- self._timer.setInterval(500)
- self._timer.timeout.connect(self._tick)
- self.setVisible(False)
- on_language_changed(self._render)
-
- def start(self, label_key: str = "chat.running") -> None:
- self._base_key = label_key
- self._override = None
- self._ticks = 0
- self._render()
- self.setVisible(True)
- if not self._timer.isActive():
- self._timer.start()
-
- def set_label(self, label_key: str) -> None:
- if label_key != self._base_key:
- self._base_key = label_key
- self._override = None
- self._render()
-
- def set_progress_text(self, text: str) -> None:
- """Show an already-formatted, literal status line (e.g. a live "reading
- page 12/40" or streamed command-output detail) instead of a translated
- key — used for fine-grained progress within a single step."""
- self._override = text
- self._render()
-
- def stop(self) -> None:
- self._timer.stop()
- self._override = None
- self.setVisible(False)
-
- def _tick(self) -> None:
- self._ticks += 1
- self._render()
-
- def _render(self) -> None:
- base = self._override if self._override is not None else tr(self._base_key)
- self._label.setText(format_status_line(base, self._ticks))
-
-
-class MessageBubble(QFrame):
- """One message; assistant/tool bubbles render markdown via QTextBrowser."""
-
- def __init__(self, role: str, title: str = "", collapsible: bool = False,
- collapsed: bool = True):
- super().__init__()
- self.role = role
- self._text = ""
- self._collapsible = collapsible
- self._title = title
- self._head = None
- # Point-conversation layout: [dot rail][content column].
- outer = QHBoxLayout(self)
- outer.setContentsMargins(0, 0, 0, 0)
- outer.setSpacing(6)
- self._gutter = _TimelineGutter(role)
- outer.addWidget(self._gutter)
- content = QWidget()
- lay = QVBoxLayout(content)
- lay.setContentsMargins(2, 4, 8, 8)
- lay.setSpacing(4)
- self._content_layout = lay
- outer.addWidget(content, 1)
-
- if title:
- if collapsible:
- # Clickable header that folds long tool output away to keep the
- # transcript short. Collapsed by default; click to expand.
- self._head = QPushButton(title)
- self._head.setCursor(Qt.PointingHandCursor)
- self._head.setStyleSheet(
- "QPushButton { text-align:left; border:none; background:transparent;"
- f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
- self._head.clicked.connect(self._toggle_body)
- lay.addWidget(self._head)
- else:
- head = QLabel(title)
- head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
- lay.addWidget(head)
-
- self.body = QTextBrowser()
- self.body.setOpenExternalLinks(True)
- self.body.setFrameShape(QFrame.NoFrame)
- # Text color adapts to theme.
- self._apply_theme_styles(role)
- lay.addWidget(self.body)
-
- self._apply_style(role)
- if collapsible and collapsed:
- self.body.setVisible(False)
- if collapsible:
- self._update_head()
-
- def _toggle_body(self) -> None:
- self.body.setVisible(not self.body.isVisible())
- if self.body.isVisible():
- self._autosize()
- self._update_head()
-
- def _update_head(self) -> None:
- if not self._head:
- return
- expanded = self.body.isVisible()
- arrow = "▾" if expanded else "▸"
- preview = ""
- if not expanded and self._text.strip():
- first = self._text.strip().splitlines()[0]
- if len(first) > 70:
- first = first[:70] + "…"
- preview = f" {first}"
- self._head.setText(f"{arrow} {self._title}{preview}")
-
- def _current_theme(self) -> str:
- """Resolve the current app theme (light or dark)."""
- return _app_theme()
-
- def _apply_theme_styles(self, role: str) -> None:
- """Apply text color to the body QTextBrowser based on current theme + role."""
- p = _p()
- text_color = {
- "success": p.success,
- "error": p.danger,
- "tool": p.text_muted, # secondary, like Claude's steps
- }.get(role, p.text)
- self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
-
- def _apply_style(self, role: str) -> None:
- """Flat timeline row — no bubble box; the left dot/rail conveys role and
- structure (Claude-Code style). The user's own message gets a faint tint
- so questions are easy to pick out when scanning."""
- p = _p()
- if role == "user":
- self.setStyleSheet(
- f"QFrame {{ background: {p.surface}; border: none; "
- f"border-radius: {p.radius}px; }}")
- else:
- self.setStyleSheet("QFrame { background: transparent; border: none; }")
-
- def apply_theme(self) -> None:
- """Re-apply theme-dependent styles so existing rows adapt when the app
- theme switches (light ↔ dark)."""
- self._apply_theme_styles(self.role)
- self._apply_style(self.role)
- self._gutter.set_role(self.role)
-
- def chat_view(self):
- """Walk up the parent chain to find the enclosing ChatView, if any."""
- p = self.parent()
- while p is not None:
- if isinstance(p, ChatView):
- return p
- p = p.parent()
- return None
-
- def append_delta(self, delta: str) -> None:
- self._text += delta
- self.set_markdown(self._text)
-
- def set_markdown(self, text: str) -> None:
- self._text = text
- self.body.setMarkdown(text)
- self._autosize()
- if self._collapsible:
- self._update_head()
-
- def set_plain(self, text: str) -> None:
- self._text = text
- self.body.setPlainText(text)
- self._autosize()
- if self._collapsible:
- self._update_head()
-
- def append_plain(self, delta: str) -> None:
- self._text += delta
- self.set_plain(self._text)
-
- def set_diff(self, diff_text: str) -> None:
- """Render a unified diff (see :func:`diff_to_html`) with colored
- before/after lines instead of a flat text block."""
- self._text = diff_text
- self.body.setHtml(diff_to_html(diff_text))
- self._autosize()
- if self._collapsible:
- self._update_head()
-
- def add_usage(self, text: str) -> None:
- """A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost),
- like Claude Code. Replaces any previous usage line on this bubble."""
- existing = getattr(self, "_usage_lbl", None)
- if existing is not None:
- existing.setText(text)
- return
- lbl = QLabel(text)
- lbl.setObjectName("faint")
- lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
- self._usage_lbl = lbl
- self._content_layout.addWidget(lbl)
-
- def add_delete_link(self, callback) -> None:
- link = QLabel(f'{tr("chat.delete_link")}')
- link.setToolTip(tr("chat.delete_tooltip"))
- link.linkActivated.connect(lambda *_: callback())
- self._content_layout.addWidget(link)
-
- def add_folder_link(self, folder: str, label: str | None = None) -> None:
- label = label or tr("chat.open_workspace")
- link = QLabel(f'{label}')
- link.setToolTip(str(folder))
- link.linkActivated.connect(lambda *_: open_folder(folder))
- self._content_layout.addWidget(link)
-
- def add_attachments(self, paths) -> None:
- """Show attached files: images as thumbnails, others as clickable links."""
- for p in paths:
- path = str(p)
- name = Path(path).name
- if is_image(path):
- pix = QPixmap(path)
- if not pix.isNull():
- thumb = QLabel()
- thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation))
- thumb.setToolTip(name)
- thumb.setCursor(Qt.PointingHandCursor)
- self._content_layout.addWidget(thumb)
- continue
- file_link = QLabel(f'{name}')
- file_link.setToolTip(path)
- file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
- self._content_layout.addWidget(file_link)
-
- def _autosize(self) -> None:
- width = self.body.viewport().width()
- if width <= 0:
- width = 560 # sensible default before the widget is laid out
- self.body.document().setTextWidth(width)
- height = int(self.body.document().size().height()) + 12
- self.body.setFixedHeight(max(28, min(height, 1200)))
-
- def resizeEvent(self, event): # noqa: N802 - re-flow on width change
- super().resizeEvent(event)
- self._autosize()
-
-
-class ChatView(QScrollArea):
- """Scrollable chat transcript.
-
- Emits ``theme_changed`` (via the apply_theme method) so every child
- ``MessageBubble`` can re-apply its theme-aware inline styles when the
- app switches between light and dark modes."""
-
- def __init__(self):
- super().__init__()
- self.setWidgetResizable(True)
- self._container = QWidget()
- self._lay = QVBoxLayout(self._container)
- self._lay.setContentsMargins(12, 12, 12, 12)
- self._lay.setSpacing(10)
- self._lay.addStretch(1)
- self.setWidget(self._container)
-
- def apply_theme(self) -> None:
- """Ask every MessageBubble inside this view to re-apply theme styles.
-
- Called from ``ChatPanel.apply_theme`` whenever the app theme changes."""
- for i in range(self._lay.count()):
- item = self._lay.itemAt(i)
- w = item.widget() if item else None
- if isinstance(w, MessageBubble):
- w.apply_theme()
-
- def _add(self, bubble: MessageBubble) -> MessageBubble:
- # insert before the trailing stretch
- self._lay.insertWidget(self._lay.count() - 1, bubble)
- self._scroll_to_bottom()
- return bubble
-
- def add_user(self, text: str) -> MessageBubble:
- b = MessageBubble("user", tr("chat.you"))
- b.set_plain(text)
- return self._add(b)
-
- def add_assistant(self, title: str | None = None) -> MessageBubble:
- b = MessageBubble("assistant", title or tr("chat.assistant"))
- return self._add(b)
-
- def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble:
- # Tool steps (run command, generated code/diff, output) are collapsible to
- # keep the transcript short — collapsed when OK, expanded on error.
- b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
- b.set_plain(body)
- return self._add(b)
-
- def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble:
- """Like :meth:`add_tool`, but renders ``diff_text`` as a colored
- before/after diff (see :func:`diff_to_html`) instead of flat text."""
- b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
- b.set_diff(diff_text)
- return self._add(b)
-
- def add_plan(self, body: str) -> MessageBubble:
- """The task plan shown INLINE in the timeline (never a pop-up or side
- panel) — a permanent, always-expanded row whose steps tick off as they
- complete. The agent re-sends the full list on each update; the caller
- updates this same row in place via ``set_plain``."""
- b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False)
- b.set_plain(body)
- return self._add(b)
-
- def add_reasoning(self, title: str | None = None) -> MessageBubble:
- # The model's private reasoning — a collapsed, collapsible box so the user
- # can see it's thinking (and expand to read) without it flooding the chat.
- b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True)
- return self._add(b)
-
- def add_error(self, text: str) -> MessageBubble:
- b = MessageBubble("error", tr("chat.error"))
- b.set_plain(text)
- return self._add(b)
-
- def add_status(self, text: str) -> MessageBubble:
- """A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành')."""
- b = MessageBubble("tool", "")
- b.set_plain(text)
- return self._add(b)
-
- def add_success(self, text: str) -> MessageBubble:
- """Like :meth:`add_status`, but styled green — used for the "turn done"
- marker so completion reads as an unmistakable success signal."""
- b = MessageBubble("success", "")
- b.set_plain(text)
- return self._add(b)
-
- def clear(self) -> None:
- while self._lay.count() > 1:
- item = self._lay.takeAt(0)
- w = item.widget()
- if w:
- w.deleteLater()
-
- def scroll_to_bottom(self) -> None:
- """Scroll to the newest message, deferred so freshly-added bubbles have
- finished sizing (their height is computed after layout)."""
- QTimer.singleShot(0, self._scroll_to_bottom)
- QTimer.singleShot(80, self._scroll_to_bottom)
-
- def _scroll_to_bottom(self) -> None:
- bar = self.verticalScrollBar()
- bar.setValue(bar.maximum())
diff --git a/ui/composer.py b/ui/composer.py
index 0f41822..4d717d7 100644
--- a/ui/composer.py
+++ b/ui/composer.py
@@ -1,663 +1,11 @@
-"""Message composer: multiline input, attachments, Send/Stop, message queue.
+"""Vỏ chuyển tiếp — R08-T02.
-Several turns can run at once (up to the configured parallel limit). Once that
-limit is reached the composer switches to "Queue" mode: extra messages (with
-their attachments) are held in the queue and dispatched automatically as running
-turns finish and free up a slot. Files/images can be attached to a message.
+Phần thân đã chuyển sang ``presentation/chat/composer_widget.py`` (thanh công
+cụ) và ``chat_input_box.py`` (ô nhập).
"""
from __future__ import annotations
-from datetime import datetime
-from pathlib import Path
-from typing import Dict, List
-
-from PySide6.QtCore import Qt, Signal
-from PySide6.QtGui import QImage, QKeyEvent
-from PySide6.QtWidgets import (
- QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
- QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
+from ..presentation.chat.chat_input_box import _Input, _SkillPopup # noqa: F401
+from ..presentation.chat.composer_widget import ( # noqa: F401
+ Composer,
)
-
-from ..config import CONFIG_DIR
-from ..i18n import on_language_changed, tr
-from ..theme import current_palette
-from .icons import icon, IconLabel
-
-
-def _save_pasted_image(image) -> str | None:
- """Save a clipboard/drag QImage to the config dir; return its path."""
- try:
- if not isinstance(image, QImage) or image.isNull():
- return None
- folder = CONFIG_DIR / "pasted"
- folder.mkdir(parents=True, exist_ok=True)
- name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png"
- path = folder / name
- if image.save(str(path), "PNG"):
- return str(path)
- except Exception:
- return None
- return None
-
-
-def _is_local_skill_command(text: str) -> bool:
- """True for a bare ``/skill`` (list) or ``/skill:`` (select) command that
- is answered inline instantly — these must run even while a turn is busy, so they
- bypass the message queue (unlike ``/skill: ``, which is a real
- turn and should queue)."""
- import re
- t = (text or "").strip()
- return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t))
-
-
-def _is_local_agent_command(text: str) -> bool:
- """Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare
- ``/agent`` (list) or ``/agent:`` (select) is answered inline instantly."""
- import re
- t = (text or "").strip()
- return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t))
-
-
-def _paths_from_mime(md) -> List[str]:
- paths: List[str] = []
- if md.hasUrls():
- for u in md.urls():
- if u.isLocalFile():
- paths.append(u.toLocalFile())
- if not paths and md.hasImage():
- p = _save_pasted_image(md.imageData())
- if p:
- paths.append(p)
- return paths
-
-
-class _SkillPopup(QListWidget):
- """The ``/skill`` picker.
-
- Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it
- does NOT grab the keyboard, so the input keeps focus and the user can keep
- typing their request after ``/skill``. Navigation / accept / Esc are handled by
- the parent ``_Input``'s key handler (which still receives every key); clicking
- an item selects it; the popup auto-hides when the input loses focus."""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
- | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
- self.setAttribute(Qt.WA_ShowWithoutActivating, True)
- self.setFocusPolicy(Qt.NoFocus)
-
-
-class _Input(QPlainTextEdit):
- """Plain text edit: submits on Enter, accepts pasted/dropped images & files."""
-
- submit = Signal()
- media_added = Signal(list)
- manage_skills = Signal() # user picked "Manage skills…" in the /skill popup
-
- MIN_HEIGHT = 64 # ~2 lines
- MAX_HEIGHT = 220 # ~8 lines, then it scrolls
-
- def __init__(self):
- super().__init__()
- self.setAcceptDrops(True)
- # Use a clean Latin/Vietnamese-friendly UI font for the input (the global
- # '*' rule falls back to Japanese faces, which mis-render some glyphs).
- self.setStyleSheet(
- "font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;"
- )
- # Grow with the text (up to MAX_HEIGHT), then scroll instead.
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
- self.textChanged.connect(self._adjust_height)
- # "/skill" + "/agent" command popup — lists skills / agents inline.
- self._skill_popup = _SkillPopup(self)
- self._popup_kind = "skill" # which command the popup is showing
- self._skill_popup.itemClicked.connect(self._accept_item)
- self.textChanged.connect(self._maybe_show_skills)
- self._adjust_height()
-
- # ---- /skill autocomplete ----------------------------------------
- def _skill_token(self):
- """Locate a ``/skill[:partial]`` command the cursor is currently typing —
- ANYWHERE in the message, not just at the start (so "dùng /skill:foo …"
- with text typed before it still triggers the picker). Mirrors
- ``core.skills.parse_skill_command``'s whitespace-boundary rule.
-
- Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the
- ``/skill`` token begins in the document, ``partial_filter`` is the text
- typed after ``:`` (``''`` while still typing the command word itself) — or
- ``None`` when the cursor isn't inside a ``/skill`` token."""
- import re
- pos = self.textCursor().position()
- before = self.toPlainText()[:pos]
- # The token is the whitespace-delimited word ending at the cursor; its
- # start must be the document start or follow whitespace (same boundary
- # parse_skill_command enforces with its (?= 2 and "/skill".startswith(token):
- return start, "" # typing "/s", "/sk", … "/skill" → show the whole list
- m = re.match(r"^/skill:?([\w\-.]*)$", token)
- return (start, m.group(1)) if m else None
-
- def _skill_filter(self):
- """Return the partial filter while a '/skill' command is being typed
- (anywhere in the message), or None."""
- tok = self._skill_token()
- return tok[1] if tok else None
-
- def _agent_token(self):
- """Locate a ``/agent[:partial]`` command the cursor is typing (mirror of
- ``_skill_token``). Returns ``(start_offset, partial)`` or None."""
- import re
- pos = self.textCursor().position()
- before = self.toPlainText()[:pos]
- start = re.search(r"\S*$", before).start()
- token = before[start:]
- if len(token) >= 2 and "/agent".startswith(token):
- return start, ""
- m = re.match(r"^/agent:?([\w\-.]*)$", token)
- return (start, m.group(1)) if m else None
-
- def _maybe_show_skills(self) -> None:
- # One popup serves both commands: show skills while typing /skill, agents
- # while typing /agent (Cowork parity with the Co4E chat).
- stok = self._skill_token()
- if stok is not None:
- self._popup_kind = "skill"
- self._populate_skill_popup(stok[1])
- self._show_cmd_popup()
- return
- atok = self._agent_token()
- if atok is not None:
- self._popup_kind = "agent"
- self._populate_agent_popup(atok[1])
- self._show_cmd_popup()
- return
- self._skill_popup.hide()
-
- def _populate_skill_popup(self, filt: str) -> None:
- try:
- from ..core.skills import builtin_skills, list_skills
- # Include always-on built-ins so the picker is usable before the user
- # has created any custom skill.
- skills = list_skills() + builtin_skills()
- except Exception:
- skills = []
- f = (filt or "").lower()
- matches = [s for s in skills
- if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()]
- self._skill_popup.clear()
- for s in matches:
- text = ("✓ " if s.enabled else " ") + s.name
- if s.description:
- text += f" — {s.description}"
- item = QListWidgetItem(text)
- item.setData(Qt.UserRole, s.slug)
- self._skill_popup.addItem(item)
- if not matches:
- empty = QListWidgetItem(tr("composer.no_skills"))
- empty.setFlags(Qt.NoItemFlags)
- self._skill_popup.addItem(empty)
- manage = QListWidgetItem(tr("composer.manage_skills"))
- manage.setData(Qt.UserRole, "__manage__")
- self._skill_popup.addItem(manage)
- self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
-
- def _populate_agent_popup(self, filt: str) -> None:
- try:
- from ..core.agent_command import collect_agents
- agents = collect_agents("") # built-ins + local admin + custom agents
- except Exception:
- agents = []
- f = (filt or "").lower()
- matches = [a for a in agents
- if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()]
- self._skill_popup.clear()
- for a in matches:
- text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "")
- item = QListWidgetItem(text)
- item.setData(Qt.UserRole, a["slug"])
- self._skill_popup.addItem(item)
- if not matches:
- empty = QListWidgetItem(tr("composer.no_agents"))
- empty.setFlags(Qt.NoItemFlags)
- self._skill_popup.addItem(empty)
- self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
-
- def _show_cmd_popup(self) -> None:
- rows = min(7, self._skill_popup.count())
- h = 10 + rows * 22
- self._skill_popup.resize(max(300, self.width()), h)
- top_left = self.mapToGlobal(self.rect().topLeft())
- self._skill_popup.move(top_left.x(), top_left.y() - h - 2)
- self._skill_popup.show()
-
- def _dismiss_skill_popup(self) -> None:
- """Hide the /skill picker (Esc)."""
- self._skill_popup.hide()
-
- def focusOutEvent(self, e) -> None: # noqa: N802
- # The popup never grabs focus, so a click away lands here → dismiss it
- # (unless the click is on the popup itself, e.g. picking an item).
- if not self._skill_popup.underMouse():
- self._skill_popup.hide()
- super().focusOutEvent(e)
-
- def _accept_item(self, item=None) -> None:
- """Dispatch popup selection to the right handler based on which command
- (``/skill`` or ``/agent``) the popup is currently showing."""
- if self._popup_kind == "agent":
- self._accept_agent(item)
- else:
- self._accept_skill(item)
-
- def _replace_token(self, tok, replacement: str) -> None:
- pos = self.textCursor().position()
- start = tok[0] if tok else pos
- full = self.toPlainText()
- new_text = full[:start] + replacement + full[pos:]
- new_pos = start + len(replacement)
- self.blockSignals(True)
- self.setPlainText(new_text)
- self.blockSignals(False)
- cur = self.textCursor()
- cur.setPosition(min(new_pos, len(new_text)))
- self.setTextCursor(cur)
- self._adjust_height()
- self.setFocus()
-
- def _accept_skill(self, item=None) -> None:
- item = item or self._skill_popup.currentItem()
- self._skill_popup.hide()
- if item is None:
- return
- slug = item.data(Qt.UserRole)
- if slug == "__manage__":
- self.manage_skills.emit() # open the Skills manager
- return
- if not slug:
- return
- # Replace ONLY the /skill token the cursor is on — text typed before it
- # ("dùng …") and after it is preserved, so the command can sit mid-sentence.
- self._replace_token(self._skill_token(), f"/skill:{slug} ")
-
- def _accept_agent(self, item=None) -> None:
- item = item or self._skill_popup.currentItem()
- self._skill_popup.hide()
- if item is None:
- return
- slug = item.data(Qt.UserRole)
- if not slug:
- return
- self._replace_token(self._agent_token(), f"/agent:{slug} ")
-
- def _adjust_height(self, *_a) -> None:
- # QPlainTextEdit reports the document height in LINES (not pixels), so
- # convert via line spacing to get the real pixel height.
- lines = self.document().size().height() or 1
- line_px = self.fontMetrics().lineSpacing()
- h = int(lines * line_px + 2 * self.frameWidth() + 12)
- h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
- if h != self.height():
- self.setFixedHeight(h)
-
- def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802
- if self._skill_popup.isVisible():
- k = e.key()
- if k in (Qt.Key_Down, Qt.Key_Up):
- n = self._skill_popup.count()
- if n:
- step = 1 if k == Qt.Key_Down else -1
- self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n)
- return
- if k == Qt.Key_Tab:
- self._accept_item() # Tab = autocomplete the highlighted item
- return
- if k == Qt.Key_Escape:
- self._dismiss_skill_popup()
- return
- if k in (Qt.Key_Return, Qt.Key_Enter):
- item = self._skill_popup.currentItem()
- slug = item.data(Qt.UserRole) if item else None
- is_agent = self._popup_kind == "agent"
- tok = self._agent_token() if is_agent else self._skill_token()
- prefix = "/agent:" if is_agent else "/skill:"
- token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else ""
- exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}"
- if slug and slug != "__manage__" and not exact:
- # A suggestion is highlighted but not yet fully typed —
- # Enter completes it into the box first (same as Tab),
- # instead of submitting a partial/mistyped slug that
- # the parser would just reject as "not found".
- self._accept_item(item)
- return
- # Slug already fully typed (or nothing usable is highlighted,
- # e.g. the "no skills found" placeholder) — Enter RUNS the
- # /skill command as typed: hide the popup and fall through to
- # the normal submit below.
- self._skill_popup.hide()
- if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
- self.submit.emit()
- return
- super().keyPressEvent(e)
-
- def insertFromMimeData(self, source) -> None: # noqa: N802 - paste
- paths = _paths_from_mime(source)
- if paths:
- self.media_added.emit(paths)
- return
- super().insertFromMimeData(source)
-
- def canInsertFromMimeData(self, source) -> bool: # noqa: N802
- if source.hasImage() or source.hasUrls():
- return True
- return super().canInsertFromMimeData(source)
-
- def dragEnterEvent(self, e) -> None: # noqa: N802
- if e.mimeData().hasUrls() or e.mimeData().hasImage():
- e.acceptProposedAction()
- return
- super().dragEnterEvent(e)
-
- def dragMoveEvent(self, e) -> None: # noqa: N802
- if e.mimeData().hasUrls() or e.mimeData().hasImage():
- e.acceptProposedAction()
- return
- super().dragMoveEvent(e)
-
- def dropEvent(self, e) -> None: # noqa: N802
- paths = _paths_from_mime(e.mimeData())
- if paths:
- self.media_added.emit(paths)
- e.acceptProposedAction()
- return
- super().dropEvent(e)
-
-
-class Composer(QWidget):
- submitted = Signal(str, list) # (text, attachment paths)
- stop_requested = Signal()
- queue_changed = Signal(int)
- attachments_added = Signal(list) # current attachment paths (pushed to the Input box)
- attachment_removed = Signal(str) # a wrongly-added attachment was removed
- attach_limit_note = Signal(str) # shown when the attachment-count limit is hit
- manage_skills = Signal() # relayed from the /skill popup "Manage skills…"
-
- def __init__(self, placeholder_key: str = "composer.placeholder_default"):
- super().__init__()
- self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change
- self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]}
- self._attachments: List[str] = []
- self._max_attachments = 0 # 0 = unlimited; set from Settings
- self._busy = False
-
- root = QVBoxLayout(self)
- root.setContentsMargins(0, 0, 0, 0)
- root.setSpacing(6)
-
- # --- queue strip (hidden when empty) ---
- self.queue_box = QWidget()
- qlay = QVBoxLayout(self.queue_box)
- qlay.setContentsMargins(0, 0, 0, 0)
- self.queue_label = QLabel()
- self.queue_label.setObjectName("hint")
- self.queue_list = QListWidget()
- self.queue_list.setMaximumHeight(78)
- self.queue_list.itemDoubleClicked.connect(self._remove_queue_item)
- qlay.addWidget(self.queue_label)
- qlay.addWidget(self.queue_list)
- self.queue_box.setVisible(False)
- root.addWidget(self.queue_box)
-
- # --- attachments strip (hidden when empty) ---
- self.attach_box = QWidget()
- alay = QVBoxLayout(self.attach_box)
- alay.setContentsMargins(0, 0, 0, 0)
- self.attach_label = QLabel()
- self.attach_label.setObjectName("hint")
- self.attach_list = QListWidget()
- # Single horizontal row of chips; scroll sideways when there are many.
- self.attach_list.setFlow(QListView.LeftToRight)
- self.attach_list.setWrapping(False)
- self.attach_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
- self.attach_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
- self.attach_list.setFixedHeight(40)
- self.attach_list.itemDoubleClicked.connect(self._remove_attachment)
- alay.addWidget(self.attach_label)
- alay.addWidget(self.attach_list)
- self.attach_box.setVisible(False)
- root.addWidget(self.attach_box)
-
- # --- input row ---
- row = QHBoxLayout()
- self.input = _Input()
- self.input.setPlaceholderText(tr(self._placeholder_key))
- self.input.submit.connect(self._on_submit)
- self.input.media_added.connect(self._add_paths)
- self.input.manage_skills.connect(self.manage_skills.emit)
- row.addWidget(self.input, 1)
-
- btns = QVBoxLayout()
- self.attach_btn = QPushButton("")
- self.attach_btn.setIcon(icon("attach"))
- self.attach_btn.clicked.connect(self._pick_attachments)
- self.send_btn = QPushButton()
- self.send_btn.setIcon(icon("upload"))
- self.send_btn.setObjectName("primary")
- self.send_btn.clicked.connect(self._on_submit)
- self.stop_btn = QPushButton()
- self.stop_btn.setIcon(icon("stop"))
- self.stop_btn.setObjectName("danger")
- self.stop_btn.setVisible(False)
- self.stop_btn.clicked.connect(self.stop_requested.emit)
- # Attach pinned to the input's top edge, Send (and Stop, once a turn
- # is running) pinned to its bottom edge — the gap between them is
- # absorbed by this stretch instead of splitting evenly above/below
- # the whole button column, which is what centering it did before.
- btns.addWidget(self.attach_btn)
- btns.addStretch(1)
- btns.addWidget(self.send_btn)
- btns.addWidget(self.stop_btn)
- row.addLayout(btns)
- root.addLayout(row)
-
- # bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
- # right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
- # Its own strip UNDER the typing box, styled as a status line rather
- # than a second toolbar: the design asks for the typing area to be just
- # input · attach · send, with agent / routing / usage / folder reading
- # as status underneath. They stay interactive — only quieter.
- self._bottom_left_count = 0
- self.extra_bar = QWidget()
- self.extra_bar.setObjectName("composerStatus")
- self.extra_row = QHBoxLayout(self.extra_bar)
- self.extra_row.setContentsMargins(2, 2, 2, 0)
- self.extra_row.setSpacing(6)
- self.extra_row.addStretch(1)
- root.addWidget(self.extra_bar)
-
- on_language_changed(self._retranslate)
-
- def _retranslate(self) -> None:
- self.queue_list.setToolTip(tr("composer.queue_tooltip"))
- self.attach_list.setToolTip(tr("composer.attachments_tooltip"))
- self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip"))
- self.send_btn.setText(tr("composer.queue_btn") if self._busy else tr("composer.send"))
- self.stop_btn.setText(tr("composer.stop"))
- if self.input.toPlainText().strip() == "" and not self._attachments:
- self.input.setPlaceholderText(tr(self._placeholder_key))
- self._refresh_queue()
- self._refresh_attachments()
-
- def add_bottom_right(self, widget) -> None:
- self.extra_row.addWidget(widget)
-
- def add_bottom_left(self, widget) -> None:
- """Insert before the stretch, after any previously-added left widget —
- so repeated calls read left-to-right in call order, same row as
- whatever add_bottom_right widgets (e.g. the Agent combo) sit on the
- right of the stretch."""
- self.extra_row.insertWidget(self._bottom_left_count, widget)
- self._bottom_left_count += 1
-
- # ---- public API --------------------------------------------------
- def set_text(self, text: str) -> None:
- self.input.setPlainText(text)
- self.input.setFocus()
-
- def reset_input(self) -> None:
- """Clear the input + pending attachments and restore the default placeholder
- (used on New chat so no stale text or 'Attached: …' hint carries over)."""
- self.input.clear()
- self._attachments = []
- self._refresh_attachments()
- self.input.setPlaceholderText(tr(self._placeholder_key))
-
- def set_busy(self, busy: bool) -> None:
- """Capacity gate: when True, new sends are queued (the Send button reads
- 'Queue'). Independent of whether any turn is running — see set_running."""
- self._busy = busy
- self.send_btn.setText(tr("composer.queue_btn") if busy else tr("composer.send"))
-
- def set_running(self, running: bool) -> None:
- """Show the Stop button whenever at least one turn is running (may be True
- even when not at capacity, so a single in-flight message can be stopped)."""
- self.stop_btn.setVisible(running)
-
- def has_queue(self) -> bool:
- return bool(self._queue)
-
- def pop_next(self) -> Dict | None:
- if not self._queue:
- return None
- item = self._queue.pop(0)
- self._refresh_queue()
- return item
-
- def clear_queue(self) -> None:
- self._queue.clear()
- self._refresh_queue()
-
- def enqueue(self, text: str, attachments: List[str] | None = None) -> None:
- self._queue.append({"text": text, "attachments": list(attachments or [])})
- self._refresh_queue()
-
- # ---- attachments -------------------------------------------------
- def set_max_attachments(self, n: int) -> None:
- self._max_attachments = max(0, int(n or 0))
-
- def _add_one(self, path: str) -> bool:
- """Add a file unless it's a duplicate or the count limit is reached.
- Returns False (and notifies) when the limit blocked it."""
- if not path or path in self._attachments:
- return True
- if self._max_attachments and len(self._attachments) >= self._max_attachments:
- self.attach_limit_note.emit(tr("chatpanel.attach_limit", n=self._max_attachments))
- return False
- self._attachments.append(path)
- return True
-
- def _pick_attachments(self) -> None:
- files, _ = QFileDialog.getOpenFileNames(
- self, tr("composer.attach_dialog_title"), "",
- tr("composer.attach_dialog_filter"),
- )
- for f in files:
- if not self._add_one(f):
- break
- self._refresh_attachments()
-
- def _add_paths(self, paths: List[str]) -> None:
- """Add attachments from paste / drag-drop."""
- for p in paths:
- if not self._add_one(p):
- break
- self._refresh_attachments()
- if paths:
- names = ", ".join(Path(p).name for p in paths)
- self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names))
-
- def _remove_attachment(self, item: QListWidgetItem) -> None:
- idx = self.attach_list.row(item)
- if 0 <= idx < len(self._attachments):
- self._remove_attachment_path(self._attachments[idx])
-
- def _remove_attachment_path(self, path: str) -> None:
- """Remove one wrongly-added file (✕ button or double-click)."""
- if path in self._attachments:
- self._attachments.remove(path)
- self._refresh_attachments()
- self.attachment_removed.emit(path) # also drop it from the Input panel
-
- def _refresh_attachments(self) -> None:
- self.attach_list.clear()
- for p in self._attachments:
- item = QListWidgetItem()
- row = QWidget()
- _cp = current_palette()
- row.setStyleSheet(
- f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
- f" border-radius: {_cp.radius_sm}px;")
- h = QHBoxLayout(row)
- h.setContentsMargins(8, 2, 4, 2)
- h.setSpacing(4)
- short = Path(p).name
- if len(short) > 22:
- short = short[:19] + "…"
- name = IconLabel("attach", short, size=13)
- name.setToolTip(p)
- remove = QPushButton()
- remove.setIcon(icon("close", size=12))
- remove.setObjectName("danger")
- remove.setFixedSize(18, 18)
- remove.setToolTip(tr("composer.remove_tooltip"))
- remove.setCursor(Qt.PointingHandCursor)
- remove.clicked.connect(lambda _=False, path=p: self._remove_attachment_path(path))
- h.addWidget(name) # compact chip (no stretch → many fit in one row)
- h.addWidget(remove)
- item.setSizeHint(row.sizeHint())
- self.attach_list.addItem(item)
- self.attach_list.setItemWidget(item, row)
- self.attach_label.setText(tr("composer.attachments_label", n=len(self._attachments)))
- self.attach_box.setVisible(bool(self._attachments))
- if self._attachments:
- self.attachments_added.emit(list(self._attachments))
-
- # ---- submit / queue ----------------------------------------------
- def _on_submit(self) -> None:
- text = self.input.toPlainText().strip()
- attachments = list(self._attachments)
- if not text and not attachments:
- return
- self.input.clear()
- self._attachments = []
- self._refresh_attachments()
- self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint
- # A local /skill or /agent list/select command is answered inline instantly
- # — run it now even while a turn is busy (don't bury it in the queue).
- if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)):
- self._queue.append({"text": text, "attachments": attachments})
- self._refresh_queue()
- else:
- self.submitted.emit(text, attachments)
-
- def _remove_queue_item(self, item: QListWidgetItem) -> None:
- idx = self.queue_list.row(item)
- if 0 <= idx < len(self._queue):
- self._queue.pop(idx)
- self._refresh_queue()
-
- def _refresh_queue(self) -> None:
- self.queue_list.clear()
- for i, entry in enumerate(self._queue, 1):
- text = entry.get("text", "")
- n = len(entry.get("attachments", []))
- preview = text if len(text) <= 70 else text[:70] + "…"
- if n:
- preview += f" (+{n})"
- self.queue_list.addItem(f"{i}. {preview}")
- self.queue_label.setText(tr("composer.queue_label", n=len(self._queue)))
- self.queue_box.setVisible(bool(self._queue))
- self.queue_changed.emit(len(self._queue))