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.
This commit is contained in:
Jinwoo-H
2026-09-14 18:33:09 -04:00
parent 2895c96f61
commit 0497e6fe93
15 changed files with 972 additions and 279 deletions
+8
View File
@@ -33,6 +33,14 @@ export type AiVaultApi = {
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
/**
* 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<AiVaultSearchStatus>
/** Deletes and rebuilds this desktop's local search index. */
clearSearchIndex: () => Promise<void>
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
+2
View File
@@ -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<void> => ipcRenderer.invoke('aiVault:clearSearchIndex'),
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
@@ -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<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => 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(
<ConfirmationDialogContext.Provider value={confirm}>
<SessionHistoryServerRow environment={environment} details={details} onError={onError} />
</ConfirmationDialogContext.Provider>
)
}
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(
<SessionHistoryComputerRow
kind="server"
name="build-box"
version="1.4.202"
status="Up to date · 12 files indexed"
details={['2 files could not be read and will be retried.']}
checked
onToggle={vi.fn()}
/>
)
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(
<SessionHistoryComputerRow kind="local" name="Local Mac" checked={false} onToggle={vi.fn()} />
)
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()
})
@@ -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 (
<div className={cn('flex items-center gap-3 py-3', dimmed && 'opacity-60')}>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate text-sm font-medium">{name}</span>
{version ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{translate('sessionHistory.settings.serverVersion', 'Orca v{{version}}', { version })}
</span>
) : null}
</div>
{status ? (
<p role="status" className="text-xs text-muted-foreground">
{status}
{action ? (
<Button
type="button"
variant="link"
size="xs"
className="ml-1 h-auto p-0 align-baseline text-xs"
onClick={action.onClick}
>
{action.label}
</Button>
) : null}
</p>
) : null}
{details.map((line) => (
<p key={line} className="text-xs text-muted-foreground">
{line}
</p>
))}
</div>
<div className="shrink-0">
<SettingsSwitch
checked={checked}
disabled={disabled}
onChange={onToggle}
ariaLabel={translate(
'sessionHistory.settings.rowSwitchLabel',
'Index sessions on {{host}}',
{
host: name
}
)}
/>
</div>
</div>
)
}
@@ -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<AiVaultSearchStatus | null>(null)
const [failed, setFailed] = useState(false)
const intervalMs = isSweeping(status) ? SWEEPING_POLL_MS : SETTLED_POLL_MS
useEffect(() => {
if (!enabled) {
setStatus(null)
setFailed(false)
return
}
if (!visible) {
return
}
let disposed = false
let inFlight = false
async function read(): Promise<void> {
if (inFlight || disposed) {
return
}
inFlight = true
try {
const next = await Promise.resolve().then(() =>
window.api.aiVault.searchStatus(LOCAL_EXECUTION_HOST_ID)
)
if (!disposed) {
setStatus(next)
setFailed(false)
}
} catch {
if (!disposed) {
setStatus(null)
setFailed(true)
}
} finally {
inFlight = false
}
}
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
return () => {
disposed = true
stopPolling()
}
}, [enabled, visible, refresh, intervalMs])
let message = translate('sessionHistory.status.checking', 'Checking index…')
if (!enabled) {
message = translate(
'sessionHistory.status.off',
'Search is off. Any existing index copy is kept.'
)
} else if (failed) {
message = translate('sessionHistory.status.error', 'Could not read index status. Retrying…')
} else if (status) {
message = statusMessage(status)
}
const live = enabled && status?.enabled === true
return (
<SettingsRow
label={translate('sessionHistory.status.title', 'Index status')}
description={
<span role="status" className="space-y-1 block">
<span className="block">{message}</span>
{live && status.phase === 'degraded' && status.filesFailed > 0 ? (
<span className="block">
{translate(
'sessionHistory.status.unreadable',
'{{failed}} files could not be read and will be retried.',
{ failed: status.filesFailed }
)}
</span>
) : null}
{live && isSweeping(status) ? (
<span className="block text-muted-foreground">
{translate(
'sessionHistory.status.stopHint',
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
)}
</span>
) : null}
{live && status.degradedRoots.length > 0 ? (
<span className="block">
{translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', {
roots: status.degradedRoots.length
})}
</span>
) : null}
</span>
}
control={null}
/>
)
}
@@ -0,0 +1,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<void> {
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<void> {
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 (
<SessionHistoryComputerRow
{...row}
dimmed
checked={false}
disabled
status={translate(
'sessionHistory.settings.serverTooOld',
'Update this server to enable session search.'
)}
action={{
label: translate('sessionHistory.settings.updateServer', 'Update server'),
onClick: openServerSettings
}}
/>
)
}
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 (
<SessionHistoryComputerRow
{...row}
dimmed={!checking}
checked={enabled}
disabled
status={
checking
? sessionSearchCheckingMessage()
: translate('sessionHistory.settings.serverOffline', 'Offline · index kept as it was')
}
/>
)
}
let statusText = sessionSearchCheckingMessage()
if (failed) {
statusText = sessionSearchReadErrorMessage()
} else if (status) {
statusText = enabled
? sessionSearchStatusMessage(status)
: translate('sessionHistory.settings.serverOff', 'Off')
}
return (
<SessionHistoryComputerRow
{...row}
checked={enabled}
disabled={busy}
status={statusText}
details={sessionSearchStatusDetails(status)}
/>
)
}
@@ -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<string, unknown>) => 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()
})
@@ -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<string | null>(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 (
<div className="divide-y divide-border">
<SettingsSwitchRow
label={translate('sessionHistory.settings.enable', 'Enable session history search')}
description={
isWebClient
? translate(
'sessionHistory.settings.webUnsupported',
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
)
: translate(
'sessionHistory.settings.consent',
'Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.'
)
}
checked={policy.enabled}
disabled={busy || isWebClient}
onChange={() => void toggleEnabled()}
/>
{!isWebClient ? (
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
) : null}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="pt-2">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="-ml-2 text-xs">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
<div className="space-y-3">
<div className="divide-y divide-border">
<div className="space-y-1 py-3">
<Label className="select-text">
{translate('sessionHistory.settings.indexComputers', 'Index agent sessions')}
</Label>
<p className="select-text text-xs text-muted-foreground">
{isWebClient
? translate(
'sessionHistory.settings.webUnsupported',
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
)
: translate(
'sessionHistory.settings.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.'
)}
</p>
</div>
<SessionHistoryComputerRow
kind="local"
name={getLocalExecutionHostLabel()}
checked={policy.enabled}
disabled={busy || isWebClient}
onToggle={() => void toggleEnabled()}
{...(isWebClient
? {}
: { status: localStatusText, details: sessionSearchStatusDetails(localStatus) })}
/>
{isWebClient
? null
: environments.map((environment) => (
<SessionHistoryServerRow
key={environment.id}
environment={environment}
details={detailsByEnvironmentId[environment.id]}
onError={setError}
/>
))}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="pt-2">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="-ml-2 text-xs">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
description={
policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
)
}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void deleteIndex()}
>
{translate('sessionHistory.settings.delete', 'Delete index')}
</Button>
}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
description={
policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
)
}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void deleteIndex()}
>
{translate('sessionHistory.settings.delete', 'Delete index')}
</Button>
}
/>
</CollapsibleContent>
</Collapsible>
{error ? (
<p role="alert" className="pt-3 text-xs text-destructive">
{error}
</p>
) : null}
</CollapsibleContent>
</Collapsible>
{error ? (
<p role="alert" className="pt-3 text-xs text-destructive">
{error}
</p>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'sessionHistory.settings.sshNote',
'SSH hosts appear here once indexing is available on SSH.'
)}
</p>
</div>
)
}
@@ -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…')
}
@@ -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') ? (
<SessionHistorySettingsPane
key={JSON.stringify(model.settings.aiVaultSearch)}
settings={model.settings}
updateSettings={model.updateSettingsOrThrow}
/>
@@ -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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
await act(async () => {
answer(current)
})
expect(screen.getByRole('status')).toHaveTextContent('Search is off')
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
it('stops polling while inactive or hidden and re-reads on a refresh bump', async () => {
mocks.visible = false
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={1} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
const view = poll()
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
@@ -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<AiVaultSearchStatus | null>(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<void> {
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 }
}
@@ -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.'
)
}
],
+14 -5
View File
@@ -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."
}
}
}
@@ -39,6 +39,8 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['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) => {