fix(mobile): fold subagent lifecycle rows into one batch per spawn#10211

merged2
t3code/mobile-work-row-expand-only-with-extra
t3code/mobile-subagent-spawn-batches
openedSep 5, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
3 unresolved threads · Jump to file
Selected thread: apps/mobile/src/lib/threadActivity.ts:725 — 🟡 **Medium** `lib/threadActivity.ts:725` Workflow batches on mobile omit synthesized `:wf:` child agents, so the `agentSpawn` row contains only the coordinator and cannot show child progress or details. `deriveWorkLogEntries` filters nonterminal `timelineBypass` tasks before this loop, but Claude emits every workflow-member `task.progress` with `timelineBypass: true`; preserve those member rows (or otherwise pass them through) so `mergeAgentSpawnEntries` can add them. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> ```text In file @apps/mobile/src/lib/threadActivity.ts around line 725: Workflow batches on mobile omit synthesized `:wf:` child agents, so the `agentSpawn` row contains only the coordinator and cannot show child progress or details. `deriveWorkLogEntries` filters nonterminal `timelineBypass` tasks before this loop, but Claude emits every workflow-member `task.progress` with `timelineBypass: true`; preserve those member rows (or otherwise pass them through) so `mergeAgentSpawnEntries` can add them. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"195358a06841b2d4d03feb0d212d1ca7"} -->
0 / 2 viewed2 files changedLine totals unavailable

apps/mobile/src/lib/threadActivity.ts · Earlier revision eb8fb60a · right 688

Original diff
@@ -589,13 +613,97 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
   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>,
+  agents: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
+): DerivedWorkLogEntry {
+  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")) {
M
macroscopeappBotSep 5, 2026

🟡 Medium lib/threadActivity.ts:688

Direct subagent batches that finish with Codex's status: "idle" remain labeled inProgress instead of completing. extractWorkLogToolLifecycleStatus returns undefined for this task.updated terminal update, and agentSpawnLifecycleStatus treats every undefined member status as running at line 688; map this terminal idle status to completed before aggregation so finished batches stop showing as working.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/lib/threadActivity.ts around line 688:
2
3Direct subagent batches that finish with Codex's `status: "idle"` remain labeled `inProgress` instead of completing. `extractWorkLogToolLifecycleStatus` returns `undefined` for this `task.updated` terminal update, and `agentSpawnLifecycleStatus` treats every undefined member status as running at line 688; map this terminal `idle` status to `completed` before aggregation so finished batches stop showing as working.
J
Julius MarmingeSep 5, 2026

Confirmed. Fixed in a83379df0: a task row whose payload status is idle now maps to completed for the batch (Codex's turn-completed is resumable, not terminal, but for the batch row it is a finished member). Covered by the new "treats a Codex child's idle turn end as a finished batch member" test.

apps/mobile/src/lib/threadActivity.ts · Earlier revision eb8fb60a · right 725

Original diff
@@ -604,13 +712,32 @@ function collapseDerivedWorkLogEntries(
         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);
M
macroscopeappBotSep 5, 2026

🟡 Medium lib/threadActivity.ts:725

Workflow batches on mobile omit synthesized :wf: child agents, so the agentSpawn row contains only the coordinator and cannot show child progress or details. deriveWorkLogEntries filters nonterminal timelineBypass tasks before this loop, but Claude emits every workflow-member task.progress with timelineBypass: true; preserve those member rows (or otherwise pass them through) so mergeAgentSpawnEntries can add them.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/lib/threadActivity.ts around line 725:
2
3Workflow batches on mobile omit synthesized `:wf:` child agents, so the `agentSpawn` row contains only the coordinator and cannot show child progress or details. `deriveWorkLogEntries` filters nonterminal `timelineBypass` tasks before this loop, but Claude emits every workflow-member `task.progress` with `timelineBypass: true`; preserve those member rows (or otherwise pass them through) so `mergeAgentSpawnEntries` can add them.
J
Julius MarmingeSep 5, 2026

Confirmed. isAgentInternalActivity dropped bypassed non-terminal task rows before batching, so a workflow batch only ever held its coordinator. Fixed in a83379df0: agent lifecycle rows (agentKind: "agent") now pass the internal filter the way web's session-logic does, so bypassed members fold into the batch. Covered by the new "folds bypassed Claude workflow members" test.

apps/mobile/src/lib/threadActivity.ts · Earlier revision eb8fb60a · right 647

Original diff
@@ -589,13 +613,97 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
   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>,
+  agents: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
+): DerivedWorkLogEntry {
+  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),
M
macroscopeappBotSep 5, 2026

🟡 Medium lib/threadActivity.ts:647

When a workflow coordinator completes, the batch remains inProgress if a member such as wf-1:wf:0 never emits its own terminal event, so mobile permanently shows “1 working” instead of a completed batch. agentSpawnLifecycleStatus(agents) retains that member's stale inProgress status; settle unfinished members to the coordinator's terminal state before calculating the batch status, matching the runtime workflow fold.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/lib/threadActivity.ts around line 647:
2
3When a workflow coordinator completes, the batch remains `inProgress` if a member such as `wf-1:wf:0` never emits its own terminal event, so mobile permanently shows “1 working” instead of a completed batch. `agentSpawnLifecycleStatus(agents)` retains that member's stale `inProgress` status; settle unfinished members to the coordinator's terminal state before calculating the batch status, matching the runtime workflow fold.
J
Julius MarmingeSep 5, 2026

Confirmed. Fixed in a83379df0: when the coordinator is in a terminal state, agentSpawnRow settles any member still inProgress/unknown to the coordinator's state before aggregating. The new workflow test has one member that never reports its own end and asserts the batch reads "Ran 2 subagents · completed".