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
This commit is contained in:
Kelvin Amoaba
2026-11-09 00:37:50 +00:00
committed by GitHub
parent e7b74c031b
commit a353e6293e
14 changed files with 887 additions and 151 deletions
+1 -1
View File
@@ -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
@@ -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<unknown> {
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<void> {
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<IDBDatabase> | null = null
const database = (): Promise<IDBDatabase> => {
opened ??= new Promise<IDBDatabase>((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<void> => {
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<string, unknown>()
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.
@@ -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<AiVaultAppliedScan | null>(null)
const acceptedScanRef = useRef<AiVaultAppliedScan | null>(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)
@@ -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>
): 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<ReturnType<typeof setTimeout> | 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<Set<string> | 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])
}
@@ -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<string, { savedAt: number }>()
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()
})
})
@@ -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<string | null> {
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<IDBDatabase>) | 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<string, { limit: AiVaultSessionLimit; rows: readonly AiVaultSession[] }>()
// Save order this run. A skipped save still counts as use, so eviction stays least recently shown.
const lastUse = new Map<string, number>()
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<IDBDatabase> {
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<SavedAiVaultSessionList | null> {
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<void> {
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<void> {
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.
}
}
@@ -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<SavedAiVaultSessionList | null> {
return readLastAiVaultSessionResult({
key: aiVaultSessionResultCacheKey(
request.executionHostScope,
request.scopePaths,
request.scopeOnly
),
limit: request.sessionLimit,
scopePaths: request.scopePaths
})
}
export function cacheAiVaultSessionList(
request: AiVaultSessionListRequest,
result: AiVaultListResult,
@@ -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)
})
})
@@ -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<T>(pending: Promise<T | null>, 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,
@@ -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<string, AiVaultListResult>
read: ((key: string) => Promise<AiVaultListResult | null>) | 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<AiVaultListResult>>()
const cancelListSessionsMock = vi.fn<(args: { requestToken: string }) => Promise<void>>()
@@ -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<AiVaultListResult>
resolve: (result: AiVaultListResult) => void
} {
let resolve: (result: AiVaultListResult) => void = () => {}
const promise = new Promise<AiVaultListResult>((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).
@@ -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<ReturnType<typeof setTimeout> | 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<Set<string> | 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,
@@ -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<string, CachedSessionResult>()
@@ -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<SavedAiVaultSessionList | null> {
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)
}
@@ -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')
@@ -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<IDBDatabase> {
let opened: Promise<IDBDatabase> | null = null
return () => {
opened ??= new Promise<IDBDatabase>((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<T>(request: IDBRequest<T>): Promise<T> {
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<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
transaction.onabort = () =>
reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
}