authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:57:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:57:44-07:00
log579a77588684fc4012e28754cc4f662aa24730c7
tree1e1812d466852bd5b1cf459ac9cad63d1b7156fb
parent7eda989d38a30d5e35c9efbd946aae3d6f9c065a
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 450 insertions(+), 33 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+222-7
......@@ -745,12 +745,12 @@ describe("buildThreadFeed", () => {
745745 tone: "info" as const,
746746 summary: "Task completed",
747747 payload: {
748 taskId: "ae3f85a",
748 taskId: "bpxcizf97",
749749 status: "completed",
750750 title: "Audit the PR",
751751 detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.",
752 agentKind: "agent",
753 taskType: "local_agent",
752 agentKind: "background",
753 taskType: "local_bash",
754754 },
755755 },
756756 label: "**Tooling note:** Bash is unusable. # Audit No blockers.",
......@@ -2745,16 +2745,104 @@ describe("quiet timeline: nested agents", () => {
27452745 const rows = buildThreadFeed(thread).flatMap((entry) =>
27462746 entry.type === "activity-group" ? entry.activities : [],
27472747 );
2748 // The agent folds into its spawn batch, which stays live after a resume.
27482749 expect(rows).toMatchObject([
27492750 {
27502751 lifecycleStatus: "inProgress",
2751 summary: "Reviewer",
2752 workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" },
2752 summary: "Kicked off 1 subagent · 1 working",
2753 workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } },
27532754 },
27542755 ]);
27552756 },
27562757 );
27572758
2759 it("folds a turn's direct spawns into one batch row that tracks their states", () => {
2760 const turnId = TurnId.make("turn-spawn");
2761 const agent = (
2762 id: string,
2763 kind: "task.started" | "task.progress" | "task.completed" | "task.updated",
2764 taskId: string,
2765 status: string,
2766 seconds: number,
2767 extra: Record<string, unknown> = {},
2768 ) =>
2769 makeActivity({
2770 id: EventId.make(id),
2771 kind,
2772 summary: `${taskId} ${status}`,
2773 createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
2774 turnId,
2775 payload: {
2776 taskId,
2777 agentKind: "agent",
2778 taskType: "local_agent",
2779 title: `Agent ${taskId}`,
2780 status,
2781 ...extra,
2782 },
2783 });
2784 const shell = makeActivity({
2785 id: EventId.make("shell-1"),
2786 kind: "task.completed",
2787 summary: "Task completed",
2788 createdAt: "2026-04-01T00:00:05.000Z",
2789 turnId,
2790 payload: {
2791 taskId: "sh-1",
2792 agentKind: "background",
2793 taskType: "local_bash",
2794 status: "completed",
2795 title: "Run tests",
2796 detail: "Run tests",
2797 },
2798 });
2799 const activities = [
2800 agent("a-start", "task.started", "a", "running", 1),
2801 agent("b-start", "task.started", "b", "running", 2),
2802 agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }),
2803 shell,
2804 agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }),
2805 ];
2806 const rowsFor = (extraActivities: ReadonlyArray<ReturnType<typeof makeActivity>>) =>
2807 buildThreadFeed(
2808 makeThread({
2809 id: ThreadId.make("thread-spawn"),
2810 projectId: ProjectId.make("project-1"),
2811 title: "Spawns",
2812 activities: [...activities, ...extraActivities],
2813 }),
2814 ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []));
2815
2816 const running = rowsFor([]);
2817 expect(running.map((row) => [row.id, row.summary])).toEqual([
2818 ["a-progress", "Kicked off 2 subagents · 2 working"],
2819 ["shell-1", "Run tests"],
2820 ]);
2821 expect(running[0]).toMatchObject({
2822 lifecycleStatus: "inProgress",
2823 workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } },
2824 });
2825
2826 const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]);
2827 expect(oneDone[0]).toMatchObject({
2828 id: "a-progress",
2829 summary: "Kicked off 2 subagents · 1 working",
2830 lifecycleStatus: "inProgress",
2831 });
2832
2833 const allDone = rowsFor([
2834 agent("a-done", "task.completed", "a", "completed", 7),
2835 agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }),
2836 ]);
2837 expect(allDone[0]).toMatchObject({
2838 id: "a-progress",
2839 summary: "Ran 2 subagents · 1 failed",
2840 lifecycleStatus: "failed",
2841 status: "failure",
2842 });
2843 expect(allDone).toHaveLength(2);
2844 });
2845
27582846 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
27592847 "replaces Antigravity batch progress with %s",
27602848 (status) => {
......@@ -2802,19 +2890,146 @@ describe("quiet timeline: nested agents", () => {
28022890 const rows = buildThreadFeed(thread).flatMap((entry) =>
28032891 entry.type === "activity-group" ? entry.activities : [],
28042892 );
2893 // Turn-less batches never share a spawn group, so each keeps its own row.
28052894 expect(rows).toHaveLength(2);
28062895 expect(rows[0]).toMatchObject({
28072896 lifecycleStatus: status === "failed" ? "failed" : "stopped",
2808 detail,
2809 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" },
2897 summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`,
2898 workEntry: {
2899 taskId: "trajectory:4",
2900 toolTitle: "Antigravity subagent batch",
2901 agentSpawn: { agents: [{ detail }] },
2902 },
28102903 });
2904 expect(rows[0]?.getFullDetail()).toContain(detail);
28112905 expect(rows[1]).toMatchObject({
28122906 lifecycleStatus: "inProgress",
2907 summary: "Kicked off 1 subagent · 1 working",
28132908 workEntry: { taskId: "trajectory:5" },
28142909 });
28152910 },
28162911 );
28172912
2913 it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => {
2914 const turnId = TurnId.make("turn-workflow");
2915 const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`;
2916 const thread = makeThread({
2917 id: ThreadId.make("thread-workflow"),
2918 projectId: ProjectId.make("project-1"),
2919 title: "Workflow",
2920 activities: [
2921 makeActivity({
2922 id: EventId.make("wf-progress"),
2923 kind: "task.progress",
2924 summary: "Workflow running",
2925 createdAt: at(1),
2926 turnId,
2927 payload: {
2928 taskId: "wf-1",
2929 taskType: "local_workflow",
2930 workflowName: "review",
2931 agentKind: "agent",
2932 title: "review",
2933 status: "running",
2934 },
2935 }),
2936 // Members are synthesized with timelineBypass and never render alone.
2937 ...[0, 1].map((index) =>
2938 makeActivity({
2939 id: EventId.make(`member-${index}`),
2940 kind: "task.progress",
2941 summary: `Agent ${index}`,
2942 createdAt: at(2 + index),
2943 turnId,
2944 payload: {
2945 taskId: `wf-1:wf:${index}`,
2946 agentKind: "agent",
2947 title: `Reviewer ${index}`,
2948 description: `Reviewer ${index}`,
2949 status: index === 0 ? "completed" : "running",
2950 parentAgentId: "wf-1",
2951 timelineBypass: true,
2952 },
2953 }),
2954 ),
2955 makeActivity({
2956 id: EventId.make("wf-done"),
2957 kind: "task.completed",
2958 summary: "Task completed",
2959 createdAt: at(10),
2960 turnId,
2961 payload: {
2962 taskId: "wf-1",
2963 taskType: "local_workflow",
2964 workflowName: "review",
2965 agentKind: "agent",
2966 status: "completed",
2967 title: "review",
2968 },
2969 }),
2970 ],
2971 });
2972 const rows = buildThreadFeed(thread).flatMap((entry) =>
2973 entry.type === "activity-group" ? entry.activities : [],
2974 );
2975 expect(rows).toHaveLength(1);
2976 // The member that never reported its own end settles with the coordinator.
2977 expect(rows[0]).toMatchObject({
2978 id: "wf-progress",
2979 summary: "Ran 2 subagents · completed",
2980 lifecycleStatus: "completed",
2981 workEntry: {
2982 agentSpawn: {
2983 workflowId: "wf-1",
2984 agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"],
2985 },
2986 },
2987 });
2988 expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed");
2989 });
2990
2991 it("treats a Codex child's idle turn end as a finished batch member", () => {
2992 const turnId = TurnId.make("turn-codex");
2993 const child = (
2994 id: string,
2995 kind: "task.started" | "task.updated",
2996 status: string,
2997 seconds: number,
2998 ) =>
2999 makeActivity({
3000 id: EventId.make(id),
3001 kind,
3002 summary: `${status}`,
3003 createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`,
3004 turnId,
3005 payload: {
3006 taskId: "child-1",
3007 agentKind: "agent",
3008 title: "math_one",
3009 status,
3010 timelineBypass: true,
3011 },
3012 });
3013 const thread = makeThread({
3014 id: ThreadId.make("thread-codex"),
3015 projectId: ProjectId.make("project-1"),
3016 title: "Codex children",
3017 activities: [
3018 child("c-start", "task.started", "running", 1),
3019 child("c-running", "task.updated", "running", 2),
3020 child("c-idle", "task.updated", "idle", 5),
3021 ],
3022 });
3023 const rows = buildThreadFeed(thread).flatMap((entry) =>
3024 entry.type === "activity-group" ? entry.activities : [],
3025 );
3026 expect(rows).toHaveLength(1);
3027 expect(rows[0]).toMatchObject({
3028 summary: "Ran 1 subagent · completed",
3029 lifecycleStatus: "completed",
3030 });
3031 });
3032
28183033 it("keeps a nested agent's terminal row but hides its background work", () => {
28193034 const thread = makeThread({
28203035 id: ThreadId.make("thread-nested"),
apps/mobile/src/lib/threadActivity.ts+228-26
......@@ -110,7 +110,20 @@ export interface WorkLogEntry {
110110 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
111111 sourceActivityKind?: OrchestrationThreadActivity["kind"];
112112 toolCallId?: string;
113 agentSpawn?: boolean;
113 /**
114 * One row per workflow run or per-turn batch of direct spawns, like web's
115 * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row
116 * also carries each agent's terminal state to derive its status label.
117 */
118 agentSpawn?: {
119 readonly workflowId: string | null;
120 readonly agentTaskIds: ReadonlyArray<string>;
121 readonly agents: ReadonlyArray<{
122 readonly title: string;
123 readonly status: WorkLogToolLifecycleStatus | undefined;
124 readonly detail: string | undefined;
125 }>;
126 };
114127 toolData?: unknown;
115128}
116129
......@@ -119,6 +132,9 @@ interface DerivedWorkLogEntry extends WorkLogEntry {
119132 collapseKey?: string;
120133 /** Grouping key for subagent lifecycle rows (one row per agent). */
121134 taskId?: string;
135 isWorkflowCoordinator?: boolean;
136 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */
137 isBackgroundTask?: boolean;
122138}
123139
124140type RawThreadFeedEntry =
......@@ -388,10 +404,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
388404
389405/**
390406 * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
391 * activity lives in the Agents sheet, not the work log. Terminal rows are
392 * kept — with no Agents surface on mobile they are the terminal signal
393 * (a surface that hides rows must keep its own terminal signal). That means
394 * task.completed and terminal task.updated, including Antigravity cancellation.
407 * activity lives in the Agents sheet, not the work log. Agent lifecycle rows
408 * pass even when bypassed or owned by another agent, because they fold into
409 * their spawn batch rather than rendering on their own; that is how Codex
410 * children (all bypassed) and Claude workflow members reach the batch row.
411 * Terminal rows are kept regardless — with no Agents surface on mobile they
412 * are the terminal signal.
395413 */
396414function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
397415 const payload =
......@@ -401,20 +419,26 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
401419 if (!payload) {
402420 return false;
403421 }
404 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity);
405 if (payload.timelineBypass === true && !isTerminalTaskRow) {
406 return true;
407 }
408 // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
409 // the only signal mobile gets (no Agents sheet), so it stays. Only an
410 // agent's own background work (stamped "background") is internal — same
411 // rule as web (review finding: hiding on agentId alone dropped nested
412 // completions with no replacement UI).
422 const isTaskRow =
423 activity.kind === "task.progress" ||
424 activity.kind === "task.updated" ||
425 activity.kind === "task.completed";
413426 const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
414 if (!ownedByAgent) {
415 return false;
427 if (isTaskRow) {
428 if (!ownedByAgent && payload.timelineBypass !== true) {
429 return false;
430 }
431 // An agent's own shells stay internal; the agents themselves fold into
432 // their batch. A bypassed batch marker keeps its terminal row.
433 if (typeof payload.taskId === "string" && payload.agentKind === "agent") {
434 return false;
435 }
436 if (ownedByAgent) {
437 return true;
438 }
439 return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity));
416440 }
417 return !(isTerminalTaskRow && payload.agentKind === "agent");
441 return payload.timelineBypass === true || ownedByAgent;
418442}
419443
420444function deriveWorkLogEntries(
......@@ -511,8 +535,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
511535 if (toolCallId) {
512536 entry.toolCallId = toolCallId;
513537 }
514 if (isTaskActivity && payload?.agentKind === "agent") {
515 entry.agentSpawn = true;
538 if (isTaskActivity && payload) {
539 if (payload.agentKind !== "agent") {
540 entry.isBackgroundTask = true;
541 }
542 if (
543 payload.taskType === "local_workflow" ||
544 (typeof payload.workflowName === "string" && payload.workflowName.length > 0)
545 ) {
546 entry.isWorkflowCoordinator = true;
547 }
516548 }
517549 const itemType = extractWorkLogItemType(payload);
518550 const requestKind = extractWorkLogRequestKind(payload);
......@@ -579,6 +611,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
579611 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
580612 toolLifecycleStatus = "completed";
581613 }
614 // A Codex child that finishes its turn reports "idle" (resumable, not
615 // terminal). For the batch row that is a finished member.
616 if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") {
617 toolLifecycleStatus = "completed";
618 }
582619 if (toolLifecycleStatus) {
583620 entry.toolLifecycleStatus = toolLifecycleStatus;
584621 }
......@@ -589,13 +626,108 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
589626 return entry;
590627}
591628
629/**
630 * Spawn-group key for a subagent lifecycle row. Workflow members and their
631 * coordinator share the coordinator's group; direct spawns batch per turn.
632 * Same keys as web's session-logic so both clients fold the same rows.
633 */
634function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
635 const taskId = entry.taskId ?? "";
636 const workflowSlot = taskId.indexOf(":wf:");
637 if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`;
638 if (entry.isWorkflowCoordinator) return `wf:${taskId}`;
639 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
640}
641
642/**
643 * The batch row keeps the group's anchor identity (id, createdAt, turnId,
644 * label) so it renders where the run launched instead of drifting to the
645 * newest progress tick, and gains each member's latest lifecycle state.
646 */
647function agentSpawnRow(
648 anchor: DerivedWorkLogEntry,
649 workflowId: string | null,
650 agentTaskIds: ReadonlyArray<string>,
651 members: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
652): DerivedWorkLogEntry {
653 // A finished coordinator settles members that never reported their own
654 // end; Claude stops synthesizing member ticks once the workflow is done.
655 const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)];
656 const agents =
657 coordinator?.status !== undefined && coordinator.status !== "inProgress"
658 ? members.map((agent) =>
659 agent.status === undefined || agent.status === "inProgress"
660 ? { ...agent, status: coordinator.status }
661 : agent,
662 )
663 : members;
664 const agentSpawn = { workflowId, agentTaskIds, agents };
665 // The batch row has no detail of its own: its body lists the members.
666 const { detail: _detail, ...anchorWithoutDetail } = anchor;
667 return {
668 ...anchorWithoutDetail,
669 // The row's own lifecycle is the batch's: live while any member is, then
670 // the worst terminal state, so the group summary and shimmer follow it.
671 toolLifecycleStatus: agentSpawnLifecycleStatus(agents),
672 agentSpawn,
673 };
674}
675
676function agentSpawnMember(
677 entry: DerivedWorkLogEntry,
678 previous?: NonNullable<WorkLogEntry["agentSpawn"]>["agents"][number],
679) {
680 return {
681 title: entry.toolTitle ?? previous?.title ?? entry.label,
682 status: entry.toolLifecycleStatus ?? previous?.status,
683 detail: entry.detail ?? previous?.detail,
684 };
685}
686
687function mergeAgentSpawnEntries(
688 existing: DerivedWorkLogEntry,
689 entry: DerivedWorkLogEntry,
690): DerivedWorkLogEntry {
691 const spawn = existing.agentSpawn!;
692 const taskId = entry.taskId ?? "";
693 const memberIndex = spawn.agentTaskIds.indexOf(taskId);
694 if (memberIndex === -1) {
695 return agentSpawnRow(
696 existing,
697 spawn.workflowId,
698 [...spawn.agentTaskIds, taskId],
699 [...spawn.agents, agentSpawnMember(entry)],
700 );
701 }
702 const agents = spawn.agents.map((agent, index) =>
703 index === memberIndex ? agentSpawnMember(entry, agent) : agent,
704 );
705 return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents);
706}
707
708function agentSpawnLifecycleStatus(
709 agents: NonNullable<WorkLogEntry["agentSpawn"]>["agents"],
710): WorkLogToolLifecycleStatus {
711 const statuses = agents.map((agent) => agent.status);
712 if (statuses.some((status) => status === undefined || status === "inProgress")) {
713 return "inProgress";
714 }
715 if (statuses.includes("failed")) return "failed";
716 if (statuses.includes("stopped")) return "stopped";
717 return "completed";
718}
719
592720function collapseDerivedWorkLogEntries(
593721 entries: ReadonlyArray<DerivedWorkLogEntry>,
594722): DerivedWorkLogEntry[] {
595723 const collapsed: DerivedWorkLogEntry[] = [];
596 // Subagent rows collapse by identity, not adjacency (quiet-timeline
597 // guarantee; mirrors web's session-logic).
724 // Task rows collapse by identity, not adjacency (quiet-timeline guarantee;
725 // mirrors web's session-logic). Background tasks keep one row per taskId;
726 // agent spawns fold into one row per spawn group, decided at the FIRST row
727 // seen for a taskId because later rows can arrive under synthetic turns.
598728 const taskRowIndex = new Map<string, number>();
729 const spawnRowIndex = new Map<string, number>();
730 const spawnGroupByTaskId = new Map<string, string>();
599731 const toolLifecycleRowIndex = new Map<string, number>();
600732 for (const entry of entries) {
601733 const isTaskRow =
......@@ -604,13 +736,32 @@ function collapseDerivedWorkLogEntries(
604736 entry.sourceActivityKind === "task.completed" ||
605737 entry.sourceActivityKind === "task.updated");
606738 if (isTaskRow && entry.taskId !== undefined) {
607 const existingIndex = taskRowIndex.get(entry.taskId);
739 if (entry.isBackgroundTask) {
740 const existingIndex = taskRowIndex.get(entry.taskId);
741 if (existingIndex !== undefined) {
742 collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
743 continue;
744 }
745 taskRowIndex.set(entry.taskId, collapsed.length);
746 collapsed.push(entry);
747 continue;
748 }
749 const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry);
750 spawnGroupByTaskId.set(entry.taskId, groupKey);
751 const existingIndex = spawnRowIndex.get(groupKey);
608752 if (existingIndex !== undefined) {
609 collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
753 collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry);
610754 continue;
611755 }
612 taskRowIndex.set(entry.taskId, collapsed.length);
613 collapsed.push(entry);
756 spawnRowIndex.set(groupKey, collapsed.length);
757 collapsed.push(
758 agentSpawnRow(
759 entry,
760 groupKey.startsWith("wf:") ? groupKey.slice(3) : null,
761 [entry.taskId],
762 [agentSpawnMember(entry)],
763 ),
764 );
614765 continue;
615766 }
616767 const lifecycleKey = toolLifecycleCollapseMapKey(entry);
......@@ -821,6 +972,16 @@ function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
821972}
822973
823974function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
975 if (entry.agentSpawn) {
976 switch (entry.toolLifecycleStatus) {
977 case "failed":
978 return "failure";
979 case "completed":
980 return "success";
981 default:
982 return "neutral";
983 }
984 }
824985 if (!workLogEntryIsToolLike(entry)) {
825986 return null;
826987 }
......@@ -834,6 +995,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
834995}
835996
836997function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
998 if (entry.agentSpawn) return "agent";
837999 if (
8381000 entry.sourceActivityKind === "user-input.requested" ||
8391001 entry.sourceActivityKind === "user-input.resolved"
......@@ -860,6 +1022,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
8601022}
8611023
8621024function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
1025 if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn);
8631026 const blocks: string[] = [];
8641027 const appendBlock = (value: string | null | undefined) => {
8651028 const trimmed = value?.trim();
......@@ -887,6 +1050,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
8871050 * for every row (see the deferred-expansion test).
8881051 */
8891052function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean {
1053 if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0;
8901054 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true;
8911055 if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true;
8921056 const parts = [entry.rawCommand ?? entry.command, entry.detail]
......@@ -910,6 +1074,7 @@ function stripShellWrapper(value: string): string {
9101074
9111075/** The one-line text a collapsed work row shows. */
9121076export function workEntryRowLabel(entry: WorkLogEntry): string {
1077 if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
9131078 const presentation = resolveWorkEntryToolPresentation(entry);
9141079 if (presentation) return presentation.displayName;
9151080 const preview = workEntryPreview(entry);
......@@ -950,7 +1115,44 @@ function capitalizePhrase(value: string): string {
9501115 return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
9511116}
9521117
1118/**
1119 * Batch label for a spawn row, matching web's CTA wording. Web reads live
1120 * agent state from its Agents panel; mobile has only the lifecycle states
1121 * folded into the row, so "working" means a member has not reported a
1122 * terminal state yet.
1123 */
1124export function agentSpawnLabel(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string {
1125 const members = agentSpawnMembers(spawn);
1126 const count = Math.max(members.length, 1);
1127 const subjects = `${count} subagent${count === 1 ? "" : "s"}`;
1128 const working = members.filter(
1129 (agent) => agent.status === undefined || agent.status === "inProgress",
1130 ).length;
1131 const failed = members.filter((agent) => agent.status === "failed").length;
1132 const stopped = members.filter((agent) => agent.status === "stopped").length;
1133 if (working > 0) {
1134 return `Kicked off ${subjects} · ${working} working`;
1135 }
1136 const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed";
1137 return `Ran ${subjects} · ${status}`;
1138}
1139
1140/** Workflow coordinators sit in their own batch but are not a member. */
1141function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) {
1142 return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId);
1143}
1144
1145function agentSpawnExpandedBody(spawn: NonNullable<WorkLogEntry["agentSpawn"]>): string | null {
1146 const lines = agentSpawnMembers(spawn).map((agent) => {
1147 const status =
1148 agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status;
1149 return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`;
1150 });
1151 return lines.length > 0 ? lines.join("\n") : null;
1152}
1153
9531154function workEntryHeading(workEntry: WorkLogEntry): string {
1155 if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn);
9541156 const presentation = resolveWorkEntryToolPresentation(workEntry);
9551157 if (presentation) return presentation.displayName;
9561158 if (!workEntry.toolTitle) {
......@@ -1706,7 +1908,7 @@ function appendActivityGroupRows(
17061908 groupableRun = [];
17071909 };
17081910 for (const activity of activities) {
1709 if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) {
1911 if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) {
17101912 groupableRun.push(activity);
17111913 continue;
17121914 }