Skip to content

Commit 880a27a

Browse files
committed
fix: keep subagent spinner and footer live while a subagent runs
A subagent (Task/Agent tool call) keeps running after the parent turn ends or after the spawn tool call settles, but the conversation UI derived "loading" from the parent turn's turnComplete and the spawn's own status, so the spinner and the footer's generating indicator stopped mid-subagent, making the conversation look done. Derive live state from the subagent's own nested child tool calls: - SubagentToolView keeps its spinner while any child is non-terminal, even after the parent turn completes. - The new-thread ToolGroup / chip summary stay "Using" while a subagent in the group has active children. - Both footers (legacy ConversationView and ChatThreadFooter) keep the generating indicator alive via conversationHasActiveSubagent. Display-only: the prompt-lifecycle gates (queue/steer) still read the raw isPromptPending, so a follow-up message sends rather than queuing while a detached subagent finishes. Works for both Claude and Codex. Generated-By: PostHog Code Task-Id: 515f2e33-762c-412a-8a2d-a217bbace052
1 parent 1552f07 commit 880a27a

8 files changed

Lines changed: 341 additions & 11 deletions

File tree

packages/ui/src/features/sessions/components/ConversationView.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ConversationItem,
1616
TurnContext,
1717
} from "@posthog/ui/features/sessions/components/buildConversationItems";
18+
import { conversationHasActiveSubagent } from "@posthog/ui/features/sessions/components/buildConversationItems";
1819
import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar";
1920
import {
2021
PROMPT_RECALL_HINT_KEY,
@@ -197,6 +198,15 @@ export function ConversationView({
197198
[conversationItems, optimisticItems, isCloud],
198199
);
199200

201+
// A subagent keeps running detached after the parent prompt completes; keep the
202+
// footer's generating indicator alive until its nested child work settles. This
203+
// is display-only: the prompt-lifecycle gates (queue/steer) still read the raw
204+
// `isPromptPending`, so a follow-up message sends instead of queueing.
205+
const footerPromptPending = useMemo(
206+
() => !!isPromptPending || conversationHasActiveSubagent(items),
207+
[isPromptPending, items],
208+
);
209+
200210
// Fold each completed turn's tool-call work into a collapsible chip, and emit
201211
// the keepMounted indices (standalone MCP-app rows, whose iframes must survive
202212
// scrolling) + the item→row map in the same pass.
@@ -454,7 +464,7 @@ export function ConversationView({
454464
<div className={compact ? "pb-1" : "pb-16"}>
455465
<SessionFooter
456466
task={task}
457-
isPromptPending={isPromptPending}
467+
isPromptPending={footerPromptPending}
458468
promptStartedAt={promptStartedAt}
459469
lastGenerationDuration={
460470
lastTurnInfo?.isComplete

packages/ui/src/features/sessions/components/buildConversationItems.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { makeAttachmentUri } from "@posthog/core/sessions/promptContent";
2-
import type { AcpMessage } from "@posthog/shared";
2+
import { type AcpMessage, posthogToolMeta } from "@posthog/shared";
33
import { describe, expect, it } from "vitest";
44
import {
55
buildConversationItems,
66
type ConversationItem,
7+
conversationHasActiveSubagent,
78
} from "./buildConversationItems";
89

910
function consoleMsg(ts: number, message: string, level = "info"): AcpMessage {
@@ -952,6 +953,139 @@ describe("buildConversationItems", () => {
952953
expect(lastTurnInfo?.durationMs).toBeGreaterThan(0);
953954
});
954955
});
956+
957+
describe("active subagent detection", () => {
958+
const spawnMsg = (ts: number, toolCallId: string): AcpMessage => ({
959+
type: "acp_message",
960+
ts,
961+
message: {
962+
jsonrpc: "2.0",
963+
method: "session/update",
964+
params: {
965+
update: {
966+
sessionUpdate: "tool_call",
967+
toolCallId,
968+
kind: "other",
969+
status: "in_progress",
970+
title: "Do the thing",
971+
_meta: posthogToolMeta({ toolName: "Task" }),
972+
},
973+
},
974+
},
975+
});
976+
977+
const spawnUpdate = (
978+
ts: number,
979+
toolCallId: string,
980+
status: string,
981+
): AcpMessage => ({
982+
type: "acp_message",
983+
ts,
984+
message: {
985+
jsonrpc: "2.0",
986+
method: "session/update",
987+
params: {
988+
update: { sessionUpdate: "tool_call_update", toolCallId, status },
989+
},
990+
},
991+
});
992+
993+
const childToolMsg = (
994+
ts: number,
995+
toolCallId: string,
996+
parentId: string,
997+
status: string,
998+
): AcpMessage => ({
999+
type: "acp_message",
1000+
ts,
1001+
message: {
1002+
jsonrpc: "2.0",
1003+
method: "session/update",
1004+
params: {
1005+
update: {
1006+
sessionUpdate: "tool_call",
1007+
toolCallId,
1008+
kind: "execute",
1009+
status,
1010+
title: toolCallId,
1011+
_meta: posthogToolMeta({
1012+
toolName: "Bash",
1013+
parentToolCallId: parentId,
1014+
}),
1015+
},
1016+
},
1017+
},
1018+
});
1019+
1020+
const childToolUpdate = (
1021+
ts: number,
1022+
toolCallId: string,
1023+
status: string,
1024+
): AcpMessage => ({
1025+
type: "acp_message",
1026+
ts,
1027+
message: {
1028+
jsonrpc: "2.0",
1029+
method: "session/update",
1030+
params: {
1031+
update: { sessionUpdate: "tool_call_update", toolCallId, status },
1032+
},
1033+
},
1034+
});
1035+
1036+
it("reports an active subagent while a child tool call is in_progress", () => {
1037+
// Claude: the subagent's nested tool calls stream within the parent turn,
1038+
// before the prompt response settles it. The spawn row is already marked
1039+
// completed (its result is being assembled), but a child is mid-flight.
1040+
const events = [
1041+
userPromptMsg(1, 1, "go"),
1042+
spawnMsg(2, "spawn-1"),
1043+
spawnUpdate(3, "spawn-1", "completed"),
1044+
childToolMsg(4, "child-1", "spawn-1", "in_progress"),
1045+
];
1046+
1047+
const { items } = buildConversationItems(events, true);
1048+
expect(conversationHasActiveSubagent(items)).toBe(true);
1049+
});
1050+
1051+
it("still reports active after the parent turn ends but a child is in_flight (detached subagent)", () => {
1052+
const events = [
1053+
userPromptMsg(1, 1, "go"),
1054+
spawnMsg(2, "spawn-1"),
1055+
childToolMsg(3, "child-1", "spawn-1", "in_progress"),
1056+
spawnUpdate(4, "spawn-1", "completed"),
1057+
promptResponseMsg(5, 1),
1058+
];
1059+
1060+
const { items } = buildConversationItems(events, false);
1061+
expect(conversationHasActiveSubagent(items)).toBe(true);
1062+
});
1063+
1064+
it("clears once every child tool call has settled", () => {
1065+
const events = [
1066+
userPromptMsg(1, 1, "go"),
1067+
spawnMsg(2, "spawn-1"),
1068+
childToolMsg(3, "child-1", "spawn-1", "in_progress"),
1069+
childToolUpdate(4, "child-1", "completed"),
1070+
spawnUpdate(5, "spawn-1", "completed"),
1071+
promptResponseMsg(6, 1),
1072+
];
1073+
1074+
const { items } = buildConversationItems(events, false);
1075+
expect(conversationHasActiveSubagent(items)).toBe(false);
1076+
});
1077+
1078+
it("ignores a non-subagent tool call with no children", () => {
1079+
const events = [
1080+
userPromptMsg(1, 1, "go"),
1081+
childToolMsg(2, "t1", "nonexistent-parent", "in_progress"),
1082+
promptResponseMsg(3, 1),
1083+
];
1084+
1085+
const { items } = buildConversationItems(events, false);
1086+
expect(conversationHasActiveSubagent(items)).toBe(false);
1087+
});
1088+
});
9551089
});
9561090

9571091
// Local alias kept intentionally narrow to the shape we care about in tests.

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isJsonRpcNotification,
1414
isJsonRpcRequest,
1515
isJsonRpcResponse,
16+
readAgentToolName,
1617
readParentToolCallId,
1718
type UserShellExecuteParams,
1819
} from "@posthog/shared";
@@ -31,6 +32,7 @@ import {
3132
type SkillButtonId,
3233
} from "@posthog/ui/features/skill-buttons/prompts";
3334
import type { Step, StepStatus } from "@posthog/ui/primitives/StepList";
35+
import { SUBAGENT_SPAWN_TOOL_NAMES } from "./session-update/collaborationTools";
3436
import type { RenderItem } from "./session-update/SessionUpdateView";
3537

3638
export interface TurnContext {
@@ -165,6 +167,56 @@ function isTerminalToolStatus(status: string | null | undefined): boolean {
165167
return status != null && TERMINAL_TOOL_STATUSES.has(status);
166168
}
167169

170+
/**
171+
* Whether a subagent spawn (Task/Agent tool call) still has work in flight.
172+
* The parent turn ends when the spawn tool call returns, but the subagent keeps
173+
* running detached; its nested tool calls arrive as children on the spawn's
174+
* `TurnContext`. Without this check the spawn row's spinner stops the moment
175+
* the parent turn completes, making the conversation look done mid-subagent.
176+
*/
177+
export function hasActiveSubagent(
178+
context: Pick<TurnContext, "toolCalls" | "childItems">,
179+
spawnToolCallId: string,
180+
): boolean {
181+
const children = context.childItems.get(spawnToolCallId);
182+
if (!children?.length) return false;
183+
for (const child of children) {
184+
if (child.type !== "session_update") continue;
185+
if (child.update.sessionUpdate !== "tool_call") continue;
186+
const resolved = child.update.toolCallId
187+
? context.toolCalls.get(child.update.toolCallId)
188+
: undefined;
189+
const status = resolved?.status ?? child.update.status;
190+
if (!isTerminalToolStatus(status)) return true;
191+
}
192+
return false;
193+
}
194+
195+
/**
196+
* Whether any subagent anywhere in the conversation still has work in flight.
197+
* Drives the footer's generating indicator after the parent prompt has
198+
* completed but a detached subagent is still running.
199+
*/
200+
export function conversationHasActiveSubagent(
201+
items: ConversationItem[],
202+
): boolean {
203+
for (const item of items) {
204+
if (item.type !== "session_update") continue;
205+
if (item.update.sessionUpdate !== "tool_call") continue;
206+
if (!isSubagentSpawnUpdate(item.update)) continue;
207+
if (hasActiveSubagent(item.turnContext, item.update.toolCallId))
208+
return true;
209+
}
210+
return false;
211+
}
212+
213+
function isSubagentSpawnUpdate(
214+
update: RenderItem,
215+
): update is Extract<RenderItem, { sessionUpdate: "tool_call" }> {
216+
if (update.sessionUpdate !== "tool_call") return false;
217+
return SUBAGENT_SPAWN_TOOL_NAMES.has(readAgentToolName(update._meta) ?? "");
218+
}
219+
168220
function isThoughtItem(
169221
item: ConversationItem,
170222
): item is ConversationItem & { type: "session_update" } {

packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AcpMessage } from "@posthog/shared";
22
import type { Task } from "@posthog/shared/domain-types";
3+
import { conversationHasActiveSubagent } from "@posthog/ui/features/sessions/components/buildConversationItems";
34
import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter";
45
import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage";
56
import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems";
@@ -37,18 +38,24 @@ export function ChatThreadFooter({
3738
}: ChatThreadFooterProps) {
3839
const showDebugLogs = useSettingsStore((s) => s.debugLogsCloudRuns);
3940
const contextUsage = useContextUsage(events);
40-
const { lastTurnInfo, isCompacting, completedToolCallCount } =
41+
const { items, lastTurnInfo, isCompacting, completedToolCallCount } =
4142
useConversationItems(events, isPromptPending, { showDebugLogs });
4243
const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
4344
const queuedCount = useQueuedMessagesForTask(taskId).length;
4445
const session = useSessionForTask(taskId);
4546
const pausedDurationMs = session?.pausedDurationMs ?? 0;
4647

48+
// Keep the generating indicator alive while a detached subagent is still
49+
// running after the parent prompt completed. Display-only; the prompt
50+
// lifecycle (queue/steer) still reads the raw `isPromptPending`.
51+
const footerPromptPending =
52+
!!isPromptPending || conversationHasActiveSubagent(items);
53+
4754
return (
4855
<div className="pt-1">
4956
<SessionFooter
5057
task={task}
51-
isPromptPending={isPromptPending}
58+
isPromptPending={footerPromptPending}
5259
promptStartedAt={promptStartedAt}
5360
lastGenerationDuration={
5461
lastTurnInfo?.isComplete

packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import { readAgentToolName } from "@posthog/shared";
1010
import type { ToolCall } from "@posthog/ui/features/sessions/types";
1111
import { memo } from "react";
1212
import type { ConversationItem } from "../buildConversationItems";
13+
import { hasActiveSubagent } from "../buildConversationItems";
1314
import { grouping } from "../new-thread/conversationThreadConfig";
15+
import { isSubagentSpawnTool } from "../session-update/collaborationTools";
1416
import { SessionUpdateView } from "../session-update/SessionUpdateView";
1517
import { iconForToolCall } from "../session-update/toolCallUtils";
1618

@@ -64,13 +66,18 @@ function friendlyName(key: string): string {
6466
}
6567

6668
export function isToolActive(item: ToolGroupItem["tools"][number]): boolean {
67-
const { toolCall } = resolveTool(item);
69+
const { toolCall, toolName } = resolveTool(item);
70+
if (item.turnContext.turnCancelled) return false;
71+
6872
const incomplete =
6973
toolCall.status === "pending" || toolCall.status === "in_progress";
74+
if (incomplete && !item.turnContext.turnComplete) return true;
75+
76+
// The spawn settled and the parent turn ended, but the subagent keeps running
77+
// detached — keep the group live on its nested child work.
7078
return (
71-
incomplete &&
72-
!item.turnContext.turnCancelled &&
73-
!item.turnContext.turnComplete
79+
isSubagentSpawnTool(toolName) &&
80+
hasActiveSubagent(item.turnContext, toolCall.toolCallId)
7481
);
7582
}
7683

packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Icon } from "@phosphor-icons/react";
22
import { readAgentToolName, readMcpToolDescriptor } from "@posthog/shared";
33
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
4+
import { hasActiveSubagent } from "@posthog/ui/features/sessions/components/buildConversationItems";
45
import {
56
buildDoneLabel,
67
type CollapseMode,
@@ -140,6 +141,7 @@ function summarize(items: ConversationItem[]): GroupSummary {
140141
let liveLabel: string | null = null;
141142
let lastToolStatus: string | undefined;
142143
let trailingThoughtStreaming = false;
144+
let subagentActive = false;
143145
const icons: GroupIconEntry[] = [];
144146
const seenIcons = new Set<string>();
145147

@@ -160,6 +162,15 @@ function summarize(items: ConversationItem[]): GroupSummary {
160162
if (name && grouping.subagentToolNames.has(name)) {
161163
counts.subagents++;
162164
addIcon(SUBAGENT_ICON, "subagent");
165+
// A settled spawn with the parent turn complete can still have a detached
166+
// subagent running in its children — that keeps the chip live.
167+
if (
168+
!subagentActive &&
169+
update.toolCallId &&
170+
hasActiveSubagent(item.turnContext, update.toolCallId)
171+
) {
172+
subagentActive = true;
173+
}
163174
} else if (readMcpToolDescriptor(update._meta)) {
164175
counts.other++;
165176
addIcon(MCP_ICON, "mcp");
@@ -211,6 +222,7 @@ function summarize(items: ConversationItem[]): GroupSummary {
211222
if (trailingThoughtStreaming) liveLabel = "Thinking…";
212223
const active =
213224
trailingThoughtStreaming ||
225+
subagentActive ||
214226
lastToolStatus === "pending" ||
215227
lastToolStatus === "in_progress";
216228
const hasCountableWork =

0 commit comments

Comments
 (0)