diff --git a/src/features/workspaces/components/ai-chat/ai-chat-transcript-recovery.ts b/src/features/workspaces/components/ai-chat/ai-chat-transcript-recovery.ts index 4e762bce..0f07095a 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-transcript-recovery.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-transcript-recovery.ts @@ -1,5 +1,10 @@ import type { AiChatMessage } from "#/features/workspaces/components/ai-chat/types"; +/** Whether a store snapshot already matches the local transcript exactly. */ +export function transcriptsEqual(local: AiChatMessage[], stored: AiChatMessage[]) { + return JSON.stringify(local) === JSON.stringify(stored); +} + /** Whether a settled store snapshot proves an uncertain transport kept running. */ export function serverTranscriptAdvanced(local: AiChatMessage[], stored: AiChatMessage[]) { const localTail = local.at(-1); diff --git a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.test.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.test.ts index 03a5bae1..a71e935f 100644 --- a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.test.ts +++ b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.test.ts @@ -1,7 +1,24 @@ import { describe, expect, it } from "vitest"; import type { AiChatMessage } from "#/features/workspaces/components/ai-chat/types"; -import { serverTranscriptAdvanced } from "#/features/workspaces/components/ai-chat/ai-chat-transcript-recovery"; +import { + serverTranscriptAdvanced, + transcriptsEqual, +} from "#/features/workspaces/components/ai-chat/ai-chat-transcript-recovery"; + +describe("transcript equality", () => { + it("treats a fresh copy of the same transcript as equal", () => { + const messages = [userMessage("user-1", "hello"), assistantMessage("reply-1", "hi")]; + // The settle effect stops here: it never re-copies an unchanged snapshot. + expect(transcriptsEqual(messages, [...messages])).toBe(true); + }); + + it("sees durable metadata missing from the local row as a difference", () => { + const local = assistantMessage("reply-1", "partial"); + const stored = { ...local, metadata: { turnStatus: "interrupted" } }; + expect(transcriptsEqual([local], [stored])).toBe(false); + }); +}); describe("server transcript recovery", () => { it("keeps an unpersisted user tail after a refused request", () => { diff --git a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts index 228e512d..ff548153 100644 --- a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts +++ b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts @@ -13,7 +13,10 @@ import { import type { SerializedAiChatThreadTranscript } from "#/features/workspaces/ai/chat/functions"; import { deriveAiChatPresentation } from "#/features/workspaces/components/ai-chat/ai-chat-display-state"; import { isAiChatUsageLimitResponse } from "#/features/workspaces/components/ai-chat/ai-chat-error-state"; -import { serverTranscriptAdvanced } from "#/features/workspaces/components/ai-chat/ai-chat-transcript-recovery"; +import { + serverTranscriptAdvanced, + transcriptsEqual, +} from "#/features/workspaces/components/ai-chat/ai-chat-transcript-recovery"; import { parseCodemodeActivityEvent, type AiChatLiveCodemodeActivity, @@ -175,6 +178,14 @@ export function useWorkspaceAiChat({ // Once Postgres says the turn is settled, its complete snapshot wins. IDs // and lengths are insufficient: the SDK's local row can share an id with // the durable row while lacking its interrupted/error metadata. + // + // Compare before setting. The SDK copies the array on every set, so an + // unconditional set here changes `messages` identity, re-fires this effect + // (which lists `messages`), and the two feed each other until React throws + // error #185. + if (transcriptsEqual(messages, transcriptData)) { + return; + } setMessages(transcriptData); if (recoveredAfterError) { clearError();