diff --git a/src/renderer/components/AgentPeek.tsx b/src/renderer/components/AgentPeek.tsx new file mode 100644 index 00000000..a4046cee --- /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 00000000..55d945f1 --- /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 00000000..513e24dc --- /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 00000000..2157dd62 --- /dev/null +++ b/src/renderer/components/JsonClaudeAgentsPanel.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CornerDownRight } from 'lucide-react' +import { + useJsonClaudePendingApprovals, + useJsonClaudeSession +} from '../store' +import { + agentElapsedMs, + buildAgentTree, + collectAgents, + countRunningAgents, + formatElapsed, + type AgentSummary, + type AgentTree +} from '../../shared/state/json-claude-agents' +import { RightPanel } from './RightPanel' +import { AgentPeek } from './AgentPeek' +import { AgentStatusDot, agentStatusLabel } from './AgentStatusDot' +import { AgentStepLine } from './AgentStepLine' +import { useSessionRevision } from '../session-revision' + +/** Top-level 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 + * 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 +} + +/** 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, + depth, + isExpanded, + onToggle, + onPeek +}: { + agent: AgentSummary + tree: AgentTree + now: number + 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' + return ( +
  • +
    + {children.length > 0 ? ( + + ) : ( + // Spacer, so labels line up with siblings that do have one. +
    + {expanded && ( +
      + {children.map((child) => ( + + ))} +
    + )} +
  • + ) +} + +export function JsonClaudeAgentsPanel({ + focusedTabId +}: { + focusedTabId: string | null +}): JSX.Element | null { + const session = useJsonClaudeSession(focusedTabId ?? '') + const pendingApprovals = useJsonClaudePendingApprovals() + const [peekId, setPeekId] = useState(null) + const [showAllSettled, setShowAllSettled] = useState(false) + // 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 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 streamed token. See useSessionRevision — + // notably it is NOT entries.length, because an Agent tool_use block is + // appended to the assistant entry already in flight. + // + // `runningRef` keeps the poll alive for background agents, which + // outlive their parent's turn: `busy` alone would stop ticking while + // one is still working. Read from a ref because it's derived below + // from the very memo this feeds. + const sessionRef = useRef(session) + sessionRef.current = session + const runningRef = useRef(0) + const revision = useSessionRevision(session, runningRef.current > 0) + + // Feeding the last result back lets settled agents be handed through + // by reference — see collectAgents. Without it, every poll 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 + }, [revision, pendingToolUseIds]) + + const tree = useMemo(() => buildAgentTree(agents), [agents]) + const running = countRunningAgents(agents) + runningRef.current = running + 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 + useEffect(() => { + if (peekId && !peeked) setPeekId(null) + }, [peekId, peeked]) + + if (agents.length === 0) return null + + // 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, settledRoots - SETTLED_PREVIEW) + const shown = + hiddenCount > 0 + ? tree.roots.slice(0, runningRoots + SETTLED_PREVIEW) + : tree.roots + + return ( + <> + + {running > 0 ? `${running} running` : `${agents.length} done`} +
    + } + > + + + {peeked && ( + setPeekId(null)} + onPeekAgent={setPeekId} + /> + )} + + ) +} diff --git a/src/renderer/components/JsonClaudeTodosPanel.tsx b/src/renderer/components/JsonClaudeTodosPanel.tsx index 33f8bca5..5c7d19e4 100644 --- a/src/renderer/components/JsonClaudeTodosPanel.tsx +++ b/src/renderer/components/JsonClaudeTodosPanel.tsx @@ -1,16 +1,183 @@ +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' +import { useSessionRevision } from '../session-revision' 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) + // Keyed on the revision rather than the session object, whose identity + // churns on every streamed token — both derivations walk the whole + // entries array, and a per-token walk is CLAUDE.md anti-pattern #4. + // NOT on entries.length: a TodoWrite / TaskCreate tool_use block is + // appended to the assistant entry already in flight, so the count + // doesn't move and the panel would stay empty for the entire turn. + const sessionRef = useRef(session) + sessionRef.current = session + const revision = useSessionRevision(session) + const tasks = useMemo( + () => collectTasks(sessionRef.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [revision] + ) + const todos = useMemo( + () => getLatestTodos(sessionRef.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [revision] + ) + 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 c6c5ae77..58b7b42f 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/TaskCard.tsx b/src/renderer/components/json-mode-cards/TaskCard.tsx index 7f97baa6..ec083541 100644 --- a/src/renderer/components/json-mode-cards/TaskCard.tsx +++ b/src/renderer/components/json-mode-cards/TaskCard.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { Sparkles } from 'lucide-react' import { trunc, type ToolCardProps } from './index' +import { isLaunchStub } from '../../../shared/state/json-claude-agents' export interface TaskCardProps extends ToolCardProps { /** Pre-rendered ReactNode for the sub-agent's chronological activity. @@ -40,11 +41,23 @@ export function TaskCard({ ? (input.subagent_type as string) : 'Task' - // A background agent's tool_result lands immediately (it's the launch - // stub, not the answer), so `result` can't stand in for "still working" - // the way it does for a synchronous sub-agent. - const backgroundRunning = backgroundAgent?.status === 'running' - const taskInFlight = backgroundAgent ? backgroundRunning : !result + // Whether the user ASKED for detachment — that's all the badge means. + // A task_started record is not evidence of it: Claude Code emits one + // for every sub-agent, so keying the badge off that put "background" + // on all of them. + const isBackground = input.run_in_background === true + + // Whether the agent has FINISHED is a separate question with a + // separate source. Every sub-agent runs detached, so the tool_result + // is always a launch stub that lands immediately and is superseded by + // the real answer on task_notification — treating its arrival as + // completion marked every agent done the moment it started. + const stubbed = isLaunchStub(result?.content) + const answer = stubbed ? undefined : result + const stillRunning = backgroundAgent + ? backgroundAgent.status === 'running' + : !answer + const taskInFlight = stillRunning const autoExpandTrigger = taskInFlight || subAgentDescendantHasPendingApproval @@ -75,7 +88,7 @@ export function TaskCard({ } }, [autoExpandTrigger, subAgentDescendantHasPendingApproval]) - const isError = !!result?.isError || backgroundAgent?.status === 'failed' + const isError = !!answer?.isError || backgroundAgent?.status === 'failed' const countLabel = subAgentChildCount === 0 ? taskInFlight @@ -131,7 +144,7 @@ export function TaskCard({ > {trunc(description, 120)} - {backgroundAgent && ( + {isBackground && backgroundAgent && ( - {trunc(result.content, 3000)} + {trunc(answer.content, 3000)} )} 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 00000000..2be7dedd --- /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 0f4e387a..b3b24d36 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 ed8b882a..8d8c4cd3 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 d8421af7..e3b80450 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/renderer/session-revision.test.ts b/src/renderer/session-revision.test.ts new file mode 100644 index 00000000..00acffc2 --- /dev/null +++ b/src/renderer/session-revision.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import type { + JsonClaudeChatEntry, + JsonClaudeSession +} from '../shared/state/json-claude' +import { sessionSignature } from './session-revision' + +function session( + entries: JsonClaudeChatEntry[], + sessionId = 's1' +): JsonClaudeSession { + return { + sessionId, + worktreePath: '/tmp/wt', + state: 'running', + exitCode: null, + exitReason: null, + entries, + entriesHydrated: true, + busy: true, + permissionMode: 'default', + slashCommands: [], + autoApprovedDecisions: {}, + sessionToolApprovals: [], + sessionAllowedDecisions: {}, + backgroundAgents: {} + } as JsonClaudeSession +} + +const assistant = ( + entryId: string, + blocks: JsonClaudeChatEntry['blocks'] +): JsonClaudeChatEntry => ({ + entryId, + kind: 'assistant', + timestamp: 1, + blocks +}) + +describe('sessionSignature', () => { + it('is stable for no session and an empty session', () => { + expect(sessionSignature(null)).toBe(':0') + expect(sessionSignature(undefined)).toBe(':0') + expect(sessionSignature(session([]))).toBe('s1:0') + }) + + it('is unchanged when nothing changed', () => { + const entries = [assistant('e1', [{ type: 'text', text: 'hi' }])] + expect(sessionSignature(session(entries))).toBe( + sessionSignature(session(entries)) + ) + }) + + // The regression this exists for: a TodoWrite / TaskCreate / Agent + // tool_use block is appended to the assistant entry already in flight, + // so entries.length never moves. A panel keyed on length alone sat + // empty for the whole turn. + it('changes when a block is appended to the entry already in flight', () => { + const before = session([assistant('e1', [{ type: 'text', text: 'hi' }])]) + const after = session([ + assistant('e1', [ + { type: 'text', text: 'hi' }, + { type: 'tool_use', id: 't1', name: 'TodoWrite', input: {} } + ]) + ]) + expect(before.entries.length).toBe(after.entries.length) + expect(sessionSignature(before)).not.toBe(sessionSignature(after)) + }) + + it('changes when an entry is appended', () => { + const before = session([assistant('e1', [])]) + const after = session([assistant('e1', []), assistant('e2', [])]) + expect(sessionSignature(before)).not.toBe(sessionSignature(after)) + }) + + it('changes when two sessions agree on shape but differ in id', () => { + const entries = [assistant('e1', [{ type: 'text', text: 'hi' }])] + expect(sessionSignature(session(entries, 'a'))).not.toBe( + sessionSignature(session(entries, 'b')) + ) + }) + + it('tolerates an entry with no blocks', () => { + expect(() => + sessionSignature(session([{ entryId: 'e1', kind: 'user', timestamp: 1 }])) + ).not.toThrow() + }) +}) diff --git a/src/renderer/session-revision.ts b/src/renderer/session-revision.ts new file mode 100644 index 00000000..1a529d64 --- /dev/null +++ b/src/renderer/session-revision.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import type { JsonClaudeSession } from '../shared/state/json-claude' + +/** How often a derived panel re-checks while its session is live. */ +const REVISION_POLL_MS = 500 + +/** Constant-time stand-in for "has this session changed in a way a + * derived panel cares about". + * + * Entry COUNT alone is not that signal, which is the bug this exists to + * fix. `assistantBlockAppended` adds a tool_use block to the assistant + * entry already in flight, so a TodoWrite / TaskCreate / Agent call + * lands *without* the array growing — a panel keyed on length alone + * never re-derives and sits empty for the whole turn. The last entry's + * id and block count catch that, and stay O(1): walking the entries to + * hash them properly would cost exactly what the memo exists to avoid. + * + * Session id is folded in because two sessions can agree on every other + * field, and switching tabs between them must re-derive. */ +export function sessionSignature( + session: JsonClaudeSession | null | undefined +): string { + const entries = session?.entries + if (!session || !entries || entries.length === 0) { + return `${session?.sessionId ?? ''}:0` + } + const last = entries[entries.length - 1] + return `${session.sessionId}:${entries.length}:${last.entryId}:${ + last.blocks?.length ?? 0 + }` +} + +/** Memo key for a panel derived from a session's entries. + * + * Using the session object itself would re-derive on every streamed + * token (CLAUDE.md anti-pattern #4 — its identity churns per delta). + * This changes on structural edits, plus a slow poll while the session + * is live so in-place mutations the signature can't see — a tool_result + * patched into an existing block, a block appended to an entry that + * isn't last because a sub-agent is interleaving — still land within + * half a second instead of never. + * + * `alsoLive` keeps the poll running for work that outlives the turn: + * a background agent is still going after `busy` goes false. */ +export function useSessionRevision( + session: JsonClaudeSession | null | undefined, + alsoLive = false +): string { + const [tick, setTick] = useState(0) + const live = !!session?.busy || alsoLive + useEffect(() => { + if (!live) return + const id = setInterval(() => setTick((t) => t + 1), REVISION_POLL_MS) + return () => clearInterval(id) + }, [live]) + return `${sessionSignature(session)}:${live ? tick : 'idle'}` +} 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 00000000..099c8fec --- /dev/null +++ b/src/shared/state/json-claude-agents.test.ts @@ -0,0 +1,698 @@ +import { describe, it, expect } from 'vitest' +import type { + JsonClaudeChatEntry, + JsonClaudeSession +} from './json-claude' +import { + agentElapsedMs, + buildAgentTree, + 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') + }) + + // Claude Code runs every sub-agent detached, so the Agent tool_result + // is ALWAYS this stub and arrives immediately. Reading it as a result + // showed a green tick the instant an agent launched. + const STUB = + 'Async agent launched successfully. (This tool result is internal.)' + + it('does not treat the launch stub as a finished result', () => { + const agents = collectAgents( + session([launch('t1', 'A'), toolResult('t1', STUB)]) + ) + expect(agents[0].status).toBe('running') + expect(agents[0].result).toBeUndefined() + }) + + it('reports running from the lifecycle record even with a stub present', () => { + const agents = collectAgents( + session([launch('t1', 'A'), toolResult('t1', STUB)], { + busy: false, + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'A', + status: 'running', + startedAt: 1000 + } + } + }) + ) + expect(agents[0].status).toBe('running') + }) + + it('settles once the real report supersedes the stub', () => { + const agents = collectAgents( + session( + [ + launch('t1', 'A'), + toolResult('t1', STUB), + toolResult('t1', 'here is what I found') + ], + { + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'A', + status: 'completed', + startedAt: 1000, + completedAt: 5000 + } + } + } + ) + ) + expect(agents[0].status).toBe('completed') + expect(agents[0].result).toBe('here is what I found') + }) + + it('fails an agent whose launch itself errored, with no record to go on', () => { + // e.g. an unknown subagent_type — no task ever started, so the + // tool_result is a real error rather than a stub. + const agents = collectAgents( + session([ + launch('t1', 'Bad'), + toolResult('t1', "Agent type 'nope' not found", true) + ]) + ) + expect(agents[0].status).toBe('failed') + }) + + it('never caches an interrupted agent, since that status is inferred', () => { + const entries = [launch('t1', 'A')] + const first = collectAgents(session(entries, { busy: false })) + expect(first[0].status).toBe('interrupted') + // Same agent, session busy again — it must be free to go back to + // running rather than being frozen by the reuse cache. + const second = collectAgents(session(entries, { busy: true }), undefined, first) + expect(second[0].status).toBe('running') + }) + + it('does not call an agent background just because it has a lifecycle record', () => { + // Claude Code emits system/task_started for every sub-agent, sync + // ones included, so a record says nothing about detachment. Keying + // isBackground off it labelled every agent in the panel "BG". + const agents = collectAgents( + session([launch('t1', 'Sync'), toolResult('t1', 'done')], { + backgroundAgents: { + t1: { + agentId: 'a1', + toolUseId: 't1', + description: 'Sync', + status: 'completed', + startedAt: 1000, + completedAt: 4000, + usage: { totalTokens: 999, toolUses: 2, durationMs: 3000 } + } + } + }) + ) + expect(agents[0].isBackground).toBe(false) + expect(agents[0].status).toBe('completed') + // The record is still the best source for usage + completion time. + expect(agents[0].usage?.toolUses).toBe(2) + expect(agents[0].completedAt).toBe(4000) + }) + + 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('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('buildAgentTree', () => { + it('is empty for no agents', () => { + const tree = buildAgentTree([]) + expect(tree.roots).toEqual([]) + expect(tree.childrenByParent.size).toBe(0) + }) + + it('nests children under their parent and keeps them out of roots', () => { + const tree = buildAgentTree( + collectAgents( + session([ + launch('t1', 'Parent'), + launch('t2', 'Child', { parentToolUseId: 't1', entryId: 'e2' }) + ]) + ) + ) + expect(tree.roots.map((a) => a.toolUseId)).toEqual(['t1']) + expect( + tree.childrenByParent.get('t1')?.map((a) => a.toolUseId) + ).toEqual(['t2']) + }) + + it('nests to arbitrary depth', () => { + const tree = buildAgentTree( + collectAgents( + session([ + launch('t1', 'A'), + launch('t2', 'B', { parentToolUseId: 't1', entryId: 'e2' }), + launch('t3', 'C', { parentToolUseId: 't2', entryId: 'e3' }) + ]) + ) + ) + expect(tree.roots.map((a) => a.toolUseId)).toEqual(['t1']) + expect(tree.childrenByParent.get('t2')?.[0].toolUseId).toBe('t3') + }) + + it('promotes an orphan to a root rather than losing it', () => { + // The parent Task call was truncated out of the entries buffer. + const tree = buildAgentTree( + collectAgents( + session([launch('t2', 'Child', { parentToolUseId: 'gone' })]) + ) + ) + expect(tree.roots.map((a) => a.toolUseId)).toEqual(['t2']) + }) + + it('preserves the running-first ordering within a sibling group', () => { + const tree = buildAgentTree( + collectAgents( + session([ + launch('p', 'Parent'), + launch('c1', 'done child', { parentToolUseId: 'p', entryId: 'e2' }), + launch('c2', 'live child', { parentToolUseId: 'p', entryId: 'e3' }), + toolResult('c1', 'finished') + ]) + ) + ) + expect(tree.childrenByParent.get('p')?.map((a) => a.description)).toEqual([ + 'live child', + 'done child' + ]) + }) + + it('marks every ancestor of a running agent', () => { + const tree = buildAgentTree( + collectAgents( + session([ + launch('t1', 'A'), + launch('t2', 'B', { parentToolUseId: 't1', entryId: 'e2' }), + launch('t3', 'C', { parentToolUseId: 't2', entryId: 'e3' }) + ]) + ) + ) + expect([...tree.hasRunningDescendant].sort()).toEqual(['t1', 't2']) + }) + + it('does not mark a parent whose children have all finished', () => { + const tree = buildAgentTree( + collectAgents( + session( + [ + launch('t1', 'A'), + launch('t2', 'B', { parentToolUseId: 't1', entryId: 'e2' }), + toolResult('t1', 'done'), + toolResult('t2', 'done') + ], + { busy: false } + ) + ) + ) + expect(tree.hasRunningDescendant.size).toBe(0) + }) + + it('does not mark a running agent as its own ancestor', () => { + const tree = buildAgentTree(collectAgents(session([launch('t1', 'A')]))) + expect(tree.hasRunningDescendant.has('t1')).toBe(false) + }) +}) + +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 00000000..2f2a7bed --- /dev/null +++ b/src/shared/state/json-claude-agents.ts @@ -0,0 +1,451 @@ +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 +} + +/** Claude Code runs EVERY sub-agent detached: the Agent tool_result + * resolves immediately with this stub and the real answer arrives later + * on the task_notification, which supersedes it. So a tool_result is + * not evidence that an agent finished — reading it as one reported a + * green tick the instant an agent launched. Matched on text because + * nothing in the payload distinguishes a stub structurally. */ +const LAUNCH_STUB = /Async agent launched successfully/ + +export function isLaunchStub(content: string | undefined): boolean { + return !!content && LAUNCH_STUB.test(content) +} + +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. + * + * `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, + 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, and only once + // their report has landed: task_notification dispatches the settled + // status and the real summary as two events, so a snapshot taken + // between them would otherwise pin the launch stub as the report + // forever. `interrupted` is deliberately NOT cacheable — it's inferred + // from `!busy`, not reported, and caching an inference would freeze an + // agent that way permanently. + const reusable = new Map() + if (previous) { + for (const p of previous) { + if (p.status !== 'completed' && p.status !== 'failed') continue + if (p.result !== undefined) reusable.set(p.toolUseId, p) + } + } + + 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) => { + // 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 + 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 rawResult = resultsByToolUseId.get(site.toolUseId) + // The stub is not an answer. Drop it so a launched-but-unfinished + // agent reads as having no result at all. + const result = + rawResult && isLaunchStub(rawResult.content) ? undefined : rawResult + const bg = session.backgroundAgents?.[site.toolUseId] + // Two different questions, and conflating them is what broke this + // twice. Whether the user ASKED for detachment is `run_in_background` + // — that's the badge, and keying it off the record's presence put + // "background" on every agent. Whether the agent has FINISHED is the + // task_started/task_notification lifecycle, which every agent gets + // because they all run detached — keying that off the tool_result + // marked every agent complete the moment it launched. + const isBackground = site.requestedBackground + + let status: AgentRunStatus + let completedAt: number | undefined + if (bg) { + status = bg.status + completedAt = bg.completedAt + } else if (result) { + // No lifecycle record: either a build that predates them, a + // transcript-seeded session, or a launch that failed outright + // (unknown subagent_type) and so never started a task. + status = result.isError ? 'failed' : 'completed' + 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. 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 + blocked.add(cursor) + cursor = parent.parentToolUseId + } + } + + 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 + // 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 interface AgentTree { + /** Agents launched by the main session, plus any whose parent isn't in + * the list (truncated out of the buffer) — an orphan still has to + * appear somewhere rather than vanish. */ + roots: AgentSummary[] + childrenByParent: Map + /** Ids with at least one running agent strictly below them. The panel + * auto-expands these so a live agent is never hidden inside a + * collapsed parent. */ + hasRunningDescendant: ReadonlySet +} + +/** Regroups the flat `collectAgents` result into parent → children, + * preserving its ordering within each sibling group. */ +export function buildAgentTree(agents: readonly AgentSummary[]): AgentTree { + const present = new Set(agents.map((a) => a.toolUseId)) + const roots: AgentSummary[] = [] + const childrenByParent = new Map() + for (const a of agents) { + const parent = a.parentToolUseId + if (!parent || !present.has(parent)) { + roots.push(a) + continue + } + const list = childrenByParent.get(parent) + if (list) list.push(a) + else childrenByParent.set(parent, [a]) + } + + const byId = new Map(agents.map((a) => [a.toolUseId, a])) + const hasRunningDescendant = new Set() + for (const a of agents) { + if (a.status !== 'running') continue + let cursor = a.parentToolUseId + const seen = new Set([a.toolUseId]) + while (cursor && !seen.has(cursor)) { + seen.add(cursor) + hasRunningDescendant.add(cursor) + cursor = byId.get(cursor)?.parentToolUseId + } + } + + return { roots, childrenByParent, hasRunningDescendant } +} + +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 00000000..07eb460f --- /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 00000000..e85ae070 --- /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 a46277d8..43c9d0f4 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 aeef1494..15f62a97 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'