Show thinking tool calls inline

This commit is contained in:
George Pickett
2026-02-17 12:13:51 -08:00
parent 19c1bec9dc
commit 04f035cc07
2 changed files with 173 additions and 51 deletions
+122 -51
View File
@@ -175,16 +175,50 @@ const ExecApprovalCard = memo(function ExecApprovalCard({
);
});
const ToolCallDetails = memo(function ToolCallDetails({
line,
className,
}: {
line: string;
className?: string;
}) {
const { summaryText, body } = summarizeToolLabel(line);
return (
<details
className={
className ??
`w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} self-start rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground`
}
>
<summary className="cursor-pointer select-none font-mono text-[10px] font-semibold tracking-[0.11em]">
{summaryText}
</summary>
{body ? (
<div className="agent-markdown agent-tool-markdown mt-1 text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{rewriteMediaLinesToMarkdown(body)}
</ReactMarkdown>
</div>
) : null}
</details>
);
});
const ThinkingDetailsRow = memo(function ThinkingDetailsRow({
thinkingText,
toolLines = [],
durationMs,
showTyping,
}: {
thinkingText: string;
thinkingText?: string | null;
toolLines?: string[];
durationMs?: number;
showTyping?: boolean;
}) {
if (!thinkingText.trim()) return null;
const normalizedThinkingText = thinkingText?.trim() ?? "";
const hasThinkingText = normalizedThinkingText.length > 0;
const hasToolLines = toolLines.length > 0;
if (!hasThinkingText && !hasToolLines) return null;
return (
<details className="group rounded-[8px] border border-border/70 bg-surface-2 px-2 py-1.5 text-[10px] text-muted-foreground/80">
<summary className="flex cursor-pointer list-none items-center gap-2 opacity-65 [&::-webkit-details-marker]:hidden">
@@ -208,9 +242,22 @@ const ThinkingDetailsRow = memo(function ThinkingDetailsRow({
) : null}
</span>
</summary>
<div className="agent-markdown mt-2 min-w-0 pl-5 text-foreground/85">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{thinkingText}</ReactMarkdown>
</div>
{hasThinkingText ? (
<div className="agent-markdown mt-2 min-w-0 pl-5 text-foreground/85">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{normalizedThinkingText}</ReactMarkdown>
</div>
) : null}
{hasToolLines ? (
<div className="mt-2 space-y-1.5 pl-5">
{toolLines.map((line, index) => (
<ToolCallDetails
key={`thinking-tool-${index}-${line.slice(0, 48)}`}
line={line}
className="rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground"
/>
))}
</div>
) : null}
</details>
);
});
@@ -247,6 +294,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
name,
timestampMs,
thinkingText,
thinkingToolLines,
thinkingDurationMs,
showTypingIndicator,
contentText,
@@ -257,14 +305,17 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
name: string;
timestampMs?: number;
thinkingText?: string | null;
thinkingToolLines?: string[];
thinkingDurationMs?: number;
showTypingIndicator?: boolean;
contentText?: string | null;
streaming?: boolean;
}) {
const resolvedTimestamp = typeof timestampMs === "number" ? timestampMs : null;
const widthClass = resolveAssistantMaxWidthClass(contentText);
const hasThinking = Boolean(thinkingText?.trim());
const hasThinking = Boolean(thinkingText?.trim() || (thinkingToolLines?.length ?? 0) > 0);
const widthClass = hasThinking
? ASSISTANT_MAX_WIDTH_EXPANDED_CLASS
: resolveAssistantMaxWidthClass(contentText);
const hasContent = Boolean(contentText?.trim());
const compactStreamingIndicator = Boolean(streaming && !hasThinking && !hasContent);
@@ -321,9 +372,10 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
</div>
) : null}
{thinkingText ? (
{hasThinking ? (
<ThinkingDetailsRow
thinkingText={thinkingText}
toolLines={thinkingToolLines ?? []}
durationMs={thinkingDurationMs}
showTyping={streaming}
/>
@@ -393,48 +445,78 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
text: string | null;
timestampMs?: number;
thinkingText?: string;
thinkingToolLines: string[];
thinkingDurationMs?: number;
}
| { kind: "tool"; text: string }
> = [];
let orphanToolLines: string[] = [];
for (const item of chatItems) {
switch (item.kind) {
case "thinking":
pendingThinking = item;
break;
case "user":
pendingThinking = null;
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
break;
case "assistant":
blocks.push({
kind: "assistant",
text: item.text,
timestampMs: item.timestampMs ?? pendingThinking?.timestampMs,
thinkingText: pendingThinking?.kind === "thinking" ? pendingThinking.text : undefined,
thinkingDurationMs:
item.thinkingDurationMs ??
(pendingThinking?.kind === "thinking" ? pendingThinking.thinkingDurationMs : undefined),
});
pendingThinking = null;
break;
case "tool":
blocks.push({ kind: "tool", text: item.text });
break;
}
}
if (pendingThinking?.kind === "thinking") {
const flushPendingThinking = () => {
if (!pendingThinking || pendingThinking.kind !== "thinking") return;
blocks.push({
kind: "assistant",
text: null,
timestampMs: pendingThinking.timestampMs,
thinkingText: pendingThinking.text,
thinkingToolLines: [...orphanToolLines],
thinkingDurationMs: pendingThinking.thinkingDurationMs,
});
pendingThinking = null;
orphanToolLines = [];
};
const flushOrphanToolLines = () => {
if (orphanToolLines.length === 0) return;
for (const line of orphanToolLines) {
blocks.push({ kind: "tool", text: line });
}
orphanToolLines = [];
};
for (const item of chatItems) {
switch (item.kind) {
case "thinking":
flushPendingThinking();
pendingThinking = item;
break;
case "user":
flushPendingThinking();
flushOrphanToolLines();
blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs });
break;
case "assistant":
if (pendingThinking?.kind === "thinking") {
blocks.push({
kind: "assistant",
text: item.text,
timestampMs: item.timestampMs ?? pendingThinking.timestampMs,
thinkingText: pendingThinking.text,
thinkingToolLines: [...orphanToolLines],
thinkingDurationMs: item.thinkingDurationMs ?? pendingThinking.thinkingDurationMs,
});
pendingThinking = null;
orphanToolLines = [];
} else {
flushOrphanToolLines();
blocks.push({
kind: "assistant",
text: item.text,
timestampMs: item.timestampMs,
thinkingToolLines: [],
thinkingDurationMs: item.thinkingDurationMs,
});
}
break;
case "tool":
orphanToolLines.push(item.text);
break;
}
}
flushPendingThinking();
flushOrphanToolLines();
return (
<>
{blocks.map((block, index) => {
@@ -448,23 +530,11 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
);
}
if (block.kind === "tool") {
const { summaryText, body } = summarizeToolLabel(block.text);
return (
<details
<ToolCallDetails
key={`chat-${agentId}-tool-${index}`}
className={`w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} self-start rounded-[8px] border border-border/70 bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground`}
>
<summary className="cursor-pointer select-none font-mono text-[10px] font-semibold tracking-[0.11em]">
{summaryText}
</summary>
{body ? (
<div className="agent-markdown agent-tool-markdown mt-1 text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{rewriteMediaLinesToMarkdown(body)}
</ReactMarkdown>
</div>
) : null}
</details>
line={block.text}
/>
);
}
const streaming = running && index === blocks.length - 1 && !block.text;
@@ -476,6 +546,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
name={name}
timestampMs={block.timestampMs ?? (streaming ? runStartedAt ?? undefined : undefined)}
thinkingText={block.thinkingText ?? null}
thinkingToolLines={block.thinkingToolLines}
thinkingDurationMs={block.thinkingDurationMs}
contentText={block.text}
streaming={streaming}
@@ -4,6 +4,7 @@ import { cleanup, render, screen, within } from "@testing-library/react";
import type { AgentState } from "@/features/agents/state/store";
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
import type { GatewayModelChoice } from "@/lib/gateway/models";
import { formatThinkingMarkdown, formatToolCallMarkdown } from "@/lib/text/message-extract";
const createAgent = (): AgentState => ({
agentId: "agent-1",
@@ -87,4 +88,54 @@ describe("AgentChatPanel markdown rendering", () => {
expect(toolDetails).toBeTruthy();
expect(within(toolDetails as HTMLElement).getByText("done")).toBeInTheDocument();
});
it("nests tool calls inside the associated thinking details block", () => {
const firstToolCall = formatToolCallMarkdown({
id: "call_1",
name: "memory_search",
arguments: { query: "priority ledger" },
});
const secondToolCall = formatToolCallMarkdown({
id: "call_2",
name: "memory_search",
arguments: { query: "youtube channel tasks" },
});
render(
createElement(AgentChatPanel, {
agent: {
...createAgent(),
outputLines: [
"> how are you prioritizing this?",
firstToolCall,
secondToolCall,
formatThinkingMarkdown("Proposing multi-lane tracking system"),
"Short answer: a pinned priority ledger keeps the loop aligned.",
],
},
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend: vi.fn(),
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
const thinkingDetails = screen.getByText("Thinking (internal)").closest("details");
expect(thinkingDetails).toBeTruthy();
expect(within(thinkingDetails as HTMLElement).getByText(/proposing multi-lane tracking system/i)).toBeInTheDocument();
const memorySearchSummaries = screen.getAllByText(/MEMORY_SEARCH/);
expect(memorySearchSummaries.length).toBe(2);
for (const summary of memorySearchSummaries) {
expect(thinkingDetails).toContainElement(summary);
}
});
});