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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user