From c97c56f200b76632a51fe3f29db9aae8f7ca939b Mon Sep 17 00:00:00 2001 From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:32:15 -0500 Subject: [PATCH] perf: reduce chat sync async waterfalls and rewrites --- components/ai-chat-panel.tsx | 25 +++++++ components/ai-chat/hooks/use-chat-sync.ts | 81 ++++++++++++++++++++--- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/components/ai-chat-panel.tsx b/components/ai-chat-panel.tsx index 4842d16..630cac2 100644 --- a/components/ai-chat-panel.tsx +++ b/components/ai-chat-panel.tsx @@ -434,6 +434,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { (((session: ChatSession, msgs: UIMessage[]) => void) & { cancel: () => void; flush: () => void }) | null >(null) const lastSavedMessagesRef = useRef("") + const persistedMessageSignaturesRef = useRef>(new Map()) const cloudSaveQueueRef = useRef>(Promise.resolve()) const latestCloudSaveRequestRef = useRef(0) @@ -478,6 +479,20 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { ].join("|") }, []) + const getMessageSignatures = useCallback((msgs: UIMessage[]) => { + return msgs.map((msg) => { + const dbMessage = chatService.convertToDbMessage(msg) + return [ + msg.id, + dbMessage.role, + dbMessage.content ?? "", + JSON.stringify(dbMessage.parts ?? []), + JSON.stringify(dbMessage.tool_calls ?? []), + JSON.stringify(dbMessage.tool_results ?? []), + ].join(":") + }) + }, []) + useLoadChatHistory({ user, currentDiagramId, @@ -492,6 +507,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { invalidatePendingCloudSaves, getMessagesFingerprint, lastSavedMessagesRef, + persistedMessageSignaturesRef, localStorageKey: CHAT_HISTORY_LOCAL_KEY, }) @@ -502,6 +518,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { setIsSavingToCloud, setCloudError, setLastSavedToCloud, + persistedMessageSignaturesRef, saveDebounceMs: SAVE_DEBOUNCE_MS, }) @@ -527,6 +544,9 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { setMessages([]) lastSavedMessagesRef.current = "" + if (chatSession) { + persistedMessageSignaturesRef.current.set(chatSession.id, []) + } // Clear from Supabase if (user && chatSession) { @@ -558,6 +578,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { const aiMessages = chatService.convertFromDbMessages(dbMessages) setMessages(aiMessages) lastSavedMessagesRef.current = getMessagesFingerprint(aiMessages) + persistedMessageSignaturesRef.current.set(session.id, getMessageSignatures(aiMessages)) setLastSavedToCloud(new Date()) debugLog("[chat] Loaded session:", session.id, "with", dbMessages.length, "messages") } catch (error) { @@ -574,6 +595,9 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { const handleNewChat = async () => { setMessages([]) lastSavedMessagesRef.current = "" + if (chatSession) { + persistedMessageSignaturesRef.current.set(chatSession.id, []) + } invalidatePendingCloudSaves() setChatSession(null) setLastSavedToCloud(null) @@ -596,6 +620,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) { }) setChatSession(newSession) debugLog("[chat] Created new session:", newSession.id) + persistedMessageSignaturesRef.current.set(newSession.id, []) } catch (error) { console.error("[chat] Failed to create new session:", error) } diff --git a/components/ai-chat/hooks/use-chat-sync.ts b/components/ai-chat/hooks/use-chat-sync.ts index b6425b1..26e0e69 100644 --- a/components/ai-chat/hooks/use-chat-sync.ts +++ b/components/ai-chat/hooks/use-chat-sync.ts @@ -9,6 +9,31 @@ import { extractTitleFromMessages } from "@/lib/ai-chat/title-extractor" type DebouncedCloudSave = ReturnType void>> type StateSetter = Dispatch> +type DbMessagePayload = ReturnType + +function getDbMessages(messages: UIMessage[]): DbMessagePayload[] { + return messages.map((message) => chatService.convertToDbMessage(message)) +} + +function getMessageSignatures(messages: UIMessage[], dbMessages: DbMessagePayload[]): string[] { + return dbMessages.map((dbMessage, index) => { + const messageId = messages[index]?.id ?? `idx-${index}` + + return [ + messageId, + dbMessage.role, + dbMessage.content ?? "", + JSON.stringify(dbMessage.parts ?? []), + JSON.stringify(dbMessage.tool_calls ?? []), + JSON.stringify(dbMessage.tool_results ?? []), + ].join(":") + }) +} + +function hasMatchingPrefix(prefix: string[], target: string[]): boolean { + if (prefix.length > target.length) return false + return prefix.every((value, index) => value === target[index]) +} type LoadChatHistoryParams = { user: User | null @@ -24,6 +49,7 @@ type LoadChatHistoryParams = { invalidatePendingCloudSaves: () => void getMessagesFingerprint: (messages: UIMessage[]) => string lastSavedMessagesRef: MutableRefObject + persistedMessageSignaturesRef: MutableRefObject> localStorageKey: string } @@ -41,6 +67,7 @@ export function useLoadChatHistory({ invalidatePendingCloudSaves, getMessagesFingerprint, lastSavedMessagesRef, + persistedMessageSignaturesRef, localStorageKey, }: LoadChatHistoryParams) { useEffect(() => { @@ -63,6 +90,10 @@ export function useLoadChatHistory({ const aiMessages = dbMessages.length > 0 ? chatService.convertFromDbMessages(dbMessages) : [] setMessages(aiMessages) lastSavedMessagesRef.current = getMessagesFingerprint(aiMessages) + persistedMessageSignaturesRef.current.set( + session.id, + getMessageSignatures(aiMessages, getDbMessages(aiMessages)), + ) if (dbMessages.length > 0) { setLastSavedToCloud(new Date()) @@ -73,12 +104,18 @@ export function useLoadChatHistory({ const extractedTitle = extractTitleFromMessages(aiMessages) if (extractedTitle) { + const titleUpdatePromises: Promise[] = [] + if (session.title === "New Chat") { - await chatService.updateSession(session.id, { title: extractedTitle }) + titleUpdatePromises.push(chatService.updateSession(session.id, { title: extractedTitle })) } if (currentDiagramTitle === "Untitled Diagram") { - await updateDiagramTitle(extractedTitle) + titleUpdatePromises.push(updateDiagramTitle(extractedTitle)) + } + + if (titleUpdatePromises.length > 0) { + await Promise.allSettled(titleUpdatePromises) } } } @@ -109,6 +146,9 @@ export function useLoadChatHistory({ setMessages([]) lastSavedMessagesRef.current = "" } + if (!user) { + persistedMessageSignaturesRef.current.clear() + } } catch (error) { if (!isCancelled) { console.warn("[chat] Failed to load chat history:", error) @@ -139,6 +179,7 @@ export function useLoadChatHistory({ invalidatePendingCloudSaves, getMessagesFingerprint, lastSavedMessagesRef, + persistedMessageSignaturesRef, localStorageKey, ]) } @@ -150,6 +191,7 @@ type DebouncedCloudSaveParams = { setIsSavingToCloud: StateSetter setCloudError: StateSetter setLastSavedToCloud: StateSetter + persistedMessageSignaturesRef: MutableRefObject> saveDebounceMs: number } @@ -160,6 +202,7 @@ export function useDebouncedCloudSave({ setIsSavingToCloud, setCloudError, setLastSavedToCloud, + persistedMessageSignaturesRef, saveDebounceMs, }: DebouncedCloudSaveParams) { useEffect(() => { @@ -181,14 +224,35 @@ export function useDebouncedCloudSave({ setCloudError(null) try { - // Once we begin replacing messages, always complete clear+save together. - // Returning early after clear can leave the session empty during rapid save invalidations. - await chatService.clearMessages(session.id) + const dbMessages = getDbMessages(msgs) + const nextSignatures = getMessageSignatures(msgs, dbMessages) + const previousSignatures = persistedMessageSignaturesRef.current.get(session.id) ?? [] + let didWrite = false + + // Fast path: conversation grew and prior saved messages are an unchanged prefix. + if (previousSignatures.length > 0 && hasMatchingPrefix(previousSignatures, nextSignatures)) { + if (nextSignatures.length > previousSignatures.length) { + await chatService.saveMessages(session.id, dbMessages.slice(previousSignatures.length)) + didWrite = true + } + } else if (previousSignatures.length === 0) { + // New session with no known cloud messages yet. + if (dbMessages.length > 0) { + await chatService.saveMessages(session.id, dbMessages) + didWrite = true + } + } else { + // Fallback when existing messages changed in-place (edit/regeneration). + await chatService.clearMessages(session.id) + if (dbMessages.length > 0) { + await chatService.saveMessages(session.id, dbMessages) + } + didWrite = true + } - const dbMessages = msgs.map((msg) => chatService.convertToDbMessage(msg)) - await chatService.saveMessages(session.id, dbMessages) + persistedMessageSignaturesRef.current.set(session.id, nextSignatures) - if (requestId === latestCloudSaveRequestRef.current) { + if (didWrite && requestId === latestCloudSaveRequestRef.current) { setLastSavedToCloud(new Date()) } } catch (error) { @@ -215,6 +279,7 @@ export function useDebouncedCloudSave({ setIsSavingToCloud, setCloudError, setLastSavedToCloud, + persistedMessageSignaturesRef, saveDebounceMs, ]) }