Skip to content

Commit 7654cfc

Browse files
authored
fix: improve agent tool call animations for smoother UI (#4069)
## Summary - Stagger tool call appearances with a 150ms delay between each, preventing multiple tools from popping into the DOM at once - Add a height + opacity CSS animation so each tool smoothly expands into place instead of causing a layout jump - Collapse fast `tool_started`/`tool_finished` pairs in the queue so tools that complete before rendering skip the "running" flash - Fix `tool_finished` events failing to match their `tool_started` message when the backend omits `tool_call_id` (`Date.now()` fallback produced mismatched IDs) - Clean up elapsed time display: "326ms" instead of "326.0ms", "1.2s" for longer durations --------- Signed-off-by: Milos Jovanovic <[email protected]>
1 parent 41ef849 commit 7654cfc

File tree

4 files changed

+111
-26
lines changed

4 files changed

+111
-26
lines changed

web_src/src/App.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,18 @@
244244
color: transparent;
245245
animation: sp-ai-thinking-shimmer 1.4s linear infinite;
246246
}
247+
248+
@keyframes sp-tool-enter {
249+
from {
250+
opacity: 0;
251+
transform: translateY(-4px);
252+
}
253+
to {
254+
opacity: 1;
255+
transform: translateY(0);
256+
}
257+
}
258+
259+
.sp-tool-enter {
260+
animation: sp-tool-enter 0.2s ease-out both;
261+
}

web_src/src/components/AiBuilderChatMessage.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { cn } from "../lib/utils";
77

88
export type AiMessageProps = {
99
message: AiBuilderMessage;
10+
animate?: boolean;
1011
};
1112

12-
export function AiMessage({ message }: AiMessageProps) {
13+
export function AiMessage({ message, animate }: AiMessageProps) {
1314
if (message.role === "assistant" && message.content.trim().length === 0) {
1415
return null;
1516
}
@@ -18,27 +19,24 @@ export function AiMessage({ message }: AiMessageProps) {
1819
case "user":
1920
return <UserMessage content={message.content} />;
2021
case "tool":
21-
return <ToolMessage message={message} />;
22+
return <ToolMessage message={message} animate={animate} />;
2223
case "assistant":
2324
return <AssistantMessage content={message.content} />;
2425
default:
2526
return null;
2627
}
2728
}
2829

29-
function ToolMessage({ message }: { message: AiBuilderMessage }) {
30+
function ToolMessage({ message, animate }: { message: AiBuilderMessage; animate?: boolean }) {
3031
const isRunning = message.toolStatus === "running";
3132

32-
const className = cn(
33-
"flex items-center gap-2 px-2 text-xs leading-relaxed text-gray-500",
34-
isRunning ? "sp-ai-thinking" : "",
35-
);
36-
3733
return (
38-
<div className="w-full">
39-
<div className={className}>
34+
<div className={cn("w-full", animate && "sp-tool-enter")}>
35+
<div className="flex items-center gap-2 px-2 text-xs leading-relaxed text-gray-500">
4036
<Activity className="h-3 w-3 shrink-0 text-gray-400" aria-hidden="true" />
41-
<span className="min-w-0 whitespace-pre-wrap break-words">{message.content}</span>
37+
<span className={cn("min-w-0 whitespace-pre-wrap break-words", isRunning && "sp-ai-thinking")}>
38+
{message.content}
39+
</span>
4240
</div>
4341
</div>
4442
);

web_src/src/components/AiBuilderConversationMessageList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function buildAiConversationItems({
9797
if (showToolsInlineForLiveTurn) {
9898
for (let k = groupStart; k < j; k++) {
9999
const toolMessage = messages[k];
100-
items.push(<AiMessage key={toolMessage.id} message={toolMessage} />);
100+
items.push(<AiMessage key={toolMessage.id} message={toolMessage} animate />);
101101
}
102102
} else {
103103
const toolGroup = messages.slice(groupStart, j);

web_src/src/ui/BuildingBlocksSidebar/agentChatSupport.ts

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AiBuilderMessage, AiBuilderProposal } from "./agentChat";
33
import { normalizeAiProposal } from "./agentChatProposal";
44

55
type JsonObject = Record<string, unknown>;
6+
type ToolEvent = Extract<ChatStreamEvent, { type: "tool_started" | "tool_finished" }>;
67

78
export type ChatStreamEvent =
89
| { type: "run_started"; model?: string }
@@ -167,6 +168,36 @@ function createToolCallId(toolName: string, toolCallId?: string): string {
167168
return typeof toolCallId === "string" && toolCallId.trim().length > 0 ? toolCallId : `${toolName}-${Date.now()}`;
168169
}
169170

171+
function collapseWithFinishedEvent(
172+
event: ToolEvent,
173+
pendingEvents: ToolEvent[],
174+
): { effectiveEvent: ToolEvent; wasCollapsed: boolean } {
175+
if (event.type !== "tool_started") {
176+
return { effectiveEvent: event, wasCollapsed: false };
177+
}
178+
179+
const toolName = typeof event.tool_name === "string" ? event.tool_name : "unknown";
180+
const hasExplicitCallId = typeof event.tool_call_id === "string" && event.tool_call_id.trim().length > 0;
181+
182+
const finishedIdx = pendingEvents.findIndex((e) => {
183+
if (e.type !== "tool_finished") {
184+
return false;
185+
}
186+
const eName = typeof e.tool_name === "string" ? e.tool_name : "unknown";
187+
const eHasId = typeof e.tool_call_id === "string" && e.tool_call_id.trim().length > 0;
188+
if (hasExplicitCallId || eHasId) {
189+
return hasExplicitCallId && eHasId && e.tool_call_id === event.tool_call_id;
190+
}
191+
return eName === toolName;
192+
});
193+
194+
if (finishedIdx >= 0) {
195+
return { effectiveEvent: pendingEvents.splice(finishedIdx, 1)[0], wasCollapsed: true };
196+
}
197+
198+
return { effectiveEvent: event, wasCollapsed: false };
199+
}
200+
170201
function createAssistantStreamController({
171202
assistantMessageId,
172203
insertAiMessageBefore,
@@ -181,6 +212,9 @@ function createAssistantStreamController({
181212
let assistantContentSnapshot = "";
182213
let pendingRenderBuffer = "";
183214
let isRenderLoopRunning = false;
215+
const pendingToolEvents: ToolEvent[] = [];
216+
let isToolLoopRunning = false;
217+
const flushedToolCallIds = new Set<string>();
184218

185219
const flushPendingRenderBuffer = async () => {
186220
if (isRenderLoopRunning) {
@@ -214,36 +248,74 @@ function createAssistantStreamController({
214248
void flushPendingRenderBuffer();
215249
};
216250

217-
const upsertToolMessage = (event: Extract<ChatStreamEvent, { type: "tool_started" | "tool_finished" }>) => {
251+
const applyToolEvent = (event: ToolEvent): boolean => {
218252
const toolName = typeof event.tool_name === "string" ? event.tool_name : "unknown";
253+
const hasExplicitCallId = typeof event.tool_call_id === "string" && event.tool_call_id.trim().length > 0;
219254
const toolCallId = createToolCallId(toolName, event.tool_call_id);
220255
const toolLabel = typeof event.tool_label === "string" ? event.tool_label.trim() : "";
221256
const content = event.type === "tool_started" ? `${toolLabel}...` : toolLabel;
222257
const toolStatus = event.type === "tool_started" ? "running" : "completed";
223258

259+
const isAlreadyTracked = flushedToolCallIds.has(toolCallId);
260+
const isNameBasedUpdate = !isAlreadyTracked && event.type === "tool_finished" && !hasExplicitCallId;
261+
const isNewInsertion = !isAlreadyTracked && !isNameBasedUpdate;
262+
263+
flushedToolCallIds.add(toolCallId);
264+
224265
setAiMessages((previous) => {
225-
const existingIndex = previous.findIndex(
226-
(message) => message.role === "tool" && message.toolCallId === toolCallId,
227-
);
228-
const nextMessage: AiBuilderMessage = {
229-
id: existingIndex >= 0 ? previous[existingIndex].id : `tool-${toolCallId}`,
230-
role: "tool",
231-
content,
232-
toolCallId,
233-
toolStatus,
234-
};
266+
let existingIndex = previous.findIndex((message) => message.role === "tool" && message.toolCallId === toolCallId);
267+
268+
if (existingIndex < 0 && event.type === "tool_finished" && !hasExplicitCallId && toolLabel.length > 0) {
269+
existingIndex = previous.findIndex(
270+
(message) =>
271+
message.role === "tool" && message.toolStatus === "running" && message.content.startsWith(toolLabel),
272+
);
273+
}
274+
235275
if (existingIndex >= 0) {
236276
const updated = [...previous];
237-
updated[existingIndex] = nextMessage;
277+
updated[existingIndex] = { ...previous[existingIndex], content, toolStatus };
238278
return trimAiMessages(updated);
239279
}
240280

241-
return insertAiMessageBefore(previous, nextMessage, assistantMessageId);
281+
return insertAiMessageBefore(
282+
previous,
283+
{ id: `tool-${toolCallId}`, role: "tool", content, toolCallId, toolStatus },
284+
assistantMessageId,
285+
);
242286
});
287+
288+
return isNewInsertion;
289+
};
290+
291+
const flushPendingToolEvents = async () => {
292+
if (isToolLoopRunning) {
293+
return;
294+
}
295+
296+
isToolLoopRunning = true;
297+
try {
298+
while (pendingToolEvents.length > 0) {
299+
const event = pendingToolEvents.shift()!;
300+
const { effectiveEvent, wasCollapsed } = collapseWithFinishedEvent(event, pendingToolEvents);
301+
const isNewInsertion = applyToolEvent(effectiveEvent);
302+
303+
if (isNewInsertion || wasCollapsed) {
304+
await sleep(150);
305+
}
306+
}
307+
} finally {
308+
isToolLoopRunning = false;
309+
}
310+
};
311+
312+
const upsertToolMessage = (event: ToolEvent) => {
313+
pendingToolEvents.push(event);
314+
void flushPendingToolEvents();
243315
};
244316

245317
const waitForRenderLoopIdle = async () => {
246-
while (isRenderLoopRunning || pendingRenderBuffer.length > 0) {
318+
while (isRenderLoopRunning || pendingRenderBuffer.length > 0 || isToolLoopRunning || pendingToolEvents.length > 0) {
247319
await sleep(10);
248320
}
249321
};

0 commit comments

Comments
 (0)