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 f9debc57..fca37b9b 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -77,6 +77,17 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean) return true; } +/** + * Whether the standalone typing indicator should render its own assistant-name + * label. True only when it stands in for a not-yet-started assistant row (it IS + * the nascent bubble). False once an assistant row exists at the tail, because + * that row's MessageItem already shows the same name — avoids a duplicate label. + */ +export function typingIndicatorShowsName(messages: UIMessage[]): boolean { + const last = messages[messages.length - 1]; + return !(last && last.role === "assistant"); +} + /** * Scrollable transcript. Auto-scrolls to the newest message as it streams in, * but only while the user is pinned to the bottom — if they scrolled up to read @@ -173,7 +184,7 @@ export default function MessageList({ assistantName={assistantName} /> ))} - {typing && } + {typing && } ); diff --git a/apps/client/src/features/ai-chat/components/typing-indicator-shows-name.test.ts b/apps/client/src/features/ai-chat/components/typing-indicator-shows-name.test.ts new file mode 100644 index 00000000..1d48a28a --- /dev/null +++ b/apps/client/src/features/ai-chat/components/typing-indicator-shows-name.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { UIMessage } from "@ai-sdk/react"; +import { typingIndicatorShowsName } from "@/features/ai-chat/components/message-list.tsx"; + +/** + * Pure-helper tests for whether the standalone "Thinking…" indicator renders its + * own dimmed assistant-name label. It should only show the name while it stands + * in for a not-yet-started assistant row; once an assistant row exists at the + * tail, that row's MessageItem already shows the same name, so the indicator + * must show only the dots to avoid a duplicate stacked label. + */ +const msg = ( + role: "user" | "assistant", + parts: UIMessage["parts"], +): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage; + +describe("typingIndicatorShowsName", () => { + it("shows the name with no messages yet (standalone, just submitted)", () => { + expect(typingIndicatorShowsName([])).toBe(true); + }); + + it("shows the name when the last message is still the user's", () => { + expect( + typingIndicatorShowsName([msg("user", [{ type: "text", text: "q" }])]), + ).toBe(true); + }); + + it("hides the name when an assistant row exists at the tail", () => { + expect( + typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "" }])]), + ).toBe(false); + }); + + it("hides the name when the assistant row's last part is a tool", () => { + const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number]; + expect( + typingIndicatorShowsName([msg("assistant", [doneTool])]), + ).toBe(false); + }); +}); diff --git a/apps/client/src/features/ai-chat/components/typing-indicator.tsx b/apps/client/src/features/ai-chat/components/typing-indicator.tsx index fce0dcee..c811c2bd 100644 --- a/apps/client/src/features/ai-chat/components/typing-indicator.tsx +++ b/apps/client/src/features/ai-chat/components/typing-indicator.tsx @@ -10,6 +10,12 @@ interface TypingIndicatorProps { * (agent role) name. */ assistantName?: string; + /** + * Whether to render the dimmed assistant-name label. Defaults to true + * (standalone behavior preserved). Set false between agent steps where the + * assistant row above already shows the same name, to avoid a duplicate label. + */ + showName?: boolean; } /** @@ -24,15 +30,17 @@ interface TypingIndicatorProps { * typing line is always the generic "Thinking…" (it never includes the * role/identity name). */ -export default function TypingIndicator({ assistantName }: TypingIndicatorProps) { +export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) { const { t } = useTranslation(); const name = resolveAssistantName(assistantName); return ( - - {name ?? t("AI agent")} - + {showName !== false && ( + + {name ?? t("AI agent")} + + )}