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. */}
+