From a353e6293e9523c78fe6ebd694d5c8dacbfbf39d Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:37:50 +0000 Subject: [PATCH] fix(session-history): show the last list at once after a restart while it rescans (#39380) * fix(session-history): show the last list at once after a restart while it rescans The renderer's list cache lived only in memory and deleted entries on every rescan or chat link change, so a restart or a forced rescan left the panel on a skeleton until the host finished scanning. Save each scope's last rows in IndexedDB (no message text), mark outdated entries stale instead of dropping them, and paint that list as refreshing until the rescan replaces it. Fixes #39308 * perf(session-history): skip the saved-list write when a rescan changed nothing * fix(session-history): skip unchanged saves across panels and keep a saved list through a cancelled publish * fix(session-history): evict saved lists by last use and save once the profile loads * test(session-history): drop an identity-only refresh test and correct the truncation comment * fix(session-history): let the React compiler handle the moved new-session rescan --- config/react-compiler-bailout-baseline.txt | 2 +- .../native-chat-composer-draft-indexeddb.ts | 64 ++--- .../scanning/ai-vault-applied-scan.ts | 27 +- .../scanning/ai-vault-new-session-rescan.ts | 59 +++++ .../ai-vault-saved-session-lists.test.ts | 235 +++++++++++++++++ .../scanning/ai-vault-saved-session-lists.ts | 245 ++++++++++++++++++ .../scanning/ai-vault-session-list-request.ts | 18 +- .../ai-vault-session-publication-gate.test.ts | 49 ++++ .../ai-vault-session-publication-gate.ts | 21 +- .../scanning/ai-vault-session-refresh.test.ts | 86 ++++++ .../scanning/ai-vault-session-refresh.ts | 114 +++----- .../scanning/ai-vault-session-result-cache.ts | 65 ++++- .../ai-vault-session-delete-action.ts | 2 + src/renderer/src/lib/indexeddb-connection.ts | 51 ++++ 14 files changed, 887 insertions(+), 151 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-new-session-rescan.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.test.ts create mode 100644 src/renderer/src/lib/indexeddb-connection.ts diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 933ce4f0a05..b1a89fbefe0 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -309,7 +309,7 @@ 1 src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx 2 src/renderer/src/components/right-sidebar/ai-vault/scanning/AiVaultScanProgressStrip.tsx 1 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-association-observation.ts -9 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts +8 src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts 1 src/renderer/src/components/right-sidebar/ai-vault/search/AiVaultPanelSearch.tsx 1 src/renderer/src/components/right-sidebar/ai-vault/search/use-ai-vault-search.ts 2 src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-delete-action.ts diff --git a/src/renderer/src/components/native-chat/drafts/native-chat-composer-draft-indexeddb.ts b/src/renderer/src/components/native-chat/drafts/native-chat-composer-draft-indexeddb.ts index 744ea580cde..41ebdcefbd2 100644 --- a/src/renderer/src/components/native-chat/drafts/native-chat-composer-draft-indexeddb.ts +++ b/src/renderer/src/components/native-chat/drafts/native-chat-composer-draft-indexeddb.ts @@ -4,60 +4,28 @@ import type { NativeChatComposerDraftStorage, StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' +import { + committedTransaction, + indexedDbConnection, + settledRequest +} from '@/lib/indexeddb-connection' const DATABASE_NAME = 'orca-native-chat-composer-drafts' const DATABASE_VERSION = 1 const DRAFTS = 'drafts' -function settled(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} - -/** Resolves once the transaction is on disk; rejects when it aborts (a full disk included). */ -function committed(transaction: IDBTransaction): Promise { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve() - transaction.onerror = () => reject(transaction.error) - transaction.onabort = () => reject(transaction.error ?? new Error('draft write aborted')) - }) -} - export function createIndexedDbNativeChatComposerDraftStorage( factory: IDBFactory ): NativeChatComposerDraftStorage { - let opened: Promise | null = null - const database = (): Promise => { - opened ??= new Promise((resolve, reject) => { - const request = factory.open(DATABASE_NAME, DATABASE_VERSION) - request.onupgradeneeded = () => { - if (!request.result.objectStoreNames.contains(DRAFTS)) { - request.result.createObjectStore(DRAFTS) - } - } - request.onsuccess = () => { - const db = request.result - // Close replaced/upgraded connections so later writes reopen instead of reusing a dead database. - db.onversionchange = () => db.close() - db.onclose = () => { - opened = null - } - resolve(db) - } - request.onerror = () => reject(request.error) - request.onblocked = () => reject(new Error('draft database is blocked')) - }).catch((error: unknown) => { - opened = null - throw error - }) - return opened - } + const database = indexedDbConnection(factory, DATABASE_NAME, DATABASE_VERSION, (db) => { + if (!db.objectStoreNames.contains(DRAFTS)) { + db.createObjectStore(DRAFTS) + } + }) const change = async (apply: (drafts: IDBObjectStore) => void): Promise => { const transaction = (await database()).transaction(DRAFTS, 'readwrite') - const done = committed(transaction) + const done = committedTransaction(transaction) apply(transaction.objectStore(DRAFTS)) // Why: commit now rather than when the task ends, so a quit right after still lands it. transaction.commit?.() @@ -68,8 +36,8 @@ export function createIndexedDbNativeChatComposerDraftStorage( loadAll: async () => { const drafts = (await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS) const [keys, values] = await Promise.all([ - settled(drafts.getAllKeys()), - settled(drafts.getAll()) + settledRequest(drafts.getAllKeys()), + settledRequest(drafts.getAll()) ]) const loaded = new Map() if (Array.isArray(keys) && Array.isArray(values)) { @@ -82,7 +50,9 @@ export function createIndexedDbNativeChatComposerDraftStorage( return loaded }, read: async (scopeKey) => - settled((await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS).get(scopeKey)), + settledRequest( + (await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS).get(scopeKey) + ), write: (scopeKey, draft: StoredNativeChatComposerDraft) => change((drafts) => drafts.put(draft, scopeKey)), remove: (scopeKeys) => @@ -93,7 +63,7 @@ export function createIndexedDbNativeChatComposerDraftStorage( }), update: async (scopeKey, apply) => { const transaction = (await database()).transaction(DRAFTS, 'readwrite') - const done = committed(transaction) + const done = committedTransaction(transaction) const drafts = transaction.objectStore(DRAFTS) const read = drafts.get(scopeKey) // Why inside the read's callback: the write must land in the same transaction as the read. diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-applied-scan.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-applied-scan.ts index 39669f90f66..2cb35424e13 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-applied-scan.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-applied-scan.ts @@ -10,38 +10,29 @@ export type AiVaultAppliedScan = { scopeKey: string /** Workspace and Project rows hold only that scope, so they never stand in for another one. */ scopeOnly: boolean + /** The scope's last list, shown until its rescan answers. */ + stale?: true } export function useAppliedAiVaultScan(): { scan: AiVaultAppliedScan | null - applyScan: ( - published: AiVaultListResult, - limit: AiVaultSessionLimit, - scopeKey: string, - scopeOnly: boolean, - onApplied?: (result: AiVaultListResult) => void - ) => void + applyScan: (applied: AiVaultAppliedScan, onApplied?: (result: AiVaultListResult) => void) => void } { const [scan, setScan] = useState(null) const acceptedScanRef = useRef(null) // Preserve identity on unchanged republication so React still bails out. const applyScan = useCallback( - ( - published: AiVaultListResult, - limit: AiVaultSessionLimit, - scopeKey: string, - scopeOnly: boolean, - onApplied?: (result: AiVaultListResult) => void - ) => { + (applied: AiVaultAppliedScan, onApplied?: (result: AiVaultListResult) => void) => { const previous = acceptedScanRef.current - const result = reuseAiVaultListResult(previous?.result ?? null, published) + const result = reuseAiVaultListResult(previous?.result ?? null, applied.result) const next = previous !== null && previous.result === result && - previous.limit === limit && - previous.scopeKey === scopeKey + previous.limit === applied.limit && + previous.scopeKey === applied.scopeKey && + previous.stale === applied.stale ? previous - : { result, limit, scopeKey, scopeOnly } + : { ...applied, result } acceptedScanRef.current = next setScan(next) onApplied?.(result) diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-new-session-rescan.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-new-session-rescan.ts new file mode 100644 index 00000000000..0d52cda7f98 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-new-session-rescan.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useAppStore } from '@/store/index' +import { getAgentSessionIdsKey } from './ai-vault-agent-session-refresh-key' +import { claimAiVaultForcedRescan, aiVaultForcedRescanDelay } from './ai-vault-forced-rescan-budget' + +/** In-app launches never blur; rescan on new provider session IDs, avoiding frequent state transitions. */ +export function useAiVaultNewSessionRescan( + refresh: (args: { background: true; force: true }) => Promise +): void { + // The budget is shared across remounts so session creation cannot amplify full scans; a throttled + // rescan waits for budget rather than leaving new sessions invisible. + const forcedRescanTimerRef = useRef | null>(null) + const requestForcedRescan = useCallback(() => { + function attempt(): void { + if (claimAiVaultForcedRescan()) { + void refresh({ background: true, force: true }) + return + } + if (forcedRescanTimerRef.current !== null) { + return + } + forcedRescanTimerRef.current = setTimeout(() => { + forcedRescanTimerRef.current = null + attempt() + }, aiVaultForcedRescanDelay()) + } + attempt() + }, [refresh]) + + useEffect( + () => () => { + if (forcedRescanTimerRef.current !== null) { + clearTimeout(forcedRescanTimerRef.current) + forcedRescanTimerRef.current = null + } + }, + [] + ) + + const agentSessionIdsKey = useAppStore((s) => getAgentSessionIdsKey(s.agentStatusByPaneKey)) + const seenAgentSessionIdsRef = useRef | null>(null) + useEffect(() => { + const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + // The mount refresh already covers sessions live at mount time. + if (seenAgentSessionIdsRef.current === null) { + seenAgentSessionIdsRef.current = new Set(ids) + return + } + const seen = seenAgentSessionIdsRef.current + const freshIds = ids.filter((id) => !seen.has(id)) + if (freshIds.length === 0) { + return + } + for (const id of freshIds) { + seen.add(id) + } + requestForcedRescan() + }, [agentSessionIdsKey, requestForcedRescan]) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.test.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.test.ts new file mode 100644 index 00000000000..2566ce2f079 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AiVaultListResult, + AiVaultSession +} from '../../../../../../shared/ai-vault/contracts/ai-vault-types' + +const store = vi.hoisted(() => { + const listeners = new Set<(state: { activeOrcaProfileId: string | null }) => void>() + const state: { activeOrcaProfileId: string | null } = { activeOrcaProfileId: 'profile-1' } + return { + state, + setProfile: (id: string | null) => { + state.activeOrcaProfileId = id + listeners.forEach((listener) => listener(state)) + }, + useAppStore: { + getState: () => state, + subscribe: (listener: (next: typeof state) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } + } +}) +vi.mock('@/store/index', () => ({ useAppStore: store.useAppStore })) + +type Handler = (() => void) | null +type Request = { result: unknown; onsuccess: Handler; onerror: Handler } +type Transaction = { + oncomplete: Handler + onerror: Handler + onabort: Handler + error: Error | null +} + +// A stand-in for the IndexedDB calls the module makes: one store, one savedAt index, commits on commit(). +function fakeIndexedDb(): { puts: unknown[]; refuseNextWrite: () => void } { + const rows = new Map() + const puts: unknown[] = [] + let refuse = false + const answer = (result: unknown): Request => { + const request: Request = { result, onsuccess: null, onerror: null } + queueMicrotask(() => request.onsuccess?.()) + return request + } + const database = { + objectStoreNames: { contains: () => false }, + createObjectStore: () => ({ createIndex: () => {} }), + transaction: () => { + const staged = new Map(rows) + const handlers: Transaction = { oncomplete: null, onerror: null, onabort: null, error: null } + const transaction = Object.assign(handlers, { + objectStore: () => ({ + get: (key: string) => answer(rows.get(key)), + put: (value: { savedAt: number }, key: string) => { + puts.push(value) + staged.set(key, value) + }, + delete: (key: string) => staged.delete(key), + index: () => ({ + getAllKeys: () => + answer([...staged].sort(([, a], [, b]) => a.savedAt - b.savedAt).map(([key]) => key)) + }) + }), + commit: () => + queueMicrotask(() => { + if (refuse) { + refuse = false + transaction.error = new Error('quota') + transaction.onabort?.() + return + } + rows.clear() + staged.forEach((value, key) => rows.set(key, value)) + transaction.oncomplete?.() + }) + }) + return transaction + } + } + vi.stubGlobal('indexedDB', { + open: () => { + const request: { result: unknown; onupgradeneeded: Handler; onsuccess: Handler } = { + result: database, + onupgradeneeded: null, + onsuccess: null + } + queueMicrotask(() => { + request.onupgradeneeded?.() + request.onsuccess?.() + }) + return request + } + }) + return { puts, refuseNextWrite: () => (refuse = true) } +} + +function listOf(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: '2026-07-01T00:00:00.000Z' } +} + +function session(id: string): AiVaultSession { + return { + id, + executionHostId: 'local', + agent: 'claude', + sessionId: id, + title: id, + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/${id}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-01T00:00:00.000Z', + messageCount: 2, + totalTokens: 0, + previewMessages: [ + { role: 'user', text: 'opening ask', timestamp: null }, + { role: 'assistant', text: 'latest reply', timestamp: null } + ], + firstUserPrompt: 'opening ask', + lastUserPrompt: 'opening ask', + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: id, + subagent: null + } +} + +async function loadModule() { + vi.resetModules() + return import('./ai-vault-saved-session-lists') +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + store.state.activeOrcaProfileId = 'profile-1' +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('saved Session History lists', () => { + it('keeps only the latest turn of each row and skips a rescan that changed nothing', async () => { + const { puts } = fakeIndexedDb() + const lists = await loadModule() + const result = listOf([session('a')]) + + await lists.saveAiVaultSessionList('workspace', 250, result) + await lists.saveAiVaultSessionList('workspace', 250, { ...result }) + // A remount or a return to the scope rescans into fresh copies of the same rows. + await lists.saveAiVaultSessionList('workspace', 250, listOf([session('a')])) + + expect(puts).toHaveLength(1) + const saved = await lists.readSavedAiVaultSessionList('workspace') + expect(saved?.limit).toBe(250) + expect(saved?.result.sessions).toEqual([ + expect.objectContaining({ + id: 'a', + previewMessages: [{ role: 'assistant', text: 'latest reply', timestamp: null }], + previewMessagesTruncated: true + }) + ]) + expect(saved?.result.sessions[0]).not.toHaveProperty('firstUserPrompt') + expect(saved?.result.sessions[0]).not.toHaveProperty('lastUserPrompt') + }) + + it('writes the same rows again after a refused write', async () => { + const { puts, refuseNextWrite } = fakeIndexedDb() + const lists = await loadModule() + const result = listOf([session('a')]) + + refuseNextWrite() + await lists.saveAiVaultSessionList('workspace', 250, result) + expect(await lists.readSavedAiVaultSessionList('workspace')).toBeNull() + + await lists.saveAiVaultSessionList('workspace', 250, result) + expect(puts).toHaveLength(2) + expect(await lists.readSavedAiVaultSessionList('workspace')).not.toBeNull() + }) + + it('writes the same rows again once newer lists pushed them out', async () => { + const { puts } = fakeIndexedDb() + const lists = await loadModule() + const result = listOf([session('a')]) + + await lists.saveAiVaultSessionList('workspace', 250, result) + for (let index = 0; index < 8; index++) { + vi.setSystemTime(Date.now() + 1) + await lists.saveAiVaultSessionList(`other-${index}`, 250, listOf([])) + } + expect(await lists.readSavedAiVaultSessionList('workspace')).toBeNull() + + await lists.saveAiVaultSessionList('workspace', 250, result) + expect(puts).toHaveLength(10) + expect(await lists.readSavedAiVaultSessionList('workspace')).not.toBeNull() + }) + + it('saves and reads once startup has loaded the profile', async () => { + fakeIndexedDb() + const lists = await loadModule() + store.state.activeOrcaProfileId = null + const save = lists.saveAiVaultSessionList('workspace', 250, listOf([session('a')])) + store.setProfile('profile-1') + await save + store.state.activeOrcaProfileId = null + + const read = lists.readSavedAiVaultSessionList('workspace') + store.setProfile('profile-1') + + expect((await read)?.result.sessions.map((row) => row.id)).toEqual(['a']) + }) + + it('keeps a list shown again without changes over lists last shown before it', async () => { + fakeIndexedDb() + const lists = await loadModule() + const result = listOf([session('a')]) + await lists.saveAiVaultSessionList('workspace', 250, result) + for (let index = 0; index < 7; index++) { + vi.setSystemTime(Date.now() + 1) + await lists.saveAiVaultSessionList(`other-${index}`, 250, listOf([])) + } + + await lists.saveAiVaultSessionList('workspace', 250, result) + vi.setSystemTime(Date.now() + 1) + await lists.saveAiVaultSessionList('newest', 250, listOf([])) + + expect(await lists.readSavedAiVaultSessionList('workspace')).not.toBeNull() + expect(await lists.readSavedAiVaultSessionList('other-0')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.ts new file mode 100644 index 00000000000..1947029598f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-saved-session-lists.ts @@ -0,0 +1,245 @@ +// Last list per scope, kept across restarts so History can paint it while the rescan runs. + +import type { + AiVaultListResult, + AiVaultSession +} from '../../../../../../shared/ai-vault/contracts/ai-vault-types' +import { latestSessionConversationTurn } from '../../../../../../shared/ai-vault/session-display/ai-vault-session-display' +import { useAppStore } from '@/store/index' +import { isWebClientLocation } from '@/lib/web-client-location' +import { + committedTransaction, + indexedDbConnection, + settledRequest +} from '@/lib/indexeddb-connection' +import { catalogRowsEqual } from '@/lib/catalog-reconciliation/catalog-row-reconciliation' +import type { AiVaultSessionLimit } from '../session-list/ai-vault-session-limit' + +const DATABASE_NAME = 'orca-ai-vault-session-lists' +const DATABASE_VERSION = 1 +const LISTS = 'lists' +const SAVED_AT = 'savedAt' +const MAX_SAVED_LISTS = 8 +// The saved copy only has to fill the first screens until the scan replaces it. +const MAX_SAVED_ROWS = 250 +// Bump when AiVaultSession changes shape, so rows saved by an older build are never painted. +const ROW_FORMAT = 1 +// Past this the scan has usually answered, and a saved list arriving later is dropped anyway. +const PROFILE_WAIT_MS = 5_000 + +export type SavedAiVaultSessionList = { limit: AiVaultSessionLimit; result: AiVaultListResult } + +type StoredList = { + format: typeof ROW_FORMAT + savedAt: number + limit: AiVaultSessionLimit + sessions: AiVaultSession[] +} + +// A web origin can be re-paired to another host, so it keeps nothing. +function savedListsAvailable(): boolean { + return typeof indexedDB !== 'undefined' && !isWebClientLocation() +} + +// Profiles share one IndexedDB, so each key names its profile. +function storageKey( + key: string, + profileId = useAppStore.getState().activeOrcaProfileId +): string | null { + return savedListsAvailable() && profileId ? JSON.stringify([profileId, key]) : null +} + +// After a restart the panel can mount, and even finish a scan, before startup has loaded the profile list. +function loadedProfileId(): Promise { + const current = useAppStore.getState().activeOrcaProfileId + if (current || !savedListsAvailable()) { + return Promise.resolve(current) + } + return new Promise((resolve) => { + const finish = (profileId: string | null) => { + clearTimeout(timer) + unsubscribe() + resolve(profileId) + } + const timer = setTimeout(() => finish(null), PROFILE_WAIT_MS) + const unsubscribe = useAppStore.subscribe((state) => { + if (state.activeOrcaProfileId) { + finish(state.activeOrcaProfileId) + } + }) + }) +} + +let connection: (() => Promise) | null = null +// What each stored key last wrote, so a rescan that changed nothing (every focus refresh) skips the +// copy and the write. Only the saved rows are kept, never the whole list. +const lastSaved = new Map() +// Save order this run. A skipped save still counts as use, so eviction stays least recently shown. +const lastUse = new Map() +let uses = 0 + +function unchangedSinceSave( + stored: string, + limit: AiVaultSessionLimit, + rows: readonly AiVaultSession[] +): boolean { + const last = lastSaved.get(stored) + if (!last || last.limit !== limit) { + return false + } + // The panel's rows keep their identity across unchanged rescans, so this is usually a pointer walk. + if (last.rows.length === rows.length && last.rows.every((row, index) => row === rows[index])) { + return true + } + // A remount or a return to this scope brings fresh copies; comparing them costs far less than a write. + if (!catalogRowsEqual(last.rows, rows)) { + return false + } + lastSaved.set(stored, { limit, rows }) + return true +} + +function database(): Promise { + connection ??= indexedDbConnection(indexedDB, DATABASE_NAME, DATABASE_VERSION, (db) => { + if (!db.objectStoreNames.contains(LISTS)) { + db.createObjectStore(LISTS).createIndex(SAVED_AT, SAVED_AT) + } + }) + return connection() +} + +// Rows keep only what the list shows: the latest turn, not the conversation or prompts. +function savedRow(session: AiVaultSession): AiVaultSession { + const { + previewMessages: _previewMessages, + previewMessagesTruncated: _previewMessagesTruncated, + firstUserPrompt: _firstUserPrompt, + lastUserPrompt: _lastUserPrompt, + antigravityOpeningPrompt: _antigravityOpeningPrompt, + ...row + } = session + const latest = latestSessionConversationTurn(session) + // Marked truncated, as the row contract asks, because the opening ask is no longer in the window. + return latest + ? { ...row, previewMessages: [latest], previewMessagesTruncated: true } + : { ...row, previewMessages: [] } +} + +function isStoredList(value: unknown): value is StoredList { + return ( + typeof value === 'object' && + value !== null && + 'format' in value && + value.format === ROW_FORMAT && + 'savedAt' in value && + typeof value.savedAt === 'number' && + 'limit' in value && + (value.limit === 'unlimited' || typeof value.limit === 'number') && + 'sessions' in value && + Array.isArray(value.sessions) + ) +} + +export async function readSavedAiVaultSessionList( + key: string +): Promise { + const stored = storageKey(key, await loadedProfileId()) + if (!stored) { + return null + } + try { + const value: unknown = await settledRequest( + (await database()).transaction(LISTS, 'readonly').objectStore(LISTS).get(stored) + ) + if (!isStoredList(value)) { + return null + } + const scannedAt = new Date(value.savedAt).toISOString() + return { limit: value.limit, result: { sessions: value.sessions, issues: [], scannedAt } } + } catch { + return null + } +} + +export async function saveAiVaultSessionList( + key: string, + limit: AiVaultSessionLimit, + result: AiVaultListResult +): Promise { + const stored = storageKey(key, await loadedProfileId()) + if (!stored) { + return + } + // Host lists are newest first, so the cut keeps the rows the panel opens on. + const rows = result.sessions.slice(0, MAX_SAVED_ROWS) + // A cut list covers no deeper than its rows. + const savedLimit = + result.sessions.length > MAX_SAVED_ROWS && (limit === 'unlimited' || limit > MAX_SAVED_ROWS) + ? MAX_SAVED_ROWS + : limit + lastUse.set(stored, ++uses) + if (unchangedSinceSave(stored, savedLimit, rows)) { + return + } + const list: StoredList = { + format: ROW_FORMAT, + savedAt: Date.now(), + limit: savedLimit, + sessions: rows.map(savedRow) + } + try { + const transaction = (await database()).transaction(LISTS, 'readwrite') + const done = committedTransaction(transaction) + const lists = transaction.objectStore(LISTS) + lists.put(list, stored) + // The index orders keys by write time; the stable sort then puts this run's uses last, in order. + const keys = lists.index(SAVED_AT).getAllKeys() + keys.onsuccess = () => { + const byUse = keys.result + .map(String) + .sort((a, b) => (lastUse.get(a) ?? 0) - (lastUse.get(b) ?? 0)) + for (const oldest of byUse.slice(0, Math.max(0, byUse.length - MAX_SAVED_LISTS))) { + lists.delete(oldest) + // Evicted keys must write again, even when their rows haven't changed. + lastSaved.delete(oldest) + lastUse.delete(oldest) + } + transaction.commit?.() + } + await done + lastSaved.set(stored, { limit: savedLimit, rows }) + } catch { + // A refused write only costs the next restart its instant list; the next scan tries again. + } +} + +/** Drops a deleted session from every saved list so no scope repaints it before its rescan. */ +export async function forgetSavedAiVaultSession(sessionId: string): Promise { + if (!savedListsAvailable()) { + return + } + // Whatever lists held the row are rewritten, so the next scan of each scope saves again. + lastSaved.clear() + try { + const transaction = (await database()).transaction(LISTS, 'readwrite') + const done = committedTransaction(transaction) + const cursorRequest = transaction.objectStore(LISTS).openCursor() + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result + if (!cursor) { + return + } + const value: unknown = cursor.value + if (isStoredList(value) && value.sessions.some((session) => session.id === sessionId)) { + cursor.update({ + ...value, + sessions: value.sessions.filter((session) => session.id !== sessionId) + }) + } + cursor.continue() + } + await done + } catch { + // The next rescan of each scope drops the row anyway. + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-list-request.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-list-request.ts index 0f6758bf179..ace3b702f1a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-list-request.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-list-request.ts @@ -10,8 +10,10 @@ import type { AiVaultSessionLimit } from '../session-list/ai-vault-session-limit import { aiVaultSessionResultCacheKey, cacheAiVaultSessionResult, - readCachedAiVaultSessionResult + readCachedAiVaultSessionResult, + readLastAiVaultSessionResult } from './ai-vault-session-result-cache' +import type { SavedAiVaultSessionList } from './ai-vault-saved-session-lists' /** Match panel requests so readers share host/renderer caches instead of evicting one another. */ export type AiVaultSessionListRequest = { @@ -52,6 +54,20 @@ export function readCachedAiVaultSessionList( }) } +export function readLastAiVaultSessionList( + request: AiVaultSessionListRequest +): Promise { + return readLastAiVaultSessionResult({ + key: aiVaultSessionResultCacheKey( + request.executionHostScope, + request.scopePaths, + request.scopeOnly + ), + limit: request.sessionLimit, + scopePaths: request.scopePaths + }) +} + export function cacheAiVaultSessionList( request: AiVaultSessionListRequest, result: AiVaultListResult, diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.test.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.test.ts new file mode 100644 index 00000000000..f01e17da20b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + markInputQuietSchedulerInput, + resetInputQuietSchedulerForTest +} from '@/lib/input-quiet-scheduler' +import type { AiVaultListResult } from '../../../../../../shared/ai-vault/contracts/ai-vault-types' +import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' + +const FIRST: AiVaultListResult = { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } +const SECOND: AiVaultListResult = { ...FIRST, scannedAt: '2026-08-09T00:00:01.000Z' } + +afterEach(() => { + vi.useRealTimers() + resetInputQuietSchedulerForTest() +}) + +describe('AiVaultSessionPublicationGate', () => { + // A chat-link snapshot right after a restart cancels the gate; it must not drop the saved list. + it('shows a late saved list after a cancel but never over a newer published list', async () => { + const shown = vi.fn() + const gate = new AiVaultSessionPublicationGate() + gate.showUntilPublished(Promise.resolve(FIRST), shown) + gate.cancel() + await Promise.resolve() + expect(shown).toHaveBeenCalledWith(FIRST) + + const late = vi.fn() + gate.showUntilPublished(Promise.resolve(FIRST), late) + gate.publish(SECOND, vi.fn()) + await Promise.resolve() + expect(late).not.toHaveBeenCalled() + }) + + it('still shows a late saved list when the list published meanwhile was cancelled before it landed', async () => { + vi.useFakeTimers() + const shown = vi.fn() + const published = vi.fn() + const gate = new AiVaultSessionPublicationGate() + gate.showUntilPublished(Promise.resolve(FIRST), shown) + markInputQuietSchedulerInput() + gate.publish(SECOND, published) + gate.cancel() + await Promise.resolve() + vi.advanceTimersByTime(1_000) + + expect(published).not.toHaveBeenCalled() + expect(shown).toHaveBeenCalledWith(FIRST) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.ts index 7cddac6fc7f..cf23b16c5d5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-publication-gate.ts @@ -9,11 +9,28 @@ export class AiVaultSessionPublicationGate { private generation = 0 private cancelPending: (() => void) | null = null + private published = 0 + + /** Applies a list that arrives later unless a list was published meanwhile; cancels don't drop it. */ + showUntilPublished(pending: Promise, apply: (value: T) => void): void { + const published = this.published + void pending.then((value) => { + if (value !== null && published === this.published) { + startTransition(() => apply(value)) + } + }) + } + publish(result: AiVaultListResult, apply: (result: AiVaultListResult) => void): void { this.cancel() const generation = this.generation - if (hasInputBeenQuietFor(AI_VAULT_PUBLICATION_QUIET_MS)) { + // Counted when it lands: a cancelled publication must not drop a saved list still on its way. + const land = () => { + this.published += 1 startTransition(() => apply(result)) + } + if (hasInputBeenQuietFor(AI_VAULT_PUBLICATION_QUIET_MS)) { + land() return } this.cancelPending = scheduleAfterInputQuiet( @@ -22,7 +39,7 @@ export class AiVaultSessionPublicationGate { return } this.cancelPending = null - startTransition(() => apply(result)) + land() }, { delayMs: 0, diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.test.ts index 89eb1fccb6f..46215308a4b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.test.ts @@ -20,6 +20,7 @@ import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from '../session-list/ai-vault-session-limit' +import { resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' const EMPTY_RESULT: AiVaultListResult = { sessions: [], @@ -27,6 +28,25 @@ const EMPTY_RESULT: AiVaultListResult = { scannedAt: '2026-07-01T00:00:00.000Z' } +// Stands in for IndexedDB, which happy-dom lacks; it outlives the in-memory cache like a restart does. +const savedLists = vi.hoisted(() => { + const state: { + lists: Map + read: ((key: string) => Promise) | null + } = { lists: new Map(), read: null } + return state +}) +vi.mock('./ai-vault-saved-session-lists', () => ({ + readSavedAiVaultSessionList: async (key: string) => { + const result = await (savedLists.read?.(key) ?? savedLists.lists.get(key) ?? null) + return result && { limit: 250, result } + }, + saveAiVaultSessionList: async (key: string, _limit: unknown, result: AiVaultListResult) => { + savedLists.lists.set(key, result) + }, + forgetSavedAiVaultSession: async () => {} +})) + const listSessionsMock = vi.fn<(args: AiVaultListArgs) => Promise>() const cancelListSessionsMock = vi.fn<(args: { requestToken: string }) => Promise>() @@ -156,6 +176,8 @@ beforeEach(() => { } } resetAiVaultForcedRescanThrottleForTest() + savedLists.lists.clear() + savedLists.read = null useAppStore.setState(initialAppState, true) }) @@ -294,4 +316,68 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) }) +function deferredList(): { + promise: Promise + resolve: (result: AiVaultListResult) => void +} { + let resolve: (result: AiVaultListResult) => void = () => {} + const promise = new Promise((done) => (resolve = done)) + return { promise, resolve } +} + +async function restartWithScanPending(): Promise<(result: AiVaultListResult) => void> { + listSessionsMock.mockResolvedValueOnce({ + ...EMPTY_RESULT, + sessions: [makeVaultSession(1), makeVaultSession(2)] + }) + await renderHook(['/repo'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT, true) + await flushMicrotasks() + expect(latest?.sessions.map((session) => session.id)).toEqual(['session-1', 'session-2']) + roots.splice(0).forEach((root) => act(() => root.unmount())) + resetAiVaultSessionResultCacheForTest() + const scan = deferredList() + listSessionsMock.mockReturnValueOnce(scan.promise) + return scan.resolve +} + +describe('useAiVaultSessionRefresh after a restart', () => { + it('shows the last list as refreshing until the rescan replaces it', async () => { + const resolveScan = await restartWithScanPending() + + await renderHook(['/repo'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT, true) + await flushMicrotasks() + + expect(latest?.sessions.map((session) => session.id)).toEqual(['session-1', 'session-2']) + expect(latest?.loading).toBe(true) + expect(latest?.scopePending).toBe(true) + + await act(async () => { + resolveScan({ ...EMPTY_RESULT, sessions: [makeVaultSession(2)] }) + }) + await flushMicrotasks() + + expect(latest?.sessions.map((session) => session.id)).toEqual(['session-2']) + expect(latest?.loading).toBe(false) + }) + + it('keeps the rescan when the saved list answers after it', async () => { + const resolveScan = await restartWithScanPending() + const read = deferredList() + savedLists.read = () => read.promise + + await renderHook(['/repo'], 'local', DEFAULT_AI_VAULT_SESSION_LIMIT, true) + await act(async () => { + resolveScan({ ...EMPTY_RESULT, sessions: [makeVaultSession(3)] }) + }) + await flushMicrotasks() + await act(async () => { + read.resolve({ ...EMPTY_RESULT, sessions: [makeVaultSession(1)] }) + }) + await flushMicrotasks() + + expect(latest?.sessions.map((session) => session.id)).toEqual(['session-3']) + expect(latest?.loading).toBe(false) + }) +}) + // Plain HTTP hides crypto.randomUUID; fallback tokens must remain v4 UUIDs (#18096). diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts index cc67e934686..b5d482f2675 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-refresh.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { aiVaultScanErrorText } from './ai-vault-scan-issue-copy' import type { ExecutionHostScope } from '../../../../../../shared/execution-host' -import { useAppStore } from '@/store/index' import type { AiVaultSessionLimit } from '../session-list/ai-vault-session-limit' import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' import { EMPTY_AI_VAULT_SESSIONS } from '../session-list/ai-vault-session-identity' @@ -16,19 +15,16 @@ import { refreshAiVaultSessionList, type AiVaultConditionalSnapshot, cacheAiVaultSessionList, - readCachedAiVaultSessionList + readCachedAiVaultSessionList, + readLastAiVaultSessionList } from './ai-vault-session-list-request' -import { - getAgentSessionIdsKey, - resetAgentSessionIdsRefreshKeys -} from './ai-vault-agent-session-refresh-key' +import { resetAgentSessionIdsRefreshKeys } from './ai-vault-agent-session-refresh-key' +import { useAiVaultNewSessionRescan } from './ai-vault-new-session-rescan' +import { saveAiVaultSessionList } from './ai-vault-saved-session-lists' import { useAiVaultAssociationObservation } from './ai-vault-association-observation' import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid' - -// Share the forced-rescan budget across remounts so session creation cannot amplify full scans. import { claimAiVaultForcedRescan, - aiVaultForcedRescanDelay, resetAiVaultForcedRescanBudgetForTest } from './ai-vault-forced-rescan-budget' import { @@ -121,24 +117,43 @@ export function useAiVaultSessionRefresh( executionHostScope: hostScope, sessionLimit: selectedLimit } - const baseKey = aiVaultSessionResultCacheKey(hostScope, request.scopePaths, request.scopeOnly) - const cachedResult = - args.reuseLoadedDepth === true ? readCachedAiVaultSessionList(request) : null - if (cachedResult) { - const scanKey = `${baseKey}\n${selectedLimit}` - setFailure(null) - setTerminalScopeKey(null) - publicationGateRef.current.publish(cachedResult, (published) => { - if (!mountedRef.current || scanKey !== currentScanScopeKey() || !associationCurrent()) { - return - } - applyScan(published, selectedLimit, scanKey, request.scopeOnly, (applied) => { + const { scopeOnly } = request + const baseKey = aiVaultSessionResultCacheKey(hostScope, request.scopePaths, scopeOnly) + const scanKey = `${baseKey}\n${selectedLimit}` + const applyListed = (published: AiVaultListResult, scanned = false) => + applyScan( + { result: published, limit: selectedLimit, scopeKey: scanKey, scopeOnly }, + (applied) => { conditionalSnapshotRef.current = published.fingerprint ? { scopeKey: scanKey, fingerprint: published.fingerprint, result: applied } : null - }) + // The applied rows keep their identity when a rescan changed nothing, so the save skips. + if (scanned) { + void saveAiVaultSessionList(baseKey, selectedLimit, applied) + } + } + ) + const cachedResult = + args.reuseLoadedDepth === true ? readCachedAiVaultSessionList(request) : null + if (cachedResult) { + setFailure(null) + setTerminalScopeKey(null) + publicationGateRef.current.publish(cachedResult, (published) => { + if (mountedRef.current && scanKey === currentScanScopeKey() && associationCurrent()) { + applyListed(published) + } }) setLoading(false) + } else if (args.reuseLoadedDepth === true) { + publicationGateRef.current.showUntilPublished( + readLastAiVaultSessionList(request), + // Chat links only change which rows are linked; the stale list is replaced by the rescan anyway. + ({ result, limit }) => { + if (mountedRef.current && scanKey === currentScanScopeKey()) { + applyScan({ result, limit, scopeKey: scanKey, scopeOnly, stale: true }) + } + } + ) } // Queue scope changes during an in-flight scan so the new view still refreshes. if (refreshInFlightRef.current) { @@ -165,7 +180,6 @@ export function useAiVaultSessionRefresh( setLoading(true) } setTerminalScopeKey(null) - const scanKey = `${baseKey}\n${selectedLimit}` const requestCurrent = () => mountedRef.current && refreshIdRef.current === refreshId && @@ -190,11 +204,7 @@ export function useAiVaultSessionRefresh( cacheAiVaultSessionList(request, result, { replaceHostEntries: args.force === true }) publicationGateRef.current.publish(result, (published) => { if (requestCurrent()) { - applyScan(published, selectedLimit, scanKey, request.scopeOnly, (applied) => { - conditionalSnapshotRef.current = published.fingerprint - ? { scopeKey: scanKey, fingerprint: published.fingerprint, result: applied } - : null - }) + applyListed(published, true) } }) } catch (err) { @@ -228,23 +238,6 @@ export function useAiVaultSessionRefresh( [applyScan, currentScanScopeKey] ) - // Defer throttled new-session rescans until budget frees so sessions cannot remain invisible indefinitely. - const forcedRescanTimerRef = useRef | null>(null) - const requestForcedRescan = useCallback(() => { - if (claimAiVaultForcedRescan()) { - void refresh({ background: true, force: true }) - return - } - const waitMs = aiVaultForcedRescanDelay() - if (forcedRescanTimerRef.current !== null) { - return - } - forcedRescanTimerRef.current = setTimeout(() => { - forcedRescanTimerRef.current = null - requestForcedRescan() - }, waitMs) - }, [refresh]) - useEffect(() => { mountedRef.current = true const requestToken = requestTokenRef.current @@ -260,14 +253,10 @@ export function useAiVaultSessionRefresh( void window.api.aiVault.cancelListSessions({ requestToken }) - if (forcedRescanTimerRef.current !== null) { - clearTimeout(forcedRescanTimerRef.current) - forcedRescanTimerRef.current = null - } } }, []) - // Panel entry reuses the renderer result first, then the host scan cache. + // Panel entry reuses the renderer result, else paints the last list while the host answers. useEffect(() => { publicationGateRef.current.cancel() pendingRefreshRef.current = false @@ -294,28 +283,11 @@ export function useAiVaultSessionRefresh( // Retry prior relay-readiness errors on host events; otherwise startup/reconnect failures can persist indefinitely. useAiVaultSessionHostRefreshEvents(error, refresh) - // In-app launches never blur; rescan on new provider session IDs, avoiding frequent state transitions. - const agentSessionIdsKey = useAppStore((s) => getAgentSessionIdsKey(s.agentStatusByPaneKey)) - const seenAgentSessionIdsRef = useRef | null>(null) - useEffect(() => { - const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') - // The mount refresh already covers sessions live at mount time. - if (seenAgentSessionIdsRef.current === null) { - seenAgentSessionIdsRef.current = new Set(ids) - return - } - const seen = seenAgentSessionIdsRef.current - const freshIds = ids.filter((id) => !seen.has(id)) - if (freshIds.length === 0) { - return - } - for (const id of freshIds) { - seen.add(id) - } - requestForcedRescan() - }, [agentSessionIdsKey, requestForcedRescan]) + useAiVaultNewSessionRescan(refresh) - const scopePending = scan?.scopeKey !== scanScopeKey && terminalScopeKey !== scanScopeKey + // A stale list keeps the scope pending, so it shows as refreshing until the rescan answers. + const scopePending = + (scan?.scopeKey !== scanScopeKey || scan?.stale === true) && terminalScopeKey !== scanScopeKey return { error, loading: loading || scopePending, diff --git a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-result-cache.ts b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-result-cache.ts index 256f60fd512..0e11209082f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-result-cache.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/scanning/ai-vault-session-result-cache.ts @@ -2,9 +2,15 @@ import type { AiVaultListResult } from '../../../../../../shared/ai-vault/contra import type { ExecutionHostScope } from '../../../../../../shared/execution-host' import { aiVaultCachedDepthServes, + aiVaultSessionDepthCovers, truncateAiVaultListResult } from '../../../../../../shared/ai-vault/scanning/ai-vault-session-depth' import type { AiVaultSessionLimit } from '../session-list/ai-vault-session-limit' +import { + forgetSavedAiVaultSession, + readSavedAiVaultSessionList, + type SavedAiVaultSessionList +} from './ai-vault-saved-session-lists' const MAX_CACHED_SESSION_SCOPES = 8 @@ -12,6 +18,8 @@ type CachedSessionResult = { executionHostScope: ExecutionHostScope limit: AiVaultSessionLimit result: AiVaultListResult + /** Outdated by a rescan or a chat link change: shown while refreshing, never reused as current. */ + stale: boolean } const cachedSessionResults = new Map() @@ -35,7 +43,11 @@ export function readCachedAiVaultSessionResult(args: { scopePaths: readonly string[] }): AiVaultListResult | null { const cached = cachedSessionResults.get(args.key) - if (!cached || !aiVaultCachedDepthServes(cached.limit, args.limit, args.scopePaths)) { + if ( + !cached || + cached.stale || + !aiVaultCachedDepthServes(cached.limit, args.limit, args.scopePaths) + ) { return null } cachedSessionResults.delete(args.key) @@ -43,6 +55,24 @@ export function readCachedAiVaultSessionResult(args: { return truncateAiVaultListResult(cached.result, args.limit, args.scopePaths) } +/** The last list this scope showed, current or not, from this run or a previous one, at the depth it holds. */ +export async function readLastAiVaultSessionResult(args: { + key: string + limit: AiVaultSessionLimit + scopePaths: readonly string[] +}): Promise { + const last = cachedSessionResults.get(args.key) ?? (await readSavedAiVaultSessionList(args.key)) + if (!last) { + return null + } + return aiVaultSessionDepthCovers(last.limit, args.limit) + ? { + limit: args.limit, + result: truncateAiVaultListResult(last.result, args.limit, args.scopePaths) + } + : { limit: last.limit, result: last.result } +} + export function cacheAiVaultSessionResult(args: { key: string executionHostScope: ExecutionHostScope @@ -52,17 +82,14 @@ export function cacheAiVaultSessionResult(args: { replaceHostEntries: boolean }): void { if (args.replaceHostEntries) { - for (const [key, cached] of cachedSessionResults) { - if (cached.executionHostScope === args.executionHostScope) { - cachedSessionResults.delete(key) - } - } + markAiVaultSessionResultsStale((host) => host === args.executionHostScope) } cachedSessionResults.delete(args.key) cachedSessionResults.set(args.key, { executionHostScope: args.executionHostScope, limit: args.limit, - result: args.result + result: args.result, + stale: false }) while (cachedSessionResults.size > MAX_CACHED_SESSION_SCOPES) { const oldestKey = cachedSessionResults.keys().next().value @@ -77,10 +104,26 @@ export function resetAiVaultSessionResultCacheForTest(): void { cachedSessionResults.clear() } -export function invalidateAiVaultSessionResults(host: ExecutionHostScope): void { - for (const [key, entry] of cachedSessionResults) { - if (entry.executionHostScope === host || entry.executionHostScope === 'all') { - cachedSessionResults.delete(key) +function markAiVaultSessionResultsStale(matches: (host: ExecutionHostScope) => boolean): void { + for (const entry of cachedSessionResults.values()) { + if (matches(entry.executionHostScope)) { + entry.stale = true } } } + +export function invalidateAiVaultSessionResults(host: ExecutionHostScope): void { + markAiVaultSessionResultsStale((entry) => entry === host || entry === 'all') +} + +/** Drops a deleted session from every kept list, so no scope paints it while it rescans. */ +export function forgetAiVaultSessionResult(sessionId: string): void { + for (const entry of cachedSessionResults.values()) { + const sessions = entry.result.sessions.filter((session) => session.id !== sessionId) + if (sessions.length !== entry.result.sessions.length) { + entry.result = { ...entry.result, sessions, fingerprint: undefined } + entry.stale = true + } + } + void forgetSavedAiVaultSession(sessionId) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-delete-action.ts index ae78a97e420..7fb06d2e319 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-delete-action.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-delete-action.ts @@ -9,6 +9,7 @@ import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibilit import { lastVerifiedRuntimeStatus } from '../../../../../../shared/remote-runtime/host-status/runtime-host-status' import { resolveContinuedRuntimeEnvironmentRevision } from '@/runtime/rpc/environment-revisions/runtime-environment-revision' import { useAppStore } from '@/store/index' +import { forgetAiVaultSessionResult } from '../scanning/ai-vault-session-result-cache' import '../session-list/ai-vault-session-filters' import { agentLabel } from '../../../../../../shared/ai-vault/session-display/ai-vault-session-filters' import { @@ -135,6 +136,7 @@ export function useAiVaultSessionDeleteAction({ // Use one failure message rather than exposing raw main-side reasons. throw new Error(result.outcome) } + forgetAiVaultSessionResult(session.id) onDeleted?.(session) toast.success( translate('auto.components.right.sidebar.AiVaultPanel.sessionDeleted', 'Session deleted') diff --git a/src/renderer/src/lib/indexeddb-connection.ts b/src/renderer/src/lib/indexeddb-connection.ts new file mode 100644 index 00000000000..f085461ddf9 --- /dev/null +++ b/src/renderer/src/lib/indexeddb-connection.ts @@ -0,0 +1,51 @@ +// One cached IndexedDB connection per database, reopened after it closes, fails or is upgraded elsewhere. + +export function indexedDbConnection( + factory: IDBFactory, + name: string, + version: number, + upgrade: (database: IDBDatabase) => void +): () => Promise { + let opened: Promise | null = null + return () => { + opened ??= new Promise((resolve, reject) => { + const request = factory.open(name, version) + request.onupgradeneeded = () => upgrade(request.result) + request.onsuccess = () => { + const db = request.result + db.onversionchange = () => { + // An explicit close fires no close event, so forget the handle here too. + opened = null + db.close() + } + db.onclose = () => { + opened = null + } + resolve(db) + } + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error(`${name} database is blocked`)) + }).catch((error: unknown) => { + opened = null + throw error + }) + return opened + } +} + +export function settledRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +/** Resolves once the transaction is on disk; rejects when it aborts (a full disk included). */ +export function committedTransaction(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => + reject(transaction.error ?? new Error('IndexedDB transaction aborted')) + }) +}