fix(ai-chat): stop duplicating the assistant name in the typing indicator

While a multi-step agent turn is "thinking between tool steps", the
assistant identity name (e.g. the role name) was rendered twice, stacked:
once by the assistant message row (MessageItem) and once by the standalone
TypingIndicator below it. The indicator's name label only makes sense when
it stands in for a not-yet-started assistant row; between steps the row
above already shows the same name.

Render the indicator's dimmed name label only when it is standalone (no
assistant row at the tail yet); otherwise show just the "Thinking…" dots.

- typing-indicator.tsx: optional showName prop (default true); the name
  label renders only when showName !== false
- message-list.tsx: exported typingIndicatorShowsName(messages) helper;
  pass showName to the indicator at the render site
- typing-indicator-shows-name.test.ts: unit-cover the four cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-23 19:32:20 +03:00
parent 4704c3b7f9
commit aeea315618
3 changed files with 64 additions and 5 deletions

View File

@@ -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 && <TypingIndicator assistantName={assistantName} />}
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />}
</Stack>
</ScrollArea>
);

View File

@@ -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);
});
});

View File

@@ -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 (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
</Text>
{showName !== false && (
<Text size="xs" c="dimmed" mb={4}>
{name ?? t("AI agent")}
</Text>
)}
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">
<span />