Skip to content

Commit b9b1283

Browse files
authored
Show tool use before it completes (#24)
* Show tool use before it completes * Review fixes * Review fixes * Review fixes * Update storybook
1 parent 523c3f2 commit b9b1283

File tree

4 files changed

+138
-38
lines changed

4 files changed

+138
-38
lines changed

ui/src/components/MultiModelResponse/ContentRound.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useState, useCallback, useMemo } from "react";
1+
import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react";
22
import type { ToolExecutionRound, Artifact, DisplaySelectionData } from "@/components/chat-types";
33
import { Artifact as ArtifactComponent } from "@/components/Artifact";
44
import { ReasoningSection } from "@/components/ReasoningSection/ReasoningSection";
@@ -47,8 +47,33 @@ function ContentRoundComponent({
4747
displaySelection,
4848
allOutputArtifacts,
4949
}: ContentRoundProps) {
50-
const [toolsExpanded, setToolsExpanded] = useState(false);
51-
const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []);
50+
const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);
51+
const [userOverride, setUserOverride] = useState(false);
52+
const [wasAutoExpanded, setWasAutoExpanded] = useState(false);
53+
54+
// Auto-expand when tools are streaming, stay open after stream ends until user collapses
55+
const toolsExpanded = useMemo(() => {
56+
if (userOverride) return isManuallyExpanded;
57+
if (isToolsStreaming) return true;
58+
if (wasAutoExpanded) return true;
59+
return isManuallyExpanded;
60+
}, [isToolsStreaming, isManuallyExpanded, userOverride, wasAutoExpanded]);
61+
62+
// Reset user override only on false→true transition (new streaming session)
63+
const prevStreamingRef = useRef(false);
64+
useEffect(() => {
65+
if (isToolsStreaming && !prevStreamingRef.current) {
66+
setUserOverride(false);
67+
setWasAutoExpanded(true);
68+
}
69+
prevStreamingRef.current = isToolsStreaming;
70+
}, [isToolsStreaming]);
71+
72+
const handleToggleTools = useCallback(() => {
73+
setIsManuallyExpanded(!toolsExpanded);
74+
setUserOverride(true);
75+
setWasAutoExpanded(false);
76+
}, [toolsExpanded]);
5277
const compactMode = useCompactMode();
5378

5479
// Resolve display selection to actual artifacts

ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ export const WithReasoningContent: Story = {
562562
};
563563

564564
/**
565-
* Test: Streaming with empty content shows Thinking indicator
565+
* Test: Streaming with running tool shows tool execution UI (not Thinking indicator)
566566
*/
567567
export const WithToolCallSearching: Story = {
568568
args: {
@@ -596,8 +596,9 @@ export const WithToolCallSearching: Story = {
596596
play: async ({ canvasElement }) => {
597597
const canvas = within(canvasElement);
598598

599-
// While streaming with no content, shows Thinking indicator
600-
await expect(canvas.getByText("Thinking")).toBeInTheDocument();
599+
// While tools are visibly running, shows tool status instead of Thinking indicator
600+
const runningElements = canvas.getAllByText("running");
601+
await expect(runningElements.length).toBeGreaterThan(0);
601602
},
602603
};
603604

ui/src/components/MultiModelResponse/MultiModelResponse.tsx

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -740,10 +740,36 @@ const ModelResponseCard = memo(function ModelResponseCard({
740740
response.toolExecutionRounds,
741741
]);
742742

743+
// Bridge live tool execution data into the last completed round so that
744+
// ContentRound can render the execution timeline while tools are still running.
745+
// Gated on isStreaming so committed (DB-loaded) messages are unaffected.
746+
const completedRoundsWithLiveTools = useMemo(() => {
747+
if (!response.isStreaming || !toolExecutionRounds.length) {
748+
return completedRounds;
749+
}
750+
const liveRound = toolExecutionRounds[toolExecutionRounds.length - 1];
751+
// No completed rounds yet (model called tool immediately) — synthesize one
752+
if (!completedRounds.length) {
753+
return [{ toolExecution: liveRound }];
754+
}
755+
const last = completedRounds[completedRounds.length - 1];
756+
// Last round already has tool execution (back-to-back tool calls) — append new round
757+
if (last.toolExecution) {
758+
return [...completedRounds, { toolExecution: liveRound }];
759+
}
760+
// Last round is text-only — inject live tools into it
761+
const merged = [...completedRounds];
762+
merged[merged.length - 1] = {
763+
...last,
764+
toolExecution: liveRound,
765+
};
766+
return merged;
767+
}, [completedRounds, toolExecutionRounds, response.isStreaming]);
768+
743769
// All output artifacts across all rounds (for resolving display_artifacts selections)
744770
const allOutputArtifacts = useMemo(() => {
745771
const result: ArtifactType[] = [];
746-
for (const round of completedRounds) {
772+
for (const round of completedRoundsWithLiveTools) {
747773
if (round.toolExecution) {
748774
for (const execution of round.toolExecution.executions) {
749775
for (const a of execution.outputArtifacts) {
@@ -753,7 +779,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
753779
}
754780
}
755781
return result;
756-
}, [completedRounds]);
782+
}, [completedRoundsWithLiveTools]);
757783

758784
// Extract display selection for a specific tool execution round
759785
const getDisplaySelectionForRound = useCallback(
@@ -959,31 +985,44 @@ const ModelResponseCard = memo(function ModelResponseCard({
959985
{/* Content: unified rendering via ContentRound for all responses */}
960986
{(() => {
961987
// Detect in-flight content that hasn't been captured in a completed round yet.
962-
// completedRounds is always populated (even for single-round responses),
988+
// completedRoundsWithLiveTools is always populated (even for single-round responses),
963989
// so this only shows content actively streaming in the current round.
964990
const currentReasoning =
965991
response.isStreaming &&
966992
response.reasoningContent &&
967-
!completedRounds.some((r) => r.reasoning === response.reasoningContent)
993+
!completedRoundsWithLiveTools.some((r) => r.reasoning === response.reasoningContent)
968994
? response.reasoningContent
969995
: null;
970996
const currentContent =
971997
response.isStreaming && response.content?.trim() ? response.content : null;
972998
const showInFlight = currentReasoning || currentContent;
999+
1000+
// Suppress the streaming status indicator when running tools are
1001+
// already visible in expanded ContentRounds (non-compact mode only)
1002+
const hasVisibleRunningTools =
1003+
!compactMode &&
1004+
completedRoundsWithLiveTools.some((r) =>
1005+
r.toolExecution?.executions.some(
1006+
(e) => e.status === "running" || e.status === "pending"
1007+
)
1008+
);
1009+
9731010
return (
9741011
<div className="space-y-3">
975-
{completedRounds.map((round, i) => (
1012+
{completedRoundsWithLiveTools.map((round, i) => (
9761013
<ContentRound
9771014
key={i}
9781015
reasoning={round.reasoning}
9791016
content={round.content}
9801017
reasoningTokenCount={
981-
completedRounds.length === 1 ? response.usage?.reasoningTokens : undefined
1018+
completedRoundsWithLiveTools.length === 1
1019+
? response.usage?.reasoningTokens
1020+
: undefined
9821021
}
9831022
toolExecutionRound={round.toolExecution}
9841023
isToolsStreaming={
9851024
response.isStreaming &&
986-
i === completedRounds.length - 1 &&
1025+
i === completedRoundsWithLiveTools.length - 1 &&
9871026
!!round.toolExecution?.executions.some(
9881027
(e) => e.status === "pending" || e.status === "running"
9891028
)
@@ -1005,10 +1044,12 @@ const ModelResponseCard = memo(function ModelResponseCard({
10051044
isReasoningStreaming={response.isStreaming && !currentContent}
10061045
/>
10071046
)}
1008-
<StreamingStatusIndicator
1009-
phase={streamingPhase}
1010-
toolStatusMessage={toolStatusMessage}
1011-
/>
1047+
{!hasVisibleRunningTools && (
1048+
<StreamingStatusIndicator
1049+
phase={streamingPhase}
1050+
toolStatusMessage={toolStatusMessage}
1051+
/>
1052+
)}
10121053
</div>
10131054
);
10141055
})()}

ui/src/components/ToolExecution/ToolExecutionStep.tsx

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,29 @@ const TOOL_LANGUAGES: Record<string, string> = {
7171
chart_render: "json",
7272
};
7373

74+
/** Extract a displayable code string from raw tool input (before artifacts are populated) */
75+
function extractCodeFromInput(input: unknown): string | null {
76+
if (!input || typeof input !== "object") return null;
77+
const obj = input as Record<string, unknown>;
78+
if (typeof obj.code === "string") return obj.code;
79+
if (typeof obj.sql === "string") return obj.sql;
80+
if (typeof obj.query === "string") return obj.query;
81+
if (obj.spec && typeof obj.spec === "object") {
82+
try {
83+
return JSON.stringify(obj.spec, null, 2);
84+
} catch {
85+
return null;
86+
}
87+
}
88+
// Generic fallback: show all args as JSON
89+
try {
90+
const json = JSON.stringify(obj, null, 2);
91+
return json === "{}" ? null : json;
92+
} catch {
93+
return null;
94+
}
95+
}
96+
7497
/** Format duration in human-readable form */
7598
function formatDuration(ms: number): string {
7699
if (ms < 1000) return `${ms}ms`;
@@ -127,17 +150,25 @@ function ToolExecutionStepComponent({
127150
);
128151
const hasOutputArtifacts = visibleOutputArtifacts.length > 0;
129152

130-
// Extract inline code preview from first code input artifact
153+
// Extract inline code preview from first code input artifact, falling back to raw input
131154
const inlineCode = useMemo(() => {
132155
const codeArtifact = execution.inputArtifacts.find((a) => a.type === "code");
133-
if (!codeArtifact) return null;
134-
const code = getCodeFromArtifact(codeArtifact);
135-
if (!code) return null;
136-
const { preview, isTruncated } = getCodePreview(code, 4);
137-
const language =
138-
(codeArtifact.data as CodeArtifactData)?.language || TOOL_LANGUAGES[execution.toolName];
139-
return { code, preview, isTruncated, artifact: codeArtifact, language };
140-
}, [execution.inputArtifacts, execution.toolName]);
156+
if (codeArtifact) {
157+
const code = getCodeFromArtifact(codeArtifact);
158+
if (code) {
159+
const { preview, isTruncated } = getCodePreview(code, 4);
160+
const language =
161+
(codeArtifact.data as CodeArtifactData)?.language || TOOL_LANGUAGES[execution.toolName];
162+
return { code, preview, isTruncated, artifact: codeArtifact, language };
163+
}
164+
}
165+
// Fallback: extract from raw input (available immediately, before artifacts populate)
166+
const rawCode = extractCodeFromInput(execution.input);
167+
if (!rawCode) return null;
168+
const { preview, isTruncated } = getCodePreview(rawCode, 4);
169+
const language = TOOL_LANGUAGES[execution.toolName] || "json";
170+
return { code: rawCode, preview, isTruncated, artifact: null, language };
171+
}, [execution.inputArtifacts, execution.toolName, execution.input]);
141172

142173
// Other input artifacts (non-code)
143174
const otherInputArtifacts = useMemo(
@@ -225,18 +256,20 @@ function ToolExecutionStepComponent({
225256
{inlineCode.language || displayName}
226257
</span>
227258
<div className="flex-1" />
228-
<button
229-
type="button"
230-
onClick={() => onArtifactClick?.(inlineCode.artifact)}
231-
className={cn(
232-
"p-0.5 rounded",
233-
"text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200",
234-
"opacity-0 group-hover/code:opacity-100 transition-opacity"
235-
)}
236-
aria-label="Expand"
237-
>
238-
<Maximize2 className="h-3.5 w-3.5" />
239-
</button>
259+
{inlineCode.artifact && (
260+
<button
261+
type="button"
262+
onClick={() => onArtifactClick?.(inlineCode.artifact!)}
263+
className={cn(
264+
"p-0.5 rounded",
265+
"text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200",
266+
"opacity-0 group-hover/code:opacity-100 transition-opacity"
267+
)}
268+
aria-label="Expand"
269+
>
270+
<Maximize2 className="h-3.5 w-3.5" />
271+
</button>
272+
)}
240273
</div>
241274

242275
{/* Code content */}

0 commit comments

Comments
 (0)