diff --git a/app/page.tsx b/app/page.tsx index 047c706..620798b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -365,6 +365,19 @@ export default function Home() { return false; }); + const waitForHistorySaveReady = async (): Promise => { + if (!initialSettingsLoadedRef.current || !cryptoKeyRef.current) await keyReadyRef.current; + if (!saveHistoryRef.current) return null; + const key = cryptoKeyRef.current; + if (!key) { + logClientEvent('history.save_skipped_no_key', 'warn', { + initialSettingsLoaded: initialSettingsLoadedRef.current, + }); + return null; + } + return key; + }; + const fetchHistoryItems = async (): Promise => { const keyTimeout = new Promise(resolve => setTimeout(resolve, 5000)); await Promise.race([keyReadyRef.current, keyTimeout]); @@ -756,59 +769,54 @@ export default function Home() { textareaRef.current?.focus(); webSearchPhaseRef.current = 'off'; setWebSearchPhase('off'); - if (saveHistoryRef.current) { - (async () => { - try { - const key = cryptoKeyRef.current; - if (!key) { - logClientEvent('history.save_skipped_no_key', 'warn'); - return; - } - const title = finalMsgs.find(m => m.role === 'user')?.content.slice(0, 60) ?? 'Untitled'; - const toSave = stripMessageHtml(finalMsgs); - const { iv, ciphertext } = await encrypt(key, { messages: toSave, model: requestModel, systemPrompt: requestSystemPrompt, title }); - const ciphertextBytes = Math.round((ciphertext.length ?? 0) * 0.75); - const body = JSON.stringify({ id: chatIdRef.current, iv, ciphertext }); - const bodyBytes = new TextEncoder().encode(body).length; - if (bodyBytes > LIMITS.historyBodyBytes || ciphertextBytes > LIMITS.maxCiphertextBytes) { - logClientEvent('history.save_too_large', 'warn', { - id: chatIdRef.current, - msgs: toSave.length, - bodyBytes, - maxBodyBytes: LIMITS.historyBodyBytes, - ciphertextBytes, - maxCiphertextBytes: LIMITS.maxCiphertextBytes, - }); - return; - } - const res = await fetch('/api/history', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, + (async () => { + try { + const key = await waitForHistorySaveReady(); + if (!key) return; + const title = finalMsgs.find(m => m.role === 'user')?.content.slice(0, 60) ?? 'Untitled'; + const toSave = stripMessageHtml(finalMsgs); + const { iv, ciphertext } = await encrypt(key, { messages: toSave, model: requestModel, systemPrompt: requestSystemPrompt, title }); + const ciphertextBytes = Math.round((ciphertext.length ?? 0) * 0.75); + const body = JSON.stringify({ id: chatIdRef.current, iv, ciphertext }); + const bodyBytes = new TextEncoder().encode(body).length; + if (bodyBytes > LIMITS.historyBodyBytes || ciphertextBytes > LIMITS.maxCiphertextBytes) { + logClientEvent('history.save_too_large', 'warn', { + id: chatIdRef.current, + msgs: toSave.length, + bodyBytes, + maxBodyBytes: LIMITS.historyBodyBytes, + ciphertextBytes, + maxCiphertextBytes: LIMITS.maxCiphertextBytes, }); - if (!res.ok) { - const error = (await res.text()).slice(0, LIMITS.maxClientEventValueChars); - logClientEvent('history.save_failed', 'warn', { - status: res.status, - requestId: res.headers.get('x-request-id'), - id: chatIdRef.current, - msgs: toSave.length, - bodyBytes, - ciphertextBytes, - error, - }); - return; - } - const { id } = await res.json(); - chatIdRef.current = id; - rememberActiveHistoryChat(id); - setSavedFlash(true); - setTimeout(() => setSavedFlash(false), 2000); - } catch (error) { - logClientEvent('history.save_failed', 'error', getClientErrorDetails(error)); + return; } - })(); - } + const res = await fetch('/api/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (!res.ok) { + const error = (await res.text()).slice(0, LIMITS.maxClientEventValueChars); + logClientEvent('history.save_failed', 'warn', { + status: res.status, + requestId: res.headers.get('x-request-id'), + id: chatIdRef.current, + msgs: toSave.length, + bodyBytes, + ciphertextBytes, + error, + }); + return; + } + const { id } = await res.json(); + chatIdRef.current = id; + rememberActiveHistoryChat(id); + setSavedFlash(true); + setTimeout(() => setSavedFlash(false), 2000); + } catch (error) { + logClientEvent('history.save_failed', 'error', getClientErrorDetails(error)); + } + })(); }; const doTick = () => { diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 496970d..0ac4825 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -460,6 +460,22 @@ test('history drawer still loads the latest 50 saved chats', () => { test('history save logs meaningful failure details before and after the network request', () => { const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + assert.ok( + pageSource.includes('const waitForHistorySaveReady = async (): Promise => {'), + 'history saves should have a dedicated readiness helper so completed responses can wait for startup hydration', + ); + assert.ok( + pageSource.includes('if (!initialSettingsLoadedRef.current || !cryptoKeyRef.current) await keyReadyRef.current;'), + 'history saves should wait for settings/key hydration when startup is still in flight', + ); + assert.ok( + pageSource.includes('const key = await waitForHistorySaveReady();'), + 'history saves should resolve readiness after the assistant response finishes, before persisting history', + ); + assert.ok( + pageSource.includes('if (!key) return;'), + 'history saves should skip only after the readiness helper decides saving is not possible', + ); assert.ok( pageSource.includes('const bodyBytes = new TextEncoder().encode(body).length;'), 'history saves should measure request size before posting so oversized chats are diagnosable',