| ... | @@ -110,7 +110,20 @@ export interface WorkLogEntry { | ... | @@ -110,7 +110,20 @@ export interface WorkLogEntry { |
| 110 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; | 110 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; |
| 111 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; | 111 | sourceActivityKind?: OrchestrationThreadActivity["kind"]; |
| 112 | toolCallId?: string; | 112 | 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 | }; |
| 114 | toolData?: unknown; | 127 | toolData?: unknown; |
| 115 | } | 128 | } |
| 116 | | 129 | |
| ... | @@ -119,6 +132,9 @@ interface DerivedWorkLogEntry extends WorkLogEntry { | ... | @@ -119,6 +132,9 @@ interface DerivedWorkLogEntry extends WorkLogEntry { |
| 119 | collapseKey?: string; | 132 | collapseKey?: string; |
| 120 | /** Grouping key for subagent lifecycle rows (one row per agent). */ | 133 | /** Grouping key for subagent lifecycle rows (one row per agent). */ |
| 121 | taskId?: string; | 134 | taskId?: string; |
| | 135 | isWorkflowCoordinator?: boolean; |
| | 136 | /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ |
| | 137 | isBackgroundTask?: boolean; |
| 122 | } | 138 | } |
| 123 | | 139 | |
| 124 | type RawThreadFeedEntry = | 140 | type RawThreadFeedEntry = |
| ... | @@ -388,10 +404,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { | ... | @@ -388,10 +404,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { |
| 388 | | 404 | |
| 389 | /** | 405 | /** |
| 390 | * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal | 406 | * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal |
| 391 | * activity lives in the Agents sheet, not the work log. Terminal rows are | 407 | * activity lives in the Agents sheet, not the work log. Agent lifecycle rows |
| 392 | * kept — with no Agents surface on mobile they are the terminal signal | 408 | * pass even when bypassed or owned by another agent, because they fold into |
| 393 | * (a surface that hides rows must keep its own terminal signal). That means | 409 | * their spawn batch rather than rendering on their own; that is how Codex |
| 394 | * task.completed and terminal task.updated, including Antigravity cancellation. | 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. |
| 395 | */ | 413 | */ |
| 396 | function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { | 414 | function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { |
| 397 | const payload = | 415 | const payload = |
| ... | @@ -401,20 +419,26 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean | ... | @@ -401,20 +419,26 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean |
| 401 | if (!payload) { | 419 | if (!payload) { |
| 402 | return false; | 420 | return false; |
| 403 | } | 421 | } |
| 404 | const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); | 422 | const isTaskRow = |
| 405 | if (payload.timelineBypass === true && !isTerminalTaskRow) { | 423 | activity.kind === "task.progress" || |
| 406 | return true; | 424 | activity.kind === "task.updated" || |
| 407 | } | 425 | activity.kind === "task.completed"; |
| 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). | | |
| 413 | const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | 426 | const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; |
| 414 | if (!ownedByAgent) { | 427 | if (isTaskRow) { |
| 415 | return false; | 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)); |
| 416 | } | 440 | } |
| 417 | return !(isTerminalTaskRow && payload.agentKind === "agent"); | 441 | return payload.timelineBypass === true || ownedByAgent; |
| 418 | } | 442 | } |
| 419 | | 443 | |
| 420 | function deriveWorkLogEntries( | 444 | function deriveWorkLogEntries( |
| ... | @@ -511,8 +535,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -511,8 +535,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 511 | if (toolCallId) { | 535 | if (toolCallId) { |
| 512 | entry.toolCallId = toolCallId; | 536 | entry.toolCallId = toolCallId; |
| 513 | } | 537 | } |
| 514 | if (isTaskActivity && payload?.agentKind === "agent") { | 538 | if (isTaskActivity && payload) { |
| 515 | entry.agentSpawn = true; | 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 | } |
| 516 | } | 548 | } |
| 517 | const itemType = extractWorkLogItemType(payload); | 549 | const itemType = extractWorkLogItemType(payload); |
| 518 | const requestKind = extractWorkLogRequestKind(payload); | 550 | const requestKind = extractWorkLogRequestKind(payload); |
| ... | @@ -579,6 +611,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -579,6 +611,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 579 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { | 611 | if (!toolLifecycleStatus && activity.kind === "tool.completed") { |
| 580 | toolLifecycleStatus = "completed"; | 612 | toolLifecycleStatus = "completed"; |
| 581 | } | 613 | } |
| | 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 | } |
| 582 | if (toolLifecycleStatus) { | 619 | if (toolLifecycleStatus) { |
| 583 | entry.toolLifecycleStatus = toolLifecycleStatus; | 620 | entry.toolLifecycleStatus = toolLifecycleStatus; |
| 584 | } | 621 | } |
| ... | @@ -589,13 +626,108 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -589,13 +626,108 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 589 | return entry; | 626 | return entry; |
| 590 | } | 627 | } |
| 591 | | 628 | |
| | 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 | */ |
| | 634 | function 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 | */ |
| | 647 | function 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 | |
| | 676 | function 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 | |
| | 687 | function 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 | |
| | 708 | function 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 | |
| 592 | function collapseDerivedWorkLogEntries( | 720 | function collapseDerivedWorkLogEntries( |
| 593 | entries: ReadonlyArray<DerivedWorkLogEntry>, | 721 | entries: ReadonlyArray<DerivedWorkLogEntry>, |
| 594 | ): DerivedWorkLogEntry[] { | 722 | ): DerivedWorkLogEntry[] { |
| 595 | const collapsed: DerivedWorkLogEntry[] = []; | 723 | const collapsed: DerivedWorkLogEntry[] = []; |
| 596 | // Subagent rows collapse by identity, not adjacency (quiet-timeline | 724 | // Task rows collapse by identity, not adjacency (quiet-timeline guarantee; |
| 597 | // guarantee; mirrors web's session-logic). | 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. |
| 598 | const taskRowIndex = new Map<string, number>(); | 728 | const taskRowIndex = new Map<string, number>(); |
| | 729 | const spawnRowIndex = new Map<string, number>(); |
| | 730 | const spawnGroupByTaskId = new Map<string, string>(); |
| 599 | const toolLifecycleRowIndex = new Map<string, number>(); | 731 | const toolLifecycleRowIndex = new Map<string, number>(); |
| 600 | for (const entry of entries) { | 732 | for (const entry of entries) { |
| 601 | const isTaskRow = | 733 | const isTaskRow = |
| ... | @@ -604,13 +736,32 @@ function collapseDerivedWorkLogEntries( | ... | @@ -604,13 +736,32 @@ function collapseDerivedWorkLogEntries( |
| 604 | entry.sourceActivityKind === "task.completed" || | 736 | entry.sourceActivityKind === "task.completed" || |
| 605 | entry.sourceActivityKind === "task.updated"); | 737 | entry.sourceActivityKind === "task.updated"); |
| 606 | if (isTaskRow && entry.taskId !== undefined) { | 738 | 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); |
| 608 | if (existingIndex !== undefined) { | 752 | if (existingIndex !== undefined) { |
| 609 | collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); | 753 | collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry); |
| 610 | continue; | 754 | continue; |
| 611 | } | 755 | } |
| 612 | taskRowIndex.set(entry.taskId, collapsed.length); | 756 | spawnRowIndex.set(groupKey, collapsed.length); |
| 613 | collapsed.push(entry); | 757 | collapsed.push( |
| | 758 | agentSpawnRow( |
| | 759 | entry, |
| | 760 | groupKey.startsWith("wf:") ? groupKey.slice(3) : null, |
| | 761 | [entry.taskId], |
| | 762 | [agentSpawnMember(entry)], |
| | 763 | ), |
| | 764 | ); |
| 614 | continue; | 765 | continue; |
| 615 | } | 766 | } |
| 616 | const lifecycleKey = toolLifecycleCollapseMapKey(entry); | 767 | const lifecycleKey = toolLifecycleCollapseMapKey(entry); |
| ... | @@ -821,6 +972,16 @@ function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { | ... | @@ -821,6 +972,16 @@ function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { |
| 821 | } | 972 | } |
| 822 | | 973 | |
| 823 | function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | 974 | function 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 | } |
| 824 | if (!workLogEntryIsToolLike(entry)) { | 985 | if (!workLogEntryIsToolLike(entry)) { |
| 825 | return null; | 986 | return null; |
| 826 | } | 987 | } |
| ... | @@ -834,6 +995,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { | ... | @@ -834,6 +995,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { |
| 834 | } | 995 | } |
| 835 | | 996 | |
| 836 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | 997 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| | 998 | if (entry.agentSpawn) return "agent"; |
| 837 | if ( | 999 | if ( |
| 838 | entry.sourceActivityKind === "user-input.requested" || | 1000 | entry.sourceActivityKind === "user-input.requested" || |
| 839 | entry.sourceActivityKind === "user-input.resolved" | 1001 | entry.sourceActivityKind === "user-input.resolved" |
| ... | @@ -860,6 +1022,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | ... | @@ -860,6 +1022,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 860 | } | 1022 | } |
| 861 | | 1023 | |
| 862 | function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | 1024 | function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| | 1025 | if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn); |
| 863 | const blocks: string[] = []; | 1026 | const blocks: string[] = []; |
| 864 | const appendBlock = (value: string | null | undefined) => { | 1027 | const appendBlock = (value: string | null | undefined) => { |
| 865 | const trimmed = value?.trim(); | 1028 | const trimmed = value?.trim(); |
| ... | @@ -887,6 +1050,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | ... | @@ -887,6 +1050,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { |
| 887 | * for every row (see the deferred-expansion test). | 1050 | * for every row (see the deferred-expansion test). |
| 888 | */ | 1051 | */ |
| 889 | function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { | 1052 | function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { |
| | 1053 | if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; |
| 890 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; | 1054 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; |
| 891 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; | 1055 | if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; |
| 892 | const parts = [entry.rawCommand ?? entry.command, entry.detail] | 1056 | const parts = [entry.rawCommand ?? entry.command, entry.detail] |
| ... | @@ -910,6 +1074,7 @@ function stripShellWrapper(value: string): string { | ... | @@ -910,6 +1074,7 @@ function stripShellWrapper(value: string): string { |
| 910 | | 1074 | |
| 911 | /** The one-line text a collapsed work row shows. */ | 1075 | /** The one-line text a collapsed work row shows. */ |
| 912 | export function workEntryRowLabel(entry: WorkLogEntry): string { | 1076 | export function workEntryRowLabel(entry: WorkLogEntry): string { |
| | 1077 | if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); |
| 913 | const presentation = resolveWorkEntryToolPresentation(entry); | 1078 | const presentation = resolveWorkEntryToolPresentation(entry); |
| 914 | if (presentation) return presentation.displayName; | 1079 | if (presentation) return presentation.displayName; |
| 915 | const preview = workEntryPreview(entry); | 1080 | const preview = workEntryPreview(entry); |
| ... | @@ -950,7 +1115,44 @@ function capitalizePhrase(value: string): string { | ... | @@ -950,7 +1115,44 @@ function capitalizePhrase(value: string): string { |
| 950 | return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; | 1115 | return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; |
| 951 | } | 1116 | } |
| 952 | | 1117 | |
| | 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 | */ |
| | 1124 | export 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. */ |
| | 1141 | function agentSpawnMembers(spawn: NonNullable<WorkLogEntry["agentSpawn"]>) { |
| | 1142 | return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); |
| | 1143 | } |
| | 1144 | |
| | 1145 | function 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 | |
| 953 | function workEntryHeading(workEntry: WorkLogEntry): string { | 1154 | function workEntryHeading(workEntry: WorkLogEntry): string { |
| | 1155 | if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn); |
| 954 | const presentation = resolveWorkEntryToolPresentation(workEntry); | 1156 | const presentation = resolveWorkEntryToolPresentation(workEntry); |
| 955 | if (presentation) return presentation.displayName; | 1157 | if (presentation) return presentation.displayName; |
| 956 | if (!workEntry.toolTitle) { | 1158 | if (!workEntry.toolTitle) { |
| ... | @@ -1706,7 +1908,7 @@ function appendActivityGroupRows( | ... | @@ -1706,7 +1908,7 @@ function appendActivityGroupRows( |
| 1706 | groupableRun = []; | 1908 | groupableRun = []; |
| 1707 | }; | 1909 | }; |
| 1708 | for (const activity of activities) { | 1910 | 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) { |
| 1710 | groupableRun.push(activity); | 1912 | groupableRun.push(activity); |
| 1711 | continue; | 1913 | continue; |
| 1712 | } | 1914 | } |