diff --git a/app/page.tsx b/app/page.tsx index 2fa5a2e..047c706 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -219,6 +219,7 @@ export default function Home() { const girlModeRef = useRef(girlMode); const initialSettingsLoadedRef = useRef(false); const pendingSettingsRef = useRef({}); + const pendingStartupHistoryRestoreRef = useRef(null); useEffect(() => { systemPromptRef.current = systemPrompt; @@ -315,6 +316,12 @@ export default function Home() { rememberActiveHistoryChat(item.id); }; + const cancelPendingStartupHistoryRestore = (clearStoredSelection = false) => { + if (!pendingStartupHistoryRestoreRef.current) return; + pendingStartupHistoryRestoreRef.current = null; + if (clearStoredSelection) rememberActiveHistoryChat(null); + }; + const rememberPendingSettings = (patch: PendingSettings) => { if (initialSettingsLoadedRef.current) return; pendingSettingsRef.current = { ...pendingSettingsRef.current, ...patch }; @@ -466,6 +473,7 @@ export default function Home() { logClientEvent('fork.parse_failed', 'warn'); } } + pendingStartupHistoryRestoreRef.current = restoredFork ? null : activeHistoryChatId; const ac = new AbortController(); fetch('/api/settings', { signal: ac.signal }) @@ -496,8 +504,11 @@ export default function Home() { // New key (or migrated from localStorage) — save to server so all deployments use it persistSettings({ keyJwk: jwk }, true); } - if (!restoredFork && activeHistoryChatId) { - const restored = await fetchHistoryItem(activeHistoryChatId); + const restoreId = pendingStartupHistoryRestoreRef.current; + if (!restoredFork && restoreId) { + const restored = await fetchHistoryItem(restoreId); + if (pendingStartupHistoryRestoreRef.current !== restoreId) return; + pendingStartupHistoryRestoreRef.current = null; if (restored.kind === 'ok') applyLoadedChat(restored.item); else if (restored.kind === 'missing') rememberActiveHistoryChat(null); } @@ -637,11 +648,13 @@ export default function Home() { const handleOpenHistory = async () => { if (showHistory) { setShowHistory(false); setHistorySearch(''); return; } + cancelPendingStartupHistoryRestore(); setShowHistory(true); await loadHistory(); }; const handleLoadChat = (item: HistoryItem) => { + cancelPendingStartupHistoryRestore(); applyLoadedChat(item); setShowHistory(false); }; @@ -663,6 +676,7 @@ export default function Home() { }; const handleInputChange = (e: React.ChangeEvent) => { + if (e.target.value.trim()) cancelPendingStartupHistoryRestore(true); setInput(e.target.value); historyIndexRef.current = -1; e.target.style.height = 'auto'; @@ -677,10 +691,12 @@ export default function Home() { if (imageFiles.length === 0) return; e.preventDefault(); + cancelPendingStartupHistoryRestore(true); readImageFiles(rejectLargeFiles(imageFiles, LIMITS.maxImageBytes, 'image'), img => setPendingImages(imgs => [...imgs, img])); }; const handleFileSelect = (e: React.ChangeEvent) => { + cancelPendingStartupHistoryRestore(true); const all = Array.from(e.target.files ?? []); const imgs = rejectLargeFiles(all.filter(f => f.type.startsWith('image/')), LIMITS.maxImageBytes, 'image'); const pdfs = rejectLargeFiles(all.filter(f => f.type === 'application/pdf'), LIMITS.maxPdfBytes, 'pdf'); @@ -693,6 +709,7 @@ export default function Home() { const handleDrop = (e: React.DragEvent) => { e.preventDefault(); + cancelPendingStartupHistoryRestore(true); const all = Array.from(e.dataTransfer.files); const imgs = rejectLargeFiles(all.filter(f => f.type.startsWith('image/')), LIMITS.maxImageBytes, 'image'); const pdfs = rejectLargeFiles(all.filter(f => f.type === 'application/pdf'), LIMITS.maxPdfBytes, 'pdf'); @@ -885,6 +902,7 @@ export default function Home() { const submitTurn = async (nextInput?: string) => { if ((!((nextInput ?? input).trim()) && pendingImages.length === 0 && pendingFiles.length === 0 && pendingPdfs.length === 0) || streaming) return; + cancelPendingStartupHistoryRestore(true); textareaRef.current?.focus(); const trimmed = (nextInput ?? input).trim(); diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 450b639..496970d 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -384,13 +384,25 @@ test('history-loaded chats persist across refreshes and clear correctly', () => 'page should read the active saved chat id during startup', ); assert.ok( - source.includes('if (!restoredFork && activeHistoryChatId) {'), + source.includes("const pendingStartupHistoryRestoreRef = useRef(null);"), + 'page should track whether the startup history restore is still allowed to apply', + ); + assert.ok( + source.includes('pendingStartupHistoryRestoreRef.current = restoredFork ? null : activeHistoryChatId;'), + 'page should remember which saved chat is eligible for startup restore', + ); + assert.ok( + source.includes('if (!restoredFork && restoreId) {'), 'history restore should only run when a forked chat is not taking over startup', ); assert.ok( - source.includes('const restored = await fetchHistoryItem(activeHistoryChatId);'), + source.includes('const restored = await fetchHistoryItem(restoreId);'), 'page should restore the active saved chat by directly fetching that history row', ); + assert.ok( + source.includes('if (pendingStartupHistoryRestoreRef.current !== restoreId) return;'), + 'page should skip applying a delayed restore after the user has already moved on', + ); assert.ok( source.includes('if (restored.kind === \'ok\') applyLoadedChat(restored.item);'), 'page should rehydrate the previously active saved chat after refresh', @@ -403,6 +415,10 @@ test('history-loaded chats persist across refreshes and clear correctly', () => source.includes('rememberActiveHistoryChat(null);'), 'clearing, deleting, or forking away from a saved chat should clear the persisted selection', ); + assert.ok( + source.includes('cancelPendingStartupHistoryRestore(true);'), + 'starting a new chat should cancel the pending startup restore and clear the stale saved-chat selection', + ); assert.ok( source.includes("logClientEvent('history.restore_fetch_failed'"), 'page should log a specific restore failure instead of only a generic settings load failure',