diff --git a/.changeset/subagent-composer-strip.md b/.changeset/subagent-composer-strip.md new file mode 100644 index 000000000..33b2683e6 --- /dev/null +++ b/.changeset/subagent-composer-strip.md @@ -0,0 +1,8 @@ +--- +"helmor": minor +--- + +Added a running-subagent strip above the composer. + +- While a session's spawned subagents (Claude `Task`/`Agent` or Codex `subagent_*`) are running, a strip slides in flush on top of the composer with one chip per live subagent — each with a distinct pixel-art sprite, identity color, and name — and slides out when they finish. +- Clicking a chip filters the conversation to just that subagent's output. A banner at the top of the thread names the subagent and shows its activity (type, tool uses, files touched, steps, and running/done state) with a "Show all" control to clear the filter. diff --git a/src/features/composer/container.tsx b/src/features/composer/container.tsx index f02db98dd..3d21ab23d 100644 --- a/src/features/composer/container.tsx +++ b/src/features/composer/container.tsx @@ -73,6 +73,7 @@ import { import type { PermissionPanelProps } from "./permission-panel"; import { SessionContextInjector } from "./session-context-injector"; import type { StartSubmitMode } from "./start-submit-mode"; +import { SubagentStrip } from "./subagent-strip"; import { SubmitQueueList } from "./submit-queue-list"; import { TriageQuickActions } from "./triage-quick-actions"; import type { UserInputResponseHandler } from "./user-input"; @@ -1319,6 +1320,7 @@ export const WorkspaceComposerContainer = memo( onEdit={(id) => onEditQueued?.(id)} disabled={composerUnavailable} /> + & { toolCallId: string }, +): ToolCallPart { + return { + type: "tool-call", + toolName: "Task", + args: { description: "Research the API" }, + argsText: "", + result: null, + ...overrides, + }; +} + +function codexSpawn( + toolCallId: string, + agentsStates: Record, +): ToolCallPart { + return { + type: "tool-call", + toolName: "subagent_spawn", + toolCallId, + args: { agentsStates }, + argsText: "", + }; +} + +function assistant( + content: ExtendedMessagePart[], + overrides: Partial = {}, +): ThreadMessageLike { + return { role: "assistant", id: "m1", content, ...overrides }; +} + +describe("extractRunningSubagents", () => { + it("returns empty when no subagents are present", () => { + const messages = [assistant([{ type: "text", id: "t1", text: "hello" }])]; + expect(extractRunningSubagents(messages)).toEqual([]); + }); + + it("treats a Claude Task with null result on a streaming message as running", () => { + const messages = [ + assistant([claudeTask({ toolCallId: "tc1", result: null })], { + streaming: true, + }), + ]; + const running = extractRunningSubagents(messages); + expect(running).toHaveLength(1); + expect(running[0]).toMatchObject({ + key: "tc1", + toolCallId: "tc1", + name: "Research the API", + }); + expect(running[0]?.color).toMatch(/^var\(--subagent-/); + }); + + it("treats a finished Claude Task (result set, not streaming) as not running", () => { + const messages = [ + assistant( + [claudeTask({ toolCallId: "tc1", result: "done", children: [] })], + { streaming: false }, + ), + ]; + expect(extractRunningSubagents(messages)).toEqual([]); + }); + + it("treats a Claude Task with streamingStatus=running as running regardless of message flag", () => { + const messages = [ + assistant( + [claudeTask({ toolCallId: "tc1", streamingStatus: "running" })], + { streaming: false }, + ), + ]; + expect(extractRunningSubagents(messages)).toHaveLength(1); + }); + + it("keeps a Task running after input streaming finishes (streamingStatus=done, result still null)", () => { + // Regression: `streamingStatus` flips to `done` once the args finish + // arriving while the subagent keeps executing — the chip must NOT vanish. + const messages = [ + assistant( + [ + claudeTask({ + toolCallId: "tc1", + streamingStatus: "done", + result: null, + }), + ], + { streaming: false }, + ), + ]; + expect(extractRunningSubagents(messages)).toHaveLength(1); + }); + + it("treats an errored Task as not running", () => { + const messages = [ + assistant( + [ + claudeTask({ + toolCallId: "tc1", + streamingStatus: "error", + result: "boom", + }), + ], + { streaming: true }, + ), + ]; + expect(extractRunningSubagents(messages)).toEqual([]); + }); + + it("collects parallel Claude Tasks in a single message", () => { + const messages = [ + assistant( + [ + claudeTask({ toolCallId: "tc1", args: { description: "A" } }), + claudeTask({ toolCallId: "tc2", args: { description: "B" } }), + ], + { streaming: true }, + ), + ]; + const running = extractRunningSubagents(messages); + expect(running.map((r) => r.key)).toEqual(["tc1", "tc2"]); + }); + + it("detects a nested subagent spawned inside a parent Task's children", () => { + const messages = [ + assistant( + [ + claudeTask({ + toolCallId: "parent", + children: [ + claudeTask({ + toolCallId: "child", + args: { description: "Nested" }, + }), + ], + }), + ], + { streaming: true }, + ), + ]; + const running = extractRunningSubagents(messages); + expect(running.map((r) => r.key).sort()).toEqual(["child", "parent"]); + }); + + it("treats a Codex agentsStates entry with status=running as running", () => { + const messages = [ + assistant([ + codexSpawn("sc1", { + "thread-1": { + agentNickname: "Curie", + agentRole: "worker", + status: "running", + }, + }), + ]), + ]; + const running = extractRunningSubagents(messages); + expect(running).toHaveLength(1); + expect(running[0]).toMatchObject({ + key: "thread-1", + name: "Curie", + agentType: "worker", + }); + }); + + it("drops a Codex thread once a later sighting reports completed", () => { + const messages = [ + assistant( + [ + codexSpawn("sc1", { + "thread-1": { agentNickname: "Curie", status: "running" }, + }), + ], + { id: "m1" }, + ), + assistant( + [ + { + type: "tool-call", + toolName: "subagent_wait", + toolCallId: "sc2", + args: { + agentsStates: { + "thread-1": { agentNickname: "Curie", status: "completed" }, + }, + }, + argsText: "", + }, + ], + { id: "m2" }, + ), + ]; + expect(extractRunningSubagents(messages)).toEqual([]); + }); + + it("falls back to the identity pool when a Codex nickname is missing", () => { + const messages = [ + assistant([ + codexSpawn("sc1", { + "thread-1": { status: "running" }, + }), + ]), + ]; + const running = extractRunningSubagents(messages); + expect(running).toHaveLength(1); + expect(running[0]?.name).toBeTruthy(); + expect(running[0]?.name).not.toBe(""); + }); +}); + +describe("selectSubagentBlock", () => { + it("surfaces a Claude Task's children as a single assistant message", () => { + const children: ExtendedMessagePart[] = [ + { type: "text", id: "c1", text: "child output" }, + ]; + const messages = [ + assistant([claudeTask({ toolCallId: "tc1", children })], { + streaming: true, + }), + ]; + const block = selectSubagentBlock(messages, "tc1"); + expect(block?.role).toBe("assistant"); + expect(block?.content).toEqual(children); + }); + + it("surfaces Codex subagent parts referencing the thread", () => { + const spawn = codexSpawn("sc1", { + "thread-1": { agentNickname: "Curie", status: "running" }, + }); + const messages = [assistant([spawn])]; + const block = selectSubagentBlock(messages, "thread-1"); + expect(block?.content).toEqual([spawn]); + }); + + it("returns null for an unknown key", () => { + const messages = [ + assistant([claudeTask({ toolCallId: "tc1" })], { streaming: true }), + ]; + expect(selectSubagentBlock(messages, "nope")).toBeNull(); + }); +}); + +describe("summarizeSubagent", () => { + it("counts tool uses, files touched and steps for a Claude Task", () => { + const children: ExtendedMessagePart[] = [ + { type: "text", id: "c0", text: "thinking" }, + { + type: "tool-call", + toolName: "Read", + toolCallId: "k1", + args: {}, + argsText: "", + }, + { + type: "tool-call", + toolName: "Edit", + toolCallId: "k2", + args: {}, + argsText: "", + }, + ]; + const messages = [ + assistant( + [ + claudeTask({ + toolCallId: "tc1", + args: { description: "X", subagent_type: "Explore" }, + children, + }), + ], + { streaming: true }, + ), + ]; + const summary = summarizeSubagent(messages, "tc1"); + expect(summary).toMatchObject({ + agentType: "Explore", + toolUses: 2, + filesTouched: 1, + steps: 3, + running: true, + }); + expect(summary?.color).toMatch(/^var\(--subagent-/); + }); + + it("reports running=false for a finished Task", () => { + const messages = [ + assistant( + [claudeTask({ toolCallId: "tc1", result: "done", children: [] })], + { streaming: false }, + ), + ]; + expect(summarizeSubagent(messages, "tc1")?.running).toBe(false); + }); + + it("summarizes a Codex subagent by role + status", () => { + const messages = [ + assistant([ + codexSpawn("sc1", { + "thread-1": { + agentNickname: "Curie", + agentRole: "worker", + status: "running", + }, + }), + ]), + ]; + const summary = summarizeSubagent(messages, "thread-1"); + expect(summary).toMatchObject({ agentType: "worker", running: true }); + }); + + it("returns null for an unknown key", () => { + expect( + summarizeSubagent( + [assistant([claudeTask({ toolCallId: "tc1" })], { streaming: true })], + "nope", + ), + ).toBeNull(); + }); +}); diff --git a/src/features/composer/subagent-strip/extract-subagents.ts b/src/features/composer/subagent-strip/extract-subagents.ts new file mode 100644 index 000000000..13fb266ba --- /dev/null +++ b/src/features/composer/subagent-strip/extract-subagents.ts @@ -0,0 +1,358 @@ +/** + * Pure helpers that walk the rendered thread (`ThreadMessageLike[]`) and + * surface *currently-running* spawned subagents, plus select the content for + * one subagent when the thread is filtered. + * + * This is the single source of truth shared by the composer strip + * (`use-running-subagents`) and the viewport filter (`thread-viewport`), so the + * two can never disagree about which subagents exist or what a filtered view + * shows. See the RiskCard in `.helmor/plans/subagent-composer-strip.mdx`. + * + * Two agent flavors: + * - Claude `Task` / `Agent`: a `ToolCallPart` whose `children` hold the + * subagent's nested work. Running while the part is streaming or its + * `result` is still null on a streaming message. `key` = `toolCallId`. + * - Codex `subagent_*`: per-agent lifecycle lives in `args.agentsStates` + * (keyed by `threadId`). Running when the latest sighting of that thread + * reports a live status. `key` = `threadId`. These parts carry no + * `children`; their outputs are the `agentsStates` messages themselves. + */ + +import { + type AgentState, + isSubagentToolName, + readAgentsStates, +} from "@/features/panel/message-components/subagent-tool"; +import type { + ExtendedMessagePart, + ThreadMessageLike, + ToolCallPart, +} from "@/lib/api"; +import { getSubagentIdentity } from "@/lib/subagent-identity"; + +/** Claude tool names that spawn a subagent (mirror of the Rust + * `AGENT_TOOL_NAMES` constant in `pipeline/adapter/mod.rs`). */ +const CLAUDE_AGENT_TOOL_NAMES = new Set(["Agent", "Task"]); + +export interface RunningSubagent { + /** Stable identity used by the filter store. Codex `threadId`, else the + * Claude tool call id. */ + key: string; + /** Display label — Codex nickname / Claude `description`, falling back to + * the deterministic identity pool so chips never render blank. */ + name: string; + /** A `var(--subagent-N)` reference from the identity pool. */ + color: string; + /** Claude `subagent_type` / Codex role, when known. */ + agentType: string | null; + /** Id of the top-level thread message that contains this subagent, for + * locating it later. Null when the message had no id. */ + anchorMessageId: string | null; + /** The originating tool call id (equals `key` for Claude). */ + toolCallId: string; +} + +function isToolCallPart(part: ExtendedMessagePart): part is ToolCallPart { + return part.type === "tool-call"; +} + +/** Codex per-agent live status. `running` per the plan, plus the + * `in_progress` variants the Codex client emits for an active thread. */ +function isLiveAgentStatus(status: string | null | undefined): boolean { + return ( + status === "running" || status === "in_progress" || status === "inProgress" + ); +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +/** + * Walk every message (recursing into `ToolCallPart.children`) and return the + * subagents that are running right now. Codex threads are de-duplicated by + * `threadId` with the latest sighting winning, so a thread that has since moved + * to `completed` drops out even if an earlier spawn row still reads `running`. + */ +export function extractRunningSubagents( + messages: readonly ThreadMessageLike[], +): RunningSubagent[] { + // Insertion-ordered; Codex entries get overwritten as later parts report + // newer statuses. + const byKey = new Map(); + + const visitParts = ( + parts: readonly ExtendedMessagePart[] | undefined, + anchorMessageId: string | null, + ) => { + if (!parts) return; + for (const part of parts) { + if (part.type === "collapsed-group") { + visitParts(part.tools, anchorMessageId); + continue; + } + if (!isToolCallPart(part)) continue; + + if (CLAUDE_AGENT_TOOL_NAMES.has(part.toolName)) { + collectClaude(part, anchorMessageId, byKey); + } else if (isSubagentToolName(part.toolName)) { + collectCodex(part, anchorMessageId, byKey); + } + + // Recurse regardless: a subagent can itself spawn nested subagents. + visitParts(part.children, anchorMessageId); + } + }; + + for (const message of messages) { + visitParts(message.content, message.id ?? null); + } + + const result: RunningSubagent[] = []; + for (const { entry, running } of byKey.values()) { + if (running) result.push(entry); + } + return result; +} + +function collectClaude( + part: ToolCallPart, + anchorMessageId: string | null, + byKey: Map, +): void { + // A Claude subagent runs until its tool call yields a result. `streamingStatus` + // only tracks *input* streaming — it flips to `done` once the args finish + // arriving while the subagent keeps executing — so gating on it would drop the + // chip seconds after spawn. Mirror `AgentChildrenBlock`'s `isRunning = + // result == null`; a failed call (`streamingStatus === "error"`) is not running. + const running = part.result == null && part.streamingStatus !== "error"; + const key = part.toolCallId; + const name = + readString(part.args.description) ?? + getSubagentIdentity(key, null).nickname; + byKey.set(key, { + running, + entry: { + key, + name, + color: getSubagentIdentity(key, name).color, + agentType: readString(part.args.subagent_type), + anchorMessageId, + toolCallId: part.toolCallId, + }, + }); +} + +function collectCodex( + part: ToolCallPart, + anchorMessageId: string | null, + byKey: Map, +): void { + const states = readAgentsStates(part.args); + for (const state of states) { + const key = state.threadId; + const name = getSubagentIdentity(key, state.nickname).nickname; + byKey.set(key, { + running: isLiveAgentStatus(state.status), + entry: { + key, + name, + color: getSubagentIdentity(key, state.nickname).color, + agentType: readString(state.role), + anchorMessageId, + toolCallId: part.toolCallId, + }, + }); + } +} + +/** + * Build the single synthesized assistant message shown when the thread is + * filtered to one subagent. Returns null when the key can't be resolved (the + * caller then renders an empty filtered thread). + * + * - Claude: the `Task`/`Agent` tool call's `children`. + * - Codex: every `subagent_*` tool call part referencing this `threadId`, + * since Codex outputs live on those parts (no `children`). + */ +export function selectSubagentBlock( + messages: readonly ThreadMessageLike[], + key: string, +): ThreadMessageLike | null { + const claudeChildren = findClaudeChildren(messages, key); + if (claudeChildren) { + return { + role: "assistant", + id: `subagent-filter:${key}`, + content: claudeChildren, + }; + } + + const codexParts = findCodexParts(messages, key); + if (codexParts.length > 0) { + return { + role: "assistant", + id: `subagent-filter:${key}`, + content: codexParts, + }; + } + + return null; +} + +function findClaudeTaskPart( + messages: readonly ThreadMessageLike[], + key: string, +): ToolCallPart | null { + let found: ToolCallPart | null = null; + const visit = (parts: readonly ExtendedMessagePart[] | undefined) => { + if (!parts || found) return; + for (const part of parts) { + if (found) return; + if (part.type === "collapsed-group") { + visit(part.tools); + continue; + } + if (!isToolCallPart(part)) continue; + if ( + CLAUDE_AGENT_TOOL_NAMES.has(part.toolName) && + part.toolCallId === key + ) { + found = part; + return; + } + visit(part.children); + } + }; + for (const message of messages) { + visit(message.content); + if (found) break; + } + return found; +} + +function findClaudeChildren( + messages: readonly ThreadMessageLike[], + key: string, +): ExtendedMessagePart[] | null { + const part = findClaudeTaskPart(messages, key); + return part?.children && part.children.length > 0 ? part.children : null; +} + +function findCodexParts( + messages: readonly ThreadMessageLike[], + key: string, +): ToolCallPart[] { + const parts: ToolCallPart[] = []; + const referencesThread = (states: AgentState[]) => + states.some((state) => state.threadId === key); + const visit = (children: readonly ExtendedMessagePart[] | undefined) => { + if (!children) return; + for (const part of children) { + if (part.type === "collapsed-group") { + visit(part.tools); + continue; + } + if (!isToolCallPart(part)) continue; + if ( + isSubagentToolName(part.toolName) && + referencesThread(readAgentsStates(part.args)) + ) { + parts.push(part); + } + visit(part.children); + } + }; + for (const message of messages) { + visit(message.content); + } + return parts; +} + +const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "apply_patch"]); + +/** Derived stats for the filter banner. Per-token usage is intentionally absent + * — it isn't carried on any rendered part (Task results / Codex `agentsStates`), + * so we surface the activity we *can* count instead. */ +export interface SubagentSummary { + key: string; + color: string; + agentType: string | null; + /** Total tool-call parts inside the subagent's work (recursive). */ + toolUses: number; + /** Top-level steps (parts) in the subagent's block. */ + steps: number; + /** Tool calls that edited a file (Edit/Write/MultiEdit/apply_patch). */ + filesTouched: number; + running: boolean; +} + +function countStats( + parts: readonly ExtendedMessagePart[], + acc: { toolUses: number; filesTouched: number }, +): void { + for (const part of parts) { + if (part.type === "collapsed-group") { + countStats(part.tools, acc); + continue; + } + if (!isToolCallPart(part)) continue; + acc.toolUses += 1; + if (FILE_EDIT_TOOLS.has(part.toolName)) acc.filesTouched += 1; + if (part.children) countStats(part.children, acc); + } +} + +/** + * Compute display stats for one subagent (for the filter banner). Returns null + * when the key resolves to nothing in the current thread. + */ +export function summarizeSubagent( + messages: readonly ThreadMessageLike[], + key: string, +): SubagentSummary | null { + const task = findClaudeTaskPart(messages, key); + if (task) { + const children = task.children ?? []; + const acc = { toolUses: 0, filesTouched: 0 }; + countStats(children, acc); + return { + key, + color: getSubagentIdentity(key, null).color, + agentType: readString(task.args.subagent_type), + toolUses: acc.toolUses, + steps: children.length, + filesTouched: acc.filesTouched, + running: task.result == null && task.streamingStatus !== "error", + }; + } + + const codexParts = findCodexParts(messages, key); + if (codexParts.length > 0) { + const acc = { toolUses: 0, filesTouched: 0 }; + countStats(codexParts, acc); + // Latest sighting wins for role + status. + let role: string | null = null; + let running = false; + for (const part of codexParts) { + for (const state of readAgentsStates(part.args)) { + if (state.threadId !== key) continue; + role = readString(state.role) ?? role; + running = isLiveAgentStatus(state.status); + } + } + return { + key, + color: getSubagentIdentity(key, null).color, + agentType: role, + toolUses: acc.toolUses, + steps: codexParts.length, + filesTouched: acc.filesTouched, + running, + }; + } + + return null; +} diff --git a/src/features/composer/subagent-strip/index.test.tsx b/src/features/composer/subagent-strip/index.test.tsx new file mode 100644 index 000000000..badefdef7 --- /dev/null +++ b/src/features/composer/subagent-strip/index.test.tsx @@ -0,0 +1,90 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useSubagentFilterStore } from "@/features/conversation/state/subagent-filter-store"; +import type { ThreadMessageLike, ToolCallPart } from "@/lib/api"; +import { createHelmorQueryClient, helmorQueryKeys } from "@/lib/query-client"; +import { SubagentStrip } from "./index"; + +const SESSION = "session-1"; + +function threadKey(sessionId: string) { + return [...helmorQueryKeys.sessionMessages(sessionId), "thread"]; +} + +function runningTask(toolCallId: string, description: string): ToolCallPart { + return { + type: "tool-call", + toolName: "Task", + toolCallId, + args: { description }, + argsText: "", + result: null, + }; +} + +function renderStrip(messages: ThreadMessageLike[]) { + const queryClient = createHelmorQueryClient(); + queryClient.setQueryData(threadKey(SESSION), messages); + return render( + + + , + ); +} + +describe("SubagentStrip", () => { + beforeEach(() => { + useSubagentFilterStore.setState({ activeBySession: {} }); + }); + afterEach(() => { + cleanup(); + useSubagentFilterStore.setState({ activeBySession: {} }); + }); + + it("collapses (aria-hidden) when no subagents are running", () => { + renderStrip([ + { + role: "assistant", + id: "m1", + content: [{ type: "text", id: "t", text: "hi" }], + }, + ]); + expect(screen.getByTestId("subagent-strip")).toHaveAttribute( + "aria-hidden", + "true", + ); + }); + + it("renders a chip per running subagent and toggles the filter on click", () => { + renderStrip([ + { + role: "assistant", + id: "m1", + streaming: true, + content: [ + runningTask("tc1", "Curie task"), + runningTask("tc2", "Dewey task"), + ], + }, + ]); + + const strip = screen.getByTestId("subagent-strip"); + expect(strip).toHaveAttribute("aria-hidden", "false"); + + const chip = screen.getByRole("button", { name: /Curie task/ }); + expect(chip).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(chip); + expect(useSubagentFilterStore.getState().activeBySession[SESSION]).toEqual({ + key: "tc1", + name: "Curie task", + }); + + // Clicking the active chip again clears the filter. + fireEvent.click(screen.getByRole("button", { name: /Curie task/ })); + expect( + useSubagentFilterStore.getState().activeBySession[SESSION], + ).toBeUndefined(); + }); +}); diff --git a/src/features/composer/subagent-strip/index.tsx b/src/features/composer/subagent-strip/index.tsx new file mode 100644 index 000000000..6c9bfb1de --- /dev/null +++ b/src/features/composer/subagent-strip/index.tsx @@ -0,0 +1,100 @@ +/** + * A slim strip of currently-running subagent chips, glued to the top edge of + * the composer (rounded top corners, composer-matching surface) inside the + * composer's `pointer-events-none` overlay zone. Appears only while at least + * one subagent is running and animates its height + opacity in/out. Clicking a + * chip filters the thread to that subagent's outputs (toggling off when + * re-clicked). Each chip carries a deterministic pixel-art sprite so agents are + * visually distinct. + */ + +import { useRef } from "react"; +import { useSubagentFilter } from "@/features/conversation/state/subagent-filter-store"; +import { cn } from "@/lib/utils"; +import type { RunningSubagent } from "./extract-subagents"; +import { SubagentPixelAvatar } from "./pixel-avatar"; +import { useRunningSubagents } from "./use-running-subagents"; + +export function SubagentStrip({ sessionId }: { sessionId: string | null }) { + const running = useRunningSubagents(sessionId); + const { active, setFilter, clearFilter } = useSubagentFilter(sessionId); + + // Keep the last non-empty list mounted through the collapse transition so + // chips slide out with content rather than snapping to empty. + const lastNonEmptyRef = useRef(running); + if (running.length > 0) { + lastNonEmptyRef.current = running; + } + const open = running.length > 0; + const display = open ? running : lastNonEmptyRef.current; + + return ( +
+
+ {/* translate-y-px closes the hairline seam: the overlay only overlaps + the composer by 1px, so we push the (border-less) bottom edge down + to sit flush over the composer's top border. */} +
+ + Running + + {display.map((agent) => { + const isActive = active?.key === agent.key; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/features/composer/subagent-strip/pixel-avatar.tsx b/src/features/composer/subagent-strip/pixel-avatar.tsx new file mode 100644 index 000000000..d21aaeee3 --- /dev/null +++ b/src/features/composer/subagent-strip/pixel-avatar.tsx @@ -0,0 +1,87 @@ +/** + * Deterministic pixel-art identicon for a subagent — a small symmetric + * blockies-style sprite seeded by the agent's stable key and tinted with its + * identity color. Gives each running subagent a distinct "pixelated form" the + * way GitHub identicons do, so chips are visually separable at a glance. + * + * Pure + stable: the same key always renders the same sprite, so a chip never + * flickers its shape as the thread re-renders. + */ + +import { useMemo } from "react"; + +// 5x5 grid, left half (3 cols) decided by the hash then mirrored → symmetric. +const GRID = 5; +const HALF = Math.ceil(GRID / 2); + +// 32-bit FNV-1a — same family as `subagent-identity`, kept local so this +// component has no test-only dependency. +function fnv1a(input: string): number { + let hash = 2166136261; + for (let i = 0; i < input.length; i++) { + hash = (hash ^ input.charCodeAt(i)) * 16777619; + hash >>>= 0; + } + return hash; +} + +/** A length-25 on/off mask for the 5x5 grid, mirrored across the vertical axis. */ +function buildMask(seedKey: string): boolean[] { + const mask = new Array(GRID * GRID).fill(false); + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < HALF; col++) { + // Independent hash per cell so the sprite uses the full key entropy + // rather than a handful of bits off one number. + const on = fnv1a(`${seedKey}:${row}:${col}`) % 2 === 0; + if (!on) continue; + mask[row * GRID + col] = true; + mask[row * GRID + (GRID - 1 - col)] = true; + } + } + return mask; +} + +export function SubagentPixelAvatar({ + seedKey, + color, + size = 16, + className, +}: { + seedKey: string; + color: string; + size?: number; + className?: string; +}) { + const mask = useMemo(() => buildMask(seedKey), [seedKey]); + const cell = size / GRID; + return ( + + ); +} diff --git a/src/features/composer/subagent-strip/use-running-subagents.ts b/src/features/composer/subagent-strip/use-running-subagents.ts new file mode 100644 index 000000000..7d5e15d6d --- /dev/null +++ b/src/features/composer/subagent-strip/use-running-subagents.ts @@ -0,0 +1,28 @@ +/** + * Reads the displayed session's thread and returns the subagents that are + * running right now. Self-sufficient (takes only a `sessionId`) so the strip + * can mount in the composer's overlay zone without threading a messages array + * down — it reads the same React Query cache the panel already populates. + */ + +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { ThreadMessageLike } from "@/lib/api"; +import { sessionThreadMessagesQueryOptions } from "@/lib/query-client"; +import { + extractRunningSubagents, + type RunningSubagent, +} from "./extract-subagents"; + +const EMPTY_MESSAGES: readonly ThreadMessageLike[] = []; + +export function useRunningSubagents( + sessionId: string | null, +): RunningSubagent[] { + const { data } = useQuery({ + ...sessionThreadMessagesQueryOptions(sessionId ?? "__none__"), + enabled: Boolean(sessionId), + }); + const messages = data ?? EMPTY_MESSAGES; + return useMemo(() => extractRunningSubagents(messages), [messages]); +} diff --git a/src/features/conversation/state/subagent-filter-store.ts b/src/features/conversation/state/subagent-filter-store.ts new file mode 100644 index 000000000..a398ddc49 --- /dev/null +++ b/src/features/conversation/state/subagent-filter-store.ts @@ -0,0 +1,74 @@ +/** + * Shared state for the "filter the thread to one subagent" feature. + * + * A module-level Zustand store (sibling to `streaming-store.ts`) keyed by + * `sessionId` so the composer strip (which toggles the filter) and the thread + * viewport (which applies it) — two separate subtrees — agree on the active + * subagent without prop-drilling. Survives container remounts. + * + * The active entry carries the display `name` alongside its `key` so the + * filtering banner can still label the subagent after it finishes and drops out + * of the running list (Claude names derive from `args.description`, which a key + * alone can't recover). + */ + +import { useCallback } from "react"; +import { create } from "zustand"; + +export type ActiveSubagentFilter = { key: string; name: string }; + +type SubagentFilterState = { + /** `sessionId -> active subagent filter` (absent for "show all"). */ + activeBySession: Record; + setFilter: (sessionId: string, filter: ActiveSubagentFilter) => void; + clearFilter: (sessionId: string) => void; +}; + +export const useSubagentFilterStore = create((set) => ({ + activeBySession: {}, + setFilter: (sessionId, filter) => + set((state) => { + const current = state.activeBySession[sessionId]; + if (current && current.key === filter.key && current.name === filter.name) + return state; + return { + activeBySession: { ...state.activeBySession, [sessionId]: filter }, + }; + }), + clearFilter: (sessionId) => + set((state) => { + if (!(sessionId in state.activeBySession)) return state; + const { [sessionId]: _removed, ...rest } = state.activeBySession; + return { activeBySession: rest }; + }), +})); + +/** The active subagent filter for a session, or null when unfiltered. */ +export function useActiveSubagentFilter( + sessionId: string | null, +): ActiveSubagentFilter | null { + return useSubagentFilterStore((state) => + sessionId ? (state.activeBySession[sessionId] ?? null) : null, + ); +} + +/** Stable `{ active, setFilter, clearFilter }` bound to one session. */ +export function useSubagentFilter(sessionId: string | null): { + active: ActiveSubagentFilter | null; + setFilter: (filter: ActiveSubagentFilter) => void; + clearFilter: () => void; +} { + const active = useActiveSubagentFilter(sessionId); + const setFilterRaw = useSubagentFilterStore((state) => state.setFilter); + const clearFilterRaw = useSubagentFilterStore((state) => state.clearFilter); + const setFilter = useCallback( + (filter: ActiveSubagentFilter) => { + if (sessionId) setFilterRaw(sessionId, filter); + }, + [sessionId, setFilterRaw], + ); + const clearFilter = useCallback(() => { + if (sessionId) clearFilterRaw(sessionId); + }, [sessionId, clearFilterRaw]); + return { active, setFilter, clearFilter }; +} diff --git a/src/features/panel/message-components/subagent-tool.tsx b/src/features/panel/message-components/subagent-tool.tsx index c32208e2e..3bb4ed5eb 100644 --- a/src/features/panel/message-components/subagent-tool.tsx +++ b/src/features/panel/message-components/subagent-tool.tsx @@ -43,7 +43,7 @@ export function isSubagentSpawnPart(part: ToolCallPart): boolean { return part.toolName === "subagent_spawn"; } -interface AgentState { +export interface AgentState { threadId: string; nickname: string | null; role: string | null; @@ -51,7 +51,7 @@ interface AgentState { message: string | null; } -function readAgentsStates(args: Record): AgentState[] { +export function readAgentsStates(args: Record): AgentState[] { const raw = args.agentsStates; if (!raw || typeof raw !== "object") return []; const out: AgentState[] = []; diff --git a/src/features/panel/subagent-filter-banner.tsx b/src/features/panel/subagent-filter-banner.tsx new file mode 100644 index 000000000..206738cb6 --- /dev/null +++ b/src/features/panel/subagent-filter-banner.tsx @@ -0,0 +1,101 @@ +/** + * Thin filter bar pinned at the top of the thread viewport whenever a subagent + * filter is active. Shows the subagent's identity (pixel sprite + name + type) + * and a few derived activity stats (tool uses / files / steps + running state), + * with a "Show all" affordance. Stays even after the subagent finishes and the + * composer strip collapses, so the filter is always clearable. + * + * Per-token usage isn't surfaced — it isn't carried on any rendered part, so the + * banner reports the activity it can actually count (see `summarizeSubagent`). + */ + +import { Hammer, ListTree, Pencil, X } from "lucide-react"; +import type { SubagentSummary } from "@/features/composer/subagent-strip/extract-subagents"; +import { SubagentPixelAvatar } from "@/features/composer/subagent-strip/pixel-avatar"; +import { useSubagentFilter } from "@/features/conversation/state/subagent-filter-store"; + +export function SubagentFilterBanner({ + sessionId, + summary, +}: { + sessionId: string; + summary: SubagentSummary | null; +}) { + const { active, clearFilter } = useSubagentFilter(sessionId); + if (!active) return null; + const color = summary?.color; + return ( +
+ {color ? ( + + ) : null} + + Filtering by + {active.name} + + {summary?.agentType ? ( + + {summary.agentType} + + ) : null} + + {summary ? ( +
+ }> + {summary.toolUses}{" "} + {summary.toolUses === 1 ? "tool use" : "tool uses"} + + {summary.filesTouched > 0 ? ( + }> + {summary.filesTouched}{" "} + {summary.filesTouched === 1 ? "file" : "files"} + + ) : null} + }> + {summary.steps} {summary.steps === 1 ? "step" : "steps"} + + + {summary.running ? ( + + ) : ( + + )} + {summary.running ? "Running" : "Done"} + +
+ ) : null} + + +
+ ); +} + +function Stat({ + icon, + children, +}: { + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} diff --git a/src/features/panel/thread-viewport.tsx b/src/features/panel/thread-viewport.tsx index 79a599809..995e43c41 100644 --- a/src/features/panel/thread-viewport.tsx +++ b/src/features/panel/thread-viewport.tsx @@ -15,6 +15,11 @@ import { import { useStickToBottom } from "use-stick-to-bottom"; import { HelmorLogoAnimated } from "@/components/helmor-logo-animated"; import { Button } from "@/components/ui/button"; +import { + selectSubagentBlock, + summarizeSubagent, +} from "@/features/composer/subagent-strip/extract-subagents"; +import { useActiveSubagentFilter } from "@/features/conversation/state/subagent-filter-store"; import type { ThreadMessageLike } from "@/lib/api"; import { HelmorProfiler } from "@/lib/dev-react-profiler"; import { estimateThreadRowHeights } from "@/lib/message-layout-estimator"; @@ -32,6 +37,7 @@ import { resetAnchoredToggle, UserMessageExpansionProvider, } from "./message-components"; +import { SubagentFilterBanner } from "./subagent-filter-banner"; import { useEscapeBottomLock } from "./thread-viewport/use-escape-bottom-lock"; import { useStreamingIndicatorSync } from "./thread-viewport/use-streaming-indicator-sync"; @@ -117,6 +123,24 @@ export function ActiveThreadViewport({ onInitializeScript?: (scriptType: WorkspaceScriptType) => void; }) { const stackRef = useRef(null); + const subagentFilter = useActiveSubagentFilter(pane.sessionId); + // When a subagent filter is active, collapse the thread to that subagent's + // outputs (Claude `Task` children / Codex `subagent_*` rows). Transforming + // the source array — not hiding DOM rows — keeps virtualization measurements + // correct. An unresolved key yields an empty thread; the banner stays so the + // filter is always clearable. + const filteredMessages = useMemo(() => { + if (!subagentFilter) return pane.messages; + const block = selectSubagentBlock(pane.messages, subagentFilter.key); + return block ? [block] : []; + }, [subagentFilter, pane.messages]); + const subagentSummary = useMemo( + () => + subagentFilter + ? summarizeSubagent(pane.messages, subagentFilter.key) + : null, + [subagentFilter, pane.messages], + ); const [widthBucket, setWidthBucket] = useState(0); const pendingBucketRef = useRef(null); // 32px buckets so estimator/measureHeights caches only invalidate when @@ -179,16 +203,20 @@ export function ActiveThreadViewport({ ref={stackRef} className="relative flex min-h-0 flex-1 flex-col overflow-hidden" > +