import React from 'react'; import type { AssistantMessage, Message, Part } from '@opencode-ai/sdk/v2'; import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore'; import { opencodeClient } from '@/lib/opencode/client'; import { readSessionCursor } from '@/lib/messageCursorPersistence'; import { extractTextFromPart } from '@/stores/utils/messageUtils'; type SessionMessageRecord = { info: Message; parts: Part[] }; const isAssistantMessage = (message: Message): message is AssistantMessage => message.role === 'assistant'; const getCompletionTimestamp = (record: { info: Message }): number | undefined => { const message = record.info; if (!isAssistantMessage(message)) { return undefined; } const completed = message.time?.completed; return typeof completed === 'number' ? completed : undefined; }; const isUserMessageInfo = (info?: Message): boolean => { if (!info) return false; if (info.role === 'user') return true; const infoExt = info as { clientRole?: string; userMessageMarker?: boolean }; if (infoExt.clientRole === 'user') return true; return Boolean(infoExt.userMessageMarker); }; const normalizeMessageText = (message?: SessionMessageRecord): string => { const parts = Array.isArray(message?.parts) ? message.parts : []; const raw = parts .map((part) => extractTextFromPart(part)) .join(' ') .replace(/\s+/g, ' ') .trim(); return raw; }; const findServerIndexForLocalUserMessage = ( localMessage: SessionMessageRecord | undefined, serverMessages: SessionMessageRecord[] ): number => { if (!localMessage || !isUserMessageInfo(localMessage.info)) { return -1; } const localText = normalizeMessageText(localMessage); const localCreated = typeof localMessage.info?.time?.created === 'number' ? localMessage.info.time.created : undefined; let bestIndex = -1; let bestScore = Number.POSITIVE_INFINITY; serverMessages.forEach((candidate, index) => { if (!isUserMessageInfo(candidate.info)) { return; } const candidateText = normalizeMessageText(candidate); const textMatches = Boolean(localText && candidateText && candidateText === localText); const candidateCreated = typeof candidate.info?.time?.created === 'number' ? candidate.info.time.created : undefined; const timeDelta = typeof localCreated === 'number' && typeof candidateCreated === 'number' ? Math.abs(candidateCreated - localCreated) : null; if (textMatches) { const score = typeof timeDelta === 'number' ? timeDelta : 0; if (score < bestScore) { bestIndex = index; bestScore = score; } return; } if (!localText && !candidateText && typeof timeDelta === 'number' && timeDelta < 1500 && timeDelta < bestScore) { bestIndex = index; bestScore = timeDelta; } }); if (bestIndex !== -1) { return bestIndex; } if (typeof localCreated === 'number') { return serverMessages.findIndex((candidate) => { if (!isUserMessageInfo(candidate.info)) return false; const candidateCreated = typeof candidate.info?.time?.created === 'number' ? candidate.info.time.created : undefined; if (typeof candidateCreated !== 'number') return false; return Math.abs(candidateCreated - localCreated) < 1000; }); } return -1; }; export const useMessageSync = () => { const { currentSessionId, messages, streamingMessageIds } = useSessionStore(); const streamingMessageId = React.useMemo(() => { if (!currentSessionId) return null; return streamingMessageIds.get(currentSessionId) ?? null; }, [currentSessionId, streamingMessageIds]); const syncTimeoutRef = React.useRef(undefined); const lastSyncRef = React.useRef(0); const syncMessages = React.useCallback(async () => { if (!currentSessionId) return; if (streamingMessageId) return; const now = Date.now(); if (now - lastSyncRef.current < 2000) return; lastSyncRef.current = now; try { const currentMessages = (messages.get(currentSessionId) || []) as SessionMessageRecord[]; const latestMessages = (await opencodeClient.getSessionMessages(currentSessionId)) as SessionMessageRecord[]; const cursorRecord = await readSessionCursor(currentSessionId); if (!latestMessages) return; const lastLocalMessage = currentMessages[currentMessages.length - 1]; if (lastLocalMessage) { const directIndex = latestMessages.findIndex((m) => m.info.id === lastLocalMessage.info.id); const fuzzyIndex = directIndex === -1 ? findServerIndexForLocalUserMessage(lastLocalMessage, latestMessages) : directIndex; const lastLocalIndex = fuzzyIndex; if (lastLocalIndex !== -1) { if (lastLocalIndex < latestMessages.length - 1) { const newMessages = latestMessages.slice(lastLocalIndex + 1); console.log(`[SYNC] Found ${newMessages.length} new messages to append`); const updatedMessages = [...currentMessages, ...newMessages]; const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, updatedMessages); } else { const serverLastMessage = latestMessages[lastLocalIndex]; const localLastMessage = currentMessages[currentMessages.length - 1]; const serverCompleted = getCompletionTimestamp(serverLastMessage); const localCompleted = getCompletionTimestamp(localLastMessage); if (serverCompleted && !localCompleted) { console.log('[SYNC] Last message completed on server'); const updatedMessages = [...currentMessages.slice(0, -1), serverLastMessage]; const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, updatedMessages); } } } else { if (isUserMessageInfo(lastLocalMessage.info)) { const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES); console.log('[SYNC] Local user message missing by ID; merging latest messages for deduplication'); const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, messagesToLoad); } else { console.log('[SYNC] Local messages not found on server - skipping sync'); } } } else if (cursorRecord) { const cursorIndex = latestMessages.findIndex(m => m.info.id === cursorRecord.messageId); if (cursorIndex !== -1) { if (cursorIndex < latestMessages.length - 1) { const newMessages = latestMessages.slice(cursorIndex + 1); const limited = newMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES); if (limited.length > 0) { console.log(`[SYNC] Restoring ${limited.length} messages after cursor`); const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, limited); } } } else if (latestMessages.length > 0) { console.log('[SYNC] Cursor not found on server response, loading recent messages'); const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES); const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, messagesToLoad); } } else if (latestMessages.length > 0) { const messagesToLoad = latestMessages.slice(-MEMORY_LIMITS.VIEWPORT_MESSAGES); console.log(`[SYNC] Loading last ${messagesToLoad.length} messages`); const { syncMessages } = useSessionStore.getState(); syncMessages(currentSessionId, messagesToLoad); } } catch (error) { console.debug('Background sync failed:', error); } }, [currentSessionId, messages, streamingMessageId]); React.useEffect(() => { const handleFocus = () => { console.log('[FOCUS] Window focused - checking for updates'); syncMessages(); }; window.addEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus); }, [syncMessages]); React.useEffect(() => { if (!currentSessionId || streamingMessageId) return; const scheduleSync = () => { if (document.visibilityState === 'visible') { syncMessages(); } syncTimeoutRef.current = setTimeout(scheduleSync, 30000); }; syncTimeoutRef.current = setTimeout(scheduleSync, 30000); return () => { if (syncTimeoutRef.current) { clearTimeout(syncTimeoutRef.current); } }; }, [currentSessionId, streamingMessageId, syncMessages]); React.useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { console.log('[FOCUS] Tab became visible - checking for updates'); syncMessages(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => document.removeEventListener('visibilitychange', handleVisibilityChange); }, [syncMessages]); };