diff --git a/app/page.tsx b/app/page.tsx index 481aebe..ee47e2a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -247,6 +247,7 @@ export default function Home() { const systemPromptRef = useRef(systemPrompt); const saveHistoryRef = useRef(saveHistory); const girlModeRef = useRef(girlMode); + const chatStateVersionRef = useRef(0); const initialSettingsLoadedRef = useRef(false); const pendingSettingsRef = useRef({}); const pendingStartupHistoryRestoreRef = useRef(null); @@ -337,6 +338,7 @@ export default function Home() { }; const applyLoadedChat = (item: HistoryItem) => { + chatStateVersionRef.current += 1; setMessages(withRenderedMessages(item.messages)); setModel(item.model); localStorage.setItem(MODEL_KEY, item.model); @@ -504,6 +506,7 @@ export default function Home() { if (fork) { try { const { messages: m, model: mo, systemPrompt: sp } = JSON.parse(fork); + chatStateVersionRef.current += 1; setMessages(withRenderedMessages(m)); setModel(mo); localStorage.setItem(MODEL_KEY, mo); @@ -731,8 +734,24 @@ export default function Home() { }; const startFreshChat = () => { + chatStateVersionRef.current += 1; + abortControllerRef.current?.abort(); cancelPendingStartupHistoryRestore(true); setMessages([]); + setInput(''); + setPendingImages([]); + setPendingFiles([]); + setPendingPdfs([]); + setShowHistory(false); + setHistorySearch(''); + setHistoryOpeningId(null); + setStreaming(false); + setStreamingContent(''); + setStreamingHtml(''); + setConnected(false); + setSavedFlash(false); + webSearchPhaseRef.current = 'off'; + setWebSearchPhase('off'); chatIdRef.current = null; rememberActiveHistoryChat(null); }; @@ -783,6 +802,7 @@ export default function Home() { const doStream = async (msgs: Message[], useWebSearch = false) => { const SMOOTH_RATE = 3; + const chatStateVersion = chatStateVersionRef.current; const requestModel = model; const requestSystemPrompt = systemPrompt; const requestMessages = toConversationMessages(msgs); @@ -806,6 +826,7 @@ export default function Home() { abortControllerRef.current = controller; const finalize = (text: string) => { + if (chatStateVersionRef.current !== chatStateVersion) return; const finalMsgs: Message[] = [...msgs, withRenderedHtml({ role: 'assistant', content: text })]; setMessages(finalMsgs); if (streamingHtmlTimerRef.current) { @@ -871,6 +892,7 @@ export default function Home() { return; } const { id } = await res.json(); + if (chatStateVersionRef.current !== chatStateVersion || chatIdRef.current !== currentHistoryId) return; chatIdRef.current = id; rememberActiveHistoryChat(id); setSavedFlash(true); @@ -882,6 +904,10 @@ export default function Home() { }; const doTick = () => { + if (chatStateVersionRef.current !== chatStateVersion) { + cancelTicker(); + return; + } const received = receivedRef.current; const isDone = streamDoneRef.current; const pos = displayPosRef.current; @@ -913,6 +939,10 @@ export default function Home() { body: JSON.stringify({ messages: requestMessages, model: requestModel, systemPrompt: requestSystemPrompt, webSearch: useWebSearch }), signal: controller.signal, }); + if (chatStateVersionRef.current !== chatStateVersion) { + cancelTicker(); + return; + } const requestId = res.headers.get('x-request-id'); if (!res.ok) { @@ -935,6 +965,10 @@ export default function Home() { while (true) { const { done, value } = await reader.read(); if (done) break; + if (chatStateVersionRef.current !== chatStateVersion) { + cancelTicker(); + return; + } if (!didConnect) { didConnect = true; setConnected(true); } const raw = decoder.decode(value, { stream: true }); if (raw.includes('\0') && webSearchPhaseRef.current === 'searching') { @@ -948,6 +982,7 @@ export default function Home() { } catch (err) { cancelTicker(); + if (chatStateVersionRef.current !== chatStateVersion) return; const partial = receivedRef.current; setStreamingContent(''); setStreaming(false); @@ -1079,7 +1114,7 @@ export default function Home() { return (
- GIPPIDY + { e.preventDefault(); startFreshChat(); }}>GIPPIDY {MODELS.find(m => m.id === model)?.label} {savedFlash && ✓ saved} diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 18e3637..261a524 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -426,9 +426,11 @@ test('history-loaded chats persist across refreshes and clear correctly', () => ); assert.ok( source.includes('const startFreshChat = () => {') && - source.includes('GIPPIDY') && + source.includes('chatStateVersionRef.current += 1;') && + source.includes('abortControllerRef.current?.abort();') && + source.includes(' { e.preventDefault(); startFreshChat(); }}>GIPPIDY') && source.includes(''), - 'the logo and [CLEAR] should use the same fresh-chat path so explicit new-chat actions clear the persisted active history selection', + 'the logo and [CLEAR] should use the same fresh-chat path so explicit new-chat actions clear persisted selection and invalidate stale in-flight work', ); assert.ok( source.includes("logClientEvent('history.restore_fetch_failed'"), @@ -510,6 +512,11 @@ test('history save logs meaningful failure details before and after the network pageSource.includes('const currentHistoryId = chatIdRef.current;'), 'history saves should snapshot the current saved-chat id before building the request body', ); + assert.ok( + pageSource.includes('const chatStateVersion = chatStateVersionRef.current;') && + pageSource.includes('if (chatStateVersionRef.current !== chatStateVersion || chatIdRef.current !== currentHistoryId) return;'), + 'history saves should ignore stale save completions after the user has started a different chat', + ); assert.ok( pageSource.includes(`currentHistoryId ? { id: currentHistoryId, iv, ciphertext, titleIv, titleCiphertext } @@ -548,6 +555,11 @@ test('history save logs meaningful failure details before and after the network pageSource.includes('titleCiphertextBytes,'), 'history save logs should include the split title ciphertext size for debugging preview writes', ); + assert.ok( + pageSource.includes('if (chatStateVersionRef.current !== chatStateVersion) return;') && + pageSource.includes('if (chatStateVersionRef.current !== chatStateVersion) {\n cancelTicker();\n return;\n }'), + 'stream finalization should stop when the user has already switched to a different chat', + ); }); test('history save route emits one wide canonical history.save log per POST attempt', () => {