feat(session-history): add local search settings and index controls (#20582)

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
This commit is contained in:
Jinwoo Hong
2026-09-16 11:59:22 -04:00
committed by GitHub
parent 0b28d354fe
commit dec0e2cd56
27 changed files with 1037 additions and 24 deletions
@@ -29,10 +29,12 @@ Results contain `kind: 'results'`, `hits`, `page: { cursor, hasMore }`,
contains `route`, optional `repairedTerms`, and `scope`. Diagnostics never appear
at the top level. Status is never attached to search results.
A cursor belongs to one query and one host's index generation. Query, scope,
filters, and sorting must remain the same; page size may change. Writes that
advance the generation can invalidate it, including retention purges. A refused
cursor yields `{ kind: 'stale-cursor', generation, expectedGeneration? }` and the
A cursor belongs to one query, one host's index generation, and an opaque persisted
index incarnation. Query, scope, filters, and sorting must remain the same; page
size may change. Writes that advance the generation can invalidate it, including
retention purges. Clearing or rebuilding the database invalidates it even when the
new generation counter matches. A refused cursor yields
`{ kind: 'stale-cursor', generation, expectedGeneration? }` and the
client discards it and issues page 1 without a cursor. Reusing that refused cursor
continues to fail; there is no server-side cursor acknowledgement state.
Malformed cursors and cursors for a different query yield
@@ -131,7 +133,33 @@ uses the absent-service sentinel above. Transport failures, authentication error
and invalid payloads remain errors. Unknown request fields are stripped for wire
compatibility.
The process-local `setSessionSearchService(service | null)` registry is the only
production seam in this PR. Tests use fake services and a real synthetic store.
Nothing constructs an engine or indexer in production. PR 3b owns process
lifecycle, consent/settings application, and registration of the production service.
The process-local `setSessionSearchService(service | null)` registry connects
these endpoints to the production service installed by PR 3b. Desktop indexing
runs in the scanner child; orcad and the SSH relay register their own in-process
services. Registration alone does not grant consent.
## Desktop index controls (PR8)
Settings → Agent Session History controls this desktop's persisted
`aiVaultSearch { enabled, historyDays }` policy. It stays local even when another
execution host is selected. Paired clients cannot grant consent or clear an index
through this surface; SSH relay registration remains disabled without a separate
host consent mechanism.
`aiVault.clearSearchIndex()` is a no-argument desktop-only preload operation over
`aiVault:clearSearchIndex`. It addresses the local scanner child, not the selected
remote host. The child's existing interactive request lane executes `searchClear`
through `SessionSearchInstance.clear()`: close the indexer and database handles,
remove the SQLite database and sidecars, then reconstruct only if consent remains
enabled. Errors propagate to the settings pane. This operation never deletes
original transcripts. There is no new runtime or relay method. Opaque cursors also
carry a persistent database identity: clearing creates a new identity, so a
pre-clear or legacy cursor returns `stale-cursor` even if the rebuilt numeric
generation happens to match. Reopening the same database preserves its identity.
Disabling closes the indexer and keeps the index copy; clearing deletes the copy.
Changing retention reuses the existing close-and-construct policy application.
The settings pane reads status only while enabled and visible, polls only an
observed indexing phase, and stops on completion or error. Opening the pane,
changing policy, or pressing Refresh obtains a new observation. The indexer's own
schedule does not depend on the pane.
@@ -16,7 +16,7 @@ import {
type SessionSearchScope,
type SessionSearchSourcePresence
} from './session-search-engine-types'
import { readIndexGeneration } from './session-search-index-generation'
import { readIndexGeneration, readIndexIncarnation } from './session-search-index-generation'
import {
rankSessionHits,
type MessageRow,
@@ -97,6 +97,7 @@ export class SessionSearchEngine {
const startedAt = performance.now()
ensureSessionSearchQuerySchema(this.db)
const generation = readIndexGeneration(this.db)
const incarnation = readIndexIncarnation(this.db)
const scope = request.scope ?? 'all'
const sort = request.filters?.sort ?? 'relevance'
// Not a bare `slice`: cutting between a surrogate pair leaves a lone half
@@ -114,7 +115,7 @@ export class SessionSearchEngine {
// cost a query, and the caller has to hear about it either way.
const pageKey = sessionSearchPageKey(request)
const offset = request.cursor
? decodeSessionSearchCursor(request.cursor, generation, pageKey)
? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation)
: 0
const plan = planSessionSearchQuery(split.text)
@@ -140,7 +141,9 @@ export class SessionSearchEngine {
},
page: {
hasMore,
cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null
cursor: hasMore
? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation)
: null
},
truncated: {
// Decided by retrieval, which is the only layer that knows whether a cap
@@ -5,7 +5,7 @@ import { afterEach, expect, it } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchEngine } from './session-search-engine'
import { readIndexGeneration } from './session-search-index-generation'
import { readIndexGeneration, readIndexIncarnation } from './session-search-index-generation'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
@@ -225,12 +225,15 @@ it('keeps the generation across a reopen, because the bump rides its own commit'
throw error
})
await indexOneTranscript(root, first)
const indexed = readIndexGeneration(reader(path))
const firstReader = reader(path)
const indexed = readIndexGeneration(firstReader)
const incarnation = readIndexIncarnation(firstReader)
first.close()
const second = new SessionSearchStore(path)
try {
expect(readIndexGeneration(reader(path))).toBe(indexed)
expect(readIndexIncarnation(reader(path))).toBe(incarnation)
} finally {
second.close()
}
@@ -1,6 +1,7 @@
import type SyncDatabase from '../sqlite/sync-database'
const GENERATION_KEY = 'index_generation'
const INCARNATION_KEY = 'index_incarnation'
export const SESSION_SEARCH_GENERATION_TRIGGERS = [
'search_generation_file_insert',
@@ -40,3 +41,11 @@ export function readIndexGeneration(db: SyncDatabase): number {
const parsed = row ? Number(row.value) : Number.NaN
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
}
export function readIndexIncarnation(db: SyncDatabase): string {
const row: unknown = db.prepare('SELECT value FROM meta WHERE key = ?').get(INCARNATION_KEY)
if (!row || typeof row !== 'object' || !('value' in row) || typeof row.value !== 'string') {
throw new Error('Session search index has no incarnation.')
}
return row.value
}
@@ -167,6 +167,27 @@ it('removes the database on clear and rebuilds only while consent stands', async
expect(errors).toEqual([])
})
it('refuses a page cursor minted before clear even when the rebuilt generation matches', async () => {
for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) {
await writeClaudeTranscript(transcriptPath(id), [`shared clear fence ${id}`], id)
}
const subject = newInstance()
subject.apply({ enabled: true, historyDays: null })
await subject.settled()
const first = await subject.search({ query: 'shared clear fence', limit: 1 })
if (first.kind !== 'results' || !first.page.cursor) {
throw new Error('expected a paged result')
}
subject.clear()
await subject.settled()
expect(subject.status().generation).toBe(first.generation)
expect(
await subject.search({ query: 'shared clear fence', limit: 1, cursor: first.page.cursor })
).toMatchObject({ kind: 'stale-cursor', generation: first.generation })
expect(errors).toEqual([])
})
it('keeps pagination stable when the clock crosses retention before a purge', async () => {
for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) {
await writeClaudeTranscript(transcriptPath(id), [`distinctive conversation ${id}`], id)
@@ -18,6 +18,8 @@ export class SessionSearchCursorError extends Error {
}
type CursorPayload = {
/** Database incarnation; changes when the index is rebuilt. */
i: string
/** Index generation. */
g: number
/**
@@ -50,8 +52,13 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string {
return createHash('sha256').update(identity).digest('base64url').slice(0, 16)
}
export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string {
const payload: CursorPayload = { g: generation, o: offset, k: key }
export function encodeSessionSearchCursor(
generation: number,
offset: number,
key: string,
incarnation: string
): string {
const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key }
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url')
}
@@ -63,7 +70,12 @@ export function encodeSessionSearchCursor(generation: number, offset: number, ke
* can tell "the index moved under you, ask for page one" from "this cursor is
* not ours" and act on the first without showing anyone an error.
*/
export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number {
export function decodeSessionSearchCursor(
cursor: string,
generation: number,
key: string,
incarnation: string
): number {
let payload: CursorPayload
try {
payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload
@@ -92,6 +104,10 @@ export function decodeSessionSearchCursor(cursor: string, generation: number, ke
if (claimed !== generation) {
throw new SessionSearchCursorError('stale-generation', generation, claimed)
}
// Cursors minted before incarnation fencing are stale across a possible rebuild.
if (payload.i !== incarnation) {
throw new SessionSearchCursorError('stale-generation', generation, claimed)
}
if (payload.k !== key) {
throw new SessionSearchCursorError('different-query', generation, claimed)
}
@@ -203,10 +203,18 @@ describe('a cursor is refused rather than reinterpreted', () => {
describe('cursor encoding', () => {
const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } }
const incarnation = 'index-a'
it('round-trips an offset within its own generation and query', () => {
const key = sessionSearchPageKey(request)
expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40)
expect(
decodeSessionSearchCursor(
encodeSessionSearchCursor(7, 40, key, incarnation),
7,
key,
incarnation
)
).toBe(40)
})
it('keys a request by what changes its ranking, and not by its page size', () => {
@@ -225,7 +233,7 @@ describe('cursor encoding', () => {
})
it.each([
['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1],
['a negative offset', encodeSessionSearchCursor(1, -1, 'k', incarnation), 1],
['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1],
['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined],
['text that is not base64url JSON', 'zzz!!', undefined],
@@ -246,7 +254,7 @@ describe('cursor encoding', () => {
// wrong with the cursor, and the generation it claimed whenever that
// survived parsing.
try {
decodeSessionSearchCursor(cursor, 7, 'k')
decodeSessionSearchCursor(cursor, 7, 'k', incarnation)
expect.unreachable('a malformed cursor is not an empty one')
} catch (error) {
const rejected = error as SessionSearchCursorError
@@ -255,6 +263,15 @@ describe('cursor encoding', () => {
expect(rejected.expectedGeneration).toBe(claimed)
}
})
it('treats legacy and previous-incarnation cursors as stale', () => {
const legacy = Buffer.from('{"g":7,"o":1,"k":"k"}').toString('base64url')
for (const cursor of [legacy, encodeSessionSearchCursor(7, 1, 'k', 'index-before')]) {
expect(() => decodeSessionSearchCursor(cursor, 7, 'k', incarnation)).toThrow(
'stale-generation'
)
}
})
})
describe('the candidate limit is a tunable default, and says when it cut', () => {
@@ -1,4 +1,5 @@
import { mkdirSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { dirname } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
@@ -131,6 +132,10 @@ function openExisting(path: string): SyncDatabase {
db = openWithPragmas(path)
}
db.exec(SCHEMA_SQL)
db.prepare('INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)').run(
'index_incarnation',
randomUUID()
)
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run(
'schema_version',
String(SESSION_SEARCH_SCHEMA_VERSION)
@@ -26,6 +26,7 @@ export type AiVaultServiceOperation =
| 'searchSessions'
| 'searchStatus'
| 'searchReconcile'
| 'searchClear'
// Typed from the union so a new operation cannot be added without landing here,
// and held as strings so recognising one costs no assertion.
@@ -36,7 +37,8 @@ const AI_VAULT_SERVICE_OPERATIONS: ReadonlySet<string> = new Set<AiVaultServiceO
'firstPrompt',
'searchSessions',
'searchStatus',
'searchReconcile'
'searchReconcile',
'searchClear'
])
export type AiVaultServiceSubagentRequest = {
@@ -83,6 +85,7 @@ export type AiVaultServiceRequestBody =
| { type: 'request'; operation: 'searchSessions'; request: AiVaultSearchRequest }
| { type: 'request'; operation: 'searchStatus' }
| { type: 'request'; operation: 'searchReconcile' }
| { type: 'request'; operation: 'searchClear' }
export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number }
@@ -104,6 +107,7 @@ export type AiVaultServiceResultValue =
| { operation: 'searchSessions'; value: AiVaultSearchResponse }
| { operation: 'searchStatus'; value: AiVaultSearchStatus }
| { operation: 'searchReconcile'; value: null }
| { operation: 'searchClear'; value: null }
export type AiVaultServiceChildMessage =
| { type: 'sessionSearchRoots'; id: number }
@@ -75,6 +75,12 @@ async function indexedSessions(term: string, expected: string[]): Promise<void>
)
}
it('refuses to clear when the child has no search instance', async () => {
await expect(
subject.execute({ type: 'request', id: 1, operation: 'searchClear' })
).rejects.toThrow('Agent Session History search is not available.')
})
it('refreshes a late root without rebuilding the index', async () => {
await writeMessageGraphTranscript(openclawTranscript(spawnRoot, 'early-session'), [
'a conversation in a root the spawn already knew'
@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
@@ -156,6 +156,21 @@ it('discovers a new root through the parent exchange on manual reconciliation',
}
})
it('clears the owned index and rebuilds from the transcripts still on disk', async () => {
const transcriptPath = join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`)
expect((await searchSessions('distinctive')).kind).toBe('results')
rmSync(transcriptPath)
expect(await call({ type: 'request', operation: 'searchClear' })).toEqual({
operation: 'searchClear',
value: null,
type: 'result',
id: expect.any(Number)
})
expect(await searchSessions('distinctive')).toMatchObject({ kind: 'results', hits: [] })
expect(existsSync(harness.databasePath)).toBe(true)
})
it('answers disabled once consent is withdrawn, without a respawn', async () => {
emit({ type: 'sessionSearch', init: searchInit(false) })
expect(await searchSessions('distinctive')).toEqual({ kind: 'unavailable', reason: 'disabled' })
@@ -15,7 +15,7 @@ import type {
type SearchOperation = Extract<
AiVaultServiceRequest,
{ operation: 'searchSessions' | 'searchStatus' | 'searchReconcile' }
{ operation: 'searchSessions' | 'searchStatus' | 'searchReconcile' | 'searchClear' }
>
/**
@@ -61,7 +61,8 @@ export class SessionScannerServiceSearch {
return (
request.operation === 'searchSessions' ||
request.operation === 'searchStatus' ||
request.operation === 'searchReconcile'
request.operation === 'searchReconcile' ||
request.operation === 'searchClear'
)
}
@@ -77,6 +78,13 @@ export class SessionScannerServiceSearch {
await instance?.reconcile()
return { operation: 'searchReconcile', value: null }
}
if (request.operation === 'searchClear') {
if (!instance) {
throw new Error('Agent Session History search is not available.')
}
instance.clear()
return { operation: 'searchClear', value: null }
}
return {
operation: 'searchSessions',
value: instance
@@ -109,6 +109,10 @@ export function reconcileSessionSearchInService(): Promise<void> {
return getSharedClient().request({ type: 'request', operation: 'searchReconcile' })
}
export function clearSessionSearchInService(): Promise<void> {
return getSharedClient().request({ type: 'request', operation: 'searchClear' })
}
/** Boot and every settings change: push the policy and keep a child while the index runs. */
export function updateSessionSearchInService(init: AiVaultSessionSearchInit): void {
getSharedClient().updateSessionSearch(init)
+13 -1
View File
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, sshSearch, runtimeSearch } = vi.hoisted(() => ({
const { clearSearch, handlers, sshSearch, runtimeSearch } = vi.hoisted(() => ({
clearSearch: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
sshSearch: vi.fn(),
runtimeSearch: vi.fn()
}))
vi.mock('../ai-vault/session-scanner-service-spawn', () => ({
clearSessionSearchInService: clearSearch
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (name: string, handler: (...args: unknown[]) => Promise<unknown>) =>
@@ -25,6 +29,7 @@ beforeEach(() => {
handlers.clear()
sshSearch.mockReset()
runtimeSearch.mockReset()
clearSearch.mockReset()
registerAiVaultSearchHandlers({
callRuntimeSearch: runtimeSearch
})
@@ -50,6 +55,13 @@ describe('desktop IPC and preload search boundary', () => {
expect(sshSearch).not.toHaveBeenCalled()
expect(runtimeSearch).not.toHaveBeenCalled()
})
it('clears only the desktop-local child-owned index', async () => {
clearSearch.mockResolvedValue(undefined)
await aiVaultApi.clearSearchIndex()
expect(clearSearch).toHaveBeenCalledOnce()
expect(sshSearch).not.toHaveBeenCalled()
expect(runtimeSearch).not.toHaveBeenCalled()
})
it('rejects malformed renderer input and uses typed unavailable', async () => {
expect(await aiVaultApi.searchSessions({ query: 'needle' })).toEqual({
kind: 'unavailable',
+2
View File
@@ -20,6 +20,7 @@ import {
type ParsedExecutionHost
} from '../../shared/execution-host'
import { requestActiveSshSessionSearch } from './ssh'
import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn'
export type RuntimeSessionSearchCall = (
environmentId: string,
@@ -48,6 +49,7 @@ export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptio
const scope = requestedSearchScope(rawScope)
return statusByExecutionHost(scope)
})
ipcMain.handle('aiVault:clearSearchIndex', () => clearSessionSearchInService())
}
/**
+2
View File
@@ -33,6 +33,8 @@ export type AiVaultApi = {
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
/** Deletes and rebuilds this desktop's local search index. */
clearSearchIndex: () => Promise<void>
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise<AiVaultSessionTitlesResult>
cancelListSessions: (args: { requestToken: string }) => Promise<void>
+1
View File
@@ -33,6 +33,7 @@ export const aiVaultApi = {
searchClient(executionHostScope).searchSessions(request),
searchStatus: (executionHostScope?: ExecutionHostId) =>
searchClient(executionHostScope).searchStatus(),
clearSearchIndex: (): Promise<void> => ipcRenderer.invoke('aiVault:clearSearchIndex'),
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
@@ -0,0 +1,210 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() }))
vi.mock('@/hooks/use-window-stream-visibility', () => ({
useWindowStreamVisible: () => mocks.visible
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
const current: AiVaultSearchStatus = {
...unavailableSessionSearchStatus(),
enabled: true,
phase: 'current',
filesIndexed: 12,
lastSweepCompletedAt: 1
}
beforeEach(() => {
vi.useFakeTimers()
mocks.visible = true
mocks.status.mockReset().mockResolvedValue(current)
vi.stubGlobal('api', undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { aiVault: { searchStatus: mocks.status } }
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('keeps polling a settled index so counts stay live between sweeps', async () => {
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(mocks.status).toHaveBeenCalledWith('local')
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 })
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 30 files indexed')
})
it('reports a first scan by count and later sweeps by percentage', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 6,
lastSweepCompletedAt: null
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far')
expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop')
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 5,
filesFailed: 1,
lastSweepCompletedAt: 1
})
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files')
})
it('polls a sweep faster than a settled index', async () => {
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
const started = mocks.status.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000)
})
expect(mocks.status.mock.calls.length - started).toBe(3)
})
it('names unreadable files while degraded and still reports progress', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 8,
filesDue: 1,
filesFailed: 1,
degradedRoots: [{ reason: 'unreadable' }]
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Indexing · 80% · 8 of 10 files')
expect(status).toHaveTextContent('1 files could not be read and will be retried.')
expect(status).toHaveTextContent('Unverified source roots: 1')
})
it('calls a drained degraded index up to date', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 9,
filesDue: 0,
filesFailed: 2
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 9 files indexed')
expect(screen.getByRole('status')).toHaveTextContent('2 files could not be read')
expect(screen.queryByText(/Turn off search to stop/)).not.toBeInTheDocument()
})
it('offers no refresh control now that status is live', async () => {
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('does not describe an absent service as an empty current index', async () => {
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent(
'not ready or the search service is unavailable'
)
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
})
it('recovers on its own after a failed read', async () => {
mocks.status.mockRejectedValueOnce(new Error('offline'))
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
})
it('handles a synchronous bridge failure without losing the poll', async () => {
mocks.status.mockImplementationOnce(() => {
throw new Error('bridge unavailable')
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
})
it('fences pending responses across disable and hiding', async () => {
let answer: (value: AiVaultSearchStatus) => void = () => undefined
mocks.status.mockReturnValue(
new Promise<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
await act(async () => {
answer(current)
})
expect(screen.getByRole('status')).toHaveTextContent('Search is off')
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
mocks.visible = false
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
mocks.visible = true
mocks.status.mockResolvedValue(current)
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
await act(async () => {})
expect(mocks.status).toHaveBeenCalledTimes(2)
})
it('does not overlap slow status requests and stops polling on unmount', async () => {
let answer: (value: AiVaultSearchStatus) => void = () => undefined
mocks.status.mockReturnValue(
new Promise<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
await act(async () => {
answer({ ...current, phase: 'indexing', filesDue: 2 })
})
const beforeUnmount = mocks.status.mock.calls.length
view.unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
})
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useWindowStreamVisible } from '@/hooks/use-window-stream-visibility'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { translate } from '@/i18n/i18n'
import { SettingsRow } from './SettingsFormControls'
const SWEEPING_POLL_MS = 2_000
const SETTLED_POLL_MS = 10_000
// A pass still has files due, so counts move between polls; a settled index only changes on the next sweep.
function isSweeping(status: AiVaultSearchStatus | null): boolean {
if (!status?.enabled) {
return false
}
return status.phase === 'indexing' || (status.phase === 'degraded' && status.filesDue > 0)
}
function sweepMessage(status: AiVaultSearchStatus): string {
if (status.lastSweepCompletedAt === null) {
// No completed sweep yet, so the denominator is still growing and a percentage would mislead.
return translate('sessionHistory.status.firstScan', 'Indexing… {{indexed}} files so far', {
indexed: status.filesIndexed
})
}
const total = status.filesIndexed + status.filesDue + status.filesFailed
const percent = total > 0 ? Math.floor((status.filesIndexed / total) * 100) : 0
return translate(
'sessionHistory.status.progress',
'Indexing · {{percent}}% · {{indexed}} of {{total}} files',
{ percent, indexed: status.filesIndexed, total }
)
}
function statusMessage(status: AiVaultSearchStatus): string {
if (!status.enabled || status.phase === 'idle' || status.phase === 'closed') {
return translate(
'sessionHistory.status.unavailable',
'Index is not ready or the search service is unavailable.'
)
}
if (isSweeping(status)) {
return sweepMessage(status)
}
return translate('sessionHistory.status.upToDate', 'Up to date · {{indexed}} files indexed', {
indexed: status.filesIndexed
})
}
export function SessionHistoryIndexStatus({
enabled,
refresh
}: {
enabled: boolean
refresh: number
}): React.JSX.Element {
const visible = useWindowStreamVisible(0)
const [status, setStatus] = useState<AiVaultSearchStatus | null>(null)
const [failed, setFailed] = useState(false)
const intervalMs = isSweeping(status) ? SWEEPING_POLL_MS : SETTLED_POLL_MS
useEffect(() => {
if (!enabled) {
setStatus(null)
setFailed(false)
return
}
if (!visible) {
return
}
let disposed = false
let inFlight = false
async function read(): Promise<void> {
if (inFlight || disposed) {
return
}
inFlight = true
try {
const next = await Promise.resolve().then(() =>
window.api.aiVault.searchStatus(LOCAL_EXECUTION_HOST_ID)
)
if (!disposed) {
setStatus(next)
setFailed(false)
}
} catch {
if (!disposed) {
setStatus(null)
setFailed(true)
}
} finally {
inFlight = false
}
}
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
return () => {
disposed = true
stopPolling()
}
}, [enabled, visible, refresh, intervalMs])
let message = translate('sessionHistory.status.checking', 'Checking index…')
if (!enabled) {
message = translate(
'sessionHistory.status.off',
'Search is off. Any existing index copy is kept.'
)
} else if (failed) {
message = translate('sessionHistory.status.error', 'Could not read index status. Retrying…')
} else if (status) {
message = statusMessage(status)
}
const live = enabled && status?.enabled === true
return (
<SettingsRow
label={translate('sessionHistory.status.title', 'Index status')}
description={
<span role="status" className="space-y-1 block">
<span className="block">{message}</span>
{live && status.phase === 'degraded' && status.filesFailed > 0 ? (
<span className="block">
{translate(
'sessionHistory.status.unreadable',
'{{failed}} files could not be read and will be retried.',
{ failed: status.filesFailed }
)}
</span>
) : null}
{live && isSweeping(status) ? (
<span className="block text-muted-foreground">
{translate(
'sessionHistory.status.stopHint',
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
)}
</span>
) : null}
{live && status.degradedRoots.length > 0 ? (
<span className="block">
{translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', {
roots: status.degradedRoots.length
})}
</span>
) : null}
</span>
}
control={null}
/>
)
}
@@ -0,0 +1,210 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
const mocks = vi.hoisted(() => ({ web: false, visible: true, status: vi.fn(), clear: vi.fn() }))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
vi.mock('@/hooks/use-window-stream-visibility', () => ({
useWindowStreamVisible: () => mocks.visible
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
function pane(
enabled = false,
confirm = vi.fn().mockResolvedValue(true),
save = vi.fn().mockResolvedValue(undefined),
historyDays: number | null = null
) {
return render(
<ConfirmationDialogContext.Provider value={confirm}>
<SessionHistorySettingsPane
settings={{
...getDefaultSettings('/synthetic'),
aiVaultSearch: { enabled, historyDays }
}}
updateSettings={save}
/>
</ConfirmationDialogContext.Provider>
)
}
async function openAdvanced(): Promise<void> {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Advanced/ }))
})
}
const current: AiVaultSearchStatus = {
...unavailableSessionSearchStatus(),
enabled: true,
phase: 'current',
filesIndexed: 12,
lastSweepCompletedAt: 1
}
beforeEach(() => {
vi.useFakeTimers()
mocks.web = false
mocks.visible = true
mocks.status.mockReset().mockResolvedValue(current)
mocks.clear.mockReset().mockResolvedValue(undefined)
vi.stubGlobal('api', undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { aiVault: { searchStatus: mocks.status, clearSearchIndex: mocks.clear } }
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('requires opt-in and saves the existing policy without touching transcripts or polling while off', async () => {
const save = vi.fn().mockResolvedValue(undefined)
const confirm = vi.fn().mockResolvedValue(true)
pane(false, confirm, save)
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
expect(screen.getByText(/Content is not redacted/)).toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).not.toHaveBeenCalled()
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Start indexing agent sessions?',
description: expect.stringContaining('content is not redacted'),
confirmLabel: 'Start indexing'
})
)
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
})
it('leaves search off when the indexing consent is declined', async () => {
const save = vi.fn().mockResolvedValue(undefined)
pane(false, vi.fn().mockResolvedValue(false), save)
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
it('turns search off without asking again', async () => {
const confirm = vi.fn().mockResolvedValue(true)
const save = vi.fn().mockResolvedValue(undefined)
pane(true, confirm, save)
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(confirm).not.toHaveBeenCalled()
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: false, historyDays: null } })
})
it('keeps the stored retention window without offering a control for it', async () => {
const save = vi.fn().mockResolvedValue(undefined)
pane(false, undefined, save, 30)
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByText(/Searchable history/)).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: 30 } })
})
it('shows failed saves inline and unlocks controls', async () => {
pane(false, undefined, vi.fn().mockRejectedValue(new Error('write failed')))
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(screen.getByRole('alert')).toHaveTextContent('Could not save')
expect(screen.getByRole('switch')).toBeEnabled()
})
it('hides the delete control behind Advanced', async () => {
pane(false)
expect(screen.queryByRole('button', { name: 'Delete index' })).not.toBeInTheDocument()
await openAdvanced()
expect(screen.getByRole('button', { name: 'Delete index' })).toBeInTheDocument()
expect(screen.getByText(/Search stays off/)).toBeInTheDocument()
})
it('deletes only after confirmation, supports deleting while disabled, and reports failures', async () => {
const confirm = vi.fn().mockResolvedValue(false)
pane(false, confirm)
await openAdvanced()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
})
expect(mocks.clear).not.toHaveBeenCalled()
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Delete this computers search index?',
description: expect.stringContaining('Search stays off'),
confirmLabel: 'Delete index'
})
)
confirm.mockResolvedValue(true)
mocks.clear.mockRejectedValue(new Error('service unavailable'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
})
expect(mocks.clear).toHaveBeenCalledOnce()
expect(screen.getByRole('alert')).toHaveTextContent('Could not clear')
})
it('does not execute a confirmation after navigating away', async () => {
let accept: (value: boolean) => void = () => undefined
const confirmation = new Promise<boolean>((resolve) => {
accept = resolve
})
const view = pane(false, vi.fn().mockReturnValue(confirmation))
await openAdvanced()
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
view.unmount()
await act(async () => {
accept(true)
})
expect(mocks.clear).not.toHaveBeenCalled()
})
it('leaves paired-client controls unsupported without local calls', async () => {
mocks.web = true
pane(true)
expect(screen.getByRole('switch')).toBeDisabled()
await openAdvanced()
expect(screen.getByRole('button', { name: 'Delete index' })).toBeDisabled()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).not.toHaveBeenCalled()
})
it('keeps the last index status visible while a save is in flight', async () => {
let finishSave: () => void = () => undefined
const save = vi.fn().mockReturnValue(
new Promise<void>((resolve) => {
finishSave = resolve
})
)
pane(true, vi.fn().mockResolvedValue(true), save)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
await act(async () => {
finishSave()
})
})
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
AiVaultSearchSettingsSchema,
resolveAiVaultSearchSettings
} from '../../../../shared/ai-vault-search-settings'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { isWebClientLocation } from '@/lib/web-client-location'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls'
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
export function SessionHistorySettingsPane({
settings,
updateSettings
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}): React.JSX.Element {
const policy = resolveAiVaultSearchSettings(settings)
const isWebClient = isWebClientLocation()
const confirm = useConfirmationDialog()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [refresh, setRefresh] = useState(0)
const [advancedOpen, setAdvancedOpen] = useState(false)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
async function save(updates: Partial<typeof policy>): Promise<void> {
setBusy(true)
setError(null)
try {
await updateSettings({
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
})
} catch {
if (mounted.current) {
setError(
translate(
'sessionHistory.settings.saveError',
'Could not save session search settings. Try again.'
)
)
}
} finally {
if (mounted.current) {
setBusy(false)
}
}
}
async function toggleEnabled(): Promise<void> {
if (policy.enabled) {
await save({ enabled: false })
return
}
setBusy(true)
let accepted = false
try {
accepted = await confirm({
title: translate('sessionHistory.settings.enableTitle', 'Start indexing agent sessions?'),
description: translate(
'sessionHistory.settings.enableConsent',
'Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.'
),
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing')
})
} finally {
if (mounted.current) {
setBusy(false)
}
}
if (!accepted || !mounted.current) {
return
}
await save({ enabled: true })
}
async function deleteIndex(): Promise<void> {
setBusy(true)
setError(null)
try {
const accepted = await confirm({
title: translate(
'sessionHistory.settings.deleteTitle',
'Delete this computers search index?'
),
description: policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
),
confirmLabel: translate('sessionHistory.settings.delete', 'Delete index'),
confirmVariant: 'destructive'
})
if (!accepted || !mounted.current) {
return
}
await window.api.aiVault.clearSearchIndex()
if (mounted.current) {
setRefresh((value) => value + 1)
toast.success(
translate(
'sessionHistory.settings.cleared',
'Search index cleared. Original transcripts were kept.'
)
)
}
} catch {
if (mounted.current) {
setError(
translate('sessionHistory.settings.clearError', 'Could not clear the index. Try again.')
)
}
} finally {
if (mounted.current) {
setBusy(false)
}
}
}
return (
<div className="divide-y divide-border">
<SettingsSwitchRow
label={translate('sessionHistory.settings.enable', 'Enable session history search')}
description={
isWebClient
? translate(
'sessionHistory.settings.webUnsupported',
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
)
: translate(
'sessionHistory.settings.consent',
'Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.'
)
}
checked={policy.enabled}
disabled={busy || isWebClient}
onChange={() => void toggleEnabled()}
/>
{!isWebClient ? (
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
) : null}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="xs" className="-ml-2">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
description={
policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
)
}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void deleteIndex()}
>
{translate('sessionHistory.settings.delete', 'Delete index')}
</Button>
}
/>
</CollapsibleContent>
</Collapsible>
{error ? (
<p role="alert" className="pt-3 text-xs text-destructive">
{error}
</p>
) : null}
</div>
)
}
@@ -13,6 +13,7 @@ import {
} from './settings-capability-section-renderers'
import {
renderArtifactsSettingsSection,
renderSessionHistorySettingsSection,
renderAutomationsSettingsSection,
renderGeneralSettingsSection,
renderIntegrationsSettingsSection,
@@ -127,6 +128,7 @@ export function renderSettingsPage(context: SettingsRenderContext): React.JSX.El
{renderAutomationsSettingsSection(context)}
{renderArtifactsSettingsSection(context)}
{renderShareSkillsSettingsSection(context)}
{renderSessionHistorySettingsSection(context)}
{renderGitSettingsSection(context)}
{renderTasksSettingsSection(context)}
{renderTerminalSettingsSection(context)}
@@ -1,3 +1,4 @@
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
import { ArtifactsSettingsPane } from './ArtifactsSettingsPane'
import { AutomationsSettingsPane } from './AutomationsSettingsPane'
import { GeneralPane } from './GeneralPane'
@@ -176,3 +177,28 @@ export function renderShareSkillsSettingsSection(
</SettingsSection>
)
}
export function renderSessionHistorySettingsSection(
context: SettingsRenderContext
): React.JSX.Element {
const { model, navigation, view } = context
return (
<SettingsSection
id="session-history"
title={translate('sessionHistory.settings.title', 'Agent Session History')}
description={translate(
'sessionHistory.settings.description',
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
)}
searchEntries={navigation.getSectionSearchEntries('session-history')}
>
{view.isSectionMounted('session-history') ? (
<SessionHistorySettingsPane
key={JSON.stringify(model.settings.aiVaultSearch)}
settings={model.settings}
updateSettings={model.updateSettingsOrThrow}
/>
) : null}
</SettingsSection>
)
}
@@ -15,6 +15,7 @@ import {
BookOpen,
CalendarClock,
Files,
History,
GitBranch,
Globe,
ListChecks,
@@ -68,6 +69,25 @@ export function buildWorkflowSettingsSections(
group: 'workflows',
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
},
{
id: 'session-history',
title: translate('sessionHistory.settings.title', 'Agent Session History'),
description: translate(
'sessionHistory.settings.description',
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
),
icon: History,
searchEntries: [
{
title: translate('sessionHistory.settings.enable', 'Enable session history search'),
description: translate(
'sessionHistory.settings.searchDescription',
'Transcript indexing, index status and delete index.'
)
}
],
group: 'workflows'
},
{
id: 'git',
title: translate(
+35
View File
@@ -17845,6 +17845,41 @@
"finished": "finished"
}
},
"sessionHistory": {
"status": {
"checking": "Checking index…",
"off": "Search is off. Any existing index copy is kept.",
"error": "Could not read index status. Retrying…",
"unavailable": "Index is not ready or the search service is unavailable.",
"title": "Index status",
"firstScan": "Indexing… {{indexed}} files so far",
"progress": "Indexing · {{percent}}% · {{indexed}} of {{total}} files",
"upToDate": "Up to date · {{indexed}} files indexed",
"unreadable": "{{failed}} files could not be read and will be retried.",
"stopHint": "Turn off search to stop. Progress is kept and resumes when you turn it back on.",
"roots": "Unverified source roots: {{roots}}"
},
"settings": {
"saveError": "Could not save session search settings. Try again.",
"enableTitle": "Start indexing agent sessions?",
"enableConsent": "Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.",
"enableConfirm": "Start indexing",
"advanced": "Advanced",
"deleteIndexCopy": "Delete index copy",
"deleteTitle": "Delete this computers search index?",
"deleteEnabled": "Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.",
"deleteDisabled": "Remove the search index from this computer. Original transcripts are not touched. Search stays off.",
"delete": "Delete index",
"cleared": "Search index cleared. Original transcripts were kept.",
"clearError": "Could not clear the index. Try again.",
"enable": "Enable session history search",
"webUnsupported": "Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.",
"consent": "Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.",
"title": "Agent Session History",
"description": "Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.",
"searchDescription": "Transcript indexing, index status and delete index."
}
},
"aiVault": {
"subagents": {
"loading": "Loading subagents…",
@@ -39,6 +39,7 @@ const SETTINGS_NAV_TARGETS = [
'agents',
'orchestration',
'artifacts',
'session-history',
'share-skills',
'automations',
'orca-account',
@@ -39,6 +39,8 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
addressesOwnRuntime(executionHostScope)
? search.searchStatus()
: Promise.resolve(unavailableSessionSearchStatus()),
clearSearchIndex: () =>
Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')),
listSessions: (args?: AiVaultListArgs) => {
const environment = requireActiveEnvironment()
const executionHostId = toRuntimeExecutionHostId(environment.id)