# Files changed · PR \#10211 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10211/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): fold subagent lifecycle rows into one batch per spawn](https://git.cafe/pingdotgg/t3code/pull/10211.md)

Version: 5

Head commit: a83379df0175a23b22518b52fcb7f968248f4935

Base commit: e4d0f1d58a4424c17f0ffb5335da27cd66fa21ae

Comparison base: e4d0f1d58a4424c17f0ffb5335da27cd66fa21ae

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/lib/threadActivity.test.ts

Status: modified; +222; −7

```diff
@@ -745,12 +745,12 @@
         tone: "info" as const,
         summary: "Task completed",
         payload: {
-          taskId: "ae3f85a",
+          taskId: "bpxcizf97",
           status: "completed",
           title: "Audit the PR",
           detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.",
-          agentKind: "agent",
-          taskType: "local_agent",
+          agentKind: "background",
+          taskType: "local_bash",
         },
       },
       label: "**Tooling note:** Bash is unusable. # Audit No blockers.",
@@ -2745,16 +2745,104 @@
       const rows = buildThreadFeed(thread).flatMap((entry) =>
         entry.type === "activity-group" ? entry.activities : [],
       );
+      // The agent folds into its spawn batch, which stays live after a resume.
       expect(rows).toMatchObject([
         {
           lifecycleStatus: "inProgress",
+          summary: "Kicked off 1 subagent · 1 working",
-          summary: "Reviewer",
-          workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" },
+          workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } },
         },
       ]);
     },
   );
 
+  it("folds a turn's direct spawns into one batch row that tracks their states", () => {
+    const turnId = TurnId.make("turn-spawn");
+    const agent = (
+      id: string,
+      kind: "task.started" | "task.progress" | "task.completed" | "task.updated",
+      taskId: string,
+      status: string,
+      seconds: number,
+      extra: Record<string, unknown> = {},
+    ) =>
+      makeActivity({
+        id: EventId.make(id),
+        kind,
+        summary: `${taskId} ${status}`,
+        createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
+        turnId,
+        payload: {
+          taskId,
+          agentKind: "agent",
+          taskType: "local_agent",
+          title: `Agent ${taskId}`,
+          status,
+          ...extra,
+        },
+      });
+    const shell = makeActivity({
+      id: EventId.make("shell-1"),
+      kind: "task.completed",
+      summary: "Task completed",
+      createdAt: "2026-04-01T00:00:05.000Z",
+      turnId,
+      payload: {
+        taskId: "sh-1",
+        agentKind: "background",
+        taskType: "local_bash",
+        status: "completed",
+        title: "Run tests",
+        detail: "Run tests",
+      },
+    });
+    const activities = [
+      agent("a-start", "task.started", "a", "running", 1),
+      agent("b-start", "task.started", "b", "running", 2),
+      agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }),
+      shell,
+      agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }),
+    ];
+    const rowsFor = (extraActivities: ReadonlyArray<ReturnType<typeof makeActivity>>) =>
+      buildThreadFeed(
+        makeThread({
+          id: ThreadId.make("thread-spawn"),
+          projectId: ProjectId.make("project-1"),
+          title: "Spawns",
+          activities: [...activities, ...extraActivities],
+        }),
+      ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
+
+    const running = rowsFor([]);
+    expect(running.map((row) => [row.id, row.summary])).toEqual([
+      ["a-progress", "Kicked off 2 subagents · 2 working"],
+      ["shell-1", "Run tests"],
+    ]);
+    expect(running[0]).toMatchObject({
+      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",
+      summary: "Kicked off 2 subagents · 1 working",
+      lifecycleStatus: "inProgress",
+    });
+
+    const allDone = rowsFor([
+      agent("a-done", "task.completed", "a", "completed", 7),
+      agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
+    ]);
+    expect(allDone[0]).toMatchObject({
+      id: "a-progress",
+      summary: "Ran 2 subagents · 1 failed",
+      lifecycleStatus: "failed",
+      status: "failure",
+    });
+    expect(allDone).toHaveLength(2);
+  });
+
   it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
     "replaces Antigravity batch progress with %s",
     (status) => {
@@ -2802,19 +2890,146 @@
       const rows = buildThreadFeed(thread).flatMap((entry) =>
         entry.type === "activity-group" ? entry.activities : [],
       );
+      // Turn-less batches never share a spawn group, so each keeps its own row.
       expect(rows).toHaveLength(2);
       expect(rows[0]).toMatchObject({
         lifecycleStatus: status === "failed" ? "failed" : "stopped",
-        detail,
-        workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" },
+        summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`,
+        workEntry: {
+          taskId: "trajectory:4",
+          toolTitle: "Antigravity subagent batch",
+          agentSpawn: { agents: [{ detail }] },
+        },
       });
+      expect(rows[0]?.getFullDetail()).toContain(detail);
       expect(rows[1]).toMatchObject({
         lifecycleStatus: "inProgress",
+        summary: "Kicked off 1 subagent · 1 working",
         workEntry: { taskId: "trajectory:5" },
       });
     },
   );
 
+  it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => {
+    const turnId = TurnId.make("turn-workflow");
+    const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
+    const thread = makeThread({
+      id: ThreadId.make("thread-workflow"),
+      projectId: ProjectId.make("project-1"),
+      title: "Workflow",
+      activities: [
+        makeActivity({
+          id: EventId.make("wf-progress"),
+          kind: "task.progress",
+          summary: "Workflow running",
+          createdAt: at(1),
+          turnId,
+          payload: {
+            taskId: "wf-1",
+            taskType: "local_workflow",
+            workflowName: "review",
+            agentKind: "agent",
+            title: "review",
+            status: "running",
+          },
+        }),
+        // Members are synthesized with timelineBypass and never render alone.
+        ...[0, 1].map((index) =>
+          makeActivity({
+            id: EventId.make(`member-${index}`),
+            kind: "task.progress",
+            summary: `Agent ${index}`,
+            createdAt: at(2 + index),
+            turnId,
+            payload: {
+              taskId: `wf-1:wf:${index}`,
+              agentKind: "agent",
+              title: `Reviewer ${index}`,
+              description: `Reviewer ${index}`,
+              status: index === 0 ? "completed" : "running",
+              parentAgentId: "wf-1",
+              timelineBypass: true,
+            },
+          }),
+        ),
+        makeActivity({
+          id: EventId.make("wf-done"),
+          kind: "task.completed",
+          summary: "Task completed",
+          createdAt: at(10),
+          turnId,
+          payload: {
+            taskId: "wf-1",
+            taskType: "local_workflow",
+            workflowName: "review",
+            agentKind: "agent",
+            status: "completed",
+            title: "review",
+          },
+        }),
+      ],
+    });
+    const rows = buildThreadFeed(thread).flatMap((entry) =>
+      entry.type === "activity-group" ? entry.activities : [],
+    );
+    expect(rows).toHaveLength(1);
+    // The member that never reported its own end settles with the coordinator.
+    expect(rows[0]).toMatchObject({
+      id: "wf-progress",
+      summary: "Ran 2 subagents · completed",
+      lifecycleStatus: "completed",
+      workEntry: {
+        agentSpawn: {
+          workflowId: "wf-1",
+          agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"],
+        },
+      },
+    });
+    expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
+  });
+
+  it("treats a Codex child's idle turn end as a finished batch member", () => {
+    const turnId = TurnId.make("turn-codex");
+    const child = (
+      id: string,
+      kind: "task.started" | "task.updated",
+      status: string,
+      seconds: number,
+    ) =>
+      makeActivity({
+        id: EventId.make(id),
+        kind,
+        summary: `${status}`,
+        createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
+        turnId,
+        payload: {
+          taskId: "child-1",
+          agentKind: "agent",
+          title: "math_one",
+          status,
+          timelineBypass: true,
+        },
+      });
+    const thread = makeThread({
+      id: ThreadId.make("thread-codex"),
+      projectId: ProjectId.make("project-1"),
+      title: "Codex children",
+      activities: [
+        child("c-start", "task.started", "running", 1),
+        child("c-running", "task.updated", "running", 2),
+        child("c-idle", "task.updated", "idle", 5),
+      ],
+    });
+    const rows = buildThreadFeed(thread).flatMap((entry) =>
+      entry.type === "activity-group" ? entry.activities : [],
+    );
+    expect(rows).toHaveLength(1);
+    expect(rows[0]).toMatchObject({
+      summary: "Ran 1 subagent · completed",
+      lifecycleStatus: "completed",
+    });
+  });
+
   it("keeps a nested agent's terminal row but hides its background work", () => {
     const thread = makeThread({
       id: ThreadId.make("thread-nested"),
```

## apps/mobile/src/lib/threadActivity.ts

Status: modified; +228; −26

```diff
@@ -110,7 +110,20 @@
   toolLifecycleStatus?: WorkLogToolLifecycleStatus;
   sourceActivityKind?: OrchestrationThreadActivity["kind"];
   toolCallId?: string;
-  agentSpawn?: boolean;
+  /**
+   * One row per workflow run or per-turn batch of direct spawns, like web's
+   * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row
+   * also carries each agent's terminal state to derive its status label.
+   */
+  agentSpawn?: {
+    readonly workflowId: string | null;
+    readonly agentTaskIds: ReadonlyArray<string>;
+    readonly agents: ReadonlyArray<{
+      readonly title: string;
+      readonly status: WorkLogToolLifecycleStatus | undefined;
+      readonly detail: string | undefined;
+    }>;
+  };
   toolData?: unknown;
 }
 
@@ -119,6 +132,9 @@
   collapseKey?: string;
   /** Grouping key for subagent lifecycle rows (one row per agent). */
   taskId?: string;
+  isWorkflowCoordinator?: boolean;
+  /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
+  isBackgroundTask?: boolean;
 }
 
 type RawThreadFeedEntry =
@@ -388,10 +404,12 @@
 
 /**
  * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
- * activity lives in the Agents sheet, not the work log. Terminal rows are
- * kept — with no Agents surface on mobile they are the terminal signal
- * (a surface that hides rows must keep its own terminal signal). That means
- * task.completed and terminal task.updated, including Antigravity cancellation.
+ * activity lives in the Agents sheet, not the work log. Agent lifecycle rows
+ * pass even when bypassed or owned by another agent, because they fold into
+ * their spawn batch rather than rendering on their own; that is how Codex
+ * children (all bypassed) and Claude workflow members reach the batch row.
+ * Terminal rows are kept regardless — with no Agents surface on mobile they
+ * are the terminal signal.
  */
 function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
   const payload =
@@ -400,21 +418,27 @@
       : null;
   if (!payload) {
     return false;
-  }
-  const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity);
-  if (payload.timelineBypass === true && !isTerminalTaskRow) {
-    return true;
   }
-  // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
-  // the only signal mobile gets (no Agents sheet), so it stays. Only an
-  // agent's own background work (stamped "background") is internal — same
-  // rule as web (review finding: hiding on agentId alone dropped nested
-  // completions with no replacement UI).
+  const isTaskRow =
+    activity.kind === "task.progress" ||
+    activity.kind === "task.updated" ||
+    activity.kind === "task.completed";
   const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
-  if (!ownedByAgent) {
-    return false;
+  if (isTaskRow) {
+    if (!ownedByAgent && payload.timelineBypass !== true) {
+      return false;
+    }
+    // An agent's own shells stay internal; the agents themselves fold into
+    // their batch. A bypassed batch marker keeps its terminal row.
+    if (typeof payload.taskId === "string" && payload.agentKind === "agent") {
+      return false;
+    }
+    if (ownedByAgent) {
+      return true;
+    }
+    return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity));
   }
-  return !(isTerminalTaskRow && payload.agentKind === "agent");
+  return payload.timelineBypass === true || ownedByAgent;
 }
 
 function deriveWorkLogEntries(
@@ -511,8 +535,16 @@
   if (toolCallId) {
     entry.toolCallId = toolCallId;
   }
+  if (isTaskActivity && payload) {
+    if (payload.agentKind !== "agent") {
+      entry.isBackgroundTask = true;
+    }
+    if (
+      payload.taskType === "local_workflow" ||
-  if (isTaskActivity && payload?.agentKind === "agent") {
-    entry.agentSpawn = true;
+      (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
+    ) {
+      entry.isWorkflowCoordinator = true;
+    }
   }
   const itemType = extractWorkLogItemType(payload);
   const requestKind = extractWorkLogRequestKind(payload);
@@ -579,6 +611,11 @@
   if (!toolLifecycleStatus && activity.kind === "tool.completed") {
     toolLifecycleStatus = "completed";
   }
+  // A Codex child that finishes its turn reports "idle" (resumable, not
+  // terminal). For the batch row that is a finished member.
+  if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") {
+    toolLifecycleStatus = "completed";
+  }
   if (toolLifecycleStatus) {
     entry.toolLifecycleStatus = toolLifecycleStatus;
   }
@@ -589,13 +626,108 @@
   return entry;
 }
 
+/**
+ * Spawn-group key for a subagent lifecycle row. Workflow members and their
+ * coordinator share the coordinator's group; direct spawns batch per turn.
+ * Same keys as web's session-logic so both clients fold the same rows.
+ */
+function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
+  const taskId = entry.taskId ?? "";
+  const workflowSlot = taskId.indexOf(":wf:");
+  if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`;
+  if (entry.isWorkflowCoordinator) return `wf:${taskId}`;
+  return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
+}
+
+/**
+ * The batch row keeps the group's anchor identity (id, createdAt, turnId,
+ * label) so it renders where the run launched instead of drifting to the
+ * newest progress tick, and gains each member's latest lifecycle state.
+ */
+function agentSpawnRow(
+  anchor: DerivedWorkLogEntry,
+  workflowId: string | null,
+  agentTaskIds: ReadonlyArray<string>,
+  members: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
+): DerivedWorkLogEntry {
+  // A finished coordinator settles members that never reported their own
+  // end; Claude stops synthesizing member ticks once the workflow is done.
+  const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)];
+  const agents =
+    coordinator?.status !== undefined && coordinator.status !== "inProgress"
+      ? members.map((agent) =>
+          agent.status === undefined || agent.status === "inProgress"
+            ? { ...agent, status: coordinator.status }
+            : agent,
+        )
+      : members;
+  const agentSpawn = { workflowId, agentTaskIds, agents };
+  // The batch row has no detail of its own: its body lists the members.
+  const { detail: _detail, ...anchorWithoutDetail } = anchor;
+  return {
+    ...anchorWithoutDetail,
+    // The row's own lifecycle is the batch's: live while any member is, then
+    // the worst terminal state, so the group summary and shimmer follow it.
+    toolLifecycleStatus: agentSpawnLifecycleStatus(agents),
+    agentSpawn,
+  };
+}
+
+function agentSpawnMember(
+  entry: DerivedWorkLogEntry,
+  previous?: NonNullable<WorkLogEntry["agentSpawn"]>["agents"][number],
+) {
+  return {
+    title: entry.toolTitle ?? previous?.title ?? entry.label,
+    status: entry.toolLifecycleStatus ?? previous?.status,
+    detail: entry.detail ?? previous?.detail,
+  };
+}
+
+function mergeAgentSpawnEntries(
+  existing: DerivedWorkLogEntry,
+  entry: DerivedWorkLogEntry,
+): DerivedWorkLogEntry {
+  const spawn = existing.agentSpawn!;
+  const taskId = entry.taskId ?? "";
+  const memberIndex = spawn.agentTaskIds.indexOf(taskId);
+  if (memberIndex === -1) {
+    return agentSpawnRow(
+      existing,
+      spawn.workflowId,
+      [...spawn.agentTaskIds, taskId],
+      [...spawn.agents, agentSpawnMember(entry)],
+    );
+  }
+  const agents = spawn.agents.map((agent, index) =>
+    index === memberIndex ? agentSpawnMember(entry, agent) : agent,
+  );
+  return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents);
+}
+
+function agentSpawnLifecycleStatus(
+  agents: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
+): WorkLogToolLifecycleStatus {
+  const statuses = agents.map((agent) => agent.status);
+  if (statuses.some((status) => status === undefined || status === "inProgress")) {
+    return "inProgress";
+  }
+  if (statuses.includes("failed")) return "failed";
+  if (statuses.includes("stopped")) return "stopped";
+  return "completed";
+}
+
 function collapseDerivedWorkLogEntries(
   entries: ReadonlyArray<DerivedWorkLogEntry>,
 ): DerivedWorkLogEntry[] {
   const collapsed: DerivedWorkLogEntry[] = [];
-  // Subagent rows collapse by identity, not adjacency (quiet-timeline
-  // guarantee; mirrors web's session-logic).
+  // Task rows collapse by identity, not adjacency (quiet-timeline guarantee;
+  // mirrors web's session-logic). Background tasks keep one row per taskId;
+  // agent spawns fold into one row per spawn group, decided at the FIRST row
+  // seen for a taskId because later rows can arrive under synthetic turns.
   const taskRowIndex = new Map<string, number>();
+  const spawnRowIndex = new Map<string, number>();
+  const spawnGroupByTaskId = new Map<string, string>();
   const toolLifecycleRowIndex = new Map<string, number>();
   for (const entry of entries) {
     const isTaskRow =
@@ -604,13 +736,32 @@
         entry.sourceActivityKind === "task.completed" ||
         entry.sourceActivityKind === "task.updated");
     if (isTaskRow && entry.taskId !== undefined) {
-      const existingIndex = taskRowIndex.get(entry.taskId);
+      if (entry.isBackgroundTask) {
+        const existingIndex = taskRowIndex.get(entry.taskId);
+        if (existingIndex !== undefined) {
+          collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
+          continue;
+        }
+        taskRowIndex.set(entry.taskId, collapsed.length);
+        collapsed.push(entry);
+        continue;
+      }
+      const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry);
+      spawnGroupByTaskId.set(entry.taskId, groupKey);
+      const existingIndex = spawnRowIndex.get(groupKey);
       if (existingIndex !== undefined) {
-        collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
+        collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry);
         continue;
       }
+      spawnRowIndex.set(groupKey, collapsed.length);
-      taskRowIndex.set(entry.taskId, collapsed.length);
-      collapsed.push(entry);
+      collapsed.push(
+        agentSpawnRow(
+          entry,
+          groupKey.startsWith("wf:") ? groupKey.slice(3) : null,
+          [entry.taskId],
+          [agentSpawnMember(entry)],
+        ),
+      );
       continue;
     }
     const lifecycleKey = toolLifecycleCollapseMapKey(entry);
@@ -821,6 +972,16 @@
 }
 
 function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
+  if (entry.agentSpawn) {
+    switch (entry.toolLifecycleStatus) {
+      case "failed":
+        return "failure";
+      case "completed":
+        return "success";
+      default:
+        return "neutral";
+    }
+  }
   if (!workLogEntryIsToolLike(entry)) {
     return null;
   }
@@ -834,6 +995,7 @@
 }
 
 function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
+  if (entry.agentSpawn) return "agent";
   if (
     entry.sourceActivityKind === "user-input.requested" ||
     entry.sourceActivityKind === "user-input.resolved"
@@ -860,6 +1022,7 @@
 }
 
 function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
+  if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn);
   const blocks: string[] = [];
   const appendBlock = (value: string | null | undefined) => {
     const trimmed = value?.trim();
@@ -887,6 +1050,7 @@
  * for every row (see the deferred-expansion test).
  */
 function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean {
+  if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
   if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
   if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
   const parts = [entry.rawCommand ?? entry.command, entry.detail]
@@ -910,6 +1074,7 @@
 
 /** The one-line text a collapsed work row shows. */
 export function workEntryRowLabel(entry: WorkLogEntry): string {
+  if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
   const presentation = resolveWorkEntryToolPresentation(entry);
   if (presentation) return presentation.displayName;
   const preview = workEntryPreview(entry);
@@ -950,7 +1115,44 @@
   return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
 }
 
+/**
+ * Batch label for a spawn row, matching web's CTA wording. Web reads live
+ * agent state from its Agents panel; mobile has only the lifecycle states
+ * folded into the row, so "working" means a member has not reported a
+ * terminal state yet.
+ */
+export function agentSpawnLabel(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string {
+  const members = agentSpawnMembers(spawn);
+  const count = Math.max(members.length, 1);
+  const subjects = `${count} subagent${count === 1 ? "" : "s"}`;
+  const working = members.filter(
+    (agent) => agent.status === undefined || agent.status === "inProgress",
+  ).length;
+  const failed = members.filter((agent) => agent.status === "failed").length;
+  const stopped = members.filter((agent) => agent.status === "stopped").length;
+  if (working > 0) {
+    return `Kicked off ${subjects} · ${working} working`;
+  }
+  const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed";
+  return `Ran ${subjects} · ${status}`;
+}
+
+/** Workflow coordinators sit in their own batch but are not a member. */
+function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) {
+  return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
+}
+
+function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {
+  const lines = agentSpawnMembers(spawn).map((agent) => {
+    const status =
+      agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status;
+    return `${agent.title} · ${status}${agent.detail ? `\n  ${agent.detail}` : ""}`;
+  });
+  return lines.length > 0 ? lines.join("\n") : null;
+}
+
 function workEntryHeading(workEntry: WorkLogEntry): string {
+  if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn);
   const presentation = resolveWorkEntryToolPresentation(workEntry);
   if (presentation) return presentation.displayName;
   if (!workEntry.toolTitle) {
@@ -1706,7 +1908,7 @@
     groupableRun = [];
   };
   for (const activity of activities) {
+    if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) {
-    if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) {
       groupableRun.push(activity);
       continue;
     }
```
