Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions components/ai-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) {
(((session: ChatSession, msgs: UIMessage[]) => void) & { cancel: () => void; flush: () => void }) | null
>(null)
const lastSavedMessagesRef = useRef<string>("")
const persistedMessageSignaturesRef = useRef<Map<string, string[]>>(new Map())
const cloudSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const latestCloudSaveRequestRef = useRef(0)

Expand Down Expand Up @@ -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,
Expand All @@ -492,6 +507,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) {
invalidatePendingCloudSaves,
getMessagesFingerprint,
lastSavedMessagesRef,
persistedMessageSignaturesRef,
localStorageKey: CHAT_HISTORY_LOCAL_KEY,
})

Expand All @@ -502,6 +518,7 @@ export function AIChatPanel({ canvasDimensions }: AIChatPanelProps) {
setIsSavingToCloud,
setCloudError,
setLastSavedToCloud,
persistedMessageSignaturesRef,
saveDebounceMs: SAVE_DEBOUNCE_MS,
})

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
81 changes: 73 additions & 8 deletions components/ai-chat/hooks/use-chat-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ import { extractTitleFromMessages } from "@/lib/ai-chat/title-extractor"
type DebouncedCloudSave = ReturnType<typeof debounce<(session: ChatSession, msgs: UIMessage[]) => void>>

type StateSetter<T> = Dispatch<SetStateAction<T>>
type DbMessagePayload = ReturnType<typeof chatService.convertToDbMessage>

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
Expand All @@ -24,6 +49,7 @@ type LoadChatHistoryParams = {
invalidatePendingCloudSaves: () => void
getMessagesFingerprint: (messages: UIMessage[]) => string
lastSavedMessagesRef: MutableRefObject<string>
persistedMessageSignaturesRef: MutableRefObject<Map<string, string[]>>
localStorageKey: string
}

Expand All @@ -41,6 +67,7 @@ export function useLoadChatHistory({
invalidatePendingCloudSaves,
getMessagesFingerprint,
lastSavedMessagesRef,
persistedMessageSignaturesRef,
localStorageKey,
}: LoadChatHistoryParams) {
useEffect(() => {
Expand All @@ -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())
Expand All @@ -73,12 +104,18 @@ export function useLoadChatHistory({
const extractedTitle = extractTitleFromMessages(aiMessages)

if (extractedTitle) {
const titleUpdatePromises: Promise<unknown>[] = []

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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -139,6 +179,7 @@ export function useLoadChatHistory({
invalidatePendingCloudSaves,
getMessagesFingerprint,
lastSavedMessagesRef,
persistedMessageSignaturesRef,
localStorageKey,
])
}
Expand All @@ -150,6 +191,7 @@ type DebouncedCloudSaveParams = {
setIsSavingToCloud: StateSetter<boolean>
setCloudError: StateSetter<string | null>
setLastSavedToCloud: StateSetter<Date | null>
persistedMessageSignaturesRef: MutableRefObject<Map<string, string[]>>
saveDebounceMs: number
}

Expand All @@ -160,6 +202,7 @@ export function useDebouncedCloudSave({
setIsSavingToCloud,
setCloudError,
setLastSavedToCloud,
persistedMessageSignaturesRef,
saveDebounceMs,
}: DebouncedCloudSaveParams) {
useEffect(() => {
Expand All @@ -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
Comment on lines +238 to +242
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat uncached signatures as unknown cloud state

The previousSignatures.length === 0 branch assumes the cloud session has no persisted messages and performs append-only writes. That assumption is false when signature cache population was skipped (for example, getMessages fails after setChatSession during history load), so later saves can append a full local transcript onto an existing cloud transcript instead of replacing it. This introduces duplicate/out-of-order history after transient load failures.

Useful? React with 👍 / 👎.

}
} 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) {
Expand All @@ -215,6 +279,7 @@ export function useDebouncedCloudSave({
setIsSavingToCloud,
setCloudError,
setLastSavedToCloud,
persistedMessageSignaturesRef,
saveDebounceMs,
])
}
Expand Down
Loading