From 0497e6fe93ab61ef7eb0176fdedbbe1db5f08af3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 14 Sep 2026 18:33:09 -0400 Subject: [PATCH] feat(settings): connected computers rows for session history indexing Agent Session History now lists every computer that can hold an index -- this computer first, then each paired Orca server -- as one row with an icon, a name, a single status line and its own switch. Indexing consent is stated once above the list, and each row carries the switch for the host it names, so turning search on for a server no longer means finding that server's own settings. Server rows poll aiVault.searchStatus on the same 2s/10s cadence as the local one while the pane is visible, and report what the host actually answered: Off, a sweep in progress, or an up-to-date count. A server that is not connected stays listed but dimmed, with its last known switch position and no claim about its index. A host that refuses the set call with host-too-old flips to an update prompt that links to Remote Servers. The old "Enable session history search" switch and the separate index-status row are gone; their status copy moved to session-history-status-copy.ts and their polling to use-session-search-status.ts, so every row shares one message builder and one poll. Advanced > Delete index copy is unchanged and still local-only, and a paired web client still sees this computer alone. window.api.aiVault.setSearchEnabled is declared and bridged here but implemented by the parallel backend PR. --- src/preload/api/ai-vault-api.ts | 8 + src/preload/api/ai-vault-bridge.ts | 2 + .../SessionHistoryComputerRow.test.tsx | 246 ++++++++++++++++++ .../settings/SessionHistoryComputerRow.tsx | 88 +++++++ .../settings/SessionHistoryIndexStatus.tsx | 149 ----------- .../settings/SessionHistoryServerRow.tsx | 173 ++++++++++++ .../SessionHistorySettingsPane.test.tsx | 64 ++++- .../settings/SessionHistorySettingsPane.tsx | 182 ++++++++----- .../settings/session-history-status-copy.ts | 96 +++++++ ...tings-setup-workflow-section-renderers.tsx | 3 +- ...tsx => use-session-search-status.test.tsx} | 137 ++++++---- .../settings/use-session-search-status.ts | 76 ++++++ .../settings-navigation-workflow-sections.ts | 6 +- src/renderer/src/i18n/locales/en.json | 19 +- .../src/web/preload-api/web-ai-vault-api.ts | 2 + 15 files changed, 972 insertions(+), 279 deletions(-) create mode 100644 src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx create mode 100644 src/renderer/src/components/settings/SessionHistoryComputerRow.tsx delete mode 100644 src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx create mode 100644 src/renderer/src/components/settings/SessionHistoryServerRow.tsx create mode 100644 src/renderer/src/components/settings/session-history-status-copy.ts rename src/renderer/src/components/settings/{SessionHistoryIndexStatus.test.tsx => use-session-search-status.test.tsx} (57%) create mode 100644 src/renderer/src/components/settings/use-session-search-status.ts diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index cf82217ec17..4feba040ca3 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,6 +33,14 @@ export type AiVaultApi = { ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise + /** + * Turns indexing on or off on one host and answers with that host's status. + * Rejects with `host-too-old` when the server predates this RPC. + */ + setSearchEnabled: ( + executionHostId: ExecutionHostId, + enabled: boolean + ) => Promise /** Deletes and rebuilds this desktop's local search index. */ clearSearchIndex: () => Promise listSessions: (args?: AiVaultListArgs) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 68feb382223..8c975074ebe 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -42,6 +42,8 @@ export const aiVaultApi = { searchClient(executionHostScope).searchSessions(request), searchStatus: (executionHostScope?: ExecutionHostId) => searchClient(executionHostScope).searchStatus(), + setSearchEnabled: (executionHostId: ExecutionHostId, enabled: boolean) => + ipcRenderer.invoke('aiVault:setSearchEnabled', executionHostId, enabled), clearSearchIndex: (): Promise => ipcRenderer.invoke('aiVault:clearSearchIndex'), listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => diff --git a/src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx b/src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx new file mode 100644 index 00000000000..f23bf549584 --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx @@ -0,0 +1,246 @@ +// @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 { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client' +import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' +import { SessionHistoryComputerRow } from './SessionHistoryComputerRow' +import { SessionHistoryServerRow } from './SessionHistoryServerRow' +import type { RuntimeHostDetails } from './runtime-environment-host-details' + +const mocks = vi.hoisted(() => ({ + visible: true, + status: vi.fn(), + setEnabled: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: 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])) +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget + }) +})) + +const runningIndex: AiVaultSearchStatus = { + ...unavailableSessionSearchStatus(), + enabled: true, + phase: 'current', + filesIndexed: 4_880, + lastSweepCompletedAt: 1 +} +const environment: PublicKnownRuntimeEnvironment = { + id: 'env-1', + name: 'build-box', + createdAt: 0, + updatedAt: 0, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'e1', kind: 'websocket', label: 'lan', endpoint: 'wss://build-box' }], + preferredEndpointId: 'e1' +} +function connectedDetails(appVersion = '1.4.202'): RuntimeHostDetails { + const runtimeStatus: RuntimeStatus = { + runtimeId: 'runtime-1', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 1, + liveLeafCount: 1, + appVersion + } + return { + status: 'ready', + runtimeStatus, + remoteControl: null, + compatibility: { kind: 'ok', clientProtocolVersion: 1, serverProtocolVersion: 1 }, + error: null + } +} +function serverRow( + details: RuntimeHostDetails | undefined, + confirm = vi.fn().mockResolvedValue(true), + onError = vi.fn() +) { + return render( + + + + ) +} +const serverSwitch = (): HTMLElement => + screen.getByRole('switch', { name: 'Index sessions on build-box' }) + +beforeEach(() => { + vi.useFakeTimers() + mocks.visible = true + mocks.status.mockReset().mockResolvedValue(runningIndex) + mocks.setEnabled.mockReset().mockResolvedValue(runningIndex) + mocks.openSettingsPage.mockReset() + mocks.openSettingsTarget.mockReset() + vi.stubGlobal('api', undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { searchStatus: mocks.status, setSearchEnabled: mocks.setEnabled } } + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('renders a computer as icon, name, version, one status line and a switch', () => { + render( + + ) + expect(screen.getByText('build-box')).toBeInTheDocument() + expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') + expect(screen.getByText('2 files could not be read and will be retried.')).toBeInTheDocument() + expect(serverSwitch()).toHaveAttribute('aria-checked', 'true') +}) + +it('omits the status line when this client cannot read the host index', () => { + render( + + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() +}) + +it('reports a reachable server by its live index status and version', async () => { + serverRow(connectedDetails()) + await act(async () => {}) + expect(mocks.status).toHaveBeenCalledWith('runtime:env-1') + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 4880 files indexed') + expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument() + expect(serverSwitch()).toHaveAttribute('aria-checked', 'true') +}) + +it('calls a server with indexing switched off Off rather than unavailable', async () => { + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + serverRow(connectedDetails()) + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent('Off') + expect(serverSwitch()).toHaveAttribute('aria-checked', 'false') +}) + +it('keeps an offline server dimmed, disabled and honest about its index', async () => { + serverRow({ + status: 'error', + runtimeStatus: null, + remoteControl: null, + compatibility: null, + error: 'unreachable' + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(mocks.status).not.toHaveBeenCalled() + expect(screen.getByRole('status')).toHaveTextContent('Offline · index kept as it was') + expect(serverSwitch()).toBeDisabled() + expect(serverSwitch()).toHaveAttribute('aria-checked', 'false') +}) + +it('asks for consent naming the server before enabling it', async () => { + const confirm = vi.fn().mockResolvedValue(true) + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + serverRow(connectedDetails(), confirm) + await act(async () => {}) + await act(async () => { + fireEvent.click(serverSwitch()) + }) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Start indexing agent sessions on build-box?', + description: expect.stringContaining('This client only receives search results'), + confirmLabel: 'Start indexing' + }) + ) + expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', true) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 4880 files indexed') +}) + +it('leaves a server untouched when the consent is declined', async () => { + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + serverRow(connectedDetails(), vi.fn().mockResolvedValue(false)) + await act(async () => {}) + await act(async () => { + fireEvent.click(serverSwitch()) + }) + expect(mocks.setEnabled).not.toHaveBeenCalled() +}) + +it('turns a server off without asking again', async () => { + const confirm = vi.fn().mockResolvedValue(true) + serverRow(connectedDetails(), confirm) + await act(async () => {}) + mocks.setEnabled.mockResolvedValue(unavailableSessionSearchStatus()) + await act(async () => { + fireEvent.click(serverSwitch()) + }) + expect(confirm).not.toHaveBeenCalled() + expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', false) + expect(screen.getByRole('status')).toHaveTextContent('Off') +}) + +it('turns a host-too-old rejection into the update-server state', async () => { + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + mocks.setEnabled.mockRejectedValue( + new Error("Error invoking remote method 'aiVault:setSearchEnabled': Error: host-too-old") + ) + const onError = vi.fn() + serverRow(connectedDetails('1.4.190'), vi.fn().mockResolvedValue(true), onError) + await act(async () => {}) + await act(async () => { + fireEvent.click(serverSwitch()) + }) + expect(screen.getByRole('status')).toHaveTextContent( + 'Update this server to enable session search' + ) + expect(serverSwitch()).toBeDisabled() + expect(onError).not.toHaveBeenCalledWith(expect.stringContaining('Could not change')) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Update server' })) + }) + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + sectionId: 'env-1' + }) +}) + +it('surfaces any other failure to change a server through the pane alert', async () => { + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + mocks.setEnabled.mockRejectedValue(new Error('relay down')) + const onError = vi.fn() + serverRow(connectedDetails(), vi.fn().mockResolvedValue(true), onError) + await act(async () => {}) + await act(async () => { + fireEvent.click(serverSwitch()) + }) + expect(onError).toHaveBeenLastCalledWith( + 'Could not change session search on build-box. Try again.' + ) + expect(serverSwitch()).toBeEnabled() +}) diff --git a/src/renderer/src/components/settings/SessionHistoryComputerRow.tsx b/src/renderer/src/components/settings/SessionHistoryComputerRow.tsx new file mode 100644 index 00000000000..1d0cf63ed8a --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryComputerRow.tsx @@ -0,0 +1,88 @@ +import { Laptop, Server } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { SettingsSwitch } from './SettingsFormControls' + +const NO_DETAILS: readonly string[] = [] + +export type SessionHistoryComputerRowProps = { + kind: 'local' | 'server' + name: string + /** Paired servers only; a host that never answered has no version to claim. */ + version?: string | null + /** Empty when this client cannot read the host's index at all, as in the browser. */ + status?: string + details?: readonly string[] + /** An offline or too-old computer is still listed, just visibly out of play. */ + dimmed?: boolean + checked: boolean + disabled?: boolean + onToggle: () => void + action?: { label: string; onClick: () => void } | null +} + +export function SessionHistoryComputerRow({ + kind, + name, + version, + status, + details = NO_DETAILS, + dimmed = false, + checked, + disabled = false, + onToggle, + action = null +}: SessionHistoryComputerRowProps): React.JSX.Element { + const Icon = kind === 'local' ? Laptop : Server + return ( +
+ +
+
+ {name} + {version ? ( + + {translate('sessionHistory.settings.serverVersion', 'Orca v{{version}}', { version })} + + ) : null} +
+ {status ? ( +

+ {status} + {action ? ( + + ) : null} +

+ ) : null} + {details.map((line) => ( +

+ {line} +

+ ))} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx b/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx deleted file mode 100644 index 5d9b407b9f5..00000000000 --- a/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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/SessionHistoryServerRow.tsx b/src/renderer/src/components/settings/SessionHistoryServerRow.tsx new file mode 100644 index 00000000000..b69b7a23830 --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryServerRow.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRuntimeServerConnectionState, + isRuntimeServerTransportConnected, + type RuntimeHostDetails +} from './runtime-environment-host-details' +import { SessionHistoryComputerRow } from './SessionHistoryComputerRow' +import { + sessionSearchCheckingMessage, + sessionSearchReadErrorMessage, + sessionSearchStatusDetails, + sessionSearchStatusMessage +} from './session-history-status-copy' +import { useSessionSearchStatus } from './use-session-search-status' + +// IPC wraps a rejection's message, so the host-too-old marker arrives inside a longer string. +function isHostTooOldError(error: unknown): boolean { + return error instanceof Error && error.message.includes('host-too-old') +} + +export function SessionHistoryServerRow({ + environment, + details, + onError +}: { + environment: PublicKnownRuntimeEnvironment + details: RuntimeHostDetails | undefined + onError: (message: string | null) => void +}): React.JSX.Element { + const hostId = toRuntimeExecutionHostId(environment.id) + const confirm = useConfirmationDialog() + const mounted = useMountedRef() + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const [tooOld, setTooOld] = useState(false) + const [busy, setBusy] = useState(false) + const connectionState = getRuntimeServerConnectionState(details) + const connected = isRuntimeServerTransportConnected(connectionState) + const { status, failed, adopt } = useSessionSearchStatus({ + executionHostId: hostId, + active: connected && !tooOld + }) + const enabled = status?.enabled === true + + async function setEnabled(next: boolean): Promise { + setBusy(true) + onError(null) + try { + adopt(await window.api.aiVault.setSearchEnabled(hostId, next)) + } catch (error) { + if (!mounted.current) { + return + } + if (isHostTooOldError(error)) { + setTooOld(true) + return + } + onError( + translate( + 'sessionHistory.settings.serverToggleError', + 'Could not change session search on {{host}}. Try again.', + { host: environment.name } + ) + ) + } finally { + if (mounted.current) { + setBusy(false) + } + } + } + + async function toggle(): Promise { + if (enabled) { + await setEnabled(false) + return + } + setBusy(true) + let accepted = false + try { + accepted = await confirm({ + title: translate( + 'sessionHistory.settings.serverEnableTitle', + 'Start indexing agent sessions on {{host}}?', + { host: environment.name } + ), + description: translate( + 'sessionHistory.settings.serverEnableConsent', + 'Orca will build a search index on {{host}}. This client only receives search results. 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.', + { host: environment.name } + ), + confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing') + }) + } finally { + if (mounted.current) { + setBusy(false) + } + } + if (!accepted || !mounted.current) { + return + } + await setEnabled(true) + } + + function openServerSettings(): void { + openSettingsPage() + openSettingsTarget({ pane: 'servers', repoId: null, sectionId: environment.id }) + } + + const row = { + kind: 'server' as const, + name: environment.name, + version: details?.runtimeStatus?.appVersion ?? null, + onToggle: () => void toggle() + } + if (tooOld) { + return ( + + ) + } + if (!connected) { + // Checking is not yet evidence of an unreachable host, so it does not claim the index was left behind. + const checking = connectionState === 'checking' + return ( + + ) + } + let statusText = sessionSearchCheckingMessage() + if (failed) { + statusText = sessionSearchReadErrorMessage() + } else if (status) { + statusText = enabled + ? sessionSearchStatusMessage(status) + : translate('sessionHistory.settings.serverOff', 'Off') + } + return ( + + ) +} diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx index 5e0ad8ffbae..2a54152f2c0 100644 --- a/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx @@ -8,7 +8,31 @@ import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-typ 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() })) +const mocks = vi.hoisted(() => { + const environments: { id: string; name: string }[] = [] + return { + web: false, + visible: true, + status: vi.fn(), + clear: vi.fn(), + setEnabled: vi.fn(), + environments + } +}) +vi.mock('./use-runtime-environment-catalog', () => ({ + useRuntimeEnvironmentCatalog: () => ({ + environments: mocks.environments, + isLoading: false, + detailsByEnvironmentId: {}, + setDetailsByEnvironmentId: vi.fn(), + mountedRef: { current: true }, + loadEnvironments: vi.fn() + }) +})) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ openSettingsPage: vi.fn(), openSettingsTarget: vi.fn() }) +})) vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web })) vi.mock('@/hooks/use-window-stream-visibility', () => ({ useWindowStreamVisible: () => mocks.visible @@ -53,12 +77,20 @@ beforeEach(() => { vi.useFakeTimers() mocks.web = false mocks.visible = true + mocks.environments = [] mocks.status.mockReset().mockResolvedValue(current) mocks.clear.mockReset().mockResolvedValue(undefined) + mocks.setEnabled.mockReset().mockResolvedValue(current) vi.stubGlobal('api', undefined) Object.defineProperty(window, 'api', { configurable: true, - value: { aiVault: { searchStatus: mocks.status, clearSearchIndex: mocks.clear } } + value: { + aiVault: { + searchStatus: mocks.status, + clearSearchIndex: mocks.clear, + setSearchEnabled: mocks.setEnabled + } + } }) }) afterEach(() => { @@ -208,3 +240,31 @@ it('keeps the last index status visible while a save is in flight', async () => finishSave() }) }) + +it('lists one row per paired Orca server under this computer, and says where SSH stands', async () => { + mocks.environments = [ + { id: 'env-1', name: 'build-box' }, + { id: 'env-2', name: 'office-mini' } + ] + pane(true) + await act(async () => {}) + const switches = screen.getAllByRole('switch') + expect(switches).toHaveLength(3) + expect(screen.getByRole('switch', { name: 'Index sessions on build-box' })).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Index sessions on office-mini' })).toBeInTheDocument() + expect(mocks.status).toHaveBeenCalledWith('local') + expect( + screen.getByText('SSH hosts appear here once indexing is available on SSH.') + ).toBeInTheDocument() +}) + +it('offers only this computer to a paired client, with no server rows', async () => { + mocks.web = true + mocks.environments = [{ id: 'env-1', name: 'build-box' }] + pane(true) + await act(async () => {}) + expect(screen.getAllByRole('switch')).toHaveLength(1) + expect(screen.getByRole('switch')).toBeDisabled() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(mocks.status).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx index c3d1326faa3..6f9882d916d 100644 --- a/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx @@ -6,14 +6,29 @@ import { AiVaultSearchSettingsSchema, resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings' +import { + getLocalExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Label } from '@/components/ui/label' 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' +import { SettingsRow } from './SettingsFormControls' +import { SessionHistoryComputerRow } from './SessionHistoryComputerRow' +import { SessionHistoryServerRow } from './SessionHistoryServerRow' +import { + sessionSearchCheckingMessage, + sessionSearchOffMessage, + sessionSearchReadErrorMessage, + sessionSearchStatusDetails, + sessionSearchStatusMessage +} from './session-history-status-copy' +import { useSessionSearchStatus } from './use-session-search-status' +import { useRuntimeEnvironmentCatalog } from './use-runtime-environment-catalog' export function SessionHistorySettingsPane({ settings, @@ -29,6 +44,12 @@ export function SessionHistorySettingsPane({ const [error, setError] = useState(null) const [refresh, setRefresh] = useState(0) const [advancedOpen, setAdvancedOpen] = useState(false) + const { environments, detailsByEnvironmentId } = useRuntimeEnvironmentCatalog() + const localRead = useSessionSearchStatus({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + active: policy.enabled && !isWebClient, + refresh + }) const mounted = useRef(true) useEffect(() => { mounted.current = true @@ -134,69 +155,104 @@ export function SessionHistorySettingsPane({ } } + // A stale answer from before the switch went off must not keep reporting progress. + const localStatus = policy.enabled ? localRead.status : null + let localStatusText = sessionSearchOffMessage() + if (policy.enabled) { + localStatusText = localRead.failed + ? sessionSearchReadErrorMessage() + : localStatus + ? sessionSearchStatusMessage(localStatus) + : sessionSearchCheckingMessage() + } + return ( -
- void toggleEnabled()} - /> - {!isWebClient ? ( - - ) : null} - - - + + + void deleteIndex()} + > + {translate('sessionHistory.settings.delete', 'Delete index')} + + } /> - - - - void deleteIndex()} - > - {translate('sessionHistory.settings.delete', 'Delete index')} - - } - /> - - - {error ? ( -

- {error} -

- ) : null} + + + {error ? ( +

+ {error} +

+ ) : null} +
+

+ {translate( + 'sessionHistory.settings.sshNote', + 'SSH hosts appear here once indexing is available on SSH.' + )} +

) } diff --git a/src/renderer/src/components/settings/session-history-status-copy.ts b/src/renderer/src/components/settings/session-history-status-copy.ts new file mode 100644 index 00000000000..c4d834b670e --- /dev/null +++ b/src/renderer/src/components/settings/session-history-status-copy.ts @@ -0,0 +1,96 @@ +import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types' +import { translate } from '@/i18n/i18n' + +export const SESSION_SEARCH_SWEEPING_POLL_MS = 2_000 +export const SESSION_SEARCH_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. +export function isSweepingSessionSearch(status: AiVaultSearchStatus | null): boolean { + if (!status?.enabled) { + return false + } + return status.phase === 'indexing' || (status.phase === 'degraded' && status.filesDue > 0) +} + +export function sessionSearchPollIntervalMs(status: AiVaultSearchStatus | null): number { + return isSweepingSessionSearch(status) + ? SESSION_SEARCH_SWEEPING_POLL_MS + : SESSION_SEARCH_SETTLED_POLL_MS +} + +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 } + ) +} + +/** The one status sentence every computer row shows, local or paired server. */ +export function sessionSearchStatusMessage(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 (isSweepingSessionSearch(status)) { + return sweepMessage(status) + } + return translate('sessionHistory.status.upToDate', 'Up to date · {{indexed}} files indexed', { + indexed: status.filesIndexed + }) +} + +/** Lines shown under the status sentence while a host is actually indexing. */ +export function sessionSearchStatusDetails(status: AiVaultSearchStatus | null): string[] { + if (!status?.enabled) { + return [] + } + const lines: string[] = [] + if (status.phase === 'degraded' && status.filesFailed > 0) { + lines.push( + translate( + 'sessionHistory.status.unreadable', + '{{failed}} files could not be read and will be retried.', + { failed: status.filesFailed } + ) + ) + } + if (isSweepingSessionSearch(status)) { + lines.push( + translate( + 'sessionHistory.status.stopHint', + 'Turn off search to stop. Progress is kept and resumes when you turn it back on.' + ) + ) + } + if (status.degradedRoots.length > 0) { + lines.push( + translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', { + roots: status.degradedRoots.length + }) + ) + } + return lines +} + +export function sessionSearchCheckingMessage(): string { + return translate('sessionHistory.status.checking', 'Checking index…') +} + +export function sessionSearchOffMessage(): string { + return translate('sessionHistory.status.off', 'Search is off. Any existing index copy is kept.') +} + +export function sessionSearchReadErrorMessage(): string { + return translate('sessionHistory.status.error', 'Could not read index status. Retrying…') +} 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 9dec6f96733..861df90842d 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 @@ -188,13 +188,12 @@ export function renderSessionHistorySettingsSection( 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.' + 'Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.' )} searchEntries={navigation.getSectionSearchEntries('session-history')} > {view.isSectionMounted('session-history') ? ( diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx b/src/renderer/src/components/settings/use-session-search-status.test.tsx similarity index 57% rename from src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx rename to src/renderer/src/components/settings/use-session-search-status.test.tsx index c47b02a9fd9..179612e38bd 100644 --- a/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx +++ b/src/renderer/src/components/settings/use-session-search-status.test.tsx @@ -1,10 +1,15 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, renderHook } 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' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + sessionSearchStatusDetails, + sessionSearchStatusMessage +} from './session-history-status-copy' +import { useSessionSearchStatus } from './use-session-search-status' const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() })) vi.mock('@/hooks/use-window-stream-visibility', () => ({ @@ -22,6 +27,20 @@ const current: AiVaultSearchStatus = { filesIndexed: 12, lastSweepCompletedAt: 1 } +function poll(active = true, refresh = 0) { + return renderHook( + (props: { active: boolean; refresh: number }) => + useSessionSearchStatus({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + active: props.active, + refresh: props.refresh + }), + { initialProps: { active, refresh } } + ) +} +function message(status: AiVaultSearchStatus | null): string { + return status ? sessionSearchStatusMessage(status) : 'no status read yet' +} beforeEach(() => { vi.useFakeTimers() mocks.visible = true @@ -39,15 +58,15 @@ afterEach(() => { }) it('keeps polling a settled index so counts stay live between sweeps', async () => { - render() + const view = poll() await act(async () => {}) expect(mocks.status).toHaveBeenCalledWith('local') - expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') + expect(message(view.result.current.status)).toBe('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') + expect(message(view.result.current.status)).toBe('Up to date · 30 files indexed') }) it('reports a first scan by count and later sweeps by percentage', async () => { @@ -58,10 +77,12 @@ it('reports a first scan by count and later sweeps by percentage', async () => { filesDue: 6, lastSweepCompletedAt: null }) - render() + const view = poll() await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far') - expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop') + expect(message(view.result.current.status)).toBe('Indexing… 4 files so far') + expect(sessionSearchStatusDetails(view.result.current.status)).toContain( + 'Turn off search to stop. Progress is kept and resumes when you turn it back on.' + ) mocks.status.mockResolvedValue({ ...current, phase: 'indexing', @@ -73,12 +94,12 @@ it('reports a first scan by count and later sweeps by percentage', async () => { await act(async () => { await vi.advanceTimersByTimeAsync(2_000) }) - expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files') + expect(message(view.result.current.status)).toBe('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() + poll() await act(async () => {}) const started = mocks.status.mock.calls.length await act(async () => { @@ -96,12 +117,14 @@ it('names unreadable files while degraded and still reports progress', async () filesFailed: 1, degradedRoots: [{ reason: 'unreadable' }] }) - render() + const view = poll() 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') + expect(message(view.result.current.status)).toBe('Indexing · 80% · 8 of 10 files') + expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([ + '1 files could not be read and will be retried.', + 'Turn off search to stop. Progress is kept and resumes when you turn it back on.', + 'Unverified source roots: 1' + ]) }) it('calls a drained degraded index up to date', async () => { @@ -112,78 +135,82 @@ it('calls a drained degraded index up to date', async () => { filesDue: 0, filesFailed: 2 }) - render() + const view = poll() 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() + expect(message(view.result.current.status)).toBe('Up to date · 9 files indexed') + expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([ + '2 files could not be read and will be retried.' + ]) }) it('does not describe an absent service as an empty current index', async () => { mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) - render() + const view = poll() await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent( - 'not ready or the search service is unavailable' + expect(message(view.result.current.status)).toBe( + 'Index is 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() + const view = poll() await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') + expect(view.result.current.failed).toBe(true) + expect(view.result.current.status).toBeNull() await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) - expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') + expect(view.result.current.failed).toBe(false) + expect(view.result.current.status).toEqual(current) }) it('handles a synchronous bridge failure without losing the poll', async () => { mocks.status.mockImplementationOnce(() => { throw new Error('bridge unavailable') }) - render() + const view = poll() await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') + expect(view.result.current.failed).toBe(true) await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) - expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') + expect(view.result.current.status).toEqual(current) }) -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() +it('stops polling while inactive or hidden and re-reads on a refresh bump', async () => { mocks.visible = false - view.rerender() + const view = poll() await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) - expect(mocks.status).toHaveBeenCalledTimes(1) + expect(mocks.status).not.toHaveBeenCalled() mocks.visible = true - mocks.status.mockResolvedValue(current) - view.rerender() + view.rerender({ active: false, refresh: 0 }) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.status).not.toHaveBeenCalled() + view.rerender({ active: true, refresh: 1 }) await act(async () => {}) - expect(mocks.status).toHaveBeenCalledTimes(2) + expect(mocks.status).toHaveBeenCalledTimes(1) +}) + +it('keeps the last answer when a host goes inactive so an offline row reports it', async () => { + const view = poll() + await act(async () => {}) + view.rerender({ active: false, refresh: 0 }) + expect(view.result.current.status).toEqual(current) + expect(view.result.current.failed).toBe(false) +}) + +it('adopts a status handed to it without waiting for the next poll', async () => { + const view = poll(false) + act(() => { + view.result.current.adopt({ ...current, filesIndexed: 99 }) + }) + expect(view.result.current.status?.filesIndexed).toBe(99) + expect(mocks.status).not.toHaveBeenCalled() }) it('does not overlap slow status requests and stops polling on unmount', async () => { @@ -193,7 +220,7 @@ it('does not overlap slow status requests and stops polling on unmount', async ( answer = resolve }) ) - const view = render() + const view = poll() await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) diff --git a/src/renderer/src/components/settings/use-session-search-status.ts b/src/renderer/src/components/settings/use-session-search-status.ts new file mode 100644 index 00000000000..717397314f3 --- /dev/null +++ b/src/renderer/src/components/settings/use-session-search-status.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react' +import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { useWindowStreamVisible } from '@/hooks/use-window-stream-visibility' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +import { sessionSearchPollIntervalMs } from './session-history-status-copy' + +export type SessionSearchStatusRead = { + status: AiVaultSearchStatus | null + failed: boolean + /** Adopt a status the caller already holds, e.g. the answer to a set call. */ + adopt: (status: AiVaultSearchStatus) => void +} + +/** + * Polls one host's index status while the pane is visible: fast during a sweep, + * slow once settled. An inactive host keeps its last answer, which is what an + * offline row reports rather than inventing "off". + */ +export function useSessionSearchStatus(args: { + executionHostId: ExecutionHostId + active: boolean + refresh?: number +}): SessionSearchStatusRead { + const { executionHostId, active } = args + const refresh = args.refresh ?? 0 + const visible = useWindowStreamVisible(0) + const [status, setStatus] = useState(null) + const [failed, setFailed] = useState(false) + const intervalMs = sessionSearchPollIntervalMs(status) + const adopt = useCallback((next: AiVaultSearchStatus) => { + setStatus(next) + setFailed(false) + }, []) + + useEffect(() => { + if (!active) { + 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(executionHostId) + ) + 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() + } + }, [executionHostId, active, visible, refresh, intervalMs]) + + return { status, failed, adopt } +} diff --git a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts index 57ea41d0143..5e4414a2434 100644 --- a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts @@ -74,15 +74,15 @@ export function buildWorkflowSettingsSections( 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.' + 'Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.' ), icon: History, searchEntries: [ { - title: translate('sessionHistory.settings.enable', 'Enable session history search'), + title: translate('sessionHistory.settings.indexComputers', 'Index agent sessions'), description: translate( 'sessionHistory.settings.searchDescription', - 'Transcript indexing, index status and delete index.' + 'Transcript indexing on this computer and paired servers, index status and delete index.' ) } ], diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3bbdc04ef5d..c946b49fb87 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17908,7 +17908,6 @@ "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", @@ -17929,12 +17928,22 @@ "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." + "description": "Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.", + "searchDescription": "Transcript indexing on this computer and paired servers, index status and delete index.", + "indexComputers": "Index agent sessions", + "computersConsent": "Each computer keeps a local index of its own transcripts, including conversation text and tool output as written. Content is not redacted. Turning a computer off stops indexing and keeps its index.", + "sshNote": "SSH hosts appear here once indexing is available on SSH.", + "serverVersion": "Orca v{{version}}", + "rowSwitchLabel": "Index sessions on {{host}}", + "serverOffline": "Offline · index kept as it was", + "serverTooOld": "Update this server to enable session search.", + "updateServer": "Update server", + "serverOff": "Off", + "serverEnableTitle": "Start indexing agent sessions on {{host}}?", + "serverEnableConsent": "Orca will build a search index on {{host}}. This client only receives search results. 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.", + "serverToggleError": "Could not change session search on {{host}}. Try again." } } } 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 d87e88d63bc..fea7dbe8836 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()), + // A browser never owns an index, so it cannot consent on a host's behalf. + setSearchEnabled: () => Promise.reject(new Error('unsupported')), clearSearchIndex: () => Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')), listSessions: (args?: AiVaultListArgs) => {