diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index b57758b50c12..9b09ef6d903d 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -153,6 +153,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
+ ThreadAgentSpawnCard,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadThinkingRow,
@@ -504,12 +505,7 @@ function MessageAttachmentFile(props: {
function MessageAttachmentUnknown(props: { readonly name: string }) {
return (
-
+
{props.name}
@@ -1357,7 +1353,7 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
- className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2"
+ className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
style={{
minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
}}
@@ -1382,6 +1378,19 @@ function renderFeedEntry(
return ;
}
+ if (entry.type === "agent-spawn") {
+ return (
+ props.onToggleWorkGroup(entry.id, entry.id)}
+ onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())}
+ />
+ );
+ }
+
if (entry.type === "work-toggle") {
return (
-
+
{label}
-
+
);
}
@@ -1508,7 +1517,7 @@ function renderFeedEntry(
})}
-
+
{timestampLabel}
{message.text.trim().length > 0 ? (
@@ -1557,7 +1566,7 @@ function renderFeedEntry(
attachmentId={attachment.id}
name={attachment.name}
mimeType={attachment.mimeType}
- className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong"
+ className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
onPressPreview={props.onPressPreview}
/>
) : isFileAttachment(attachment) ? (
@@ -1581,7 +1590,7 @@ function renderFeedEntry(
buttonSize={28}
iconSize={13}
/>
-
+
{timestampLabel}
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index 9d5b40fce6dc..e812e77ef088 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText";
import { T3Wordmark } from "../../components/T3Wordmark";
import { cn } from "../../lib/cn";
import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
-import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity";
+import {
+ type AgentSpawnSummary,
+ type ThreadFeedActivity,
+ workEntryRowLabel,
+} from "../../lib/threadActivity";
import {
resolveThreadWorkGroupInitialScroll,
shouldFollowThreadWorkGroupAppend,
@@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: {
}
function ShimmerWorkContent(props: {
+ readonly compact?: boolean;
readonly environmentId?: EnvironmentId;
readonly highlighted: boolean;
readonly icon: WorkContentIcon;
@@ -147,26 +152,29 @@ function ShimmerWorkContent(props: {
}) {
return (
-
- {props.showIcon && props.toolIcon && props.environmentId ? (
-
- ) : props.showIcon ? (
-
- ) : null}
-
+ {props.showIcon ? (
+
+ {props.toolIcon && props.environmentId ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
{
const subscription = AppState.addEventListener("change", (state) => {
@@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: {
onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)}
>
@@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
layout={WORK_LOG_LAYOUT_TRANSITION}
- className="ml-7 border-l border-border pb-1 pl-3 pt-0.5"
+ className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5"
>
{viewedImagePath ? (
@@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: {
);
}
+const AGENT_SPAWN_TONE_DOT_CLASS = {
+ working: "bg-adaptive-sky-600-400",
+ completed: "bg-adaptive-emerald-600-400",
+ failed: "bg-adaptive-rose-600-400",
+ stopped: "bg-foreground-muted",
+} as const satisfies Record;
+
+/**
+ * A batch of spawned subagents. The status line updates in place as members
+ * report progress; expanding lists each member. Text nodes carry keys tied to
+ * the row identity only, so a progress tick re-renders the labels without
+ * remounting the card (see the batch key in appendActivityGroupRows).
+ */
+export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: {
+ readonly summary: AgentSpawnSummary;
+ readonly expanded: boolean;
+ readonly iconSubtleColor: ColorValue;
+ readonly rowSizing: ReturnType;
+ readonly onToggle: () => void;
+ readonly onCopy: () => void;
+}) {
+ const { summary, expanded } = props;
+ const working = summary.tone === "working";
+ const memberCount = summary.members.length;
+ const canExpand = memberCount > 0;
+ return (
+
+ {
+ if (!canExpand) return;
+ void Haptics.selectionAsync();
+ props.onToggle();
+ }}
+ onLongPress={props.onCopy}
+ className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle"
+ >
+
+
+
+
+
+
+ {summary.title}
+
+
+
+ {working ? (
+
+ ) : (
+
+ {summary.status}
+
+ )}
+
+
+ {canExpand ? (
+
+ ) : null}
+
+ {expanded && canExpand ? (
+
+ {summary.members.map((member) => (
+
+
+
+
+ {member.title}
+
+ {member.status}
+
+ {member.detail ? (
+
+ {member.detail}
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+
+ );
+});
+
export function ThreadThinkingRow(props: {
readonly rowSizing: ReturnType;
readonly iconSubtleColor: ColorValue;
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index 6131dc9a1b31..7d6cc39ea616 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -13,6 +13,7 @@ import {
} from "@t3tools/contracts";
import {
+ agentSpawnSummary,
buildPendingUserInputAnswers,
buildThreadFeed,
derivePendingApprovals,
@@ -24,6 +25,7 @@ import {
workEntryRowLabel,
type ThreadFeedActivity,
type ThreadFeedEntry,
+ type WorkLogEntry,
} from "./threadActivity";
describe("Codex feedback pseudo-messages", () => {
@@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => {
new Set(),
latestTurn.startedAt,
);
+ // The shimmering row is the turn's live slot; once it stops shimmering
+ // the slot belongs to "Thinking" and the group keeps its own identity.
expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
["work-toggle:work-group:activity-1", "work-toggle"],
["activity-2", "activity-group"],
- ["work-live:work-group:activity-3", "work-toggle"],
+ [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"],
]);
expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
false,
@@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => {
const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);
- expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId });
+ expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId });
// The row identity is stable across re-derivations so the list can reuse it.
expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
rows[1],
@@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => {
).toEqual(["message"]);
});
+ it("keeps one live slot while calls fail and restart", () => {
+ // Recorded from a Claude session whose Bash was broken: every call went
+ // inProgress → failed within two seconds. Each transition used to insert
+ // or remove a Thinking row under the group; now the same row id holds
+ // the live call and then "Thinking", so the list updates it in place.
+ const turnId = TurnId.make("turn-failing-calls");
+ const latestTurn = {
+ turnId,
+ state: "running" as const,
+ requestedAt: "2026-04-01T00:00:00.000Z",
+ startedAt: "2026-04-01T00:00:00.000Z",
+ completedAt: null,
+ assistantMessageId: null,
+ };
+ const call = (n: number, status: "inProgress" | "failed") =>
+ makeActivity({
+ id: EventId.make(`call-${n}-${status}`),
+ kind: status === "failed" ? "tool.completed" : "tool.updated",
+ tone: "tool",
+ summary: "Command run",
+ createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`,
+ turnId,
+ payload: {
+ itemType: "command_execution",
+ toolCallId: `call-${n}`,
+ title: "Command run",
+ status,
+ detail: `Bash: ls ${n}`,
+ },
+ });
+ const liveIds = (activities: ReadonlyArray>) =>
+ deriveThreadFeedPresentation(
+ buildThreadFeed(
+ makeThread({
+ id: ThreadId.make("thread-failing-calls"),
+ projectId: ProjectId.make("project-1"),
+ title: "Failing calls",
+ latestTurn,
+ activities,
+ }),
+ ),
+ latestTurn,
+ new Set(),
+ new Set(),
+ latestTurn.startedAt,
+ ).map((row) => `${row.type}:${row.id}`);
+
+ expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]);
+ expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([
+ "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
+ "thinking:live-activity-row",
+ ]);
+ expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([
+ "work-toggle:live-activity-row",
+ ]);
+ // A call whose end was never reported, in a run before an error row,
+ // keeps its own identity: only the trailing run can hold the live slot.
+ const errorRow = makeActivity({
+ id: EventId.make("runtime-error"),
+ kind: "runtime.error",
+ tone: "error",
+ summary: "Provider error",
+ createdAt: "2026-04-01T00:00:02.500Z",
+ turnId,
+ payload: { message: "boom" },
+ });
+ expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([
+ "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1",
+ "activity-group:runtime-error",
+ "work-toggle:live-activity-row",
+ ]);
+ });
+
it("hands a settled tool run off to Thinking once assistant text streams after it", () => {
const turnId = TurnId.make("turn-streaming-tail");
const latestTurn = {
@@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => {
}),
).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
+ // The batch anchors on the first task.started: a fixed id and timestamp,
+ // unlike progress ticks (which the server rewrites in place).
const running = rowsFor([]);
expect(running.map((row) => [row.id, row.summary])).toEqual([
- ["a-progress", "Kicked off 2 subagents · 2 working"],
+ ["a-start", "Kicked off 2 subagents · 2 working"],
["shell-1", "Run tests"],
]);
expect(running[0]).toMatchObject({
+ createdAt: "2026-04-01T00:00:01.000Z",
lifecycleStatus: "inProgress",
workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
});
const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
expect(oneDone[0]).toMatchObject({
- id: "a-progress",
+ id: "a-start",
summary: "Kicked off 2 subagents · 1 working",
lifecycleStatus: "inProgress",
});
@@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => {
agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
]);
expect(allDone[0]).toMatchObject({
- id: "a-progress",
+ id: "a-start",
summary: "Ran 2 subagents · 1 failed",
lifecycleStatus: "failed",
status: "failure",
@@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => {
expect(allDone).toHaveLength(2);
});
+ it("folds the tool call that launched an agent into its spawn card", () => {
+ const turnId = TurnId.make("turn-agent-tool");
+ const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
+ const feed = buildThreadFeed(
+ makeThread({
+ id: ThreadId.make("thread-agent-tool"),
+ projectId: ProjectId.make("project-1"),
+ title: "Agent tool",
+ activities: [
+ makeActivity({
+ id: EventId.make("agent-call-updated"),
+ kind: "tool.updated",
+ tone: "tool",
+ summary: "Subagent task",
+ createdAt: at(1),
+ turnId,
+ payload: {
+ itemType: "collab_agent_tool_call",
+ toolCallId: "toolu_agent",
+ status: "inProgress",
+ title: "Subagent task",
+ detail: "Locate code",
+ data: { toolName: "Agent" },
+ },
+ }),
+ makeActivity({
+ id: EventId.make("agent-started"),
+ kind: "task.started",
+ summary: "Locate code",
+ createdAt: at(2),
+ turnId,
+ payload: {
+ taskId: "a1",
+ agentKind: "agent",
+ taskType: "local_agent",
+ title: "Locate code",
+ toolUseId: "toolu_agent",
+ },
+ }),
+ makeActivity({
+ id: EventId.make("agent-done"),
+ kind: "task.completed",
+ summary: "Locate code",
+ createdAt: at(3),
+ turnId,
+ payload: {
+ taskId: "a1",
+ agentKind: "agent",
+ taskType: "local_agent",
+ title: "Locate code",
+ toolUseId: "toolu_agent",
+ status: "completed",
+ },
+ }),
+ makeActivity({
+ id: EventId.make("agent-call-completed"),
+ kind: "tool.completed",
+ tone: "tool",
+ summary: "Subagent task",
+ createdAt: at(4),
+ turnId,
+ payload: {
+ itemType: "collab_agent_tool_call",
+ toolCallId: "toolu_agent",
+ status: "completed",
+ title: "Subagent task",
+ detail: "Locate code",
+ data: { toolName: "Agent" },
+ },
+ }),
+ ],
+ }),
+ );
+ const rows = feed.flatMap((entry) =>
+ entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
+ );
+ expect(rows).toEqual(["agent-started"]);
+ expect(
+ deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type),
+ ).toEqual(["turn-fold", "agent-spawn"]);
+ });
+
+ it("presents a spawn batch as one card whose status line follows the newest member activity", () => {
+ const turnId = TurnId.make("turn-spawn-card");
+ const latestTurn = {
+ turnId,
+ state: "running" as const,
+ requestedAt: "2026-04-01T00:00:00.000Z",
+ startedAt: "2026-04-01T00:00:00.000Z",
+ completedAt: null,
+ assistantMessageId: null,
+ };
+ const agent = (
+ id: string,
+ kind: "task.started" | "task.progress" | "task.completed",
+ taskId: string,
+ seconds: number,
+ extra: Record = {},
+ ) =>
+ makeActivity({
+ id: EventId.make(id),
+ kind,
+ summary: `Agent ${taskId}`,
+ createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
+ turnId,
+ payload: {
+ taskId,
+ agentKind: "agent",
+ taskType: "local_agent",
+ title: `Agent ${taskId}`,
+ ...extra,
+ },
+ });
+ const presentFor = (activities: ReadonlyArray>) =>
+ deriveThreadFeedPresentation(
+ buildThreadFeed(
+ makeThread({
+ id: ThreadId.make("thread-spawn-card"),
+ projectId: ProjectId.make("project-1"),
+ title: "Spawn card",
+ latestTurn,
+ activities,
+ }),
+ ),
+ latestTurn,
+ new Set(),
+ new Set(),
+ latestTurn.startedAt,
+ );
+
+ // A working card is the live activity; no Thinking row sits under it.
+ const single = presentFor([agent("a-start", "task.started", "a", 1)]);
+ expect(single.map((row) => row.type)).toEqual(["agent-spawn"]);
+ expect(single[0]).toMatchObject({
+ id: `agent-spawn:${turnId}`,
+ summary: { title: "Agent a", status: "Working", tone: "working" },
+ });
+
+ // The server upserts the progress row with a new createdAt each tick;
+ // the card keeps its identity and only the status line changes.
+ const tick = (seconds: number, detail: string) =>
+ presentFor([
+ agent("a-start", "task.started", "a", 1),
+ agent("task-progress:a", "task.progress", "a", seconds, { detail }),
+ ]);
+ expect(tick(2, "Reading a.ts")[0]).toMatchObject({
+ id: `agent-spawn:${turnId}`,
+ createdAt: "2026-04-01T00:00:01.000Z",
+ summary: { title: "Agent a", status: "Reading a.ts", tone: "working" },
+ });
+ expect(tick(3, "Reading b.ts")[0]).toMatchObject({
+ id: `agent-spawn:${turnId}`,
+ createdAt: "2026-04-01T00:00:01.000Z",
+ summary: { status: "Reading b.ts" },
+ });
+
+ const batch = presentFor([
+ agent("a-start", "task.started", "a", 1),
+ agent("b-start", "task.started", "b", 2),
+ agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }),
+ agent("a-done", "task.completed", "a", 4, { status: "completed" }),
+ ]);
+ expect(batch[0]).toMatchObject({
+ id: `agent-spawn:${turnId}`,
+ summary: {
+ title: "2 subagents",
+ status: "Grepping",
+ tone: "working",
+ members: [
+ { title: "Agent a", status: "completed", tone: "completed" },
+ { title: "Agent b", status: "working", tone: "working", detail: "Grepping" },
+ ],
+ },
+ });
+
+ const settled = presentFor([
+ agent("a-start", "task.started", "a", 1),
+ agent("b-start", "task.started", "b", 2),
+ agent("a-done", "task.completed", "a", 4, { status: "completed" }),
+ agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }),
+ ]);
+ expect(settled[0]).toMatchObject({
+ type: "agent-spawn",
+ summary: { title: "2 subagents", status: "1 failed", tone: "failed" },
+ });
+ expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]);
+ });
+
it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
"replaces Antigravity batch progress with %s",
(status) => {
@@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => {
expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
});
+ it("summarizes a spawn card from the newest member report and the batch outcome", () => {
+ type Member = NonNullable["agents"][number];
+ const member = (title: string, status: Member["status"], detail: string, seconds: number) =>
+ ({
+ title,
+ status,
+ detail,
+ updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
+ }) satisfies Member;
+ const direct = (agents: ReadonlyArray) => ({
+ workflowId: null,
+ agentTaskIds: agents.map((_, index) => `a${index}`),
+ agents,
+ });
+
+ // The newest report wins regardless of member order.
+ expect(
+ agentSpawnSummary(
+ direct([
+ member("Agent 0", "inProgress", "Reading b.ts", 5),
+ member("Agent 1", "inProgress", "Reading a.ts", 2),
+ ]),
+ "inProgress",
+ ),
+ ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" });
+
+ // A declined request is a failed batch, not a completed one.
+ expect(
+ agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"),
+ ).toMatchObject({ status: "failed", tone: "failed" });
+
+ // A coordinator that failed on its own reports the failure even when every
+ // member succeeded; before any member reports, the card has a neutral title.
+ const workflow = (agents: ReadonlyArray) => ({
+ workflowId: "wf",
+ agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)],
+ agents: [member("review", "failed", "", 9), ...agents],
+ });
+ expect(
+ agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"),
+ ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" });
+ expect(
+ agentSpawnSummary(
+ { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] },
+ "inProgress",
+ ),
+ ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] });
+ });
+
it("treats a Codex child's idle turn end as a finished batch member", () => {
const turnId = TurnId.make("turn-codex");
const child = (
@@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => {
expect(ids).toContain("nested-done");
expect(ids).not.toContain("shell-done");
expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([
- { type: "activity-group", id: "nested-done" },
+ {
+ type: "agent-spawn",
+ id: "agent-spawn:n-1",
+ activity: { id: "nested-done" },
+ summary: { title: "Task completed", status: "completed", tone: "completed" },
+ },
]);
});
});
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 1d6fb6c0e252..7286446fb2e8 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -122,6 +122,8 @@ export interface WorkLogEntry {
readonly title: string;
readonly status: WorkLogToolLifecycleStatus | undefined;
readonly detail: string | undefined;
+ /** When this member last reported, so the card can show the newest activity. */
+ readonly updatedAt: string;
}>;
};
toolData?: unknown;
@@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry {
collapseKey?: string;
/** Grouping key for subagent lifecycle rows (one row per agent). */
taskId?: string;
+ /** The tool call that launched this agent, when the provider reports one. */
+ agentSpawnToolCallId?: string;
isWorkflowCoordinator?: boolean;
/** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
isBackgroundTask?: boolean;
@@ -187,12 +191,47 @@ export type ThreadFeedEntry =
readonly expanded: boolean;
}
| {
+ /**
+ * The turn's single live slot. Web keys its live tool row and its
+ * "Thinking" row identically so the slot updates in place; here the
+ * slot holds "Thinking" whenever no tool row is shimmering, so a tool
+ * failing does not insert a row under the group it lives in.
+ */
readonly type: "thinking";
readonly id: string;
readonly createdAt: string;
readonly turnId: TurnId | null;
+ }
+ | {
+ /**
+ * One batch of spawned subagents. Rendered as its own card because a
+ * single-line tool row has no room for what the agents are doing now,
+ * which on a phone is the one thing worth showing.
+ */
+ readonly type: "agent-spawn";
+ readonly id: string;
+ readonly createdAt: string;
+ readonly turnId: TurnId | null;
+ readonly activity: ThreadFeedActivity;
+ readonly expanded: boolean;
+ readonly summary: AgentSpawnSummary;
};
+export interface AgentSpawnSummary {
+ /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */
+ readonly title: string;
+ /** Latest member activity while working, else the batch outcome. */
+ readonly status: string;
+ readonly tone: "working" | "completed" | "failed" | "stopped";
+ readonly members: ReadonlyArray<{
+ readonly title: string;
+ readonly status: string;
+ readonly tone: "working" | "completed" | "failed" | "stopped";
+ readonly detail: string | undefined;
+ readonly updatedAt: string;
+ }>;
+}
+
export type ThreadFeedLatestTurn = Pick<
OrchestrationLatestTurn,
"turnId" | "state" | "startedAt" | "completedAt"
@@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
return false;
}
const isTaskRow =
+ activity.kind === "task.started" ||
activity.kind === "task.progress" ||
activity.kind === "task.updated" ||
activity.kind === "task.completed";
@@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
return payload.timelineBypass === true || ownedByAgent;
}
+/** Agent (non-background) task.started rows seed spawn batches. */
+function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
+ const payload =
+ activity.payload && typeof activity.payload === "object"
+ ? (activity.payload as Record)
+ : null;
+ return typeof payload?.taskId === "string" && payload.agentKind === "agent";
+}
+
function deriveWorkLogEntries(
activities: ReadonlyArray,
): DerivedWorkLogEntry[] {
@@ -449,7 +498,11 @@ function deriveWorkLogEntries(
for (const activity of ordered) {
if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
if (activity.kind === "tool.started") continue;
- if (activity.kind === "task.started") continue;
+ // Like web: an agent's task.started row anchors its batch. It has a fixed
+ // id and timestamp, unlike progress ticks, whose stable per-task id is
+ // rewritten with a new createdAt on every update (and would otherwise
+ // make the batch row a "fresh" row again on each tick).
+ if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
@@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const toolPresentation = extractToolActivityPresentation(payload);
// Terminal task updates carry identity so they replace each child's progress row.
const isTaskActivity =
+ activity.kind === "task.started" ||
activity.kind === "task.progress" ||
activity.kind === "task.completed" ||
activity.kind === "task.updated";
@@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (payload.agentKind !== "agent") {
entry.isBackgroundTask = true;
}
+ const spawnToolCallId = asTrimmedString(payload.toolUseId);
+ if (spawnToolCallId) {
+ entry.agentSpawnToolCallId = spawnToolCallId;
+ }
if (
payload.taskType === "local_workflow" ||
(typeof payload.workflowName === "string" && payload.workflowName.length > 0)
@@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
entry.requestKind = requestKind;
}
let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
- if (!toolLifecycleStatus && activity.kind === "tool.completed") {
- toolLifecycleStatus = "completed";
+ if (
+ !toolLifecycleStatus &&
+ (activity.kind === "tool.completed" || activity.kind === "task.completed")
+ ) {
+ toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed";
}
// A Codex child that finishes its turn reports "idle" (resumable, not
// terminal). For the batch row that is a finished member.
@@ -681,6 +742,7 @@ function agentSpawnMember(
title: entry.toolTitle ?? previous?.title ?? entry.label,
status: entry.toolLifecycleStatus ?? previous?.status,
detail: entry.detail ?? previous?.detail,
+ updatedAt: entry.createdAt,
};
}
@@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus(
return "inProgress";
}
if (statuses.includes("failed")) return "failed";
+ if (statuses.includes("declined")) return "declined";
if (statuses.includes("stopped")) return "stopped";
return "completed";
}
@@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries(
const spawnRowIndex = new Map();
const spawnGroupByTaskId = new Map();
const toolLifecycleRowIndex = new Map();
+ // Tool calls that launched an agent (Claude's Agent tool, ACP subagent
+ // calls). The batch card is the whole story of that call, so its own
+ // lifecycle row is dropped.
+ const spawnToolCallIds = new Set(
+ entries.flatMap((entry) =>
+ entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [],
+ ),
+ );
for (const entry of entries) {
+ if (
+ entry.toolCallId !== undefined &&
+ entry.taskId === undefined &&
+ spawnToolCallIds.has(entry.toolCallId)
+ ) {
+ continue;
+ }
const isTaskRow =
entry.taskId !== undefined &&
- (entry.sourceActivityKind === "task.progress" ||
+ (entry.sourceActivityKind === "task.started" ||
+ entry.sourceActivityKind === "task.progress" ||
entry.sourceActivityKind === "task.completed" ||
entry.sourceActivityKind === "task.updated");
if (isTaskRow && entry.taskId !== undefined) {
@@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable) {
return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
}
+function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] {
+ switch (status) {
+ case undefined:
+ case "inProgress":
+ return "working";
+ case "completed":
+ return "completed";
+ case "failed":
+ case "declined":
+ return "failed";
+ case "stopped":
+ return "stopped";
+ }
+}
+
+/**
+ * What the spawn card shows. While members work, the status line is the
+ * newest member activity (its progress detail), so the card reads like the
+ * live tool row does for a single call. Once every member settles, it is the
+ * batch outcome in web's CTA wording.
+ */
+export function agentSpawnSummary(
+ spawn: NonNullable,
+ batchStatus: WorkLogToolLifecycleStatus | undefined,
+): AgentSpawnSummary {
+ const members = agentSpawnMembers(spawn).map((agent) => {
+ const tone = agentSpawnTone(agent.status);
+ return {
+ title: agent.title,
+ status: tone === "working" ? "working" : (agent.status ?? tone),
+ tone,
+ detail: agent.detail,
+ updatedAt: agent.updatedAt,
+ };
+ });
+ const tone = agentSpawnTone(batchStatus);
+ // A workflow's coordinator is not a member; before any member reports the
+ // batch has none.
+ const title =
+ members.length === 0
+ ? "Subagents"
+ : members.length === 1
+ ? members[0]!.title
+ : `${members.length} subagents`;
+ if (tone === "working") {
+ const working = members.filter((member) => member.tone === "working");
+ const latest = working
+ .filter((member) => member.detail !== undefined)
+ .reduce<(typeof working)[number] | undefined>(
+ (newest, member) =>
+ newest === undefined || member.updatedAt > newest.updatedAt ? member : newest,
+ undefined,
+ );
+ const status =
+ latest?.detail ??
+ (members.length > 1 ? `${working.length} of ${members.length} working` : "Working");
+ return { title, status, tone, members };
+ }
+ // The batch tone covers a coordinator that failed or stopped on its own.
+ const failed = members.filter((member) => member.tone === "failed").length;
+ const stopped = members.filter((member) => member.tone === "stopped").length;
+ const outcome =
+ tone === "failed" || failed > 0
+ ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed`
+ : tone === "stopped" || stopped > 0
+ ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped`
+ : "completed";
+ return { title, status: outcome, tone, members };
+}
+
function agentSpawnExpandedBody(spawn: NonNullable): string | null {
const lines = agentSpawnMembers(spawn).map((agent) => {
const status =
@@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation(
): ThreadFeedEntry[] {
const sourceFeed = feed.filter(
(entry) =>
- entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking",
+ entry.type !== "turn-fold" &&
+ entry.type !== "work-toggle" &&
+ entry.type !== "thinking" &&
+ entry.type !== "agent-spawn",
);
const activeTailGroup = sourceFeed.findLast(
(entry) => entry.type !== "message" || !isEmptyMessage(entry),
@@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation(
}
// A working turn always shows one live activity. When no tool row is
// shimmering (no tools yet, or the latest failed), that row is "Thinking".
+ // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so
+ // the handoff between them happens in place (one row, new content) instead
+ // of a row being inserted below the group every time a call fails.
if (
activeWorkStartedAt !== null &&
- !result.some((row) => row.type === "work-toggle" && row.shimmer)
+ !result.some(
+ (row) =>
+ (row.type === "work-toggle" && row.shimmer) ||
+ // A working spawn card is the live activity: its status line shows
+ // what the agents are doing, so a Thinking row under it would lie.
+ (row.type === "agent-spawn" &&
+ row.summary.tone === "working" &&
+ row.turnId === unsettledTurnId),
+ )
) {
result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId));
}
return result;
}
+/**
+ * Shared by the trailing tool group's live row and the "Thinking" row so the
+ * list keeps one mounted row for the turn's live slot (mirrors web's
+ * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them.
+ */
+export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
+
function thinkingRow(createdAt: string, turnId: TurnId | null) {
if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) {
- cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId };
+ cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId };
}
return cachedThinkingRow;
}
@@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry(
cached.isWorking !== isWorking ||
cached.activeTail !== activeTail ||
cached.rows.some(
- (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded,
+ (row) =>
+ (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) ||
+ (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded),
)
) {
const rows: ThreadFeedEntry[] = [];
@@ -1908,11 +2080,27 @@ function appendActivityGroupRows(
groupableRun = [];
};
for (const activity of activities) {
- if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) {
+ const spawn = activity.workEntry.agentSpawn;
+ if (activity.workEntry.tone !== "error" && spawn === undefined) {
groupableRun.push(activity);
continue;
}
flushGroupableRun(false);
+ if (spawn !== undefined) {
+ // Keyed by the batch, not the anchor activity: the anchor can change
+ // as members arrive, and a changed key remounts the card.
+ const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`;
+ result.push({
+ type: "agent-spawn",
+ id: groupId,
+ createdAt: activity.createdAt,
+ turnId: activity.turnId,
+ activity,
+ expanded: expandedWorkGroupIds.has(groupId),
+ summary: agentSpawnSummary(spawn, activity.lifecycleStatus),
+ });
+ continue;
+ }
result.push({
type: "activity-group",
id: activity.id,
@@ -1953,7 +2141,9 @@ function appendToolGroupRows(
const latestActivity = latestActiveActivity ?? activities.at(-1)!;
// Like web, the trailing run keeps shining after its latest call succeeds;
// only a failed, declined, or stopped call hands the live slot to "Thinking".
- const shimmer = active || (activeTail && latestActivity.status === "success");
+ // Only the trailing run can be the turn's live slot; an in-progress row in
+ // an earlier run (a call whose end was never reported) stays in place.
+ const shimmer = activeTail && (active || latestActivity.status === "success");
const singleActivity = activities.length === 1 ? latestActivity : null;
const summary = live
? liveToolActivitySummary(latestActivity, live)
@@ -1994,7 +2184,9 @@ function appendToolGroupRows(
: undefined;
result.push({
type: "work-toggle",
- id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
+ // The shimmering trailing row is the turn's live slot; it keeps that
+ // identity (and so its mounted view) until "Thinking" takes the slot.
+ id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`,
createdAt: sourceGroup.createdAt,
turnId: sourceGroup.turnId,
groupId,