The standalone "Thinking…" indicator's agent-name label switched owners a couple seconds into a turn: at first TypingIndicator rendered name+dots (4px gap), then once useChat materialized the empty/reasoning-only assistant message the name moved to the empty MessageItem row (16px messageRow margin), pushing the dots ~20px away — a visible layout jump. Introduce a shared pure helper assistantMessageHasVisibleContent() as the single source of truth mirroring MessageItem's render decisions. MessageItem now returns null for an assistant message with no visible content, and typingIndicatorShowsName keeps the name on the indicator until the assistant row has visible content. Exactly one element owns the name throughout the pre-content gap, so the layout no longer reflows. - new utils/message-content.ts + unit tests (12 cases) - message-list.tsx: typingIndicatorShowsName uses the helper - message-item.tsx: early return null when no visible content - typing-indicator-shows-name.test.ts: updated expectations Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { UIMessage } from "@ai-sdk/react";
|
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
|
|
|
/**
|
|
* Pure-helper tests for `assistantMessageHasVisibleContent`, the single source of
|
|
* truth shared by MessageItem (whether to render the bubble) and
|
|
* typingIndicatorShowsName (whether the standalone indicator owns the name). It
|
|
* must mirror MessageItem's render decisions exactly so exactly one element owns
|
|
* the agent name during the pre-content "thinking" gap.
|
|
*/
|
|
const msg = (
|
|
parts: UIMessage["parts"],
|
|
metadata?: unknown,
|
|
): UIMessage =>
|
|
({
|
|
id: Math.random().toString(),
|
|
role: "assistant",
|
|
parts,
|
|
metadata,
|
|
}) as UIMessage;
|
|
|
|
describe("assistantMessageHasVisibleContent", () => {
|
|
it("is false for an empty text part", () => {
|
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }]))).toBe(false);
|
|
});
|
|
|
|
it("is false for a whitespace-only text part", () => {
|
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: " " }]))).toBe(false);
|
|
});
|
|
|
|
it("is true for a non-empty text part", () => {
|
|
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "answer" }]))).toBe(true);
|
|
});
|
|
|
|
it("is true for a tool part", () => {
|
|
const toolPart = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
|
expect(assistantMessageHasVisibleContent(msg([toolPart]))).toBe(true);
|
|
});
|
|
|
|
it("is true when metadata.error is set (persisted error banner)", () => {
|
|
expect(
|
|
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { error: "boom" })),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is true when metadata.finishReason is 'aborted' (persisted stopped notice)", () => {
|
|
expect(
|
|
assistantMessageHasVisibleContent(msg([], { finishReason: "aborted" })),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is false for a message with no parts and no metadata", () => {
|
|
expect(assistantMessageHasVisibleContent(msg([]))).toBe(false);
|
|
});
|
|
|
|
it("is false for an unsupported part kind (reasoning)", () => {
|
|
const reasoning = { type: "reasoning", text: "let me think" } as unknown as UIMessage["parts"][number];
|
|
expect(assistantMessageHasVisibleContent(msg([reasoning]))).toBe(false);
|
|
});
|
|
|
|
it("is true for a running tool part (input-available)", () => {
|
|
// Tool visibility does not depend on tool state: MessageItem renders a
|
|
// ToolCallCard for any tool part, so a still-running tool is visible.
|
|
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
|
|
expect(assistantMessageHasVisibleContent(msg([runningTool]))).toBe(true);
|
|
});
|
|
|
|
it("is true for an empty leading text part followed by a non-empty one", () => {
|
|
// An empty leading text part followed by a non-empty one is still visible
|
|
// (mirrors the real streaming sequence where text arrives incrementally).
|
|
expect(
|
|
assistantMessageHasVisibleContent(
|
|
msg([{ type: "text", text: "" }, { type: "text", text: "answer" }]),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is false for an empty completed turn (finishReason 'stop')", () => {
|
|
// A completed turn with no text/tools and a non-aborted finishReason renders
|
|
// nothing — this is intentional (hiding a dangling name-only row), distinct
|
|
// from the `aborted`/`error` cases which DO render.
|
|
expect(
|
|
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { finishReason: "stop" })),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is false for a parts-less message (the `?? []` guard makes it safe)", () => {
|
|
// The `?? []` guard makes a parts-less object safe instead of throwing.
|
|
expect(
|
|
assistantMessageHasVisibleContent({ id: "x", role: "assistant" } as unknown as UIMessage),
|
|
).toBe(false);
|
|
});
|
|
});
|