From 79c3c86b8288b6273320e534664c2839f7174063 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 21:10:38 +0300 Subject: [PATCH] fix(ai-chat): show typing indicator while the agent thinks between tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showTypingIndicator treated any tool part in the latest assistant message as visible content, so the "AI is typing…" dots were suppressed for the rest of the turn once the first tool call appeared. During the model's "thinking" pauses after a completed tool call, the chat showed only static tool cards and no activity. Inspect the last part of the assistant message instead of any part: hide the dots only while output is actively rendering (a non-empty streaming text part, or a tool still in the "running" state — which shows its own Loader). Finished/errored tools and empty trailing text now keep the dots visible, so the indicator reappears while the model thinks between steps. Add tests covering the post-tool thinking gap and the running-tool case. --- .../ai-chat/components/message-list.tsx | 37 +++++++++++++------ .../components/show-typing-indicator.test.ts | 30 +++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index 487a7bb7..1314bf38 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { UIMessage } from "@ai-sdk/react"; import MessageItem from "@/features/ai-chat/components/message-item.tsx"; import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx"; -import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx"; +import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageListProps { @@ -43,23 +43,38 @@ interface MessageListProps { const BOTTOM_THRESHOLD = 40; /** - * Whether to show the standalone "AI is typing…" indicator. It bridges the - * gap between sending and the first streamed content, so it shows only while a - * turn is in flight AND the latest assistant message has nothing visible yet: + * Whether to show the standalone "AI is typing…" indicator. It bridges every + * gap in a turn where the assistant is working but nothing visible is actively + * being produced yet — so it shows while a turn is in flight AND the latest + * assistant message's LAST part is not live output: * - the last message is still the user's (assistant hasn't started a row), or - * - the last (assistant) message has no non-empty text and no tool part. - * Once any text/tool part arrives, MessageItem renders it and this hides. + * - the assistant row has no parts yet, or + * - its last part is an empty/whitespace text part, or + * - its last part is a finished/errored tool (the model is thinking about the + * next step between tool calls). + * It hides only while output is actively rendering: a non-empty streaming text + * part, or a tool that is still running (ToolCallCard shows its own Loader). */ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean { if (!isStreaming) return false; const last = messages[messages.length - 1]; if (!last) return true; // submitted with nothing rendered yet. if (last.role !== "assistant") return true; // assistant row not started. - const hasVisible = last.parts.some( - (p) => - (p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type), - ); - return !hasVisible; + const lastPart = last.parts[last.parts.length - 1]; + if (!lastPart) return true; // assistant row exists but has no parts yet. + // The answer text is actively streaming in -> MessageItem renders it; no dots. + if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false; + // A tool still in flight shows its own Loader in ToolCallCard -> no dots. + if ( + isToolPart(lastPart.type) && + toolRunState((lastPart as unknown as ToolUiPart).state) === "running" + ) { + return false; + } + // Otherwise the turn is in flight but nothing is actively producing visible + // output yet: a finished/errored tool with no follow-up content, or an empty + // trailing text part. The model is thinking between steps -> show the dots. + return true; } /** diff --git a/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts index 15ab75bc..a1a97815 100644 --- a/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts +++ b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts @@ -52,4 +52,34 @@ describe("showTypingIndicator", () => { showTypingIndicator([msg("assistant", [toolPart])], true), ).toBe(false); }); + + it("shows while streaming after a tool has finished (thinking between steps)", () => { + const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number]; + expect( + showTypingIndicator([msg("assistant", [doneTool])], true), + ).toBe(true); + }); + + it("shows while streaming when a finished tool is the last part after some text", () => { + const text = { type: "text", text: "Let me check" } as unknown as UIMessage["parts"][number]; + const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number]; + expect( + showTypingIndicator([msg("assistant", [text, doneTool])], true), + ).toBe(true); + }); + + it("hides while a tool is still running", () => { + const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number]; + expect( + showTypingIndicator([msg("assistant", [runningTool])], true), + ).toBe(false); + }); + + it("hides once the assistant streams non-empty text after a finished tool", () => { + const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number]; + const text = { type: "text", text: "The answer is 42" } as unknown as UIMessage["parts"][number]; + expect( + showTypingIndicator([msg("assistant", [doneTool, text])], true), + ).toBe(false); + }); });