From cae9a96d2af514122a49de58d9e6492049ffa61a Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Sun, 11 Oct 2026 13:58:54 -0400 Subject: [PATCH 1/7] feat(chat): Agents panel with live peek, plus Tasks tool support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two displays for work happening outside the main transcript. Agents panel (new `agents` right-column panel). `collectAgents` in `shared/state/json-claude-agents.ts` derives every sub-agent a session launched — synchronous or background, at any nesting depth — from entries + backgroundAgents. Nothing new goes over the wire. The panel lists them running-first with a live "currently" line, elapsed time and status; clicking one opens a peek flyout with its whole activity feed, following the tail as it streams, and nested agents link through. A dead agent is reported as `interrupted` rather than left spinning: a sync sub-agent can't outlive its parent's turn, so no result once the turn is over means the run was cut short. Tasks tool (TaskCreate/TaskUpdate). Ness only knew TodoWrite, so a session driving the newer Tasks tool showed an empty Todos panel and raw JSON in the transcript. Unlike TodoWrite these mutate one task at a time, so `collectTasks` replays the calls instead of reading the last one — recovering ids from the create results, mirroring the blocks/blockedBy graph, and surfacing owner and activeForm. The Todos panel renders Tasks when present and falls back to TodoWrite; keeping the `todos` panel key means saved per-repo panel config still applies. Both panels derive off entry count rather than session identity, which churns per streamed token — a full walk per token is CLAUDE.md anti-pattern #4. The agents panel adds a 500ms tick for in-place streaming updates, gated on something actually being live. 41 new reducer-level tests; full suite green. --- src/renderer/components/AgentPeek.tsx | 196 +++++++++ src/renderer/components/AgentStatusDot.tsx | 61 +++ src/renderer/components/AgentStepLine.tsx | 84 ++++ .../components/JsonClaudeAgentsPanel.tsx | 190 +++++++++ .../components/JsonClaudeTodosPanel.tsx | 172 +++++++- src/renderer/components/RightColumn.tsx | 3 + .../components/RightColumnToolbar.tsx | 1 + .../json-mode-cards/TaskMutationCard.tsx | 108 +++++ .../components/json-mode-cards/grouping.ts | 16 +- .../components/json-mode-cards/index.tsx | 4 + .../json-mode-cards/tool-display.ts | 6 + src/shared/state/json-claude-agents.test.ts | 383 ++++++++++++++++++ src/shared/state/json-claude-agents.ts | 352 ++++++++++++++++ src/shared/state/json-claude-tasks.test.ts | 285 +++++++++++++ src/shared/state/json-claude-tasks.ts | 201 +++++++++ src/shared/state/repo-configs.test.ts | 3 + src/shared/state/repo-configs.ts | 2 + 17 files changed, 2057 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/AgentPeek.tsx create mode 100644 src/renderer/components/AgentStatusDot.tsx create mode 100644 src/renderer/components/AgentStepLine.tsx create mode 100644 src/renderer/components/JsonClaudeAgentsPanel.tsx create mode 100644 src/renderer/components/json-mode-cards/TaskMutationCard.tsx create mode 100644 src/shared/state/json-claude-agents.test.ts create mode 100644 src/shared/state/json-claude-agents.ts create mode 100644 src/shared/state/json-claude-tasks.test.ts create mode 100644 src/shared/state/json-claude-tasks.ts diff --git a/src/renderer/components/AgentPeek.tsx b/src/renderer/components/AgentPeek.tsx new file mode 100644 index 000000000..a4046cee7 --- /dev/null +++ b/src/renderer/components/AgentPeek.tsx @@ -0,0 +1,196 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { Sparkles, X } from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import type { AgentSummary } from '../../shared/state/json-claude-agents' +import { + agentElapsedMs, + formatElapsed +} from '../../shared/state/json-claude-agents' +import { AgentStepLine } from './AgentStepLine' +import { AgentStatusDot, agentStatusLabel } from './AgentStatusDot' + +/** How much of a text block to show inline in the feed. The peek is a + * "what is it doing" view, not a transcript — the full text is in the + * chat's own Task card. */ +const TEXT_PREVIEW = 600 + +interface AgentPeekProps { + agent: AgentSummary + /** Live clock, passed down so every peek and panel row ticks off the + * same interval rather than each mounting its own. */ + now: number + onClose: () => void + /** Opens a nested agent's peek, replacing this one. */ + onPeekAgent: (toolUseId: string) => void +} + +/** A flyout over the chat showing one agent's activity as it happens. + * Deliberately not the chat's own card renderer: that machinery needs a + * full render ctx (approval cards, find-highlighting, image fetching) + * and produces the collapsed-group chrome that makes scanning a running + * agent hard. Here every step gets exactly one line, newest at the + * bottom, and the view follows along. */ +export function AgentPeek({ + agent, + now, + onClose, + onPeekAgent +}: AgentPeekProps): JSX.Element { + const feedRef = useRef(null) + const pinnedToBottomRef = useRef(true) + const stepCount = agent.steps.length + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + // Follow the tail only while the user hasn't scrolled away — an agent + // mid-run appends constantly, and yanking the viewport back down while + // someone is reading an earlier step is worse than not following. + useLayoutEffect(() => { + const el = feedRef.current + if (!el || !pinnedToBottomRef.current) return + el.scrollTop = el.scrollHeight + }, [stepCount, agent.result]) + + const elapsed = formatElapsed(agentElapsedMs(agent, now)) + const usage = agent.usage + const meta = [ + agent.subagentType, + agent.model, + `${agent.toolCount} ${agent.toolCount === 1 ? 'tool' : 'tools'}`, + usage?.totalTokens !== undefined + ? `${Math.round(usage.totalTokens / 1000)}k tokens` + : null, + elapsed + ].filter(Boolean) as string[] + + return createPortal( +
+
e.stopPropagation()} + > +
+ + + {agent.description} + + {agent.isBackground && ( + + background + + )} + + + {agentStatusLabel(agent)} + + +
+ +
+ {meta.map((m) => ( + + {m} + + ))} + {agent.parentDescription && ( + + via {agent.parentDescription} + + )} +
+ +
{ + const el = e.currentTarget + pinnedToBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 32 + }} + className="flex-1 overflow-y-auto px-3 py-2 text-xs space-y-1" + > + {agent.steps.length === 0 && ( +
+ {agent.status === 'running' + ? 'agent is starting…' + : 'no activity recorded'} +
+ )} + {agent.steps.map((step) => + step.kind === 'text' ? ( +
+ + {step.text && step.text.length > TEXT_PREVIEW + ? `${step.text.slice(0, TEXT_PREVIEW)}…` + : (step.text ?? '')} + +
+ ) : step.childAgentToolUseId ? ( + + ) : ( +
+ +
+ ) + )} + {agent.status === 'running' && ( +
+ + working… +
+ )} +
+ + {agent.result && ( +
+
+ {agent.resultIsError ? 'error' : 'report'} +
+
+ {agent.result} +
+
+ )} +
+
, + document.body + ) +} diff --git a/src/renderer/components/AgentStatusDot.tsx b/src/renderer/components/AgentStatusDot.tsx new file mode 100644 index 000000000..55d945f16 --- /dev/null +++ b/src/renderer/components/AgentStatusDot.tsx @@ -0,0 +1,61 @@ +import type { AgentSummary } from '../../shared/state/json-claude-agents' + +export function agentStatusLabel(agent: AgentSummary): string { + if (agent.hasPendingApproval) return 'needs approval' + switch (agent.status) { + case 'running': + return 'running' + case 'completed': + return 'done' + case 'failed': + return 'failed' + case 'interrupted': + return 'interrupted' + } +} + +/** Status glyph for an agent. A spinner while running so a fleet of them + * reads as alive at a glance; a static mark once settled. */ +export function AgentStatusDot({ + agent +}: { + agent: AgentSummary +}): JSX.Element { + if (agent.hasPendingApproval) { + return ( + + ⚠ + + ) + } + if (agent.status === 'running') { + return ( + + ) + } + const cls = + agent.status === 'completed' + ? 'text-success' + : agent.status === 'failed' + ? 'text-danger' + : 'text-faint' + const glyph = + agent.status === 'completed' ? '✓' : agent.status === 'failed' ? '✕' : '⊘' + return ( + + {glyph} + + ) +} diff --git a/src/renderer/components/AgentStepLine.tsx b/src/renderer/components/AgentStepLine.tsx new file mode 100644 index 000000000..513e24dcb --- /dev/null +++ b/src/renderer/components/AgentStepLine.tsx @@ -0,0 +1,84 @@ +import { Brain, MessageSquare, Sparkles, Wrench } from 'lucide-react' +import type { AgentStep } from '../../shared/state/json-claude-agents' +import { isSubAgentToolName } from '../../shared/state/json-claude-agents' +import { + extractArgs, + getToolDisplay +} from './json-mode-cards/tool-display' + +/** Argument keys that identify *what* a call acted on, in preference + * order. `extractArgs` preserves the input's own key order, which is + * usually right but not always — a Bash call leads with `command` on + * one version and `description` on another. Naming the interesting keys + * keeps the one line we have pointed at the subject. */ +const SUBJECT_KEYS = [ + 'file_path', + 'path', + 'command', + 'pattern', + 'query', + 'url', + 'prompt', + 'description', + 'subject' +] + +/** The one-line "what it acted on" for a tool step. */ +export function summarizeStepArgs(step: AgentStep): string { + if (step.kind !== 'tool') return '' + const args = extractArgs(step.input) + if (args.length === 0) return '' + const preferred = + SUBJECT_KEYS.map((k) => args.find((a) => a.key === k)).find(Boolean) ?? + args[0] + return preferred.value.replace(/\s+/g, ' ').trim() +} + +/** One line of an agent's activity feed. Shared by the Agents panel's + * "currently" line and the peek view's step list, so a step reads the + * same in both places. */ +export function AgentStepLine({ + step, + dim = false +}: { + step: AgentStep + dim?: boolean +}): JSX.Element { + if (step.kind === 'thinking') { + return ( + + + thinking + + ) + } + if (step.kind === 'text') { + return ( + + + + {step.text} + + + ) + } + const nested = isSubAgentToolName(step.toolName) + const display = getToolDisplay(step.toolName) + const Icon = nested ? Sparkles : (display.icon ?? Wrench) + const detail = summarizeStepArgs(step) + return ( + + + {display.compactLabel} + {detail && ( + + {detail} + + )} + + ) +} diff --git a/src/renderer/components/JsonClaudeAgentsPanel.tsx b/src/renderer/components/JsonClaudeAgentsPanel.tsx new file mode 100644 index 000000000..92014a503 --- /dev/null +++ b/src/renderer/components/JsonClaudeAgentsPanel.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { CornerDownRight } from 'lucide-react' +import { + useJsonClaudePendingApprovals, + useJsonClaudeSession +} from '../store' +import { + agentElapsedMs, + collectAgents, + countRunningAgents, + formatElapsed, + type AgentSummary +} from '../../shared/state/json-claude-agents' +import { RightPanel } from './RightPanel' +import { AgentPeek } from './AgentPeek' +import { AgentStatusDot } from './AgentStatusDot' +import { AgentStepLine } from './AgentStepLine' + +/** How often the panel re-derives while anything is live. `collectAgents` + * walks the whole entries array, and the focused session's object + * identity changes on every streamed token — deriving off that directly + * means one full walk per token, which is anti-pattern #4 in CLAUDE.md + * with extra allocations. This is a status display; sub-second freshness + * buys nothing, and the cap holds regardless of token rate. */ +const LIVE_REFRESH_MS = 500 + +/** One clock for the whole panel. Elapsed times are the only thing here + * that changes without a state event, so they get a single shared tick + * rather than a timer per row — and it only runs while something is + * actually running. 1s because these are second-granularity labels. */ +function useLiveClock(active: boolean): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!active) return + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, [active]) + return now +} + +function AgentRow({ + agent, + now, + onPeek +}: { + agent: AgentSummary + now: number + onPeek: () => void +}): JSX.Element { + const lastStep = agent.steps.length + ? agent.steps[agent.steps.length - 1] + : null + const running = agent.status === 'running' + return ( +
  • + +
  • + ) +} + +export function JsonClaudeAgentsPanel({ + focusedTabId +}: { + focusedTabId: string | null +}): JSX.Element | null { + const session = useJsonClaudeSession(focusedTabId ?? '') + const pendingApprovals = useJsonClaudePendingApprovals() + const [peekId, setPeekId] = useState(null) + + const pendingToolUseIds = useMemo(() => { + const ids = new Set() + for (const p of Object.values(pendingApprovals)) { + if (p.sessionId === session?.sessionId && p.toolUseId) ids.add(p.toolUseId) + } + return ids + }, [pendingApprovals, session?.sessionId]) + + // Read through a ref so the derivation isn't keyed on the session's + // identity (which churns per token). `entries.length` is the cheap + // structural signal — a launch, a tool call or a result always appends + // — and `tick` covers in-place mutation of an entry that's streaming. + // + // `sessionId` is in the deps because two sessions can have the same + // entry count — without it, switching between them could keep showing + // the previous session's agents. + const sessionRef = useRef(session) + sessionRef.current = session + const entryCount = session?.entries.length ?? 0 + const sessionId = session?.sessionId + const [tick, setTick] = useState(0) + + const agents = useMemo( + () => collectAgents(sessionRef.current, pendingToolUseIds), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sessionId, tick, entryCount, pendingToolUseIds] + ) + const running = countRunningAgents(agents) + // A background agent outlives its parent's turn, so `busy` alone would + // stop ticking while one is still working. + const live = running > 0 || !!session?.busy + useEffect(() => { + if (!live) return + const id = setInterval(() => setTick((t) => t + 1), LIVE_REFRESH_MS) + return () => clearInterval(id) + }, [live]) + const now = useLiveClock(running > 0) + + // A peek whose agent vanished (session cleared, history truncated) + // would render nothing and trap the overlay — drop it. + const peeked = peekId ? agents.find((a) => a.toolUseId === peekId) : undefined + useEffect(() => { + if (peekId && !peeked) setPeekId(null) + }, [peekId, peeked]) + + if (agents.length === 0) return null + + return ( + <> + + {running > 0 ? `${running} running` : `${agents.length}`} +
    + } + > + + + {peeked && ( + setPeekId(null)} + onPeekAgent={setPeekId} + /> + )} + + ) +} diff --git a/src/renderer/components/JsonClaudeTodosPanel.tsx b/src/renderer/components/JsonClaudeTodosPanel.tsx index 33f8bca5a..1ad689478 100644 --- a/src/renderer/components/JsonClaudeTodosPanel.tsx +++ b/src/renderer/components/JsonClaudeTodosPanel.tsx @@ -1,16 +1,186 @@ +import { useMemo, useRef, useState } from 'react' +import { Ban, User } from 'lucide-react' import { useJsonClaudeSession } from '../store' import { getLatestTodos } from '../../shared/state/json-claude-todos' +import { + collectTasks, + countOpenTasks, + isTaskBlocked, + type TaskRecord +} from '../../shared/state/json-claude-tasks' import { RightPanel } from './RightPanel' interface JsonClaudeTodosPanelProps { focusedTabId: string | null } +const STATUS_GLYPH: Record = { + pending: '☐', + in_progress: '◐', + completed: '☑' +} + +function statusColor(status: TaskRecord['status']): string { + return status === 'completed' + ? 'text-success' + : status === 'in_progress' + ? 'text-warning' + : 'text-faint' +} + +function TaskRow({ + task, + blocked, + expanded, + onToggle +}: { + task: TaskRecord + blocked: boolean + expanded: boolean + onToggle: () => void +}): JSX.Element { + // activeForm is the field's entire reason to exist — while a task runs, + // "Building the catalogue client" beats "Catalogue client". + const label = + task.status === 'in_progress' && task.activeForm + ? task.activeForm + : task.subject + const hasDetail = task.description.length > 0 + return ( +
  • + + {expanded && hasDetail && ( +
    + {task.description} +
    + )} +
  • + ) +} + +/** The session's work list. Renders the structured Tasks tool + * (TaskCreate/TaskUpdate — subjects, owners, a blocked-by graph) when + * the session uses it, and falls back to the older flat TodoWrite array + * otherwise. One panel rather than two: they're the same concept, a + * session only ever drives one of them, and keeping the `todos` panel + * key means existing per-repo panel order/visibility config still + * applies. */ export function JsonClaudeTodosPanel({ focusedTabId }: JsonClaudeTodosPanelProps): JSX.Element | null { const session = useJsonClaudeSession(focusedTabId ?? '') - const todos = getLatestTodos(session) + const [expandedId, setExpandedId] = useState(null) + // Derived off `entries.length` rather than the session object, which + // gets a new identity on every streamed token — `collectTasks` walks + // the whole array, and a per-token walk is CLAUDE.md anti-pattern #4. + // The list only ever changes on a TaskCreate/TaskUpdate call, and a + // tool call always appends an entry, so the count is a sound trigger. + // + // `sessionId` is in the deps because two different sessions can have + // the same entry count — without it, switching tabs between them would + // keep showing the previous session's list. + const sessionRef = useRef(session) + sessionRef.current = session + const entryCount = session?.entries.length ?? 0 + const sessionId = session?.sessionId + const tasks = useMemo( + () => collectTasks(sessionRef.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sessionId, entryCount] + ) + const todos = useMemo( + () => getLatestTodos(sessionRef.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sessionId, entryCount] + ) + const blockedIds = useMemo(() => { + const byId = new Map(tasks.map((t) => [t.id, t])) + const out = new Set() + for (const t of tasks) if (isTaskBlocked(t, byId)) out.add(t.id) + return out + }, [tasks]) + + if (tasks.length > 0) { + const open = countOpenTasks(tasks) + return ( + + {open}/{tasks.length} + + } + > +
      + {tasks.map((t) => ( + + setExpandedId((cur) => (cur === t.id ? null : t.id)) + } + /> + ))} +
    +
    + ) + } + if (!todos || todos.length === 0) return null const remaining = todos.filter((t) => t.status !== 'completed').length diff --git a/src/renderer/components/RightColumn.tsx b/src/renderer/components/RightColumn.tsx index c6c5ae77a..58b7b42f4 100644 --- a/src/renderer/components/RightColumn.tsx +++ b/src/renderer/components/RightColumn.tsx @@ -18,6 +18,7 @@ import { AllFilesPanel } from './AllFilesPanel' import { CostPanel } from './CostPanel' import { ContextPanel } from './ContextPanel' import { JsonClaudeTodosPanel } from './JsonClaudeTodosPanel' +import { JsonClaudeAgentsPanel } from './JsonClaudeAgentsPanel' import { ScratchpadPanel } from './ScratchpadPanel' import { RightColumnToolbar } from './RightColumnToolbar' import { useBackend } from '../backend' @@ -160,6 +161,8 @@ export function RightColumn({ ) case 'todos': return + case 'agents': + return case 'commits': return ( = { merge: 'Merge Locally', pr: 'PR Status', todos: 'Todos', + agents: 'Agents', commits: 'Branch Commits', changedFiles: 'Changed Files', allFiles: 'All Files', diff --git a/src/renderer/components/json-mode-cards/TaskMutationCard.tsx b/src/renderer/components/json-mode-cards/TaskMutationCard.tsx new file mode 100644 index 000000000..2be7dedd9 --- /dev/null +++ b/src/renderer/components/json-mode-cards/TaskMutationCard.tsx @@ -0,0 +1,108 @@ +import { ToolCardChrome, type ToolCardProps } from './index' +import { TodoIcon } from './tool-icons' +import { HighlightedText } from '../JsonModeChatFind' + +const STATUS_LABEL: Record = { + pending: 'pending', + in_progress: 'in progress', + completed: 'completed', + deleted: 'deleted' +} + +function statusColor(status: string): string { + return status === 'completed' + ? 'text-success' + : status === 'in_progress' + ? 'text-warning' + : status === 'deleted' + ? 'text-danger' + : 'text-faint' +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +function ids(v: unknown): string[] { + return Array.isArray(v) + ? v.filter((x): x is string => typeof x === 'string') + : [] +} + +/** TaskCreate / TaskUpdate. Both mutate one task, so the interesting + * content is a single line — what changed, and to what. Without this + * they fall through to GenericToolCard and a session that drives the + * Tasks tool reads as a wall of raw JSON. */ +export function TaskMutationCard({ + block, + result, + autoApproved, + sessionAllowed +}: ToolCardProps): JSX.Element { + const input = (block.input ?? {}) as Record + const isCreate = block.name === 'TaskCreate' + const subject = str(input.subject) + const description = str(input.description) + const activeForm = str(input.activeForm) + const owner = str(input.owner) + const status = str(input.status) + const taskId = str(input.taskId) + const blocks = ids(input.addBlocks) + const blockedBy = ids(input.addBlockedBy) + + // The created task's id only exists in the result ("Task #3 created + // successfully: …"), so the header can't name it until that lands. + const createdId = result?.content + ? /^Task #(\d+) created/.exec(result.content)?.[1] + : undefined + const label = createdId ?? taskId + + return ( + +
    + {subject && ( +
    + +
    + )} + {status && ( +
    + → {STATUS_LABEL[status] ?? status} +
    + )} + {activeForm && !subject && ( +
    + +
    + )} + {owner &&
    owner: {owner}
    } + {blockedBy.length > 0 && ( +
    + blocked by {blockedBy.map((i) => `#${i}`).join(', ')} +
    + )} + {blocks.length > 0 && ( +
    + blocks {blocks.map((i) => `#${i}`).join(', ')} +
    + )} + {description && ( +
    + +
    + )} + {result?.isError && ( +
    {result.content}
    + )} +
    +
    + ) +} diff --git a/src/renderer/components/json-mode-cards/grouping.ts b/src/renderer/components/json-mode-cards/grouping.ts index 0f4e387ac..b3b24d365 100644 --- a/src/renderer/components/json-mode-cards/grouping.ts +++ b/src/renderer/components/json-mode-cards/grouping.ts @@ -1,14 +1,12 @@ import type { JsonClaudeChatEntry } from '../../../shared/state/json-claude' -/** Tool names that spawn a sub-agent. Claude Code 2.1.126+ renamed - * the historical "Task" tool to "Agent" but kept routing both names - * to the same subagent runner — match either so the nesting works - * across versions. */ -export const SUBAGENT_TOOL_NAMES = new Set(['Task', 'Agent']) - -export function isSubAgentToolName(name: string | undefined): boolean { - return !!name && SUBAGENT_TOOL_NAMES.has(name) -} +// Re-exported from the shared agents module so the chat's nesting pre-pass +// and the Agents panel can never disagree about what counts as a sub-agent. +export { + SUBAGENT_TOOL_NAMES, + isSubAgentToolName +} from '../../../shared/state/json-claude-agents' +import { isSubAgentToolName } from '../../../shared/state/json-claude-agents' export interface BuildChildrenMapResult { topLevelEntries: JsonClaudeChatEntry[] diff --git a/src/renderer/components/json-mode-cards/index.tsx b/src/renderer/components/json-mode-cards/index.tsx index ed8b882a1..8d8c4cd3c 100644 --- a/src/renderer/components/json-mode-cards/index.tsx +++ b/src/renderer/components/json-mode-cards/index.tsx @@ -251,6 +251,7 @@ import { BashCard } from './BashCard' import { GrepCard } from './GrepCard' import { GlobCard } from './GlobCard' import { TodoWriteCard } from './TodoWriteCard' +import { TaskMutationCard } from './TaskMutationCard' import { TaskCard } from './TaskCard' import { GenericToolCard } from './GenericToolCard' @@ -272,6 +273,9 @@ export function dispatchToolCard(props: ToolCardProps): JSX.Element { return case 'TodoWrite': return + case 'TaskCreate': + case 'TaskUpdate': + return // 'Task' (older Claude Code) and 'Agent' (2.1.126+) both route to // the subagent runner — render either as TaskCard. case 'Task': diff --git a/src/renderer/components/json-mode-cards/tool-display.ts b/src/renderer/components/json-mode-cards/tool-display.ts index d8421af7d..e3b804505 100644 --- a/src/renderer/components/json-mode-cards/tool-display.ts +++ b/src/renderer/components/json-mode-cards/tool-display.ts @@ -131,6 +131,12 @@ const BUILTIN_ICONS: Record = { Grep: GrepIcon, Glob: GlobIcon, TodoWrite: TodoIcon, + // The Tasks tool family — TodoWrite's structured successor. Same icon + // so a session that uses either reads the same way in the transcript. + TaskCreate: TodoIcon, + TaskUpdate: TodoIcon, + TaskList: TodoIcon, + TaskGet: TodoIcon, Task: TaskIcon, Agent: TaskIcon, WebFetch: WebIcon, diff --git a/src/shared/state/json-claude-agents.test.ts b/src/shared/state/json-claude-agents.test.ts new file mode 100644 index 000000000..e3cb0657d --- /dev/null +++ b/src/shared/state/json-claude-agents.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from 'vitest' +import type { + JsonClaudeChatEntry, + JsonClaudeSession +} from './json-claude' +import { + agentElapsedMs, + collectAgents, + countRunningAgents, + formatElapsed +} from './json-claude-agents' + +function session( + entries: JsonClaudeChatEntry[], + over: Partial = {} +): JsonClaudeSession { + return { + sessionId: 's1', + worktreePath: '/tmp/wt', + state: 'running', + exitCode: null, + exitReason: null, + entries, + entriesHydrated: true, + busy: true, + permissionMode: 'default', + slashCommands: [], + autoApprovedDecisions: {}, + sessionToolApprovals: [], + sessionAllowedDecisions: {}, + backgroundAgents: {}, + ...over + } as JsonClaudeSession +} + +/** An assistant entry launching a sub-agent. */ +function launch( + id: string, + description: string, + opts: { + at?: number + entryId?: string + parentToolUseId?: string + background?: boolean + subagentType?: string + name?: string + } = {} +): JsonClaudeChatEntry { + return { + entryId: opts.entryId ?? `e-${id}`, + kind: 'assistant', + timestamp: opts.at ?? 1000, + ...(opts.parentToolUseId + ? { parentToolUseId: opts.parentToolUseId } + : {}), + blocks: [ + { + type: 'tool_use', + id, + name: opts.name ?? 'Agent', + input: { + description, + ...(opts.subagentType ? { subagent_type: opts.subagentType } : {}), + ...(opts.background ? { run_in_background: true } : {}) + } + } + ] + } +} + +/** A child entry attributed to an agent, carrying tool calls / text. */ +function childWork( + parentToolUseId: string, + blocks: JsonClaudeChatEntry['blocks'], + at = 2000, + entryId = 'c1' +): JsonClaudeChatEntry { + return { entryId, kind: 'assistant', timestamp: at, parentToolUseId, blocks } +} + +function toolResult( + toolUseId: string, + content: string, + isError = false +): JsonClaudeChatEntry { + return { + entryId: `r-${toolUseId}-${content.slice(0, 4)}`, + kind: 'tool_result', + timestamp: 3000, + blocks: [{ type: 'tool_result', toolUseId, content, isError }] + } +} + +describe('collectAgents', () => { + it('returns nothing for an empty or absent session', () => { + expect(collectAgents(null)).toEqual([]) + expect(collectAgents(session([]))).toEqual([]) + }) + + it('ignores sessions with no sub-agent launches', () => { + const e: JsonClaudeChatEntry = { + entryId: 'e1', + kind: 'assistant', + timestamp: 1, + blocks: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] + } + expect(collectAgents(session([e]))).toEqual([]) + }) + + it('treats a launch with no result as running while the session is busy', () => { + const agents = collectAgents(session([launch('t1', 'Map the data model')])) + expect(agents).toHaveLength(1) + expect(agents[0].status).toBe('running') + expect(agents[0].description).toBe('Map the data model') + expect(agents[0].isBackground).toBe(false) + expect(agents[0].depth).toBe(0) + }) + + it('reports interrupted — not running — when the turn ended with no result', () => { + const agents = collectAgents( + session([launch('t1', 'Map the data model')], { busy: false }) + ) + expect(agents[0].status).toBe('interrupted') + }) + + it('matches the legacy Task tool name as well as Agent', () => { + const agents = collectAgents( + session([launch('t1', 'Legacy', { name: 'Task' })]) + ) + expect(agents).toHaveLength(1) + }) + + it('falls back to subagent_type when no description was given', () => { + const e = launch('t1', '', { subagentType: 'Explore' }) + e.blocks![0].input = { subagent_type: 'Explore' } + const agents = collectAgents(session([e])) + expect(agents[0].description).toBe('Explore') + expect(agents[0].subagentType).toBe('Explore') + }) + + it('completes on a result, and marks an error result failed', () => { + const ok = collectAgents( + session([launch('t1', 'A'), toolResult('t1', 'all done')]) + ) + expect(ok[0].status).toBe('completed') + expect(ok[0].result).toBe('all done') + + const bad = collectAgents( + session([launch('t1', 'A'), toolResult('t1', 'boom', true)]) + ) + expect(bad[0].status).toBe('failed') + expect(bad[0].resultIsError).toBe(true) + }) + + it('builds a step feed from the agent’s child entries', () => { + const agents = collectAgents( + session([ + launch('t1', 'A'), + childWork('t1', [ + { type: 'thinking', text: 'hmm' }, + { type: 'tool_use', id: 'x1', name: 'Read', input: { file_path: '/a' } }, + { type: 'text', text: 'Found it' } + ]) + ]) + ) + expect(agents[0].toolCount).toBe(1) + expect(agents[0].steps.map((s) => s.kind)).toEqual([ + 'thinking', + 'tool', + 'text' + ]) + expect(agents[0].steps[1].input).toEqual({ file_path: '/a' }) + }) + + it('skips empty text and thinking blocks', () => { + const agents = collectAgents( + session([ + launch('t1', 'A'), + childWork('t1', [ + { type: 'text', text: ' ' }, + { type: 'thinking', text: '' } + ]) + ]) + ) + expect(agents[0].steps).toEqual([]) + }) + + it('flags a step whose tool result errored', () => { + const agents = collectAgents( + session([ + launch('t1', 'A'), + childWork('t1', [ + { type: 'tool_use', id: 'x1', name: 'Bash', input: { command: 'nope' } } + ]), + toolResult('x1', 'command not found', true) + ]) + ) + expect(agents[0].steps[0].isError).toBe(true) + }) + + it('nests an agent launched by another agent and records depth', () => { + const agents = collectAgents( + session([ + launch('t1', 'Parent'), + launch('t2', 'Child', { parentToolUseId: 't1', entryId: 'e2', at: 1100 }) + ]) + ) + const child = agents.find((a) => a.toolUseId === 't2')! + const parent = agents.find((a) => a.toolUseId === 't1')! + expect(child.depth).toBe(1) + expect(child.parentToolUseId).toBe('t1') + expect(child.parentDescription).toBe('Parent') + expect(parent.childAgentCount).toBe(1) + expect(parent.depth).toBe(0) + }) + + it('links a nested launch from the parent’s step feed', () => { + const agents = collectAgents( + session([ + launch('t1', 'Parent'), + launch('t2', 'Child', { parentToolUseId: 't1', entryId: 'e2' }) + ]) + ) + const parent = agents.find((a) => a.toolUseId === 't1')! + expect(parent.steps[0].childAgentToolUseId).toBe('t2') + }) + + it('takes a background agent’s status from the lifecycle record, not its result', () => { + const agents = collectAgents( + session( + [ + launch('t1', 'Bg', { background: true }), + // The launch stub resolves immediately and means nothing. + toolResult('t1', 'Async agent launched successfully') + ], + { + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'Bg', + status: 'running', + startedAt: 1000 + } + } + } + ) + ) + expect(agents[0].isBackground).toBe(true) + expect(agents[0].status).toBe('running') + }) + + it('carries a settled background agent’s usage totals', () => { + const agents = collectAgents( + session([launch('t1', 'Bg', { background: true })], { + busy: false, + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'Bg', + status: 'completed', + startedAt: 1000, + completedAt: 9000, + usage: { totalTokens: 12345, toolUses: 7, durationMs: 8000 } + } + } + }) + ) + expect(agents[0].status).toBe('completed') + expect(agents[0].completedAt).toBe(9000) + expect(agents[0].usage?.toolUses).toBe(7) + }) + + it('treats run_in_background as background even before task_started lands', () => { + const agents = collectAgents( + session([launch('t1', 'Bg', { background: true })]) + ) + expect(agents[0].isBackground).toBe(true) + expect(agents[0].status).toBe('running') + }) + + it('lets a later result supersede a background launch stub', () => { + const agents = collectAgents( + session([ + launch('t1', 'Bg', { background: true }), + toolResult('t1', 'launch stub'), + toolResult('t1', 'the real report') + ]) + ) + expect(agents[0].result).toBe('the real report') + }) + + it('bubbles a pending approval up the launch chain', () => { + const agents = collectAgents( + session([ + launch('t1', 'Parent'), + launch('t2', 'Child', { parentToolUseId: 't1', entryId: 'e2' }), + childWork('t2', [ + { type: 'tool_use', id: 'x9', name: 'Bash', input: { command: 'rm' } } + ]) + ]), + new Set(['x9']) + ) + expect(agents.find((a) => a.toolUseId === 't2')!.hasPendingApproval).toBe( + true + ) + expect(agents.find((a) => a.toolUseId === 't1')!.hasPendingApproval).toBe( + true + ) + }) + + it('sorts running agents first (oldest first), then settled newest-first', () => { + const agents = collectAgents( + session([ + launch('t1', 'old running', { at: 100 }), + launch('t2', 'new running', { at: 300, entryId: 'e2' }), + launch('t3', 'old done', { at: 50, entryId: 'e3' }), + launch('t4', 'new done', { at: 200, entryId: 'e4' }), + toolResult('t3', 'x'), + toolResult('t4', 'y') + ]) + ) + expect(agents.map((a) => a.description)).toEqual([ + 'old running', + 'new running', + 'new done', + 'old done' + ]) + }) + + it('counts only running agents', () => { + const agents = collectAgents( + session([ + launch('t1', 'a'), + launch('t2', 'b', { entryId: 'e2' }), + toolResult('t2', 'done') + ]) + ) + expect(countRunningAgents(agents)).toBe(1) + }) +}) + +describe('agentElapsedMs', () => { + it('measures to now while running', () => { + const [a] = collectAgents(session([launch('t1', 'A', { at: 1000 })])) + expect(agentElapsedMs(a, 6000)).toBe(5000) + }) + + it('prefers the usage-reported duration when Claude gave us one', () => { + const [a] = collectAgents( + session([launch('t1', 'A', { at: 1000, background: true })], { + busy: false, + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'A', + status: 'completed', + startedAt: 1000, + completedAt: 99_000, + usage: { durationMs: 4200 } + } + } + }) + ) + expect(agentElapsedMs(a, 500_000)).toBe(4200) + }) + + it('never goes negative on a clock that lags the start timestamp', () => { + const [a] = collectAgents(session([launch('t1', 'A', { at: 5000 })])) + expect(agentElapsedMs(a, 1000)).toBe(0) + }) +}) + +describe('formatElapsed', () => { + it('formats seconds, minutes and hours', () => { + expect(formatElapsed(0)).toBe('0s') + expect(formatElapsed(45_000)).toBe('45s') + expect(formatElapsed(65_000)).toBe('1m 5s') + expect(formatElapsed(3_600_000 + 120_000)).toBe('1h 2m') + }) +}) diff --git a/src/shared/state/json-claude-agents.ts b/src/shared/state/json-claude-agents.ts new file mode 100644 index 000000000..ad9d43ed5 --- /dev/null +++ b/src/shared/state/json-claude-agents.ts @@ -0,0 +1,352 @@ +import type { JsonClaudeChatEntry, JsonClaudeSession } from './json-claude' + +/** Tool names that spawn a sub-agent. Claude Code 2.1.126+ renamed the + * historical "Task" tool to "Agent" but kept routing both names to the + * same subagent runner — match either so this works across versions. */ +export const SUBAGENT_TOOL_NAMES = new Set(['Task', 'Agent']) + +export function isSubAgentToolName(name: string | undefined): boolean { + return !!name && SUBAGENT_TOOL_NAMES.has(name) +} + +/** `interrupted` is not a state Claude reports — it's what we infer when + * an agent has no result and the session is no longer busy. A synchronous + * sub-agent cannot outlive its parent's turn, so "no result, turn over" + * means the run was cut short (tab closed, process killed, /clear). The + * panel has to distinguish it from `running`: leaving a dead agent + * spinning forever is the exact thing an at-a-glance fleet view must not + * do. */ +export type AgentRunStatus = + | 'running' + | 'completed' + | 'failed' + | 'interrupted' + +/** One entry in an agent's activity feed. Deliberately structural — the + * tool name and raw input travel as-is so the renderer can summarize + * them with the existing `tool-display` helpers (icons, action nouns, + * arg extraction) rather than this module duplicating that knowledge. */ +export interface AgentStep { + key: string + kind: 'tool' | 'text' | 'thinking' + /** For kind 'tool'. */ + toolName?: string + /** For kind 'tool'. */ + input?: Record + /** For kind 'tool' — set when this call spawned a nested sub-agent, so + * the feed can link through instead of rendering it as a leaf. */ + childAgentToolUseId?: string + /** For kind 'text' | 'thinking'. Markdown body. */ + text?: string + isError?: boolean + timestamp: number +} + +export interface AgentSummary { + /** tool_use id of the launching Task/Agent call. Unique per session, + * and the key every child entry carries as `parentToolUseId`. */ + toolUseId: string + description: string + subagentType?: string + model?: string + isBackground: boolean + status: AgentRunStatus + startedAt: number + completedAt?: number + /** 0 for an agent the main session launched, 1 for one launched by a + * depth-0 agent, and so on. */ + depth: number + /** tool_use id of the agent that launched this one, when nested. */ + parentToolUseId?: string + /** Description of the launching agent, for the "via …" hint. */ + parentDescription?: string + /** Tool calls this agent made, including its nested Agent calls. */ + toolCount: number + /** Nested agents this one launched. */ + childAgentCount: number + /** Totals from the completion notification's usage block. Background + * agents only — a synchronous sub-agent reports none. */ + usage?: { totalTokens?: number; toolUses?: number; durationMs?: number } + /** True when this agent, or anything below it, is awaiting approval. */ + hasPendingApproval: boolean + /** Chronological activity feed. The peek view renders this directly; + * the panel renders only its last element as the "currently" line. */ + steps: AgentStep[] + /** The agent's final report, once it landed. For a background agent + * this is the real summary — the launch stub has already been + * superseded by `toolResultAttached`. */ + result?: string + resultIsError?: boolean +} + +interface LaunchSite { + toolUseId: string + description: string + subagentType?: string + model?: string + requestedBackground: boolean + startedAt: number + /** The launching entry's own parentToolUseId — i.e. the agent that + * made this call, or undefined for the main session. */ + launchedBy?: string +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +function describeLaunch(input: Record | undefined): string { + return ( + str(input?.description) ?? str(input?.subagent_type) ?? 'Agent' + ) +} + +/** Walks a session's entries once and returns every sub-agent it can see, + * synchronous or background, at any nesting depth. + * + * This is a derived selector, not slice state: everything here comes out + * of `session.entries` + `session.backgroundAgents`, both of which are + * already mirrored to every client. Nothing new needs to go over the + * wire for a second viewer to render the same fleet. + * + * Ordering is **running first** (oldest first, so the longest-running + * sits at the top and doesn't jump as siblings finish), then everything + * else newest-first. A nested agent still carries its `depth` and + * `parentDescription` so the panel can show the relationship without + * needing tree order. */ +export function collectAgents( + session: JsonClaudeSession | null | undefined, + pendingToolUseIds?: ReadonlySet +): AgentSummary[] { + if (!session) return [] + const entries = session.entries + if (!entries || entries.length === 0) return [] + + const launches: LaunchSite[] = [] + const launchById = new Map() + const childEntriesByParent = new Map() + const resultsByToolUseId = new Map< + string, + { content: string; isError: boolean } + >() + + for (const entry of entries) { + const pid = entry.parentToolUseId + if (pid) { + const list = childEntriesByParent.get(pid) + if (list) list.push(entry) + else childEntriesByParent.set(pid, [entry]) + } + if (!entry.blocks) continue + for (const b of entry.blocks) { + if (b.type === 'tool_result' && b.toolUseId) { + // Last result wins: a background agent's launch stub is + // superseded by its real summary under the same tool_use id. + resultsByToolUseId.set(b.toolUseId, { + content: b.content ?? '', + isError: !!b.isError + }) + continue + } + if (b.type !== 'tool_use' || !b.id) continue + if (!isSubAgentToolName(b.name)) continue + const site: LaunchSite = { + toolUseId: b.id, + description: describeLaunch(b.input), + subagentType: str(b.input?.subagent_type), + model: str(b.input?.model), + requestedBackground: b.input?.run_in_background === true, + startedAt: entry.timestamp, + ...(pid ? { launchedBy: pid } : {}) + } + launches.push(site) + launchById.set(site.toolUseId, site) + } + } + + if (launches.length === 0) return [] + + // Depth is resolved by walking up `launchedBy`. The chain is bounded by + // the number of launches, and the `seen` guard keeps a malformed cycle + // (shouldn't happen, but the ids come off a wire) from spinning. + const depthCache = new Map() + const depthOf = (id: string): number => { + const cached = depthCache.get(id) + if (cached !== undefined) return cached + let depth = 0 + let cursor = launchById.get(id)?.launchedBy + const seen = new Set([id]) + while (cursor && launchById.has(cursor) && !seen.has(cursor)) { + seen.add(cursor) + depth += 1 + cursor = launchById.get(cursor)?.launchedBy + } + depthCache.set(id, depth) + return depth + } + + const childAgentCounts = new Map() + for (const site of launches) { + if (!site.launchedBy) continue + childAgentCounts.set( + site.launchedBy, + (childAgentCounts.get(site.launchedBy) ?? 0) + 1 + ) + } + + const summaries: AgentSummary[] = launches.map((site) => { + const childEntries = childEntriesByParent.get(site.toolUseId) ?? [] + const steps: AgentStep[] = [] + let toolCount = 0 + let hasPendingApproval = false + + for (const entry of childEntries) { + if (!entry.blocks) continue + for (let i = 0; i < entry.blocks.length; i++) { + const b = entry.blocks[i] + const key = `${entry.entryId}-${b.id ?? b.toolUseId ?? i}` + if (b.type === 'tool_use') { + toolCount += 1 + if (b.id && pendingToolUseIds?.has(b.id)) hasPendingApproval = true + const nested = + isSubAgentToolName(b.name) && b.id && launchById.has(b.id) + ? b.id + : undefined + steps.push({ + key, + kind: 'tool', + toolName: b.name, + ...(b.input ? { input: b.input } : {}), + ...(nested ? { childAgentToolUseId: nested } : {}), + ...(b.id && resultsByToolUseId.get(b.id)?.isError + ? { isError: true } + : {}), + timestamp: entry.timestamp + }) + continue + } + if (b.type === 'text' || b.type === 'thinking') { + const text = b.text?.trim() + if (!text) continue + steps.push({ + key, + kind: b.type, + text, + timestamp: entry.timestamp + }) + } + } + } + + const result = resultsByToolUseId.get(site.toolUseId) + const bg = session.backgroundAgents?.[site.toolUseId] + const isBackground = !!bg || site.requestedBackground + + let status: AgentRunStatus + let completedAt: number | undefined + if (isBackground) { + // The task_started / task_notification pair is the only honest + // lifecycle for a background agent — its tool_result resolves + // immediately with a launch stub, so `result` means nothing here. + if (bg) { + status = bg.status + completedAt = bg.completedAt + } else if (session.busy) { + status = 'running' + } else { + // Seeded from a transcript that predates the notification, or + // the process died before it arrived. + status = result ? 'completed' : 'interrupted' + } + } else if (result) { + status = result.isError ? 'failed' : 'completed' + // A synchronous agent has no completion event of its own; the + // arrival of its result is the best timestamp available, and the + // last child entry is the closest proxy for that. + completedAt = childEntries.length + ? childEntries[childEntries.length - 1].timestamp + : undefined + } else { + status = session.busy ? 'running' : 'interrupted' + } + + const parent = site.launchedBy + ? launchById.get(site.launchedBy) + : undefined + + return { + toolUseId: site.toolUseId, + description: site.description, + ...(site.subagentType ? { subagentType: site.subagentType } : {}), + ...(site.model ? { model: site.model } : {}), + isBackground, + status, + startedAt: site.startedAt, + ...(completedAt !== undefined ? { completedAt } : {}), + depth: depthOf(site.toolUseId), + ...(parent + ? { + parentToolUseId: parent.toolUseId, + parentDescription: parent.description + } + : {}), + toolCount, + childAgentCount: childAgentCounts.get(site.toolUseId) ?? 0, + ...(bg?.usage ? { usage: bg.usage } : {}), + hasPendingApproval, + steps, + ...(result ? { result: result.content, resultIsError: result.isError } : {}) + } + }) + + // Pending approvals bubble up the launch chain — a parent whose + // descendant is blocked is itself blocked, and that's what the panel + // needs to surface. + const byId = new Map(summaries.map((s) => [s.toolUseId, s])) + for (const s of summaries) { + if (!s.hasPendingApproval) continue + let cursor = s.parentToolUseId + const seen = new Set([s.toolUseId]) + while (cursor && !seen.has(cursor)) { + seen.add(cursor) + const parent = byId.get(cursor) + if (!parent) break + parent.hasPendingApproval = true + cursor = parent.parentToolUseId + } + } + + return summaries.sort((a, b) => { + const aRunning = a.status === 'running' + const bRunning = b.status === 'running' + if (aRunning !== bRunning) return aRunning ? -1 : 1 + // Running: oldest first, so the longest-running pins to the top and + // doesn't reshuffle as siblings settle. Settled: newest first. + return aRunning ? a.startedAt - b.startedAt : b.startedAt - a.startedAt + }) +} + +export function countRunningAgents(agents: AgentSummary[]): number { + let n = 0 + for (const a of agents) if (a.status === 'running') n += 1 + return n +} + +/** Elapsed wall time for an agent, in ms. Uses the usage-reported + * duration when Claude gave us one (background agents), since that's + * measured inside the agent rather than across our event plumbing. */ +export function agentElapsedMs(agent: AgentSummary, now: number): number { + if (agent.usage?.durationMs !== undefined) return agent.usage.durationMs + const end = agent.status === 'running' ? now : (agent.completedAt ?? now) + return Math.max(0, end - agent.startedAt) +} + +export function formatElapsed(ms: number): string { + const totalSec = Math.floor(ms / 1000) + if (totalSec < 60) return `${totalSec}s` + const min = Math.floor(totalSec / 60) + const sec = totalSec % 60 + if (min < 60) return `${min}m ${sec}s` + const hr = Math.floor(min / 60) + return `${hr}h ${min % 60}m` +} diff --git a/src/shared/state/json-claude-tasks.test.ts b/src/shared/state/json-claude-tasks.test.ts new file mode 100644 index 000000000..07eb460fe --- /dev/null +++ b/src/shared/state/json-claude-tasks.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect } from 'vitest' +import type { + JsonClaudeChatEntry, + JsonClaudeSession +} from './json-claude' +import { + collectTasks, + countOpenTasks, + isTaskBlocked, + type TaskRecord +} from './json-claude-tasks' + +let seq = 0 + +function session(entries: JsonClaudeChatEntry[]): JsonClaudeSession { + return { + sessionId: 's1', + worktreePath: '/tmp/wt', + state: 'running', + exitCode: null, + exitReason: null, + entries, + entriesHydrated: true, + busy: false, + permissionMode: 'default', + slashCommands: [], + autoApprovedDecisions: {}, + sessionToolApprovals: [], + sessionAllowedDecisions: {}, + backgroundAgents: {} + } as JsonClaudeSession +} + +function call( + name: string, + input: Record, + toolUseId?: string +): JsonClaudeChatEntry { + seq += 1 + return { + entryId: `e${seq}`, + kind: 'assistant', + timestamp: seq, + blocks: [ + { type: 'tool_use', id: toolUseId ?? `tu${seq}`, name, input } + ] + } +} + +function created(toolUseId: string, id: string, subject = 'x'): JsonClaudeChatEntry { + seq += 1 + return { + entryId: `r${seq}`, + kind: 'tool_result', + timestamp: seq, + blocks: [ + { + type: 'tool_result', + toolUseId, + content: `Task #${id} created successfully: ${subject}` + } + ] + } +} + +describe('collectTasks', () => { + it('returns nothing without a session or without Task tool calls', () => { + expect(collectTasks(null)).toEqual([]) + expect(collectTasks(session([]))).toEqual([]) + expect( + collectTasks(session([call('TodoWrite', { todos: [] })])) + ).toEqual([]) + }) + + it('takes the created id from the tool result', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'Do a thing', description: 'detail' }, 'tu-a'), + created('tu-a', '7', 'Do a thing') + ]) + ) + expect(tasks).toHaveLength(1) + expect(tasks[0].id).toBe('7') + expect(tasks[0].subject).toBe('Do a thing') + expect(tasks[0].description).toBe('detail') + expect(tasks[0].status).toBe('pending') + }) + + it('falls back to create order while the result is still in flight', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'First', description: '' }), + call('TaskCreate', { subject: 'Second', description: '' }) + ]) + ) + expect(tasks.map((t) => [t.id, t.subject])).toEqual([ + ['1', 'First'], + ['2', 'Second'] + ]) + }) + + it('applies a status update', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskUpdate', { taskId: '1', status: 'in_progress' }) + ]) + ) + expect(tasks[0].status).toBe('in_progress') + }) + + it('keeps the latest of several updates to the same task', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskUpdate', { taskId: '1', status: 'in_progress' }), + call('TaskUpdate', { taskId: '1', status: 'completed' }) + ]) + ) + expect(tasks[0].status).toBe('completed') + }) + + it('applies subject / description / activeForm / owner edits', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: 'old' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskUpdate', { + taskId: '1', + subject: 'B', + description: 'new', + activeForm: 'Bing', + owner: 'explorer' + }) + ]) + ) + expect(tasks[0]).toMatchObject({ + subject: 'B', + description: 'new', + activeForm: 'Bing', + owner: 'explorer' + }) + }) + + it('removes a task set to deleted', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskCreate', { subject: 'B', description: '' }, 'tu-b'), + created('tu-b', '2', 'B'), + call('TaskUpdate', { taskId: '1', status: 'deleted' }) + ]) + ) + expect(tasks.map((t) => t.id)).toEqual(['2']) + }) + + it('mirrors addBlockedBy onto the blocker’s blocks list', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskCreate', { subject: 'B', description: '' }, 'tu-b'), + created('tu-b', '2', 'B'), + call('TaskUpdate', { taskId: '2', addBlockedBy: ['1'] }) + ]) + ) + expect(tasks.find((t) => t.id === '2')!.blockedBy).toEqual(['1']) + expect(tasks.find((t) => t.id === '1')!.blocks).toEqual(['2']) + }) + + it('mirrors addBlocks onto the blocked task’s blockedBy list', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskCreate', { subject: 'B', description: '' }, 'tu-b'), + created('tu-b', '2', 'B'), + call('TaskUpdate', { taskId: '1', addBlocks: ['2'] }) + ]) + ) + expect(tasks.find((t) => t.id === '2')!.blockedBy).toEqual(['1']) + expect(tasks.find((t) => t.id === '1')!.blocks).toEqual(['2']) + }) + + it('resolves an edge declared before the other task was created', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskUpdate', { taskId: '1', addBlocks: ['2'] }), + call('TaskCreate', { subject: 'B', description: '' }, 'tu-b'), + created('tu-b', '2', 'B') + ]) + ) + expect(tasks.find((t) => t.id === '2')!.blockedBy).toEqual(['1']) + }) + + it('stubs a task whose create was truncated out of the buffer', () => { + const tasks = collectTasks( + session([call('TaskUpdate', { taskId: '4', status: 'in_progress' })]) + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: '4', status: 'in_progress' }) + }) + + it('sorts numerically, not lexicographically', () => { + const entries: JsonClaudeChatEntry[] = [] + for (let i = 1; i <= 11; i++) { + entries.push(call('TaskCreate', { subject: `T${i}`, description: '' })) + } + const tasks = collectTasks(session(entries)) + expect(tasks.map((t) => t.id)).toEqual([ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11' + ]) + }) +}) + +describe('isTaskBlocked', () => { + const make = (over: Partial): TaskRecord => ({ + id: '1', + subject: 's', + description: '', + status: 'pending', + blocks: [], + blockedBy: [], + ...over + }) + + it('is false with no dependencies', () => { + expect(isTaskBlocked(make({}), new Map())).toBe(false) + }) + + it('is true while a blocker is still open', () => { + const blocker = make({ id: '1', status: 'in_progress' }) + const blocked = make({ id: '2', blockedBy: ['1'] }) + const byId = new Map([ + ['1', blocker], + ['2', blocked] + ]) + expect(isTaskBlocked(blocked, byId)).toBe(true) + }) + + it('is false once every blocker completed', () => { + const blocker = make({ id: '1', status: 'completed' }) + const blocked = make({ id: '2', blockedBy: ['1'] }) + const byId = new Map([ + ['1', blocker], + ['2', blocked] + ]) + expect(isTaskBlocked(blocked, byId)).toBe(false) + }) + + it('treats an unknown blocker as still open', () => { + const blocked = make({ id: '2', blockedBy: ['99'] }) + expect(isTaskBlocked(blocked, new Map([['2', blocked]]))).toBe(true) + }) + + it('never reports a task that already started as blocked', () => { + const blocker = make({ id: '1', status: 'pending' }) + const started = make({ id: '2', status: 'in_progress', blockedBy: ['1'] }) + const byId = new Map([ + ['1', blocker], + ['2', started] + ]) + expect(isTaskBlocked(started, byId)).toBe(false) + }) +}) + +describe('countOpenTasks', () => { + it('counts everything that is not completed', () => { + const tasks = collectTasks( + session([ + call('TaskCreate', { subject: 'A', description: '' }, 'tu-a'), + created('tu-a', '1', 'A'), + call('TaskCreate', { subject: 'B', description: '' }, 'tu-b'), + created('tu-b', '2', 'B'), + call('TaskUpdate', { taskId: '1', status: 'completed' }) + ]) + ) + expect(countOpenTasks(tasks)).toBe(1) + }) +}) diff --git a/src/shared/state/json-claude-tasks.ts b/src/shared/state/json-claude-tasks.ts new file mode 100644 index 000000000..e85ae0705 --- /dev/null +++ b/src/shared/state/json-claude-tasks.ts @@ -0,0 +1,201 @@ +import type { JsonClaudeSession } from './json-claude' + +/** The Tasks tool family (Claude Code 2.1.x) — the structured successor + * to TodoWrite. Unlike TodoWrite, which rewrites the whole array on + * every call, these mutate one task at a time, so the list has to be + * replayed rather than read off the last call. */ +export const TASK_TOOL_NAMES = new Set([ + 'TaskCreate', + 'TaskUpdate', + 'TaskList', + 'TaskGet' +]) + +export type TaskStatus = 'pending' | 'in_progress' | 'completed' + +export interface TaskRecord { + id: string + subject: string + description: string + /** Present-continuous label, shown instead of `subject` while the task + * is in_progress — that's the field's whole purpose. */ + activeForm?: string + status: TaskStatus + /** Agent that claimed the task. Populated when a team or a named + * background agent is working the list, which is where this view + * earns its keep over a flat todo list. */ + owner?: string + /** Tasks that cannot start until this one completes. */ + blocks: string[] + /** Tasks that must complete before this one can start. */ + blockedBy: string[] +} + +/** `Task #3 created successfully: Build the thing` — the id is minted + * server-side and only comes back in the tool_result, so this is the + * authoritative source for a created task's id. */ +const CREATE_RESULT_ID = /^Task #(\d+) created/ + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +function asStatus(v: unknown): TaskStatus | 'deleted' | undefined { + return v === 'pending' || + v === 'in_progress' || + v === 'completed' || + v === 'deleted' + ? v + : undefined +} + +function asIds(v: unknown): string[] { + if (!Array.isArray(v)) return [] + return v.filter((x): x is string => typeof x === 'string' && x.length > 0) +} + +function link( + tasks: Map, + blockerId: string, + blockedId: string +): void { + // Mirror both directions: `addBlocks` on one task and `addBlockedBy` on + // the other describe the same edge, and only one of them gets called. + const blocker = tasks.get(blockerId) + if (blocker && !blocker.blocks.includes(blockedId)) { + blocker.blocks.push(blockedId) + } + const blocked = tasks.get(blockedId) + if (blocked && !blocked.blockedBy.includes(blockerId)) { + blocked.blockedBy.push(blockerId) + } +} + +/** Replays a session's TaskCreate / TaskUpdate calls into the current + * task list, in id order. + * + * Caveat worth knowing: this can only see what's still in `entries`. A + * task created before a compaction boundary that got truncated out of + * the buffer won't appear — the same limitation `getLatestTodos` has + * always had with TodoWrite. The on-disk truth lives in + * `~/.claude/tasks//`, which the renderer has no reader for; + * if the gap ever bites, that directory is the fix, not more replay. */ +export function collectTasks( + session: JsonClaudeSession | null | undefined +): TaskRecord[] { + if (!session?.entries?.length) return [] + + // tool_use id → the created task's server id, recovered from the result. + const resultsByToolUseId = new Map() + for (const entry of session.entries) { + if (!entry.blocks) continue + for (const b of entry.blocks) { + if (b.type !== 'tool_result' || !b.toolUseId || !b.content) continue + const m = CREATE_RESULT_ID.exec(b.content) + if (m) resultsByToolUseId.set(b.toolUseId, m[1]) + } + } + + const tasks = new Map() + const deleted = new Set() + // Deferred because an `addBlockedBy` can name a task that hasn't been + // created yet at that point in the replay. + const edges: Array<[blocker: string, blocked: string]> = [] + let createOrdinal = 0 + let sawTaskTool = false + + for (const entry of session.entries) { + if (!entry.blocks) continue + for (const b of entry.blocks) { + if (b.type !== 'tool_use') continue + if (b.name === 'TaskCreate') { + sawTaskTool = true + createOrdinal += 1 + // Ids are assigned sequentially from 1 in create order, so the + // ordinal is a sound fallback while a just-issued create is still + // waiting on its result. + const id = + (b.id ? resultsByToolUseId.get(b.id) : undefined) ?? + String(createOrdinal) + if (deleted.has(id)) continue + const existing = tasks.get(id) + const record: TaskRecord = { + id, + subject: str(b.input?.subject) ?? `Task ${id}`, + description: str(b.input?.description) ?? '', + ...(str(b.input?.activeForm) + ? { activeForm: str(b.input?.activeForm) } + : {}), + status: 'pending', + blocks: existing?.blocks ?? [], + blockedBy: existing?.blockedBy ?? [] + } + tasks.set(id, record) + continue + } + if (b.name !== 'TaskUpdate') continue + sawTaskTool = true + const id = str(b.input?.taskId) + if (!id) continue + const status = asStatus(b.input?.status) + if (status === 'deleted') { + tasks.delete(id) + deleted.add(id) + continue + } + // An update can land for a task whose create was truncated out of + // the buffer. Materialize a stub rather than dropping the row — + // "task 3 is in progress" is still worth showing. + const record = + tasks.get(id) ?? + ({ + id, + subject: `Task ${id}`, + description: '', + status: 'pending', + blocks: [], + blockedBy: [] + } satisfies TaskRecord) + if (status) record.status = status + const subject = str(b.input?.subject) + if (subject) record.subject = subject + const description = str(b.input?.description) + if (description) record.description = description + const activeForm = str(b.input?.activeForm) + if (activeForm) record.activeForm = activeForm + const owner = str(b.input?.owner) + if (owner) record.owner = owner + for (const other of asIds(b.input?.addBlocks)) edges.push([id, other]) + for (const other of asIds(b.input?.addBlockedBy)) edges.push([other, id]) + tasks.set(id, record) + } + } + + if (!sawTaskTool) return [] + for (const [blocker, blocked] of edges) link(tasks, blocker, blocked) + + return [...tasks.values()].sort( + (a, b) => (Number(a.id) || 0) - (Number(b.id) || 0) + ) +} + +/** True when a task can't be started yet because something it depends on + * is still open. A completed blocker doesn't block. */ +export function isTaskBlocked( + task: TaskRecord, + byId: Map +): boolean { + if (task.status !== 'pending') return false + return task.blockedBy.some((id) => { + const blocker = byId.get(id) + // An unknown blocker is treated as still open — safer than claiming + // the task is ready when we can't see what it's waiting on. + return !blocker || blocker.status !== 'completed' + }) +} + +export function countOpenTasks(tasks: TaskRecord[]): number { + let n = 0 + for (const t of tasks) if (t.status !== 'completed') n += 1 + return n +} diff --git a/src/shared/state/repo-configs.test.ts b/src/shared/state/repo-configs.test.ts index a46277d80..43c9d0f4f 100644 --- a/src/shared/state/repo-configs.test.ts +++ b/src/shared/state/repo-configs.test.ts @@ -126,6 +126,7 @@ describe('repoConfigsReducer', () => { 'pr', 'merge', 'todos', + 'agents', 'commits', 'changedFiles', 'allFiles', @@ -138,6 +139,7 @@ describe('repoConfigsReducer', () => { 'pr', 'merge', 'todos', + 'agents', 'commits', 'changedFiles', 'allFiles', @@ -160,6 +162,7 @@ describe('repoConfigsReducer', () => { 'changedFiles', 'allFiles', 'todos', + 'agents', 'context', 'scratchpad' ]) diff --git a/src/shared/state/repo-configs.ts b/src/shared/state/repo-configs.ts index aeef14942..15f62a971 100644 --- a/src/shared/state/repo-configs.ts +++ b/src/shared/state/repo-configs.ts @@ -2,6 +2,7 @@ export type BuiltinRightPanelKey = | 'merge' | 'pr' | 'todos' + | 'agents' | 'commits' | 'changedFiles' | 'allFiles' @@ -30,6 +31,7 @@ export const DEFAULT_RIGHT_PANEL_ORDER: BuiltinRightPanelKey[] = [ 'changedFiles', 'allFiles', 'todos', + 'agents', 'cost', 'context', 'scratchpad' From 2cf6a538713e99f95b07fe343017a0888a1c1ed5 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Sun, 11 Oct 2026 14:09:02 -0400 Subject: [PATCH 2/7] perf(chat): stop rebuilding settled agents, collapse old ones in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents list is never purged — it grows for the life of a session — and the panel re-derives on a 500ms tick while anything is live. So every tick was rebuilding the step feed of every agent the session ever launched, including ones that finished long ago and can never change. Allocation churn rather than a leak, but it scales with session length and it's the renderer-side GC churn main-process telemetry can't see. collectAgents now takes the caller's previous result and hands settled agents straight back by reference, skipping their step rebuild and keeping object identity stable so React skips those rows too. Reuse is refused for a settled agent whose result hasn't landed yet, because task_notification dispatches the status and the real summary as two events — caching in between would pin the launch stub as the report forever. The approval-bubbling pass no longer assigns onto summaries in place: a reused entry is the same object the caller still holds, so mutating it would write through to their copy. It computes a set and rebuilds only the entries whose value actually changed. This is a live path, not a hypothetical — a background child outlives its parent's turn, so a finished parent can still acquire a pending approval from below. Panel-side, finished agents now collapse behind a "show N more" toggle after the first five. They're history — the transcript keeps their Task cards — and dozens of them would push the live agents the panel exists to surface off the top. --- .../components/JsonClaudeAgentsPanel.tsx | 49 +++++++- src/shared/state/json-claude-agents.test.ts | 109 ++++++++++++++++++ src/shared/state/json-claude-agents.ts | 52 ++++++++- 3 files changed, 200 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/JsonClaudeAgentsPanel.tsx b/src/renderer/components/JsonClaudeAgentsPanel.tsx index 92014a503..bdade3dfe 100644 --- a/src/renderer/components/JsonClaudeAgentsPanel.tsx +++ b/src/renderer/components/JsonClaudeAgentsPanel.tsx @@ -24,6 +24,9 @@ import { AgentStepLine } from './AgentStepLine' * buys nothing, and the cap holds regardless of token rate. */ const LIVE_REFRESH_MS = 500 +/** Finished agents shown before collapsing the rest behind a toggle. */ +const SETTLED_PREVIEW = 5 + /** One clock for the whole panel. Elapsed times are the only thing here * that changes without a state event, so they get a single shared tick * rather than a timer per row — and it only runs while something is @@ -106,6 +109,7 @@ export function JsonClaudeAgentsPanel({ const session = useJsonClaudeSession(focusedTabId ?? '') const pendingApprovals = useJsonClaudePendingApprovals() const [peekId, setPeekId] = useState(null) + const [showAllSettled, setShowAllSettled] = useState(false) const pendingToolUseIds = useMemo(() => { const ids = new Set() @@ -129,11 +133,21 @@ export function JsonClaudeAgentsPanel({ const sessionId = session?.sessionId const [tick, setTick] = useState(0) - const agents = useMemo( - () => collectAgents(sessionRef.current, pendingToolUseIds), + // Feeding the last result back lets settled agents be handed through + // by reference — see collectAgents. Without it, every tick rebuilds + // the step feed of every agent the session ever launched, and that + // list only grows. + const prevAgentsRef = useRef([]) + const agents = useMemo(() => { + const next = collectAgents( + sessionRef.current, + pendingToolUseIds, + prevAgentsRef.current + ) + prevAgentsRef.current = next + return next // eslint-disable-next-line react-hooks/exhaustive-deps - [sessionId, tick, entryCount, pendingToolUseIds] - ) + }, [sessionId, tick, entryCount, pendingToolUseIds]) const running = countRunningAgents(agents) // A background agent outlives its parent's turn, so `busy` alone would // stop ticking while one is still working. @@ -154,6 +168,18 @@ export function JsonClaudeAgentsPanel({ if (agents.length === 0) return null + // `agents` is sorted running-first, so this is a split, not a filter + // pass. Finished agents are history — the transcript keeps their Task + // cards — and a long session accumulates dozens, so only the most + // recent few are shown by default. Without this the live agents the + // panel exists to surface get pushed off the top. + const settledFrom = agents.findIndex((a) => a.status !== 'running') + const settledCount = settledFrom === -1 ? 0 : agents.length - settledFrom + const hiddenCount = showAllSettled + ? 0 + : Math.max(0, settledCount - SETTLED_PREVIEW) + const shown = hiddenCount > 0 ? agents.slice(0, running + SETTLED_PREVIEW) : agents + return ( <>
      - {agents.map((a) => ( + {shown.map((a) => ( setPeekId(a.toolUseId)} /> ))} + {(hiddenCount > 0 || showAllSettled) && settledCount > SETTLED_PREVIEW && ( +
    • + +
    • + )}
    {peeked && ( diff --git a/src/shared/state/json-claude-agents.test.ts b/src/shared/state/json-claude-agents.test.ts index e3cb0657d..d433b6a74 100644 --- a/src/shared/state/json-claude-agents.test.ts +++ b/src/shared/state/json-claude-agents.test.ts @@ -341,6 +341,115 @@ describe('collectAgents', () => { }) }) +describe('collectAgents reuse of a previous result', () => { + it('hands a settled agent back by reference instead of rebuilding it', () => { + const entries = [ + launch('t1', 'A'), + childWork('t1', [ + { type: 'tool_use', id: 'x1', name: 'Read', input: { file_path: '/a' } } + ]), + toolResult('t1', 'done') + ] + const first = collectAgents(session(entries)) + const second = collectAgents(session(entries), undefined, first) + expect(second[0]).toBe(first[0]) + expect(second[0].steps).toBe(first[0].steps) + }) + + it('still rebuilds a running agent so its step feed stays live', () => { + const before = [launch('t1', 'A')] + const first = collectAgents(session(before)) + const after = [ + ...before, + childWork('t1', [ + { type: 'tool_use', id: 'x1', name: 'Read', input: { file_path: '/a' } } + ]) + ] + const second = collectAgents(session(after), undefined, first) + expect(second[0]).not.toBe(first[0]) + expect(second[0].steps).toHaveLength(1) + }) + + it('does not reuse a settled background agent whose result has not landed', () => { + const bg = { + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'Bg', + status: 'completed' as const, + startedAt: 1000 + } + } + } + // task_notification dispatches the settled status and the real + // summary separately; a snapshot between them must not be cached. + const before = [launch('t1', 'Bg', { background: true })] + const first = collectAgents(session(before, bg)) + expect(first[0].result).toBeUndefined() + + const after = [...before, toolResult('t1', 'the real report')] + const second = collectAgents(session(after, bg), undefined, first) + expect(second[0].result).toBe('the real report') + }) + + it('picks up an approval raised against a settled parent without mutating it', () => { + // A background child outlives its parent's turn, so a finished + // parent can still acquire a pending approval from below. + const before = [ + launch('t1', 'Parent'), + toolResult('t1', 'parent done'), + launch('t2', 'Child', { + parentToolUseId: 't1', + entryId: 'e2', + background: true + }) + ] + const first = collectAgents(session(before)) + const parentBefore = first.find((a) => a.toolUseId === 't1')! + expect(parentBefore.hasPendingApproval).toBe(false) + + const after = [ + ...before, + childWork('t2', [ + { type: 'tool_use', id: 'x9', name: 'Bash', input: { command: 'rm' } } + ]) + ] + const second = collectAgents(session(after), new Set(['x9']), first) + expect(second.find((a) => a.toolUseId === 't1')!.hasPendingApproval).toBe( + true + ) + // The cached object the caller still holds must not have been + // written through. + expect(parentBefore.hasPendingApproval).toBe(false) + }) + + it('drops agents that a rewind removed from entries', () => { + const before = [ + launch('t1', 'A'), + toolResult('t1', 'done'), + launch('t2', 'B', { entryId: 'e2' }), + toolResult('t2', 'done') + ] + const first = collectAgents(session(before)) + expect(first).toHaveLength(2) + const second = collectAgents(session(before.slice(0, 2)), undefined, first) + expect(second.map((a) => a.toolUseId)).toEqual(['t1']) + }) + + it('ignores a previous result from a different session', () => { + const first = collectAgents( + session([launch('t1', 'A'), toolResult('t1', 'done')]) + ) + const other = collectAgents( + session([launch('zz', 'Other')]), + undefined, + first + ) + expect(other.map((a) => a.toolUseId)).toEqual(['zz']) + }) +}) + describe('agentElapsedMs', () => { it('measures to now while running', () => { const [a] = collectAgents(session([launch('t1', 'A', { at: 1000 })])) diff --git a/src/shared/state/json-claude-agents.ts b/src/shared/state/json-claude-agents.ts index ad9d43ed5..419b2dd2e 100644 --- a/src/shared/state/json-claude-agents.ts +++ b/src/shared/state/json-claude-agents.ts @@ -113,15 +113,40 @@ function describeLaunch(input: Record | undefined): string { * sits at the top and doesn't jump as siblings finish), then everything * else newest-first. A nested agent still carries its `depth` and * `parentDescription` so the panel can show the relationship without - * needing tree order. */ + * needing tree order. + * + * `previous` is the caller's last result, and exists because this runs + * on a timer while anything is live. A settled agent's steps are + * immutable, so rebuilding them every tick is pure allocation churn + * that grows with session length — the list of agents is never purged. + * Passing the previous result back lets settled entries be handed + * through by reference instead, which also keeps their object identity + * stable so React skips re-rendering those rows. Omit it and the + * function is exactly as it was: a full, self-contained derivation. */ export function collectAgents( session: JsonClaudeSession | null | undefined, - pendingToolUseIds?: ReadonlySet + pendingToolUseIds?: ReadonlySet, + previous?: readonly AgentSummary[] ): AgentSummary[] { if (!session) return [] const entries = session.entries if (!entries || entries.length === 0) return [] + // Only agents that have genuinely finished are reusable. The result + // check matters for a background agent: `task_notification` dispatches + // the settled status and the real result as two events, so a snapshot + // taken between them would otherwise cache a completed agent with the + // launch stub as its report, forever. + const reusable = new Map() + if (previous) { + for (const p of previous) { + if (p.status === 'running') continue + if (p.result !== undefined || p.status === 'interrupted') { + reusable.set(p.toolUseId, p) + } + } + } + const launches: LaunchSite[] = [] const launchById = new Map() const childEntriesByParent = new Map() @@ -195,6 +220,11 @@ export function collectAgents( } const summaries: AgentSummary[] = launches.map((site) => { + // Settled last time, and still present in entries — nothing about it + // can have changed, so skip rebuilding its step feed entirely. + const cached = reusable.get(site.toolUseId) + if (cached) return cached + const childEntries = childEntriesByParent.get(site.toolUseId) ?? [] const steps: AgentStep[] = [] let toolCount = 0 @@ -301,22 +331,34 @@ export function collectAgents( // Pending approvals bubble up the launch chain — a parent whose // descendant is blocked is itself blocked, and that's what the panel - // needs to surface. + // needs to surface. Computed into a set and then applied, rather than + // assigned onto the summaries in place: a reused entry is the *same + // object* the caller is still holding, so mutating it would write + // through to the previous result and defeat the identity check below. + // (A settled parent can still acquire one this way — a background + // child outlives its parent's turn.) const byId = new Map(summaries.map((s) => [s.toolUseId, s])) + const blocked = new Set() for (const s of summaries) { if (!s.hasPendingApproval) continue + blocked.add(s.toolUseId) let cursor = s.parentToolUseId const seen = new Set([s.toolUseId]) while (cursor && !seen.has(cursor)) { seen.add(cursor) const parent = byId.get(cursor) if (!parent) break - parent.hasPendingApproval = true + blocked.add(cursor) cursor = parent.parentToolUseId } } - return summaries.sort((a, b) => { + const resolved = summaries.map((s) => { + const want = blocked.has(s.toolUseId) + return s.hasPendingApproval === want ? s : { ...s, hasPendingApproval: want } + }) + + return resolved.sort((a, b) => { const aRunning = a.status === 'running' const bRunning = b.status === 'running' if (aRunning !== bRunning) return aRunning ? -1 : 1 From 74be7df157680fcf63b314d0ff4cef1b490dccb3 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Sun, 11 Oct 2026 14:17:20 -0400 Subject: [PATCH 3/7] fix(chat): stop labelling every sub-agent "background", flatten the agent list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code emits system/task_started for EVERY sub-agent, synchronous ones included. Both the Agents panel and the transcript's Task card treated the presence of that lifecycle record as proof of detachment, so every single agent got a "background" badge — verified against a session whose seven Agent calls all passed no run_in_background at all. On the Task card it also drove the spinner off the wrong lifecycle. Only the caller's own run_in_background decides it now. The record is still used for usage totals and completion time, which means synchronous agents gain token/tool/duration figures they never showed. The panel also listed nested agents inline, indented. Sorting is by status then recency, so a child routinely sorted *above* its parent: an indented row pointing at nothing, promising a tree the ordering doesn't deliver. The list is flat now — top-level agents, plus anything running at any depth so nothing live is ever hidden — and a parent shows a nested count instead. A running nested agent names its parent on its secondary line. Full hierarchy stays in the peek, which links through. Also: rows carry a tooltip explaining what the bare duration means, and the header reads "N running" / "N done" rather than an unlabelled count. --- .../components/JsonClaudeAgentsPanel.tsx | 67 ++++++++++++++----- .../components/json-mode-cards/TaskCard.tsx | 18 +++-- src/shared/state/json-claude-agents.test.ts | 26 +++++++ src/shared/state/json-claude-agents.ts | 20 ++++-- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/JsonClaudeAgentsPanel.tsx b/src/renderer/components/JsonClaudeAgentsPanel.tsx index bdade3dfe..a06a2588e 100644 --- a/src/renderer/components/JsonClaudeAgentsPanel.tsx +++ b/src/renderer/components/JsonClaudeAgentsPanel.tsx @@ -13,7 +13,7 @@ import { } from '../../shared/state/json-claude-agents' import { RightPanel } from './RightPanel' import { AgentPeek } from './AgentPeek' -import { AgentStatusDot } from './AgentStatusDot' +import { AgentStatusDot, agentStatusLabel } from './AgentStatusDot' import { AgentStepLine } from './AgentStepLine' /** How often the panel re-derives while anything is live. `collectAgents` @@ -55,19 +55,25 @@ function AgentRow({ ? agent.steps[agent.steps.length - 1] : null const running = agent.status === 'running' + const elapsed = formatElapsed(agentElapsedMs(agent, now)) + const kind = agent.subagentType ? `${agent.subagentType} agent` : 'agent' return (
  • + {expanded && ( +
    + {agent.steps.length === 0 ? ( +
    + {running ? 'starting…' : 'no activity recorded'} +
    + ) : ( + <> + {hiddenSteps > 0 && ( +
    + +{hiddenSteps} earlier{' '} + {hiddenSteps === 1 ? 'step' : 'steps'} +
    + )} + {tail.map((step) => ( +
    + +
    + ))} + + )} + {agent.result && !running && ( +
    + {agent.result.length > INLINE_RESULT + ? `${agent.result.slice(0, INLINE_RESULT).trimEnd()}…` + : agent.result} +
    + )} + +
    + )}
  • ) } @@ -135,6 +198,17 @@ export function JsonClaudeAgentsPanel({ const pendingApprovals = useJsonClaudePendingApprovals() const [peekId, setPeekId] = useState(null) const [showAllSettled, setShowAllSettled] = useState(false) + // A Set rather than a single id: comparing two running agents + // side-by-side is the main reason to expand one at all. + const [expandedIds, setExpandedIds] = useState>( + () => new Set() + ) + const toggleExpanded = (id: string): void => + setExpandedIds((cur) => { + const next = new Set(cur) + if (!next.delete(id)) next.add(id) + return next + }) const pendingToolUseIds = useMemo(() => { const ids = new Set() @@ -217,7 +291,9 @@ export function JsonClaudeAgentsPanel({ 0 ? 'max-h-96' : 'max-h-64'} actions={ // A bare count reads as nothing in particular. The panel is // about what's live, so say so — and when nothing is, say that @@ -233,6 +309,8 @@ export function JsonClaudeAgentsPanel({ key={a.toolUseId} agent={a} now={now} + expanded={expandedIds.has(a.toolUseId)} + onToggle={() => toggleExpanded(a.toolUseId)} onPeek={() => setPeekId(a.toolUseId)} /> ))} From 2c12870b4d7f95d78519aca3ffd1564ce7d4f042 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Sun, 11 Oct 2026 14:44:15 -0400 Subject: [PATCH 5/7] feat(chat): make the agent disclosure a tree, keep click for history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chevron now reveals an agent's nested agents rather than its step feed, and clicking the row still opens its history in the peek — the file-tree split, where disclosure is structure and the label is the thing. Replaces the previous reading, where ▸ expanded an inline step list and the peek had to be reached through a link. This also retires the flatten-the-list workaround. Nested agents were being hoisted to the top level because sorting by status then recency put a child above its parent, leaving an indented row pointing at nothing. Rendering an actual tree means a child sits under its parent by construction, so the indent finally means what it looks like, and nothing has to be hidden to keep the ordering honest. buildAgentTree regroups the flat result, preserving running-first order within each sibling group and promoting an orphan (parent truncated out of the buffer) to a root rather than dropping it. It also reports which agents have a running descendant: those default to open so a live agent is never buried in a collapsed parent, with the user's own click overriding that — recorded as overrides rather than as the state, so auto-expand can't fight someone who deliberately closed a row. Only top-level rows are capped by "show N more finished". A nested one is revealed by its parent's chevron, so hiding it behind a second cap would make the tree lie about what it contains. --- .../components/JsonClaudeAgentsPanel.tsx | 295 +++++++++--------- src/shared/state/json-claude-agents.test.ts | 100 ++++++ src/shared/state/json-claude-agents.ts | 45 +++ 3 files changed, 288 insertions(+), 152 deletions(-) diff --git a/src/renderer/components/JsonClaudeAgentsPanel.tsx b/src/renderer/components/JsonClaudeAgentsPanel.tsx index 214e47963..ab988a5f4 100644 --- a/src/renderer/components/JsonClaudeAgentsPanel.tsx +++ b/src/renderer/components/JsonClaudeAgentsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CornerDownRight } from 'lucide-react' import { useJsonClaudePendingApprovals, @@ -6,10 +6,12 @@ import { } from '../store' import { agentElapsedMs, + buildAgentTree, collectAgents, countRunningAgents, formatElapsed, - type AgentSummary + type AgentSummary, + type AgentTree } from '../../shared/state/json-claude-agents' import { RightPanel } from './RightPanel' import { AgentPeek } from './AgentPeek' @@ -24,17 +26,9 @@ import { AgentStepLine } from './AgentStepLine' * buys nothing, and the cap holds regardless of token rate. */ const LIVE_REFRESH_MS = 500 -/** Finished agents shown before collapsing the rest behind a toggle. */ +/** Top-level agents shown before collapsing the rest behind a toggle. */ const SETTLED_PREVIEW = 5 -/** Steps rendered inside an expanded row. The right column is narrow and - * several rows can be open at once — the peek is where a full feed - * belongs. */ -const INLINE_STEPS = 8 - -/** Characters of an agent's report shown inline before truncating. */ -const INLINE_RESULT = 400 - /** One clock for the whole panel. Elapsed times are the only thing here * that changes without a state event, so they get a single shared tick * rather than a timer per row — and it only runs while something is @@ -50,140 +44,126 @@ function useLiveClock(active: boolean): number { return now } +/** One agent, with its nested agents beneath it when expanded. + * + * Two targets on the row: the ▸/▾ chevron reveals the children, the + * rest of the row opens that agent's history in the peek. The standard + * file-tree split — disclosure is structure, the label is the thing. */ function AgentRow({ agent, + tree, now, - expanded, + depth, + isExpanded, onToggle, onPeek }: { agent: AgentSummary + tree: AgentTree now: number - expanded: boolean - onToggle: () => void - onPeek: () => void + depth: number + isExpanded: (id: string) => boolean + onToggle: (id: string) => void + onPeek: (id: string) => void }): JSX.Element { + const children = tree.childrenByParent.get(agent.toolUseId) ?? [] + const expanded = children.length > 0 && isExpanded(agent.toolUseId) const lastStep = agent.steps.length ? agent.steps[agent.steps.length - 1] : null const running = agent.status === 'running' const elapsed = formatElapsed(agentElapsedMs(agent, now)) const kind = agent.subagentType ? `${agent.subagentType} agent` : 'agent' - // Newest steps are what matter mid-run, and the panel is narrow — show - // the tail and send anyone who wants the whole thing to the peek. - const hiddenSteps = Math.max(0, agent.steps.length - INLINE_STEPS) - const tail = hiddenSteps > 0 ? agent.steps.slice(-INLINE_STEPS) : agent.steps return (
  • - + ) : ( + // Spacer, so labels line up with siblings that do have one. +
  • ) @@ -198,17 +178,13 @@ export function JsonClaudeAgentsPanel({ const pendingApprovals = useJsonClaudePendingApprovals() const [peekId, setPeekId] = useState(null) const [showAllSettled, setShowAllSettled] = useState(false) - // A Set rather than a single id: comparing two running agents - // side-by-side is the main reason to expand one at all. - const [expandedIds, setExpandedIds] = useState>( - () => new Set() + // Explicit user choices only. Rows with a running descendant default to + // open (see `expandedFor`), so this records the overrides rather than + // the state — otherwise auto-expanding would fight anyone who closed a + // row while its child was still going. + const [overrides, setOverrides] = useState>( + () => new Map() ) - const toggleExpanded = (id: string): void => - setExpandedIds((cur) => { - const next = new Set(cur) - if (!next.delete(id)) next.add(id) - return next - }) const pendingToolUseIds = useMemo(() => { const ids = new Set() @@ -247,6 +223,8 @@ export function JsonClaudeAgentsPanel({ return next // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, tick, entryCount, pendingToolUseIds]) + + const tree = useMemo(() => buildAgentTree(agents), [agents]) const running = countRunningAgents(agents) // A background agent outlives its parent's turn, so `busy` alone would // stop ticking while one is still working. @@ -258,6 +236,24 @@ export function JsonClaudeAgentsPanel({ }, [live]) const now = useLiveClock(running > 0) + // Default open when something below is still running, so a live agent + // is never buried inside a collapsed parent; the user's own click wins + // over that for as long as the panel is mounted. + const expandedFor = useCallback( + (id: string): boolean => + overrides.get(id) ?? tree.hasRunningDescendant.has(id), + [overrides, tree] + ) + const toggleExpanded = useCallback( + (id: string): void => + setOverrides((cur) => { + const next = new Map(cur) + next.set(id, !(cur.get(id) ?? tree.hasRunningDescendant.has(id))) + return next + }), + [tree] + ) + // A peek whose agent vanished (session cleared, history truncated) // would render nothing and trap the overlay — drop it. const peeked = peekId ? agents.find((a) => a.toolUseId === peekId) : undefined @@ -267,33 +263,26 @@ export function JsonClaudeAgentsPanel({ if (agents.length === 0) return null - // Nested agents are not listed alongside their parents. Sorting is by - // status then recency, so a child routinely sorted *above* its parent - // — an indented row pointing at nothing, implying a tree the order - // doesn't deliver. Nesting is legible in the peek, which links through - // parent → child, so the list stays flat: top-level agents, plus - // anything still running at any depth (nothing live is ever hidden). - const listed = agents.filter((a) => a.depth === 0 || a.status === 'running') - - // `listed` is sorted running-first, so this is a split, not a filter - // pass. Finished agents are history — the transcript keeps their Task - // cards — and a long session accumulates dozens, so only the most - // recent few are shown by default. Without this the live agents the - // panel exists to surface get pushed off the top. - const settledCount = listed.length - running + // Only top-level agents are capped — a nested one is revealed by its + // parent's chevron, so hiding it behind a second "show more" would + // make the tree lie about what it contains. `roots` keeps the + // running-first ordering, so this is a split, not a filter pass. + const runningRoots = tree.roots.filter((a) => a.status === 'running').length + const settledRoots = tree.roots.length - runningRoots const hiddenCount = showAllSettled ? 0 - : Math.max(0, settledCount - SETTLED_PREVIEW) - const shown = hiddenCount > 0 ? listed.slice(0, running + SETTLED_PREVIEW) : listed + : Math.max(0, settledRoots - SETTLED_PREVIEW) + const shown = + hiddenCount > 0 + ? tree.roots.slice(0, runningRoots + SETTLED_PREVIEW) + : tree.roots return ( <> 0 ? 'max-h-96' : 'max-h-64'} + maxHeight="max-h-72" actions={ // A bare count reads as nothing in particular. The panel is // about what's live, so say so — and when nothing is, say that @@ -308,13 +297,15 @@ export function JsonClaudeAgentsPanel({ toggleExpanded(a.toolUseId)} - onPeek={() => setPeekId(a.toolUseId)} + depth={0} + isExpanded={expandedFor} + onToggle={toggleExpanded} + onPeek={setPeekId} /> ))} - {(hiddenCount > 0 || showAllSettled) && settledCount > SETTLED_PREVIEW && ( + {settledRoots > SETTLED_PREVIEW && (