/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import type { AgentChange, AgentEntry, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; import { Button, EmptyState, Icon, IconButton, SearchField, Spinner, T, Tree, copyMenuFor, copyText, fileIconName, selectableText, showContextMenu, } from "@/ui"; import { useTx, tx } from "@/platform/i18n"; import { isImeKey } from "@/platform/ime"; import { TaskStatusIcon } from "./AgentsView"; import { Composer } from "./Composer"; import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { Markdown } from "./Markdown"; import { branchLabel, entriesKey, findSubagent, isBusy, providerIcon, statusLabel, subagentStatusLabel, subagentTitle, subagentWorking, turnNumbers, type SubagentEntry, } from "./model"; import { archiveTask, cancelTask, loadEntries, markVisible, refreshChanges, editQueued, forkTask, rerunFrom, quoteInComposer, removeQueued, resendMessage, respond, sendMessage, sendQueuedNow, setTaskConfig, useAgentCommands, useAgents, } from "./store"; import { setFocusedSession, focusedSession, type FocusedSession } from "./sessionFocus"; import styles from "./Session.module.css"; export function SessionEditor({ input }: { input: EditorInput }) { const id = (input.data as { task: string }).task; const task = useAgents((s) => s.tasks.get(id)); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); const head = useLocalHead(task?.isolation === "local" ? task.status : null); // The subagent conversation shown, with the subagents that lead to it (empty: the task's own). const [path, setPath] = useState([]); const view = path.at(-1) ?? null; const viewEntries = useAgents((s) => s.entries.get(entriesKey(id, view))); // Each subagent's card, from its parent's conversation. const cards = useAgents( useShallow((s) => path.map((sub, i) => findSubagent(s.entries.get(entriesKey(id, path[i - 1] ?? null)), sub))), ); useEffect(() => { void loadEntries(id).catch(() => undefined); void refreshChanges(id); return markVisible(id); }, [id]); useEffect(() => { if (view) void loadEntries(id, view).catch(() => undefined); }, [id, view]); const openSubagent = (sub: string) => setPath((p) => [...p, sub]); const [finding, setFinding] = useState(false); const [findFocus, setFindFocus] = useState(0); const [session] = useState(() => ({ openFind: () => { setFinding(true); setFindFocus((n) => n + 1); }, })); useEffect( () => () => { if (focusedSession() === session) setFocusedSession(null); }, [session], ); if (!task) return ; return (
setFocusedSession(session)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) { setFocusedSession(null); } }} >
{view && } setFinding(false)} /> {view ? (
Subagents take their instructions from the agent that started them.
) : ( )}
); } function providerName(id: string) { return useAgents.getState().providers.find((p) => p.id === id)?.name ?? id; } /** * Current branch of the project's repository for a local-checkout task (`status` is * `null` otherwise); followed as git changes, since the agent or the user may switch it. */ function useLocalHead(status: AgentTask["status"] | null): string | null { const [head, setHead] = useState(null); const local = status !== null; useEffect(() => { if (!local) return; let live = true; const load = () => void call(commands.vcsBranchInfo(null)).then( (b) => live && setHead(b.head ?? b.headCommit?.slice(0, 8) ?? null), () => live && setHead(null), ); load(); const off = onIdeEvent("vcsChanged", load); return () => { live = false; off(); }; }, [local, status]); return local ? head : null; } function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[] | null; head: string | null }) { const busy = isBusy(task.status); const where = task.isolation === "worktree" ? branchLabel(task.branch) : null; return (
{task.title} {providerName(task.provider)} {task.model ? ` · ${task.model}` : ""} {" · "} {where ?? ( <> Local checkout {head ? ` · ${head}` : ""} )} {" · "} {statusLabel(task.status)} {task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`} {busy && void cancelTask(task.id)} />} void executeAction("terminal.openAt", task.cwd)} /> {!busy && task.status !== "archived" && task.capabilities?.fork && ( void forkTask(task.id, null).catch((e: unknown) => notifications().notify({ level: "error", title: "Couldn't copy the task", body: String(e) }), ) } /> )} {!busy && task.status !== "archived" && ( void archiveTask(task.id)} /> )}
); } /** Context use (when the agent reports it) and the time spent on the task. */ function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { const tx = useTx(); const busy = isBusy(task.status); const now = useNow(busy ? 1000 : 60_000); const usage = task.usage; const work = entries ? workTime(entries, busy ? now : null) : 0; return ( {usage && } {work >= 1000 && {formatDuration(work)}} ); } /** Context window use: "12%", the numbers in the tooltip. */ function Usage({ usage }: { usage: AgentUsage }) { const label = usageLabel(usage); return ( {label.short} ); } /** `Date.now()`, updated every `interval` ms. */ function useNow(interval: number): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const t = setInterval(() => setNow(Date.now()), interval); return () => clearInterval(t); }, [interval]); return now; } /** Where a subagent conversation sits: the way back to the task, and the subagent's state. */ function SubagentBar({ task, path, cards, onNavigate, }: { task: AgentTask; path: string[]; cards: Array; onNavigate: (path: string[]) => void; }) { const tx = useTx(); const card = cards.at(-1); return (
onNavigate(path.slice(0, -1))} /> {card && ( {subagentStatusLabel(card.status)} )} {card && subagentWorking(card.status) && card.cancellable && ( void cancelTask(task.id, card.id)} /> )}
); } interface StreamProps { task: AgentTask; entries: AgentEntry[] | null; /** The card of the subagent whose conversation this is; `null`: the task's own. */ subagent: SubagentEntry | null; onOpenSubagent: (id: string) => void; finding: boolean; /** Bumped by Find in Conversation: focus the find field again. */ findFocus: number; onCloseFind: () => void; } function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) { const ref = useRef(null); const stick = useRef(true); useLayoutEffect(() => { const el = ref.current; if (el && stick.current) el.scrollTop = el.scrollHeight; }); const waiting = entries?.some((e) => e.type === "permission" && !e.chosen); const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user"); // Forks and undo apply to the task's own turns, not a subagent's conversation. const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]); return (
{finding && }
{ const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }} // Copy, plus Quote for a selection: replies to it in the composer. onContextMenu={(e) => { const text = selectionIn(e.currentTarget); const items = copyMenuFor(e.target) ?? []; if (text.trim() && task.status !== "archived") { items.push({ id: "quote", label: "Quote", icon: "edit", run: () => quoteInComposer(task.id, text) }); } if (!items.length) return; e.preventDefault(); showContextMenu(e, items); }} > {entries === null ? ( ) : ( entries.map((entry, i) => ( )) )} {subagent && subagentWorking(subagent.status) && (
{subagent.status === "needsInput" ? "Waiting for your answer…" : "Working…"} {subagent.startedMs != null && }
)} {!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
{task.retry ? ( ) : ( {task.status === "starting" ? "Starting the agent…" : "Working…"} )} {lastUser?.sentMs != null && !lastUser.doneMs && }
)}
); } type UserEntry = Extract; /** Time since `since`, ticking every second. */ /** The agent lost its API connection (or was refused) and retries on its own. */ function Retrying({ retry }: { retry: AgentRetry }) { const tx = useTx(); const now = useNow(1000); const { attempt, wait } = retryParts(retry, now); return ( {wait > 0 ? tx`API request failed, retrying in ${wait}s (attempt ${attempt})` : tx`API request failed, retrying (attempt ${attempt})`} ); } function Elapsed({ since }: { since: number }) { const now = useNow(1000); return {formatDuration(now - since)}; } /** Text selected in `stream`, or "" when the selection is elsewhere. */ function selectionIn(stream: HTMLElement): string { const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return ""; return stream.contains(sel.getRangeAt(0).commonAncestorContainer) ? sel.toString() : ""; } interface RangeSet { clear(): void; add(range: Range): void; } const highlights = (globalThis as { CSS?: { highlights?: Map } }).CSS?.highlights; const HighlightCtor = (globalThis as { Highlight?: new () => RangeSet }).Highlight; /** * Sets the ranges of highlight `name`. One registered highlight per name, changed in * place: WebKit doesn't repaint where a replaced highlight object was. */ function setHighlight(name: string, ranges: Range[]) { if (!highlights || !HighlightCtor) return; let set = highlights.get(name); if (!set) { set = new HighlightCtor(); highlights.set(name, set); } set.clear(); for (const r of ranges) set.add(r); } /** Scrolls `range` into view in `stream`, opening the collapsed tool output or thought holding it. */ function revealRange(range: Range, stream: HTMLElement) { for (let el = range.startContainer.parentElement; el && el !== stream; el = el.parentElement) { if (el instanceof HTMLDetailsElement) el.open = true; } const r = range.getBoundingClientRect(); const box = stream.getBoundingClientRect(); if (r.top < box.top + 24 || r.bottom > box.bottom - 24) { stream.scrollTop += r.top - box.top - box.height / 3; } } /** Find in Conversation: every match highlighted, Enter / ⇧Enter step through them. */ function FindBar({ streamRef, focus, onClose, }: { streamRef: RefObject; focus: number; onClose: () => void; }) { const tx = useTx(); const input = useRef(null); const [query, setQuery] = useState(""); const [ranges, setRanges] = useState([]); const [current, setCurrent] = useState(0); useEffect(() => { input.current?.focus(); input.current?.select(); }, [focus]); // Matches follow the conversation as it streams in (and as code gets highlighted). useEffect(() => { const stream = streamRef.current; if (!stream) return; let frame = 0; const update = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => setRanges(findRanges(stream, query.trim()))); }; update(); const observer = new MutationObserver(update); observer.observe(stream, { subtree: true, childList: true, characterData: true }); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [query, streamRef]); const index = ranges.length ? Math.min(current, ranges.length - 1) : -1; // Expanding or collapsing a tool output shows or hides its matches. const [shown, setShown] = useState(0); useEffect(() => { const stream = streamRef.current; const toggled = () => setShown((n) => n + 1); stream?.addEventListener("toggle", toggled, true); return () => stream?.removeEventListener("toggle", toggled, true); }, [streamRef]); useEffect(() => { // Only rendered matches: WebKit paints those in collapsed
over the text next to them. const visible = (r: Range) => r.getClientRects().length > 0; const current = index >= 0 ? ranges[index]! : null; setHighlight("agent-find", ranges.filter(visible)); setHighlight("agent-find-current", current && visible(current) ? [current] : []); }, [ranges, index, shown]); useEffect( () => () => { setHighlight("agent-find", []); setHighlight("agent-find-current", []); }, [], ); const step = (dir: 1 | -1) => { const stream = streamRef.current; if (!ranges.length || !stream) return; const next = (index + dir + ranges.length) % ranges.length; setCurrent(next); revealRange(ranges[next]!, stream); }; const close = () => { onClose(); streamRef.current?.focus(); }; return (
{ setQuery(v); setCurrent(0); }} aria-label={tx("Find in Conversation")} placeholder={tx("Find in conversation")} className={styles.findField} trailing={ query.trim() ? ( {ranges.length ? `${index + 1}/${ranges.length}` : tx("0 results")} ) : null } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); step(e.shiftKey ? -1 : 1); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); } }} /> step(-1)} /> step(1)} />
); } const TOOL_ICONS: Record = { read: "file", edit: "edit", delete: "minus", move: "edit", search: "search", execute: "terminal", fetch: "web", think: "todo", }; function Entry({ task, entry, turn, turnCount, onOpenSubagent, }: { task: AgentTask; entry: AgentEntry; /** Number of the turn this message started (from 0), when it started one. */ turn: number | null; turnCount: number; onOpenSubagent: (id: string) => void; }) { switch (entry.type) { case "user": return ; case "agent": return ; case "thought": return (
Thinking
{entry.text}
); case "tool": return ; case "plan": return (
Plan
{entry.items.map((item, i) => (
{item.content}
))}
); case "subagent": return onOpenSubagent(entry.id)} />; case "message": return entry.incoming ? (
Task from the parent agent
{entry.text}
) : (
Report to the parent agent
); case "permission": return ; case "error": return (
{entry.message}
); } } function UserMessage({ task, entry, turn, turnCount, }: { task: AgentTask; entry: UserEntry; turn: number | null; turnCount: number; }) { const tx = useTx(); const [editing, setEditing] = useState(false); const failed = (title: string) => (e: unknown) => notifications().notify({ level: "error", title, body: String(e) }); const fail = failed("Couldn't send the message"); const archived = task.status === "archived"; const idle = !archived && !isBusy(task.status) && turn !== null; const { fork = false, rollback = false } = task.capabilities ?? {}; // Editing and running again from a message need an agent that can drop turns. const inPlace = !archived && rollback && turn !== null; // A copy up to an earlier turn drops the later ones from the copied session. const canFork = idle && fork && (rollback || turn === turnCount - 1); if (editing && turn !== null) { return ( setEditing(false)} onSend={async (text) => { await rerunFrom(task.id, turn, text); setEditing(false); }} /> ); } return (
{entry.text} {entry.attachments.length > 0 && (
{entry.attachments.map((a, i) => ( {a} ))}
)}
{entry.sentMs != null && ( {formatSentAt(entry.sentMs)} )} {entry.sentMs != null && entry.doneMs != null && ( {` · ${formatDuration(entry.doneMs - entry.sentMs)}`} )} void copyText(entry.text)} /> {inPlace && entry.text && ( setEditing(true)} /> )} {!archived && entry.text && entry.attachments.length === 0 && ( void (inPlace ? rerunFrom(task.id, turn, entry.text) : resendMessage(task.id, entry.text)).catch(fail) } /> )} {canFork && ( void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))} /> )}
); } /** An earlier message edited where it is: sending runs the conversation again from it. */ function MessageEditor({ initial, later, onCancel, onSend, }: { initial: string; /** Turns follow it (they are dropped on send). */ later: boolean; onCancel: () => void; onSend: (text: string) => Promise; }) { const tx = useTx(); const [text, setText] = useState(initial); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const pending = useRef(false); const send = async () => { if (!text.trim() || pending.current) return; pending.current = true; setSending(true); setError(null); try { await onSend(text.trim()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) }); } finally { pending.current = false; setSending(false); } }; return (