diff --git a/docs/reference/agent-session-search-contract.md b/docs/reference/agent-session-search-contract.md index 1b21cd06531..dd813c0b498 100644 --- a/docs/reference/agent-session-search-contract.md +++ b/docs/reference/agent-session-search-contract.md @@ -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. diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts index 836842b9160..f713d8d2a3f 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -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 diff --git a/src/main/ai-vault-search/session-search-index-generation.test.ts b/src/main/ai-vault-search/session-search-index-generation.test.ts index f728759c0e4..5a6536b24e4 100644 --- a/src/main/ai-vault-search/session-search-index-generation.test.ts +++ b/src/main/ai-vault-search/session-search-index-generation.test.ts @@ -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() } diff --git a/src/main/ai-vault-search/session-search-index-generation.ts b/src/main/ai-vault-search/session-search-index-generation.ts index ee10eb0e295..a2d44d83e19 100644 --- a/src/main/ai-vault-search/session-search-index-generation.ts +++ b/src/main/ai-vault-search/session-search-index-generation.ts @@ -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 +} diff --git a/src/main/ai-vault-search/session-search-instance.test.ts b/src/main/ai-vault-search/session-search-instance.test.ts index c917bd2d8a9..3f0c4ff3880 100644 --- a/src/main/ai-vault-search/session-search-instance.test.ts +++ b/src/main/ai-vault-search/session-search-instance.test.ts @@ -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) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts index 30e07fc9a6e..8d5b60c73e5 100644 --- a/src/main/ai-vault-search/session-search-page-cursor.ts +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -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) } diff --git a/src/main/ai-vault-search/session-search-paging.test.ts b/src/main/ai-vault-search/session-search-paging.test.ts index 31341853e28..56bd7e5ce2c 100644 --- a/src/main/ai-vault-search/session-search-paging.test.ts +++ b/src/main/ai-vault-search/session-search-paging.test.ts @@ -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', () => { diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts index ca525c47c0e..d6fb6787bae 100644 --- a/src/main/ai-vault-search/session-search-schema.ts +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -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) diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts index df89600934c..82898d20f2f 100644 --- a/src/main/ai-vault/session-scanner-service-protocol.ts +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -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 = new Set ) } +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' diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts index d0c3394883d..4cd2723a198 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -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' }) diff --git a/src/main/ai-vault/session-scanner-service-search.ts b/src/main/ai-vault/session-scanner-service-search.ts index deab1c26beb..3bbff1db15f 100644 --- a/src/main/ai-vault/session-scanner-service-search.ts +++ b/src/main/ai-vault/session-scanner-service-search.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index d841fc62593..68b476d277a 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -109,6 +109,10 @@ export function reconcileSessionSearchInService(): Promise { return getSharedClient().request({ type: 'request', operation: 'searchReconcile' }) } +export function clearSessionSearchInService(): Promise { + 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) diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 8f771179c0c..53ced42e400 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -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 Promise>(), 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) => @@ -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', diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 9acb668ee14..65bcf27efab 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -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()) } /** diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 4c008eee527..624a9caa778 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,6 +33,8 @@ export type AiVaultApi = { ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise + /** Deletes and rebuilds this desktop's local search index. */ + clearSearchIndex: () => Promise listSessions: (args?: AiVaultListArgs) => Promise resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise cancelListSessions: (args: { requestToken: string }) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 7d882a0989a..73536ab0af5 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -33,6 +33,7 @@ export const aiVaultApi = { searchClient(executionHostScope).searchSessions(request), searchStatus: (executionHostScope?: ExecutionHostId) => searchClient(executionHostScope).searchStatus(), + clearSearchIndex: (): Promise => ipcRenderer.invoke('aiVault:clearSearchIndex'), listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => ipcRenderer.invoke('aiVault:resolveSessionTitles', args), diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx b/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx new file mode 100644 index 00000000000..c47b02a9fd9 --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx @@ -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) => + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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((resolve) => { + answer = resolve + }) + ) + const view = render() + view.rerender() + 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() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.status).toHaveBeenCalledTimes(1) + mocks.visible = true + mocks.status.mockResolvedValue(current) + view.rerender() + 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((resolve) => { + answer = resolve + }) + ) + const view = render() + 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) +}) diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx b/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx new file mode 100644 index 00000000000..5d9b407b9f5 --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx @@ -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(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 { + 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 ( + + {message} + {live && status.phase === 'degraded' && status.filesFailed > 0 ? ( + + {translate( + 'sessionHistory.status.unreadable', + '{{failed}} files could not be read and will be retried.', + { failed: status.filesFailed } + )} + + ) : null} + {live && isSweeping(status) ? ( + + {translate( + 'sessionHistory.status.stopHint', + 'Turn off search to stop. Progress is kept and resumes when you turn it back on.' + )} + + ) : null} + {live && status.degradedRoots.length > 0 ? ( + + {translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', { + roots: status.degradedRoots.length + })} + + ) : null} + + } + control={null} + /> + ) +} diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx new file mode 100644 index 00000000000..5e0ad8ffbae --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx @@ -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) => + 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( + + + + ) +} +async function openAdvanced(): Promise { + 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 computer’s 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((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((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() + }) +}) diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx new file mode 100644 index 00000000000..f407e6800bf --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx @@ -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) => Promise +}): React.JSX.Element { + const policy = resolveAiVaultSearchSettings(settings) + const isWebClient = isWebClientLocation() + const confirm = useConfirmationDialog() + const [busy, setBusy] = useState(false) + const [error, setError] = useState(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): Promise { + 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 { + 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 { + setBusy(true) + setError(null) + try { + const accepted = await confirm({ + title: translate( + 'sessionHistory.settings.deleteTitle', + 'Delete this computer’s 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 ( +
+ void toggleEnabled()} + /> + {!isWebClient ? ( + + ) : null} + + + + + + void deleteIndex()} + > + {translate('sessionHistory.settings.delete', 'Delete index')} + + } + /> + + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/settings-page-renderer.tsx b/src/renderer/src/components/settings/settings-page-renderer.tsx index 1c302cada17..4fb00229072 100644 --- a/src/renderer/src/components/settings/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/settings-page-renderer.tsx @@ -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)} diff --git a/src/renderer/src/components/settings/settings-setup-workflow-section-renderers.tsx b/src/renderer/src/components/settings/settings-setup-workflow-section-renderers.tsx index 409ba4aedb1..9dec6f96733 100644 --- a/src/renderer/src/components/settings/settings-setup-workflow-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-setup-workflow-section-renderers.tsx @@ -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( ) } + +export function renderSessionHistorySettingsSection( + context: SettingsRenderContext +): React.JSX.Element { + const { model, navigation, view } = context + return ( + + {view.isSectionMounted('session-history') ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts index 9d3fbf9ad28..57ea41d0143 100644 --- a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts @@ -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( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 277b5c626eb..2d98321730a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 computer’s 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…", diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 065371a88ae..7a0300f4935 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -39,6 +39,7 @@ const SETTINGS_NAV_TARGETS = [ 'agents', 'orchestration', 'artifacts', + 'session-history', 'share-skills', 'automations', 'orca-account', diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index dcab8f9dbb0..eb28193e272 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -39,6 +39,8 @@ export function createWebAiVaultApi(): NonNullable['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)