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')) + }) +}