diff --git a/app/api/history/route.ts b/app/api/history/route.ts index 4174840..46ca169 100644 --- a/app/api/history/route.ts +++ b/app/api/history/route.ts @@ -25,6 +25,8 @@ export async function GET(req: Request) { user: session.user.email, durationMs: Date.now() - start, rows: rows.length, + topId: rows[0]?.id ?? null, + topUpdatedAt: rows[0]?.updated_at ?? null, newestAt: rows[0]?.updated_at ?? null, oldestAt: rows[rows.length - 1]?.updated_at ?? null, }, 'history.list'); diff --git a/app/page.tsx b/app/page.tsx index 4af9797..2fa5a2e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -750,15 +750,35 @@ export default function Home() { 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: JSON.stringify({ id: chatIdRef.current, iv, ciphertext }), + 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; } @@ -767,8 +787,8 @@ export default function Home() { rememberActiveHistoryChat(id); setSavedFlash(true); setTimeout(() => setSavedFlash(false), 2000); - } catch { - logClientEvent('history.save_failed', 'error'); + } catch (error) { + logClientEvent('history.save_failed', 'error', getClientErrorDetails(error)); } })(); } diff --git a/tests/unit.test.ts b/tests/unit.test.ts index c349509..450b639 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -417,6 +417,59 @@ test('history-loaded chats persist across refreshes and clear correctly', () => ); }); +test('history drawer still loads the latest 50 saved chats', () => { + const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + const historyRouteSource = readFileSync(join(import.meta.dirname, '../app/api/history/route.ts'), 'utf8'); + assert.ok( + pageSource.includes("const res = await fetch('/api/history');"), + 'history drawer should load saved chats from the history list endpoint', + ); + assert.ok( + pageSource.includes('await loadHistory();'), + 'opening the history drawer should refresh the saved chat list', + ); + assert.ok( + historyRouteSource.includes('ORDER BY updated_at DESC LIMIT 50'), + 'history list endpoint should return the latest 50 saved chats first', + ); + assert.ok( + historyRouteSource.includes('topUpdatedAt: rows[0]?.updated_at ?? null'), + 'history list logs should include the updated_at timestamp of the top visible row', + ); + assert.ok( + historyRouteSource.includes("}, 'history.list');"), + 'history list endpoint should continue logging the visible latest-history window', + ); +}); + +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 bodyBytes = new TextEncoder().encode(body).length;'), + 'history saves should measure request size before posting so oversized chats are diagnosable', + ); + assert.ok( + pageSource.includes('bodyBytes > LIMITS.historyBodyBytes || ciphertextBytes > LIMITS.maxCiphertextBytes'), + 'history saves should log when the encrypted payload is too large to persist', + ); + assert.ok( + pageSource.includes("logClientEvent('history.save_too_large', 'warn', {"), + 'history saves should emit a dedicated too-large log event with size details', + ); + assert.ok( + pageSource.includes("const error = (await res.text()).slice(0, LIMITS.maxClientEventValueChars);"), + 'history save failures should capture the server error text for debugging', + ); + assert.ok( + pageSource.includes('bodyBytes,'), + 'history save failure logs should include the request body size', + ); + assert.ok( + pageSource.includes('ciphertextBytes,'), + 'history save failure logs should include the ciphertext size', + ); +}); + // ── settings validation ──────────────────────────────────────────────────────── test('validateSettingsRequest: validates and defaults girlMode', () => {