+
+ );
+}
diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts
new file mode 100644
index 00000000..93305848
--- /dev/null
+++ b/frontend/src/lib/chat-message-view.ts
@@ -0,0 +1,33 @@
+export type ChatMessage = {
+ readonly role: "user" | "assistant";
+ readonly text: string;
+ readonly suggestions?: readonly string[];
+};
+
+export type ChatMessageView = ChatMessage & {
+ readonly renderKey: string;
+ readonly state: "settled" | "streaming" | "thinking";
+};
+
+export function chatMessageViews(
+ messages: readonly ChatMessage[],
+ loading: boolean,
+ streamingText: string,
+): readonly ChatMessageView[] {
+ const settled = messages.map((message, index) => ({
+ ...message,
+ renderKey: `message-${index}`,
+ state: "settled" as const,
+ }));
+ if (!loading || messages.at(-1)?.role === "assistant") return settled;
+
+ return [
+ ...settled,
+ {
+ role: "assistant",
+ text: streamingText,
+ renderKey: `message-${messages.length}`,
+ state: streamingText ? "streaming" : "thinking",
+ },
+ ];
+}
diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts
new file mode 100644
index 00000000..977a7ec5
--- /dev/null
+++ b/frontend/tests/chat-stream-layout.test.ts
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { chatMessageViews } from "../src/lib/chat-message-view.ts";
+
+const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+
+const previousMessages = [
+ { role: "user", text: "问题" },
+] as const;
+
+test("keeps the assistant render identity stable when streaming settles", () => {
+ // Given: one assistant answer exists first as a transient stream.
+ const streaming = chatMessageViews(previousMessages, true, "完整答案");
+
+ // When: the same answer becomes part of the persisted transcript.
+ const settled = chatMessageViews([
+ ...previousMessages,
+ { role: "assistant", text: "完整答案" },
+ ], false, "");
+
+ // Then: React receives the same key and can reuse the existing message shell.
+ assert.equal(streaming.at(-1)?.renderKey, settled.at(-1)?.renderKey);
+ assert.equal(streaming.at(-1)?.state, "streaming");
+ assert.equal(settled.at(-1)?.state, "settled");
+});
+
+test("does not duplicate a completed assistant answer while loading state settles", () => {
+ // Given: the final answer has entered the transcript while request cleanup lags.
+ const completedMessages = [
+ ...previousMessages,
+ { role: "assistant", text: "完整答案" },
+ ] as const;
+
+ // When: the render view is derived with the old loading flag still true.
+ const views = chatMessageViews(completedMessages, true, "完整答案");
+
+ // Then: only the persisted answer is rendered.
+ assert.equal(views.length, completedMessages.length);
+ assert.equal(views.at(-1)?.state, "settled");
+});
+
+test("keeps the suggestion row height stable while an answer streams", () => {
+ // Given: a completed answer already supplies follow-up suggestions.
+ const suggestionBlock = pageSource.match(/\{activeSuggestions\.length > 0[\s\S]*?
\n\s*\)\}/);
+
+ // When: the suggestion visibility and button state are inspected.
+ assert.ok(suggestionBlock);
+
+ // Then: loading disables the actions without removing their layout slot.
+ assert.doesNotMatch(suggestionBlock[0].split("