Skip to content

Commit 513a49d

Browse files
committed
Render subagent output inline and fix duplicate notification forwarding
1 parent 6af98c1 commit 513a49d

File tree

8 files changed

+307
-51
lines changed

8 files changed

+307
-51
lines changed

apps/twig/src/main/services/agent/service.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,30 +1190,8 @@ For git operations while detached:
11901190
}
11911191
}
11921192

1193-
// Forward extension notifications to the renderer as ACP messages
1194-
// The extNotification callback doesn't write to the stream, so we need
1195-
// to manually emit these to the renderer
1196-
if (
1197-
method === "_posthog/sdk_session" ||
1198-
method === "_posthog/status" ||
1199-
method === "_posthog/task_notification" ||
1200-
method === "_posthog/compact_boundary"
1201-
) {
1202-
log.info("Forwarding extension notification to renderer", {
1203-
method,
1204-
taskRunId,
1205-
});
1206-
const acpMessage: AcpMessage = {
1207-
type: "acp_message",
1208-
ts: Date.now(),
1209-
message: {
1210-
jsonrpc: "2.0",
1211-
method,
1212-
params,
1213-
} as AcpMessage["message"],
1214-
};
1215-
emitToRenderer(acpMessage);
1216-
}
1193+
// Extension notifications already flow through the tapped stream
1194+
// (same pattern as sessionUpdate). No need to re-emit here.
12171195
},
12181196
};
12191197

apps/twig/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ const SessionUpdateRow = memo(function SessionUpdateRow({
224224
<SessionUpdateView
225225
item={update}
226226
toolCalls={turnContext.toolCalls}
227+
childItems={turnContext.childItems}
227228
turnCancelled={turnContext.turnCancelled}
228229
turnComplete={turnContext.turnComplete}
229230
/>

apps/twig/src/renderer/features/sessions/components/buildConversationItems.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { UserShellExecute } from "./session-update/UserShellExecuteView";
1717

1818
export interface TurnContext {
1919
toolCalls: Map<string, ToolCall>;
20+
childItems: Map<string, ConversationItem[]>;
2021
turnCancelled: boolean;
2122
turnComplete: boolean;
2223
}
@@ -154,8 +155,10 @@ function handlePromptRequest(
154155
const toolCalls = new Map<string, ToolCall>();
155156
const gitAction = parseGitActionMessage(userContent);
156157

158+
const childItems = new Map<string, ConversationItem[]>();
157159
const context: TurnContext = {
158160
toolCalls,
161+
childItems,
159162
turnCancelled: false,
160163
turnComplete: false,
161164
};
@@ -305,17 +308,93 @@ function extractUserContent(params: unknown): string {
305308
return visibleTextBlocks.map((b) => b.text).join("");
306309
}
307310

311+
function getParentToolCallId(update: SessionUpdate): string | undefined {
312+
const meta = (update as Record<string, unknown>)?._meta as
313+
| { claudeCode?: { parentToolCallId?: string } }
314+
| undefined;
315+
return meta?.claudeCode?.parentToolCallId;
316+
}
317+
318+
function pushChildItem(b: ItemBuilder, parentId: string, update: RenderItem) {
319+
const turn = b.currentTurn;
320+
if (!turn) return;
321+
let children = turn.context.childItems.get(parentId);
322+
if (!children) {
323+
children = [];
324+
turn.context.childItems.set(parentId, children);
325+
}
326+
turn.itemCount++;
327+
children.push({
328+
type: "session_update",
329+
id: `${turn.id}-child-${b.nextId()}`,
330+
update,
331+
turnContext: turn.context,
332+
});
333+
}
334+
335+
function appendTextChunkToChildren(
336+
b: ItemBuilder,
337+
parentId: string,
338+
update: SessionUpdate & {
339+
sessionUpdate: "agent_message_chunk" | "agent_thought_chunk";
340+
},
341+
) {
342+
if (update.content.type !== "text") return;
343+
const turn = b.currentTurn;
344+
if (!turn) return;
345+
let children = turn.context.childItems.get(parentId);
346+
if (!children) {
347+
children = [];
348+
turn.context.childItems.set(parentId, children);
349+
}
350+
351+
const lastChild = children[children.length - 1];
352+
if (
353+
lastChild?.type === "session_update" &&
354+
lastChild.update.sessionUpdate === update.sessionUpdate &&
355+
"content" in lastChild.update &&
356+
lastChild.update.content.type === "text"
357+
) {
358+
const prevText = (
359+
lastChild.update.content as { type: "text"; text: string }
360+
).text;
361+
children[children.length - 1] = {
362+
...lastChild,
363+
update: {
364+
...lastChild.update,
365+
content: {
366+
type: "text",
367+
text: prevText + update.content.text,
368+
},
369+
},
370+
};
371+
} else {
372+
turn.itemCount++;
373+
children.push({
374+
type: "session_update",
375+
id: `${turn.id}-child-${b.nextId()}`,
376+
update: { ...update, content: { ...update.content } },
377+
turnContext: turn.context,
378+
});
379+
}
380+
}
381+
308382
function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
309383
switch (update.sessionUpdate) {
310384
case "user_message_chunk":
311385
break;
312386

313387
case "agent_message_chunk":
314-
case "agent_thought_chunk":
315-
if (update.content.type === "text") {
388+
case "agent_thought_chunk": {
389+
if (update.content.type !== "text") break;
390+
const parentId = getParentToolCallId(update);
391+
if (parentId) {
392+
appendTextChunkToChildren(b, parentId, update);
393+
} else {
316394
appendTextChunk(b, update);
317395
}
318396
break;
397+
}
319398

320399
case "tool_call": {
321400
const turn = b.currentTurn!;
@@ -325,7 +404,12 @@ function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
325404
} else {
326405
const toolCall = { ...update };
327406
turn.toolCalls.set(update.toolCallId, toolCall);
328-
pushItem(b, toolCall);
407+
const parentId = getParentToolCallId(update);
408+
if (parentId) {
409+
pushChildItem(b, parentId, toolCall);
410+
} else {
411+
pushItem(b, toolCall);
412+
}
329413
}
330414
break;
331415
}

apps/twig/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ConversationItem } from "@features/sessions/components/buildConversationItems";
12
import type { SessionUpdate, ToolCall } from "@features/sessions/types";
23
import { memo } from "react";
34

@@ -44,13 +45,15 @@ export type RenderItem =
4445
interface SessionUpdateViewProps {
4546
item: RenderItem;
4647
toolCalls?: Map<string, ToolCall>;
48+
childItems?: Map<string, ConversationItem[]>;
4749
turnCancelled?: boolean;
4850
turnComplete?: boolean;
4951
}
5052

5153
export const SessionUpdateView = memo(function SessionUpdateView({
5254
item,
5355
toolCalls,
56+
childItems,
5457
turnCancelled,
5558
turnComplete,
5659
}: SessionUpdateViewProps) {
@@ -71,6 +74,8 @@ export const SessionUpdateView = memo(function SessionUpdateView({
7174
toolCall={toolCalls?.get(item.toolCallId) ?? item}
7275
turnCancelled={turnCancelled}
7376
turnComplete={turnComplete}
77+
childItems={childItems?.get(item.toolCallId)}
78+
childItemsMap={childItems}
7479
/>
7580
);
7681
case "tool_call_update":
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type {
2+
ConversationItem,
3+
TurnContext,
4+
} from "@features/sessions/components/buildConversationItems";
5+
import {
6+
ArrowsInSimple as ArrowsInSimpleIcon,
7+
ArrowsOutSimple as ArrowsOutSimpleIcon,
8+
Brain,
9+
} from "@phosphor-icons/react";
10+
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
11+
import { useEffect, useState } from "react";
12+
import { SessionUpdateView } from "./SessionUpdateView";
13+
import {
14+
LoadingIcon,
15+
StatusIndicators,
16+
type ToolViewProps,
17+
useToolCallStatus,
18+
} from "./toolCallUtils";
19+
20+
interface SubagentToolViewProps extends ToolViewProps {
21+
childItems: ConversationItem[];
22+
turnContext: TurnContext;
23+
}
24+
25+
export function SubagentToolView({
26+
toolCall,
27+
turnCancelled,
28+
turnComplete,
29+
childItems,
30+
turnContext,
31+
}: SubagentToolViewProps) {
32+
const { title } = toolCall;
33+
const { isLoading, isFailed, wasCancelled, isComplete } = useToolCallStatus(
34+
toolCall.status,
35+
turnCancelled,
36+
turnComplete,
37+
);
38+
39+
const [isExpanded, setIsExpanded] = useState(true);
40+
41+
useEffect(() => {
42+
if (isComplete || isFailed || wasCancelled) {
43+
setIsExpanded(false);
44+
}
45+
}, [isComplete, isFailed, wasCancelled]);
46+
47+
const hasChildren = childItems.length > 0;
48+
49+
return (
50+
<Box className="my-2 max-w-4xl overflow-hidden rounded-lg border border-gray-6 bg-gray-1">
51+
<button
52+
type="button"
53+
onClick={() => setIsExpanded(!isExpanded)}
54+
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent px-3 py-2"
55+
>
56+
<Flex align="center" gap="2">
57+
<LoadingIcon
58+
icon={Brain}
59+
isLoading={isLoading}
60+
className="text-gray-10"
61+
/>
62+
<Text size="1" className="text-gray-10">
63+
{title || "Subagent"}
64+
</Text>
65+
<StatusIndicators isFailed={isFailed} wasCancelled={wasCancelled} />
66+
</Flex>
67+
{hasChildren && (
68+
<IconButton asChild size="1" variant="ghost" color="gray">
69+
<span>
70+
{isExpanded ? (
71+
<ArrowsInSimpleIcon size={12} />
72+
) : (
73+
<ArrowsOutSimpleIcon size={12} />
74+
)}
75+
</span>
76+
</IconButton>
77+
)}
78+
</button>
79+
80+
{isExpanded && hasChildren && (
81+
<Box className="space-y-1 border-gray-6 border-t px-2 py-2">
82+
{childItems.map((child) => {
83+
if (child.type !== "session_update") return null;
84+
return (
85+
<SessionUpdateView
86+
key={child.id}
87+
item={child.update}
88+
toolCalls={turnContext.toolCalls}
89+
childItems={turnContext.childItems}
90+
turnCancelled={turnContext.turnCancelled}
91+
turnComplete={turnContext.turnComplete}
92+
/>
93+
);
94+
})}
95+
</Box>
96+
)}
97+
</Box>
98+
);
99+
}

apps/twig/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import type {
2+
ConversationItem,
3+
TurnContext,
4+
} from "@features/sessions/components/buildConversationItems";
5+
import type { ToolCall } from "@features/sessions/types";
16
import { Box } from "@radix-ui/themes";
27
import { DeleteToolView } from "./DeleteToolView";
38
import { EditToolView } from "./EditToolView";
@@ -8,15 +13,23 @@ import { PlanApprovalView } from "./PlanApprovalView";
813
import { QuestionToolView } from "./QuestionToolView";
914
import { ReadToolView } from "./ReadToolView";
1015
import { SearchToolView } from "./SearchToolView";
16+
import { SubagentToolView } from "./SubagentToolView";
1117
import { ThinkToolView } from "./ThinkToolView";
1218
import { ToolCallView } from "./ToolCallView";
1319
import type { ToolViewProps } from "./toolCallUtils";
1420

21+
interface ToolCallBlockProps extends ToolViewProps {
22+
childItems?: ConversationItem[];
23+
childItemsMap?: Map<string, ConversationItem[]>;
24+
}
25+
1526
export function ToolCallBlock({
1627
toolCall,
1728
turnCancelled,
1829
turnComplete,
19-
}: ToolViewProps) {
30+
childItems,
31+
childItemsMap,
32+
}: ToolCallBlockProps) {
2033
const meta = toolCall._meta as
2134
| { claudeCode?: { toolName?: string } }
2235
| undefined;
@@ -28,6 +41,24 @@ export function ToolCallBlock({
2841

2942
const props = { toolCall, turnCancelled, turnComplete };
3043

44+
if (toolName === "Task" && childItems && childItems.length > 0) {
45+
const turnContext: TurnContext = {
46+
toolCalls: buildChildToolCallsMap(childItems),
47+
childItems: childItemsMap ?? new Map(),
48+
turnCancelled: turnCancelled ?? false,
49+
turnComplete: turnComplete ?? false,
50+
};
51+
return (
52+
<Box className="pl-3">
53+
<SubagentToolView
54+
{...props}
55+
childItems={childItems}
56+
turnContext={turnContext}
57+
/>
58+
</Box>
59+
);
60+
}
61+
3162
const content = (() => {
3263
switch (toolCall.kind) {
3364
case "switch_mode":
@@ -57,3 +88,21 @@ export function ToolCallBlock({
5788

5889
return <Box className="pl-3">{content}</Box>;
5990
}
91+
92+
function buildChildToolCallsMap(
93+
childItems: ConversationItem[],
94+
): Map<string, ToolCall> {
95+
const map = new Map<string, ToolCall>();
96+
for (const item of childItems) {
97+
if (
98+
item.type === "session_update" &&
99+
item.update.sessionUpdate === "tool_call"
100+
) {
101+
const tc = item.update as unknown as ToolCall;
102+
if (tc.toolCallId) {
103+
map.set(tc.toolCallId, tc);
104+
}
105+
}
106+
}
107+
return map;
108+
}

0 commit comments

Comments
 (0)