From 03e318b11c6af4cc870a27a7efb5771e281673ae Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:18:39 +0000 Subject: [PATCH] fix(chat): stop the transcript settle effect from looping The settle effect called setMessages(transcriptData) with no equality guard. The AI SDK copies the array on every set, so messages identity changed, the effect (which lists messages) re-fired, and the two fed each other. experimental_throttle paced it at about ten passive-effect updates per second until React threw error #185. The guard passes for any settled thread that has a transcript, so this was the normal idle state of the chat surface, not an edge case. Deep-compare transcriptData against messages before setting. After the first set the two match, so the next effect run returns early. The compare runs only when the thread is settled and ready, never during streaming. Generated-By: PostHog Desktop Task-Id: a0af39e0-b4ce-4fc6-8d9d-e99ec5e3bdef --- .../ai-chat/ai-chat-transcript-recovery.ts | 5 +++++ .../ai-chat/useWorkspaceAiChat.test.ts | 19 ++++++++++++++++++- .../components/ai-chat/useWorkspaceAiChat.ts | 13 ++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) 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();