feat(settings): connected computers rows for session history indexing (#20887)

* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

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

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

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

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* 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.

(cherry picked from commit 0497e6fe93)

* fix(settings): treat a host-too-old status read as an outdated server

(cherry picked from commit a3d751f6e7)

* fix(settings): turn search off before deleting its index

Delete index cleared the index while search was on, so the host closed, removed and immediately reconstructed it and everything reindexed. Turn local search off first, then clear, so the rebuild only happens when the user switches search back on.

(cherry picked from commit 0515588681)

* feat(settings): product-facing copy for session search

Say search, not index or transcript; lead with what the user gets and
where it shows up; one plain privacy sentence; count sessions, not files;
drop the mechanics that change no decision (stop hint, SSH note, source
roots jargon).

(cherry picked from commit 267af1afb3)

* fix(settings): let Button and Collapsible own their spacing and type

* fix(settings): let Button and Collapsible own their spacing and type

* feat(session-search): report how many messages an index holds

The status contract gains an optional messagesIndexed, read from the store
beside the file-state counts and cached the same way, so a settings row can
say what is searchable rather than how many files were opened. Optional on
the wire: a paired server that predates the field degrades to a session count.

* feat(right-sidebar): let a caller open the session panel ready to type

showAiVaultSearch opens the sidebar on the vault tab and sets one flag. The
panel takes the flag, widens its scope to every computer, focuses the search
box and clears the flag, so a later remount stays where the user left it.

* feat(settings): redesign Agent Session Search for many computers

Renames the pane, splits the list into this computer and paired Orca
servers, and puts a count of what is on above it with a Turn on all that
skips offline and too-old hosts and keeps going past a host that refuses.
Consenting once persists a standing consent so a server that later becomes
reachable turns on without another dialog; turning one off by hand drops it.
Rows past the sixth fold away, ordered by what the user can act on. Status
sentences now say how much is searchable instead of Ready, and an off
computer says so with its switch alone.

* fix(settings): hide the fleet roll-up when no server is paired

With only this computer, the count, the Turn on all button and the two
subheads all restate the single switch under them. Show them once a paired
Orca server exists, which is the first point at which they say anything.

* fix(settings): turn session search on without a confirmation dialog

Each switch and Turn on all now act on the click. The dialogs restated the
row they sat under and stood between the user and a preference they can
reverse with the same control. Clearing search data keeps its dialog: that
one destroys something.

* fix(settings): say how many computers Turn on would reach

Drops the summary sentence: every row already states whether it is offline
or needs an update, so counting those again above the list said nothing new.
What is left is the one thing the list cannot say, the size of the action,
carried by the button's own label. With nothing left to turn on, the
standing consent speaks in its place, and only when it is armed.

* Revert "fix(settings): say how many computers Turn on would reach"

This reverts commit 42a4320ae1. The roll-up row's design is still open, so
the branch keeps the summary sentence and the plain Turn on all button until
it is settled. The dialog removal in 30b0786cc6 stands.

* feat(settings): offer one stateless Enable on all computers button

The row above the list is now just that button. It appears when a paired
server is reachable, new enough and off, acts on exactly those plus this
computer, and disappears when there is nothing left to do. What it offers is
read off the rows each render, so it cannot disagree with them.

Deletes the standing auto-enable consent with it: the persisted flag, the
code that armed and cleared it, the per-host memory of which switches the
user had touched, and the line that promised future computers would turn
themselves on. A preference that acts on hosts the user never sees is worse
than a button they press when they mean it.

* fix(right-sidebar): keep the focus-request callback out of render

React Doctor flagged the ref written during render; useEffectEvent is the
codebase's pattern for a latest-callback the effect reads.
This commit is contained in:
Jinwoo Hong
2026-09-16 13:31:26 -04:00
committed by GitHub
parent aee98ccaa0
commit 740887fbbb
29 changed files with 2096 additions and 559 deletions
@@ -61,6 +61,14 @@ function newIndexer(
return indexer
}
/** What the index holds, counted the way `status()` counts it. */
function indexedMessageCount(): number {
const row = harness.read((db: SyncDatabase) =>
db.prepare('SELECT count(*) AS n FROM messages').get()
)
return row && typeof row === 'object' && 'n' in row && typeof row.n === 'number' ? row.n : -1
}
/** Sessions a published-view read returns for one term, the only legal shape. */
function sessionsMatching(term: string): string[] {
return harness.read((db: SyncDatabase) =>
@@ -256,6 +264,9 @@ it('resumes after close and reopen without re-reading what it already indexed',
// `filesIndexed` is the count of rows the index holds at their current stat,
// so it stays 2. That nothing was opened again is the read loop's own test.
expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 })
// The same number the pane shows as "messages searchable", read from the rows rather than counted as they land.
expect(indexedMessageCount()).toBeGreaterThan(0)
expect(reopened.status().messagesIndexed).toBe(indexedMessageCount())
expect(
harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get())
).toEqual(indexedRows)
@@ -40,6 +40,8 @@ export type SessionSearchIndexStatus = {
filesDue: number
/** Rows whose last read did not commit. */
filesFailed: number
/** Messages the index holds across every indexed row. */
messagesIndexed: number
degradedRoots: SessionSearchDegradedRoot[]
lastReconcileAt: number | null
/** When a whole-machine sweep last finished; null until one has. */
@@ -198,13 +200,15 @@ export class SessionSearchIndexer {
current: 0,
due: 0,
failed: 0,
sessionsByAgent: {}
sessionsByAgent: {},
messages: 0
}
return {
phase: this.phase(settled),
filesIndexed: settled.current,
filesDue: settled.due + this.left,
filesFailed: settled.failed,
messagesIndexed: settled.messages,
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
lastReconcileAt: this.lastReconcileAt,
lastSweepCompletedAt: this.lastSweepCompletedAt,
@@ -24,7 +24,8 @@ async function fixture() {
// degradedRoots is re-stated because the contract type leaves `root` optional
// for relay redaction, while the indexer always names the root it degraded.
const indexer = {
status: () => ({ ...status, degradedRoots: [], sessionsByAgent: {} }),
// messagesIndexed is optional on the wire and required of an indexer, which has read the rows.
status: () => ({ ...status, messagesIndexed: 0, degradedRoots: [], sessionsByAgent: {} }),
reconcile: vi.fn(async () => {})
}
const service = createSessionSearchService({ engine: harness.engine, indexer })
@@ -42,7 +42,7 @@ export type SessionSearchFileRow = {
failedMtimeMs: number | null
}
/** How many rows are in each state; the whole of the indexer's progress report. */
/** How many rows are in each state, sessions per agent, and the indexed message total; the whole of the indexer's progress report. */
export type SessionSearchStateCounts = {
current: number
due: number
@@ -56,6 +56,7 @@ export type SessionSearchStateCounts = {
* file-state count could show it.
*/
sessionsByAgent: Record<string, number>
messages: number
}
/**
@@ -280,7 +281,7 @@ export class SessionSearchStore {
}
}
/** Rows per state. The status is this query and the pass's own degraded roots. */
/** Rows per state and indexed messages. The status is these queries and the pass's own degraded roots. */
stateCounts(): SessionSearchStateCounts {
const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as {
state: SessionSearchFileState
@@ -290,11 +291,13 @@ export class SessionSearchStore {
current: 0,
due: 0,
failed: 0,
sessionsByAgent: this.sessionsByAgent()
sessionsByAgent: this.sessionsByAgent(),
messages: 0
}
for (const row of rows) {
counts[row.state] = Number(row.n)
}
counts.messages = this.messageCount()
return counts
}
@@ -314,6 +317,15 @@ export class SessionSearchStore {
return counts
}
/** Messages the index holds. Read with the file states so both describe one moment. */
private messageCount(): number {
const row: unknown = this.db.prepare('SELECT count(*) AS n FROM messages').get()
if (row && typeof row === 'object' && 'n' in row && typeof row.n === 'number') {
return row.n
}
return 0
}
/**
* Drops a source's rows. Only a proven deletion may call this: an unreadable
* source is `unverifiable`, not `missing`, and keeps its rows
@@ -48,6 +48,7 @@ import {
buildRuntimeAiVaultHostScopeOptions,
useAiVaultExecutionHostScope
} from './ai-vault-host-scope'
import { useAiVaultSearchFocusRequest } from './use-ai-vault-search-focus-request'
import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options'
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
@@ -298,6 +299,11 @@ export default function AiVaultPanel(): React.JSX.Element {
setScope(nextScope)
}, [])
// Settings asks for "everything, ready to type".
const focusSearchRequestId = useAiVaultSearchFocusRequest(
useCallback(() => handleScopeChange('all'), [handleScopeChange])
)
const toggleGroup = useCallback((key: string) => {
setCollapsedGroups((current) => {
const next = new Set(current)
@@ -332,6 +338,7 @@ export default function AiVaultPanel(): React.JSX.Element {
hideEmptySessions={hideEmptySessions}
sessionLimit={sessionLimit}
adjustmentCount={viewAdjustmentCount}
focusSearchRequestId={focusSearchRequestId}
onQueryChange={setQuery}
onScopeChange={handleScopeChange}
onExecutionHostScopeChange={onExecutionHostScopeChange}
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
vi.mock('./AiVaultPanelControls', () => ({
VaultHostScopeMenu: () => null,
VaultScopeSwitch: () => null,
VaultViewMenu: () => null
}))
afterEach(cleanup)
function header(focusSearchRequestId: number) {
return render(
<AiVaultPanelHeader
query=""
loading={false}
shownCount={0}
sessionCount={0}
hasScanResult={false}
activeWorktreePath={null}
activeProjectKey={null}
scope="all"
executionHostScope="local"
hostScopeOptions={[]}
agents={[]}
sort="updated"
group="project"
hideEmptySessions={false}
sessionLimit={250}
adjustmentCount={0}
focusSearchRequestId={focusSearchRequestId}
onQueryChange={vi.fn()}
onScopeChange={vi.fn()}
onExecutionHostScopeChange={vi.fn()}
onAgentEnabledChange={vi.fn()}
onAllAgentsEnabledChange={vi.fn()}
onSortChange={vi.fn()}
onGroupChange={vi.fn()}
onHideEmptySessionsChange={vi.fn()}
onSessionLimitChange={vi.fn()}
onReset={vi.fn()}
onRefresh={vi.fn()}
/>
)
}
it('leaves focus alone until someone asks for the search box', () => {
header(0)
expect(screen.getByRole('textbox', { name: 'Search sessions' })).not.toHaveFocus()
})
it('focuses the search box when Settings sends the user here', () => {
header(1)
expect(screen.getByRole('textbox', { name: 'Search sessions' })).toHaveFocus()
})
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import { LoaderCircle, RefreshCw, Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
@@ -30,6 +31,8 @@ type AiVaultPanelHeaderProps = {
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
adjustmentCount: number
/** Bumped by a caller that sent the user here, e.g. Settings; focuses the search box once. */
focusSearchRequestId?: number
onQueryChange: (query: string) => void
onScopeChange: (scope: AiVaultScope) => void
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
@@ -61,6 +64,7 @@ export function AiVaultPanelHeader({
hideEmptySessions,
sessionLimit,
adjustmentCount,
focusSearchRequestId = 0,
onQueryChange,
onScopeChange,
onExecutionHostScopeChange,
@@ -73,6 +77,13 @@ export function AiVaultPanelHeader({
onReset,
onRefresh
}: AiVaultPanelHeaderProps): React.JSX.Element {
const searchInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (focusSearchRequestId > 0) {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}, [focusSearchRequestId])
return (
<div className="shrink-0 border-b border-sidebar-border px-2.5 py-2">
<div className="flex items-center gap-1.5">
@@ -173,6 +184,7 @@ export function AiVaultPanelHeader({
<div className="mt-2 flex h-8 items-center gap-1.5 rounded-md border border-sidebar-border bg-input/50 px-2 focus-within:border-sidebar-ring focus-within:ring-[2px] focus-within:ring-sidebar-ring/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input
ref={searchInputRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={translate(
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useAiVaultSearchFocusRequest } from './use-ai-vault-search-focus-request'
afterEach(() => {
cleanup()
useAppStore.getState().clearAiVaultSearchFocusRequest()
})
it('opens the sidebar on the session panel when Settings asks for it', () => {
act(() => {
useAppStore.getState().showAiVaultSearch()
})
const state = useAppStore.getState()
expect(state.rightSidebarOpen).toBe(true)
expect(state.rightSidebarTab).toBe('vault')
expect(state.aiVaultSearchFocusRequested).toBe(true)
})
it('widens the scope once and clears the request so a remount stays put', () => {
const onRequest = vi.fn()
const view = renderHook(() => useAiVaultSearchFocusRequest(onRequest))
expect(view.result.current).toBe(0)
act(() => {
useAppStore.getState().showAiVaultSearch()
})
expect(onRequest).toHaveBeenCalledOnce()
expect(view.result.current).toBe(1)
expect(useAppStore.getState().aiVaultSearchFocusRequested).toBe(false)
view.unmount()
const remounted = renderHook(() => useAiVaultSearchFocusRequest(onRequest))
expect(onRequest).toHaveBeenCalledOnce()
expect(remounted.result.current).toBe(0)
})
@@ -0,0 +1,27 @@
import { useEffect, useEffectEvent, useState } from 'react'
import { useAppStore } from '@/store'
/**
* Consumes Settings' "open the panel, ready to type" request.
*
* Returns an id the header focuses its box on, and runs `onRequest` once so the
* panel can widen its own scope. The request is cleared as it is taken, so a
* later remount of the panel stays where the user left it.
*/
export function useAiVaultSearchFocusRequest(onRequest: () => void): number {
const [focusRequestId, setFocusRequestId] = useState(0)
const requested = useAppStore((state) => state.aiVaultSearchFocusRequested)
const clearRequest = useAppStore((state) => state.clearAiVaultSearchFocusRequest)
const runRequest = useEffectEvent(onRequest)
useEffect(() => {
if (!requested) {
return
}
runRequest()
setFocusRequestId((value) => value + 1)
clearRequest()
}, [clearRequest, requested])
return focusRequestId
}
@@ -0,0 +1,277 @@
// @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'
import type { SessionSearchComputerState } from './session-search-computer-rollup'
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,
messagesIndexed: 1_400_000,
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(),
onStateChange?: (environmentId: string, state: SessionSearchComputerState) => void
) {
return render(
<ConfirmationDialogContext.Provider value={confirm}>
<SessionHistoryServerRow
environment={environment}
details={details}
onError={onError}
{...(onStateChange ? { onStateChange } : {})}
/>
</ConfirmationDialogContext.Provider>
)
}
const serverSwitch = (): HTMLElement =>
screen.getByRole('switch', { name: 'Search 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="12 sessions · 1.4K messages searchable"
details={['2 sessions 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('12 sessions · 1.4K messages searchable')
expect(screen.getByText('2 sessions 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('4,880 sessions · 1.4M messages searchable')
expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument()
expect(serverSwitch()).toHaveAttribute('aria-checked', 'true')
})
it('leaves a reachable server with search off to its switch, with no status sentence', async () => {
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
serverRow(connectedDetails())
await act(async () => {})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
})
it('publishes each server state the pane counts and orders by', async () => {
const onStateChange = vi.fn()
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
serverRow(connectedDetails(), undefined, undefined, onStateChange)
await act(async () => {})
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'off')
mocks.status.mockResolvedValue(runningIndex)
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'on')
})
it('publishes offline and needs-update states without claiming either too early', async () => {
const onStateChange = vi.fn()
const { unmount } = serverRow(undefined, undefined, undefined, onStateChange)
await act(async () => {})
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'checking')
unmount()
serverRow(
{
status: 'error',
runtimeStatus: null,
remoteControl: null,
compatibility: null,
error: 'unreachable'
},
undefined,
undefined,
onStateChange
)
await act(async () => {})
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'offline')
cleanup()
mocks.status.mockRejectedValue(new Error("Error invoking remote method 'x': Error: host-too-old"))
serverRow(connectedDetails('1.4.190'), undefined, undefined, onStateChange)
await act(async () => {})
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'needs-update')
})
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')
expect(serverSwitch()).toBeDisabled()
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
})
it('turns a server on straight from its switch, with nothing to confirm', 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).not.toHaveBeenCalled()
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', true)
expect(screen.getByRole('status')).toHaveTextContent('4,880 sessions · 1.4M messages searchable')
})
it('turns a server off straight from its switch', 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.queryByRole('status')).not.toBeInTheDocument()
})
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('Needs a newer version of Orca.')
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,90 @@
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 border-t border-border 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 align-baseline"
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',
'Search sessions on {{host}}',
{
host: name
}
)}
/>
</div>
</div>
)
}
@@ -1,210 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() }))
vi.mock('@/hooks/use-window-stream-visibility', () => ({
useWindowStreamVisible: () => mocks.visible
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
const current: AiVaultSearchStatus = {
...unavailableSessionSearchStatus(),
enabled: true,
phase: 'current',
filesIndexed: 12,
lastSweepCompletedAt: 1
}
beforeEach(() => {
vi.useFakeTimers()
mocks.visible = true
mocks.status.mockReset().mockResolvedValue(current)
vi.stubGlobal('api', undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { aiVault: { searchStatus: mocks.status } }
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('keeps polling a settled index so counts stay live between sweeps', async () => {
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(mocks.status).toHaveBeenCalledWith('local')
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 })
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 30 files indexed')
})
it('reports a first scan by count and later sweeps by percentage', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 6,
lastSweepCompletedAt: null
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far')
expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop')
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 5,
filesFailed: 1,
lastSweepCompletedAt: 1
})
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files')
})
it('polls a sweep faster than a settled index', async () => {
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
const started = mocks.status.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000)
})
expect(mocks.status.mock.calls.length - started).toBe(3)
})
it('names unreadable files while degraded and still reports progress', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 8,
filesDue: 1,
filesFailed: 1,
degradedRoots: [{ reason: 'unreadable' }]
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Indexing · 80% · 8 of 10 files')
expect(status).toHaveTextContent('1 files could not be read and will be retried.')
expect(status).toHaveTextContent('Unverified source roots: 1')
})
it('calls a drained degraded index up to date', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 9,
filesDue: 0,
filesFailed: 2
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 9 files indexed')
expect(screen.getByRole('status')).toHaveTextContent('2 files could not be read')
expect(screen.queryByText(/Turn off search to stop/)).not.toBeInTheDocument()
})
it('offers no refresh control now that status is live', async () => {
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('does not describe an absent service as an empty current index', async () => {
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent(
'not ready or the search service is unavailable'
)
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
})
it('recovers on its own after a failed read', async () => {
mocks.status.mockRejectedValueOnce(new Error('offline'))
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
})
it('handles a synchronous bridge failure without losing the poll', async () => {
mocks.status.mockImplementationOnce(() => {
throw new Error('bridge unavailable')
})
render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
})
it('fences pending responses across disable and hiding', async () => {
let answer: (value: AiVaultSearchStatus) => void = () => undefined
mocks.status.mockReturnValue(
new Promise<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
await act(async () => {
answer(current)
})
expect(screen.getByRole('status')).toHaveTextContent('Search is off')
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
mocks.visible = false
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
mocks.visible = true
mocks.status.mockResolvedValue(current)
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
await act(async () => {})
expect(mocks.status).toHaveBeenCalledTimes(2)
})
it('does not overlap slow status requests and stops polling on unmount', async () => {
let answer: (value: AiVaultSearchStatus) => void = () => undefined
mocks.status.mockReturnValue(
new Promise<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
await act(async () => {
answer({ ...current, phase: 'indexing', filesDue: 2 })
})
const beforeUnmount = mocks.status.mock.calls.length
view.unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
})
@@ -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,180 @@
import { useEffect, useState } from 'react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
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 type { SessionSearchComputerState } from './session-search-computer-rollup'
import {
isHostTooOldError,
sessionSearchCheckingMessage,
sessionSearchReadErrorMessage,
sessionSearchStatusDetails,
sessionSearchStatusMessage
} from './session-history-status-copy'
import { useSessionSearchStatus } from './use-session-search-status'
export function SessionHistoryServerRow({
environment,
details,
refresh = 0,
onError,
onStateChange
}: {
environment: PublicKnownRuntimeEnvironment
details: RuntimeHostDetails | undefined
/** Bumped by the pane after it changes this host from outside the row. */
refresh?: number
onError: (message: string | null) => void
/** Lets the pane count and order computers it does not itself poll. */
onStateChange?: (environmentId: string, state: SessionSearchComputerState) => void
}): React.JSX.Element {
const hostId = toRuntimeExecutionHostId(environment.id)
const mounted = useMountedRef()
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const [tooOldOnSet, setTooOldOnSet] = useState(false)
const [busy, setBusy] = useState(false)
const connectionState = getRuntimeServerConnectionState(details)
const connected = isRuntimeServerTransportConnected(connectionState)
const { status, failed, hostTooOld, adopt } = useSessionSearchStatus({
executionHostId: hostId,
active: connected && !tooOldOnSet,
refresh
})
// A status read or a set call can each prove the server predates session search.
const tooOld = tooOldOnSet || hostTooOld
const enabled = status?.enabled === true
const state = resolveServerState({
tooOld,
connected,
checking: connectionState === 'checking',
enabled,
answered: Boolean(status)
})
useEffect(() => {
onStateChange?.(environment.id, state)
}, [environment.id, onStateChange, state])
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)) {
setTooOldOnSet(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)
}
}
}
function toggle(): Promise<void> {
return setEnabled(!enabled)
}
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', 'Needs a newer version of Orca.')}
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')
}
/>
)
}
// An off computer says so with its switch; a sentence repeating it is noise.
let statusText: string | undefined = sessionSearchCheckingMessage()
if (failed) {
statusText = sessionSearchReadErrorMessage()
} else if (status) {
statusText = enabled ? sessionSearchStatusMessage(status) : undefined
}
return (
<SessionHistoryComputerRow
{...row}
checked={enabled}
disabled={busy}
{...(statusText === undefined ? {} : { status: statusText })}
details={sessionSearchStatusDetails(status)}
/>
)
}
/** What the pane needs to count and order this row, from what the row already knows. */
function resolveServerState(args: {
tooOld: boolean
connected: boolean
checking: boolean
enabled: boolean
answered: boolean
}): SessionSearchComputerState {
if (args.tooOld) {
return 'needs-update'
}
// A probe still in flight is not evidence of an unreachable host.
if (args.checking) {
return 'checking'
}
if (!args.connected) {
return 'offline'
}
if (!args.answered) {
return 'checking'
}
return args.enabled ? 'on' : 'off'
}
@@ -2,13 +2,49 @@
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 { toast } from 'sonner'
import { getDefaultSettings } from '../../../../shared/constants'
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
const mocks = vi.hoisted(() => ({ web: false, visible: true, status: vi.fn(), clear: vi.fn() }))
const mocks = vi.hoisted(() => {
const environments: { id: string; name: string }[] = []
const statusByHost: Record<string, unknown> = {}
const details: Record<string, unknown> = {}
return {
web: false,
visible: true,
status: vi.fn(),
statusByHost,
clear: vi.fn(),
setEnabled: vi.fn(),
environments,
details,
closeSettingsPage: vi.fn(),
showAiVaultSearch: vi.fn()
}
})
vi.mock('./use-runtime-environment-catalog', () => ({
useRuntimeEnvironmentCatalog: () => ({
environments: mocks.environments,
isLoading: false,
detailsByEnvironmentId: mocks.details,
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(),
closeSettingsPage: mocks.closeSettingsPage,
showAiVaultSearch: mocks.showAiVaultSearch
})
}))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
vi.mock('@/hooks/use-window-stream-visibility', () => ({
useWindowStreamVisible: () => mocks.visible
@@ -37,6 +73,42 @@ function pane(
</ConfirmationDialogContext.Provider>
)
}
const CONNECTED_DETAILS = {
status: 'ready',
runtimeStatus: {
runtimeId: 'runtime-1',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 1,
liveLeafCount: 1,
appVersion: '1.4.202'
},
remoteControl: null,
compatibility: { kind: 'ok', clientProtocolVersion: 1, serverProtocolVersion: 1 },
error: null
}
const OFFLINE_DETAILS = {
status: 'error',
runtimeStatus: null,
remoteControl: null,
compatibility: null,
error: 'unreachable'
}
/** Answers status per host so one pane can hold servers in different states. */
function statusByHost(): void {
mocks.status.mockImplementation(async (hostId: string) => {
const answer = mocks.statusByHost[hostId]
if (answer === undefined) {
return unavailableSessionSearchStatus()
}
if (answer === 'too-old') {
throw new Error("Error invoking remote method 'x': Error: host-too-old")
}
return answer
})
}
const enableAllButton = () => screen.queryByRole('button', { name: 'Enable on all computers' })
async function openAdvanced(): Promise<void> {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Advanced/ }))
@@ -47,18 +119,33 @@ const current: AiVaultSearchStatus = {
enabled: true,
phase: 'current',
filesIndexed: 12,
messagesIndexed: 3_400,
lastSweepCompletedAt: 1
}
const off: AiVaultSearchStatus = unavailableSessionSearchStatus()
beforeEach(() => {
vi.useFakeTimers()
mocks.web = false
mocks.visible = true
mocks.environments = []
mocks.details = {}
mocks.statusByHost = {}
mocks.closeSettingsPage.mockReset()
mocks.showAiVaultSearch.mockReset()
mocks.status.mockReset().mockResolvedValue(current)
mocks.clear.mockReset().mockResolvedValue(undefined)
mocks.setEnabled.mockReset().mockResolvedValue(current)
vi.mocked(toast.success).mockClear()
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(() => {
@@ -67,12 +154,12 @@ afterEach(() => {
vi.unstubAllGlobals()
})
it('requires opt-in and saves the existing policy without touching transcripts or polling while off', async () => {
it('turns search on from the switch alone, touching no transcript while it is off', async () => {
const save = vi.fn().mockResolvedValue(undefined)
const confirm = vi.fn().mockResolvedValue(true)
pane(false, confirm, save)
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
expect(screen.getByText(/Content is not redacted/)).toBeInTheDocument()
expect(screen.getByText(/Nothing leaves that computer/)).toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
@@ -80,27 +167,21 @@ it('requires opt-in and saves the existing policy without touching transcripts o
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Start indexing agent sessions?',
description: expect.stringContaining('content is not redacted'),
confirmLabel: 'Start indexing'
})
)
expect(confirm).not.toHaveBeenCalled()
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
})
it('leaves search off when the indexing consent is declined', async () => {
const save = vi.fn().mockResolvedValue(undefined)
pane(false, vi.fn().mockResolvedValue(false), save)
it('sends the user to the sidebar panel with one click', async () => {
pane(true)
await act(async () => {})
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
})
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
expect(mocks.showAiVaultSearch).toHaveBeenCalledOnce()
expect(mocks.closeSettingsPage).toHaveBeenCalledOnce()
})
it('turns search off without asking again', async () => {
it('turns search off from the switch alone', async () => {
const confirm = vi.fn().mockResolvedValue(true)
const save = vi.fn().mockResolvedValue(undefined)
pane(true, confirm, save)
@@ -133,10 +214,10 @@ it('shows failed saves inline and unlocks controls', async () => {
it('hides the delete control behind Advanced', async () => {
pane(false)
expect(screen.queryByRole('button', { name: 'Delete index' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
await openAdvanced()
expect(screen.getByRole('button', { name: 'Delete index' })).toBeInTheDocument()
expect(screen.getByText(/Search stays off/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
expect(screen.getByText(/Removes the searchable copy/)).toBeInTheDocument()
})
it('deletes only after confirmation, supports deleting while disabled, and reports failures', async () => {
@@ -144,25 +225,68 @@ it('deletes only after confirmation, supports deleting while disabled, and repor
pane(false, confirm)
await openAdvanced()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
})
expect(mocks.clear).not.toHaveBeenCalled()
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Delete this computers search index?',
description: expect.stringContaining('Search stays off'),
confirmLabel: 'Delete index'
title: 'Clear search data on this computer?',
description: expect.stringContaining('Removes the searchable copy'),
confirmLabel: 'Clear'
})
)
confirm.mockResolvedValue(true)
mocks.clear.mockRejectedValue(new Error('service unavailable'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
})
expect(mocks.clear).toHaveBeenCalledOnce()
expect(screen.getByRole('alert')).toHaveTextContent('Could not clear')
})
it('turns search off before deleting so the host does not rebuild the index', async () => {
const order: string[] = []
const save = vi.fn().mockImplementation(async () => {
order.push('save')
})
mocks.clear.mockImplementation(async () => {
order.push('clear')
})
pane(true, vi.fn().mockResolvedValue(true), save)
await openAdvanced()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
})
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: false, historyDays: null } })
expect(order).toEqual(['save', 'clear'])
expect(screen.getAllByText(/Turns off search and removes/).length).toBeGreaterThan(0)
expect(toast.success).toHaveBeenCalledWith('Search turned off and search data cleared.')
})
it('deletes without a settings write when search is already off', async () => {
const save = vi.fn().mockResolvedValue(undefined)
pane(false, vi.fn().mockResolvedValue(true), save)
await openAdvanced()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
})
expect(save).not.toHaveBeenCalled()
expect(mocks.clear).toHaveBeenCalledOnce()
expect(toast.success).toHaveBeenCalledWith('Search data cleared.')
})
it('keeps the index when turning search off fails', async () => {
const save = vi.fn().mockRejectedValue(new Error('write failed'))
pane(true, vi.fn().mockResolvedValue(true), save)
await openAdvanced()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
})
expect(mocks.clear).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('Could not save')
expect(screen.getByRole('button', { name: 'Clear' })).toBeEnabled()
})
it('does not execute a confirmation after navigating away', async () => {
let accept: (value: boolean) => void = () => undefined
const confirmation = new Promise<boolean>((resolve) => {
@@ -170,7 +294,7 @@ it('does not execute a confirmation after navigating away', async () => {
})
const view = pane(false, vi.fn().mockReturnValue(confirmation))
await openAdvanced()
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
view.unmount()
await act(async () => {
accept(true)
@@ -183,7 +307,7 @@ it('leaves paired-client controls unsupported without local calls', async () =>
pane(true)
expect(screen.getByRole('switch')).toBeDisabled()
await openAdvanced()
expect(screen.getByRole('button', { name: 'Delete index' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
@@ -199,12 +323,209 @@ it('keeps the last index status visible while a save is in flight', async () =>
)
pane(true, vi.fn().mockResolvedValue(true), save)
await act(async () => {})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
await act(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: 'Search sessions on build-box' })).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Search sessions on office-mini' })).toBeInTheDocument()
expect(mocks.status).toHaveBeenCalledWith('local')
expect(screen.getByText('This computer')).toBeInTheDocument()
expect(screen.getByText('Orca remote servers')).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(enableAllButton()).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument()
expect(mocks.status).not.toHaveBeenCalled()
})
/** Local on, one server on, one off, one offline, one too old. */
function mixedFleet(): void {
mocks.environments = [
{ id: 'on', name: 'build-01' },
{ id: 'off', name: 'gpu-a' },
{ id: 'gone', name: 'linux 1' },
{ id: 'old', name: 'm4 air' }
]
mocks.details = {
on: CONNECTED_DETAILS,
off: CONNECTED_DETAILS,
gone: OFFLINE_DETAILS,
old: CONNECTED_DETAILS
}
mocks.statusByHost = {
local: current,
'runtime:on': current,
'runtime:off': off,
'runtime:old': 'too-old'
}
statusByHost()
}
it('leaves a lone computer to its own switch, with no roll-up above it', async () => {
pane(true)
await act(async () => {})
expect(screen.getAllByRole('switch')).toHaveLength(1)
expect(enableAllButton()).not.toBeInTheDocument()
expect(screen.queryByText('This computer')).not.toBeInTheDocument()
expect(screen.queryByText('Orca remote servers')).not.toBeInTheDocument()
})
it('offers the button only while a paired server is reachable and off', async () => {
mixedFleet()
pane(true)
await act(async () => {})
expect(enableAllButton()).toBeInTheDocument()
// gpu-a was the only eligible one; with it on, the offline and too-old rows leave nothing to do.
mocks.statusByHost = { ...mocks.statusByHost, 'runtime:off': current }
statusByHost()
cleanup()
pane(true)
await act(async () => {})
expect(enableAllButton()).not.toBeInTheDocument()
})
it('does not offer the button for a server whose state is still unknown', async () => {
mocks.environments = [{ id: 'a', name: 'gpu-a' }]
mocks.details = {}
mocks.statusByHost = { local: current }
statusByHost()
pane(true)
await act(async () => {})
expect(enableAllButton()).not.toBeInTheDocument()
})
it('enables every reachable server and skips the ones it cannot', async () => {
mixedFleet()
const confirm = vi.fn().mockResolvedValue(true)
pane(true, confirm)
await act(async () => {})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
})
expect(confirm).not.toHaveBeenCalled()
// linux 1 is offline and m4 air is too old, so neither is asked; build-01 is already on.
expect(mocks.setEnabled.mock.calls.map((call) => call[0])).toEqual(['runtime:off'])
})
it('enables this computer as part of enabling them all', async () => {
mocks.environments = [{ id: 'off', name: 'gpu-a' }]
mocks.details = { off: CONNECTED_DETAILS }
mocks.statusByHost = { local: off, 'runtime:off': off }
statusByHost()
const save = vi.fn().mockResolvedValue(undefined)
pane(false, undefined, save)
await act(async () => {})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
})
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:off', true)
})
it('keeps going after a host refuses, and names the one that did', async () => {
mocks.environments = [
{ id: 'a', name: 'gpu-a' },
{ id: 'b', name: 'gpu-b' }
]
mocks.details = { a: CONNECTED_DETAILS, b: CONNECTED_DETAILS }
mocks.statusByHost = { local: current, 'runtime:a': off, 'runtime:b': off }
statusByHost()
mocks.setEnabled.mockRejectedValueOnce(new Error('relay down')).mockResolvedValue(current)
pane(true)
await act(async () => {})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
})
expect(mocks.setEnabled.mock.calls.map((call) => call[0])).toEqual(['runtime:a', 'runtime:b'])
expect(screen.getByRole('alert')).toHaveTextContent('Could not change session search on gpu-a')
})
it('remembers nothing after a server is turned back off by hand', async () => {
mocks.environments = [{ id: 'a', name: 'gpu-a' }]
mocks.details = { a: CONNECTED_DETAILS }
mocks.statusByHost = { local: current, 'runtime:a': current }
statusByHost()
const save = vi.fn().mockResolvedValue(undefined)
pane(true, undefined, save)
await act(async () => {})
expect(enableAllButton()).not.toBeInTheDocument()
mocks.setEnabled.mockResolvedValue(off)
await act(async () => {
fireEvent.click(screen.getByRole('switch', { name: 'Search sessions on gpu-a' }))
})
// The row went off, so the offer comes straight back; no preference was written either way.
expect(enableAllButton()).toBeInTheDocument()
expect(save).not.toHaveBeenCalled()
})
it('folds the list past six computers and orders it by what the user can act on', async () => {
mocks.environments = [
{ id: 'gone', name: 'zz-offline' },
{ id: 'old', name: 'aa-old' },
{ id: 'off1', name: 'bb-off' },
{ id: 'off2', name: 'aa-off' },
{ id: 'on1', name: 'zz-on' },
{ id: 'on2', name: 'aa-on' }
]
mocks.details = {
gone: OFFLINE_DETAILS,
old: CONNECTED_DETAILS,
off1: CONNECTED_DETAILS,
off2: CONNECTED_DETAILS,
on1: CONNECTED_DETAILS,
on2: CONNECTED_DETAILS
}
mocks.statusByHost = {
local: current,
'runtime:old': 'too-old',
'runtime:off1': off,
'runtime:off2': off,
'runtime:on1': current,
'runtime:on2': current
}
statusByHost()
pane(true)
await act(async () => {})
// The local row's label is the host's own name, which differs per platform; the servers are the order under test.
const serverNames = (): string[] =>
screen
.getAllByRole('switch')
.map((element) => element.getAttribute('aria-label') ?? '')
.filter((label) => label.startsWith('Search sessions on '))
.map((label) => label.replace('Search sessions on ', ''))
.slice(1)
expect(serverNames()).toEqual(['aa-on', 'zz-on', 'aa-off', 'bb-off', 'aa-old'])
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Show 1 more' }))
})
expect(serverNames()).toEqual(['aa-on', 'zz-on', 'aa-off', 'bb-off', 'aa-old', 'zz-offline'])
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Show fewer' }))
})
expect(screen.getByRole('button', { name: 'Show 1 more' })).toBeInTheDocument()
})
@@ -1,19 +1,38 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
AiVaultSearchSettingsSchema,
resolveAiVaultSearchSettings
} from '../../../../shared/ai-vault-search-settings'
import {
getLocalExecutionHostLabel,
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { Label } from '@/components/ui/label'
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 { useAppStore } from '@/store'
import { SettingsRow } from './SettingsFormControls'
import { SessionSearchAdvancedSection } from './SessionSearchAdvancedSection'
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
import { SessionHistoryServerRow } from './SessionHistoryServerRow'
import { SessionSearchComputerList } from './SessionSearchComputerList'
import {
isTurnOnableSessionSearchState,
orderSessionSearchServers,
type SessionSearchComputerEntry,
type SessionSearchComputerState
} from './session-search-computer-rollup'
import {
sessionSearchCheckingMessage,
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,
@@ -24,11 +43,18 @@ export function SessionHistorySettingsPane({
}): React.JSX.Element {
const policy = resolveAiVaultSearchSettings(settings)
const isWebClient = isWebClientLocation()
const confirm = useConfirmationDialog()
const closeSettingsPage = useAppStore((state) => state.closeSettingsPage)
const showAiVaultSearch = useAppStore((state) => state.showAiVaultSearch)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [refresh, setRefresh] = useState(0)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [serverStates, setServerStates] = useState<Record<string, SessionSearchComputerState>>({})
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
@@ -37,21 +63,47 @@ export function SessionHistorySettingsPane({
}
}, [])
const servers = isWebClient ? [] : environments
const localEntry: SessionSearchComputerEntry = {
id: LOCAL_EXECUTION_HOST_ID,
name: getLocalExecutionHostLabel(),
state: policy.enabled ? 'on' : 'off'
}
const serverEntries = servers.map((environment) => ({
id: environment.id,
name: environment.name,
state: serverStates[environment.id] ?? 'checking',
environment
}))
const orderedServers = orderSessionSearchServers(serverEntries)
// The button's whole reason to exist: a paired server this client could switch on right now.
const enableableServers = serverEntries.filter((entry) =>
isTurnOnableSessionSearchState(entry.state)
)
const handleServerState = useCallback(
(environmentId: string, state: SessionSearchComputerState) => {
setServerStates((current) =>
current[environmentId] === state ? current : { ...current, [environmentId]: state }
)
},
[]
)
function writePolicy(updates: Partial<typeof policy>): Promise<void> {
return updateSettings({
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
})
}
async function save(updates: Partial<typeof policy>): Promise<void> {
setBusy(true)
setError(null)
try {
await updateSettings({
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
})
await writePolicy(updates)
} catch {
if (mounted.current) {
setError(
translate(
'sessionHistory.settings.saveError',
'Could not save session search settings. Try again.'
)
)
setError(saveErrorMessage())
}
} finally {
if (mounted.current) {
@@ -60,138 +112,150 @@ export function SessionHistorySettingsPane({
}
}
async function toggleEnabled(): Promise<void> {
if (policy.enabled) {
await save({ enabled: false })
return
}
setBusy(true)
let accepted = false
try {
accepted = await confirm({
title: translate('sessionHistory.settings.enableTitle', 'Start indexing agent sessions?'),
description: translate(
'sessionHistory.settings.enableConsent',
'Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.'
),
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing')
})
} finally {
if (mounted.current) {
setBusy(false)
}
}
if (!accepted || !mounted.current) {
return
}
await save({ enabled: true })
function toggleEnabled(): Promise<void> {
return save({ enabled: !policy.enabled })
}
async function deleteIndex(): Promise<void> {
/** Remembers nothing: what it acts on is read off the rows at the moment it is clicked. */
async function enableOnAllComputers(): Promise<void> {
setBusy(true)
setError(null)
try {
const accepted = await confirm({
title: translate(
'sessionHistory.settings.deleteTitle',
'Delete this computers search index?'
),
description: policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
),
confirmLabel: translate('sessionHistory.settings.delete', 'Delete index'),
confirmVariant: 'destructive'
})
if (!accepted || !mounted.current) {
return
if (!policy.enabled) {
try {
await writePolicy({ enabled: true })
} catch {
setError(saveErrorMessage())
}
}
await window.api.aiVault.clearSearchIndex()
if (mounted.current) {
setRefresh((value) => value + 1)
toast.success(
translate(
'sessionHistory.settings.cleared',
'Search index cleared. Original transcripts were kept.'
)
)
}
} catch {
if (mounted.current) {
setError(
translate('sessionHistory.settings.clearError', 'Could not clear the index. Try again.')
)
// One host at a time: a failure is that host's, and it must not stop the rest.
for (const entry of enableableServers) {
try {
await window.api.aiVault.setSearchEnabled(toRuntimeExecutionHostId(entry.id), true)
} catch {
setError(serverToggleErrorMessage(entry.name))
}
}
} finally {
if (mounted.current) {
setBusy(false)
setRefresh((value) => value + 1)
}
}
}
/** False when the settings write failed or the pane went away, so the delete is skipped. */
async function turnSearchOffBeforeDelete(): Promise<boolean> {
try {
await writePolicy({ enabled: false })
} catch {
if (mounted.current) {
setError(saveErrorMessage())
}
return false
}
return mounted.current
}
// A stale answer from before the switch went off must not keep reporting progress.
const localStatus = policy.enabled ? localRead.status : null
let localStatusText: string | undefined
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
<div>
<div className="space-y-1 py-3">
<Label className="select-text">
{translate('sessionHistory.settings.indexComputers', 'Search inside 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.'
'Turn on session search from the Orca desktop app on that computer.'
)
: translate(
'sessionHistory.settings.consent',
'Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.'
)
}
checked={policy.enabled}
disabled={busy || isWebClient}
onChange={() => void toggleEnabled()}
/>
{!isWebClient ? (
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
) : null}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="xs" className="-ml-2">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
'sessionHistory.settings.computersConsent',
'Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.'
)}
</p>
</div>
{/* Nothing left to switch on means nothing to offer: each row already speaks for itself. */}
{enableableServers.length === 0 ? null : (
<div className="flex items-center justify-end pt-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => void enableOnAllComputers()}
>
{translate('sessionHistory.settings.enableOnAll', 'Enable on all computers')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
description={
policy.enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
)
}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void deleteIndex()}
>
{translate('sessionHistory.settings.delete', 'Delete index')}
</Button>
}
</div>
)}
<SessionSearchComputerList
local={
<SessionHistoryComputerRow
kind="local"
name={localEntry.name}
checked={policy.enabled}
disabled={busy || isWebClient}
onToggle={() => void toggleEnabled()}
{...(isWebClient || localStatusText === undefined
? {}
: { status: localStatusText, details: sessionSearchStatusDetails(localStatus) })}
/>
</CollapsibleContent>
</Collapsible>
}
servers={orderedServers.map((entry) => ({
id: entry.id,
node: (
<SessionHistoryServerRow
environment={entry.environment}
details={detailsByEnvironmentId[entry.id]}
refresh={refresh}
onError={setError}
onStateChange={handleServerState}
/>
)
}))}
/>
{isWebClient ? null : (
<SettingsRow
className="border-t border-border"
label={translate('sessionHistory.settings.openInSidebar', 'Open in the sidebar')}
description={translate(
'sessionHistory.settings.openInSidebarCopy',
'Type what you remember, or ask an agent: “find the session where we fixed the login timeout.”'
)}
control={
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
showAiVaultSearch()
closeSettingsPage()
}}
>
{translate('sessionHistory.settings.open', 'Open')}
</Button>
}
/>
)}
<SessionSearchAdvancedSection
enabled={policy.enabled}
disabled={isWebClient}
turnSearchOff={turnSearchOffBeforeDelete}
onError={setError}
onCleared={() => setRefresh((value) => value + 1)}
/>
{error ? (
<p role="alert" className="pt-3 text-xs text-destructive">
{error}
@@ -200,3 +264,15 @@ export function SessionHistorySettingsPane({
</div>
)
}
function saveErrorMessage(): string {
return translate('sessionHistory.settings.saveError', 'Could not save. Try again.')
}
function serverToggleErrorMessage(host: string): string {
return translate(
'sessionHistory.settings.serverToggleError',
'Could not change session search on {{host}}. Try again.',
{ host }
)
}
@@ -0,0 +1,122 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { SettingsRow } from './SettingsFormControls'
/** Shared by the Clear row and its confirm dialog so both promise the same thing. */
export function sessionSearchClearDescription(enabled: boolean): string {
return enabled
? translate(
'sessionHistory.settings.deleteEnabled',
'Turns off search and removes the searchable copy from this computer. Your agent sessions are not affected.'
)
: translate(
'sessionHistory.settings.deleteDisabled',
'Removes the searchable copy from this computer. Your agent sessions are not affected.'
)
}
/**
* Clearing this computer's index, behind Advanced because it is the one action
* here that destroys something.
*/
export function SessionSearchAdvancedSection({
enabled,
disabled,
turnSearchOff,
onError,
onCleared
}: {
enabled: boolean
disabled: boolean
/** Returns false when the write failed or the pane went away, so the delete is skipped. */
turnSearchOff: () => Promise<boolean>
onError: (message: string | null) => void
onCleared: () => void
}): React.JSX.Element {
const confirm = useConfirmationDialog()
const mounted = useMountedRef()
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
async function clearIndex(): Promise<void> {
const wasEnabled = enabled
setBusy(true)
onError(null)
try {
const accepted = await confirm({
title: translate(
'sessionHistory.settings.deleteTitle',
'Clear search data on this computer?'
),
description: sessionSearchClearDescription(wasEnabled),
confirmLabel: translate('sessionHistory.settings.delete', 'Clear'),
confirmVariant: 'destructive'
})
if (!accepted || !mounted.current) {
return
}
// Clearing while search is on makes the host rebuild the index immediately; turn it off first.
if (wasEnabled && !(await turnSearchOff())) {
return
}
await window.api.aiVault.clearSearchIndex()
if (mounted.current) {
onCleared()
toast.success(
wasEnabled
? translate(
'sessionHistory.settings.clearedAndTurnedOff',
'Search turned off and search data cleared.'
)
: translate('sessionHistory.settings.cleared', 'Search data cleared.')
)
}
} catch {
if (mounted.current) {
onError(
translate('sessionHistory.settings.clearError', 'Could not clear search data. Try again.')
)
}
} finally {
if (mounted.current) {
setBusy(false)
}
}
}
return (
<div className="border-t border-border">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="xs">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Clear search data')}
description={sessionSearchClearDescription(enabled)}
control={
<Button
variant="outline"
size="sm"
disabled={busy || disabled}
onClick={() => void clearIndex()}
>
{translate('sessionHistory.settings.delete', 'Clear')}
</Button>
}
/>
</CollapsibleContent>
</Collapsible>
</div>
)
}
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
/** Local computer plus five servers. Past that the list stops being a list and becomes a wall. */
const VISIBLE_COMPUTER_LIMIT = 6
export type SessionSearchServerRowEntry = { id: string; node: React.ReactNode }
/**
* Presentation only: the caller has already decided which servers exist and in
* what order, so the list owns nothing but the two subheads and the fold.
*/
export function SessionSearchComputerList({
local,
servers
}: {
local: React.ReactNode
servers: readonly SessionSearchServerRowEntry[]
}): React.JSX.Element {
const [expanded, setExpanded] = useState(false)
const visibleServerCount = VISIBLE_COMPUTER_LIMIT - 1
const hiddenCount = Math.max(0, servers.length - visibleServerCount)
const shownServers = expanded ? servers : servers.slice(0, visibleServerCount)
// One row needs no heading to tell it apart from the rest; the pair of subheads appears together or not at all.
const showSubheads = servers.length > 0
return (
<div>
{showSubheads ? (
<p className="pt-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('sessionHistory.settings.thisComputer', 'This computer')}
</p>
) : null}
{local}
{showSubheads ? (
<p className="pt-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('sessionHistory.settings.remoteServers', 'Orca remote servers')}
</p>
) : null}
{shownServers.map((server) => (
<div key={server.id}>{server.node}</div>
))}
{hiddenCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? (
<>
{translate('sessionHistory.settings.showFewer', 'Show fewer')}
<ChevronUp />
</>
) : (
<>
{translate('sessionHistory.settings.showMore', 'Show {{count}} more', {
count: hiddenCount
})}
<ChevronDown />
</>
)}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,111 @@
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
import { translate } from '@/i18n/i18n'
import { formatMessageCount, formatSessionCount } from './session-search-count-format'
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
}
/** Messages are optional on the wire: a host that predates the field reports sessions only. */
function searchableMessage(status: AiVaultSearchStatus): string {
const sessions = formatSessionCount(status.filesIndexed)
if (status.messagesIndexed === undefined) {
return translate(
'sessionHistory.status.searchableSessions',
'{{sessions}} sessions searchable',
{
sessions
}
)
}
return translate(
'sessionHistory.status.searchable',
'{{sessions}} sessions · {{messages}} messages searchable',
{ sessions, messages: formatMessageCount(status.messagesIndexed) }
)
}
/**
* A sweep in flight. The denominator is what the pass knows about so far, which
* is why it is a plain fraction and never a percentage. Messages are the count
* already searchable, not a total: nothing knows how many a file holds until it
* is read.
*/
function catchingUpMessage(status: AiVaultSearchStatus): string {
const indexed = formatSessionCount(status.filesIndexed)
const total = formatSessionCount(status.filesIndexed + status.filesDue + status.filesFailed)
if (status.messagesIndexed === undefined) {
return translate(
'sessionHistory.status.catchingUpSessions',
'{{indexed}} of {{total}} sessions searchable',
{ indexed, total }
)
}
return translate(
'sessionHistory.status.catchingUp',
'{{indexed}} of {{total}} sessions · {{messages}} messages searchable',
{ indexed, total, messages: formatMessageCount(status.messagesIndexed) }
)
}
/** The one status sentence a computer row shows while its search is on. */
export function sessionSearchStatusMessage(status: AiVaultSearchStatus): string {
if (!status.enabled || status.phase === 'idle' || status.phase === 'closed') {
return translate(
'sessionHistory.status.unavailable',
'Search is not available on this computer right now.'
)
}
return isSweepingSessionSearch(status) ? catchingUpMessage(status) : searchableMessage(status)
}
/** Lines shown under the status sentence when something needs the user's attention. */
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}} sessions could not be read and will be retried.',
{ failed: status.filesFailed }
)
)
}
if (status.degradedRoots.length > 0) {
lines.push(
translate('sessionHistory.status.roots', '{{roots}} session folders could not be checked.', {
roots: status.degradedRoots.length
})
)
}
return lines
}
export function sessionSearchCheckingMessage(): string {
return translate('sessionHistory.status.checking', 'Checking…')
}
export function sessionSearchReadErrorMessage(): string {
return translate('sessionHistory.status.error', 'Could not check status. Retrying…')
}
// IPC wraps a rejection's message, so the host-too-old marker arrives inside a longer string.
export function isHostTooOldError(error: unknown): boolean {
return error instanceof Error && error.message.includes('host-too-old')
}
@@ -0,0 +1,43 @@
import { expect, it } from 'vitest'
import {
isTurnOnableSessionSearchState,
orderSessionSearchServers,
type SessionSearchComputerEntry
} from './session-search-computer-rollup'
const fleet: SessionSearchComputerEntry[] = [
{ id: 'local', name: 'Local Mac', state: 'on' },
{ id: 'a', name: 'build-01', state: 'on' },
{ id: 'b', name: 'gpu-a', state: 'off' },
{ id: 'c', name: 'linux 1', state: 'offline' },
{ id: 'd', name: 'nas', state: 'offline' },
{ id: 'e', name: 'm4 air', state: 'needs-update' },
{ id: 'f', name: 'probing', state: 'checking' }
]
it('will not offer to turn on a computer it cannot reach or that is too old', () => {
expect(isTurnOnableSessionSearchState('off')).toBe(true)
for (const state of ['on', 'offline', 'needs-update', 'checking'] as const) {
expect(isTurnOnableSessionSearchState(state)).toBe(false)
}
expect(fleet.filter((entry) => isTurnOnableSessionSearchState(entry.state))).toHaveLength(1)
})
it('orders reachable and working first, then by name inside each group', () => {
const ordered = orderSessionSearchServers([
{ id: 'f', name: 'probing', state: 'checking' },
{ id: 'c', name: 'linux 1', state: 'offline' },
{ id: 'e', name: 'm4 air', state: 'needs-update' },
{ id: 'b', name: 'gpu-a', state: 'off' },
{ id: 'a', name: 'build-01', state: 'on' },
{ id: 'z', name: 'aa-on', state: 'on' }
])
expect(ordered.map((entry) => entry.name)).toEqual([
'aa-on',
'build-01',
'gpu-a',
'probing',
'm4 air',
'linux 1'
])
})
@@ -0,0 +1,36 @@
/**
* What one computer in the pane is doing, as far as this client can tell.
*
* `checking` is not `offline`: a probe still in flight is no evidence the host
* is unreachable, so it is neither counted as offline nor skipped as one.
*/
export type SessionSearchComputerState = 'on' | 'off' | 'checking' | 'needs-update' | 'offline'
export type SessionSearchComputerEntry = {
id: string
name: string
state: SessionSearchComputerState
}
export function isTurnOnableSessionSearchState(state: SessionSearchComputerState): boolean {
return state === 'off'
}
// Reachable and working first, then what the user could act on, then what they cannot.
const STATE_RANK: Record<SessionSearchComputerState, number> = {
on: 0,
off: 1,
checking: 1,
'needs-update': 2,
offline: 3
}
/** Stable order for the server list: by state group, then by name within a group. */
export function orderSessionSearchServers<T extends SessionSearchComputerEntry>(
entries: readonly T[]
): T[] {
return [...entries].sort((left, right) => {
const byState = STATE_RANK[left.state] - STATE_RANK[right.state]
return byState === 0 ? left.name.localeCompare(right.name) : byState
})
}
@@ -0,0 +1,20 @@
/**
* Counts for the session-search status sentences.
*
* Sessions keep their grouping separators because a user recognises their own
* transcript count; messages run to millions, where the exact figure carries
* nothing the compact form does not.
*/
const sessionFormatter = new Intl.NumberFormat(undefined, { useGrouping: true })
const messageFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1
})
export function formatSessionCount(sessions: number): string {
return sessionFormatter.format(Math.max(0, Math.trunc(sessions)))
}
export function formatMessageCount(messages: number): string {
return messageFormatter.format(Math.max(0, Math.trunc(messages)))
}
@@ -185,16 +185,15 @@ export function renderSessionHistorySettingsSection(
return (
<SettingsSection
id="session-history"
title={translate('sessionHistory.settings.title', 'Agent Session History')}
title={translate('sessionHistory.settings.title', 'Agent Session Search')}
description={translate(
'sessionHistory.settings.description',
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
'Search everything your agents have said and done, on this computer and on 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}
/>
@@ -0,0 +1,270 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
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 { 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', () => ({
useWindowStreamVisible: () => mocks.visible
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
}))
const current: AiVaultSearchStatus = {
...unavailableSessionSearchStatus(),
enabled: true,
phase: 'current',
filesIndexed: 12,
messagesIndexed: 3_400,
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
mocks.status.mockReset().mockResolvedValue(current)
vi.stubGlobal('api', undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { aiVault: { searchStatus: mocks.status } }
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('keeps polling a settled index so counts stay live between sweeps', async () => {
const view = poll()
await act(async () => {})
expect(mocks.status).toHaveBeenCalledWith('local')
expect(message(view.result.current.status)).toBe('12 sessions · 3.4K messages searchable')
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30, messagesIndexed: 9_000 })
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(message(view.result.current.status)).toBe('30 sessions · 9K messages searchable')
})
it('counts a sweep in flight against what it knows about so far', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 6,
messagesIndexed: 410_000,
lastSweepCompletedAt: null
})
const view = poll()
await act(async () => {})
expect(message(view.result.current.status)).toBe('4 of 10 sessions · 410K messages searchable')
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([])
mocks.status.mockResolvedValue({
...current,
phase: 'indexing',
filesIndexed: 4,
filesDue: 5,
filesFailed: 1,
messagesIndexed: 420_000,
lastSweepCompletedAt: 1
})
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(message(view.result.current.status)).toBe('4 of 10 sessions · 420K messages searchable')
})
it('degrades to a session count when the host is too old to report messages', async () => {
const { messagesIndexed: _messagesIndexed, ...withoutMessages } = current
mocks.status.mockResolvedValue(withoutMessages)
const view = poll()
await act(async () => {})
expect(message(view.result.current.status)).toBe('12 sessions searchable')
mocks.status.mockResolvedValue({
...withoutMessages,
phase: 'indexing',
filesIndexed: 4,
filesDue: 6
})
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(message(view.result.current.status)).toBe('4 of 10 sessions searchable')
})
it('polls a sweep faster than a settled index', async () => {
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
poll()
await act(async () => {})
const started = mocks.status.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000)
})
expect(mocks.status.mock.calls.length - started).toBe(3)
})
it('names unreadable files while degraded and still counts what is searchable', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 8,
filesDue: 1,
filesFailed: 1,
degradedRoots: [{ reason: 'unreadable' }]
})
const view = poll()
await act(async () => {})
expect(message(view.result.current.status)).toBe('8 of 10 sessions · 3.4K messages searchable')
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
'1 sessions could not be read and will be retried.',
'1 session folders could not be checked.'
])
})
it('calls a drained degraded index up to date', async () => {
mocks.status.mockResolvedValue({
...current,
phase: 'degraded',
filesIndexed: 9,
filesDue: 0,
filesFailed: 2
})
const view = poll()
await act(async () => {})
expect(message(view.result.current.status)).toBe('9 sessions · 3.4K messages searchable')
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
'2 sessions 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())
const view = poll()
await act(async () => {})
expect(message(view.result.current.status)).toBe(
'Search is not available on this computer right now.'
)
})
it('recovers on its own after a failed read', async () => {
mocks.status.mockRejectedValueOnce(new Error('offline'))
const view = poll()
await act(async () => {})
expect(view.result.current.failed).toBe(true)
expect(view.result.current.status).toBeNull()
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
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')
})
const view = poll()
await act(async () => {})
expect(view.result.current.failed).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(view.result.current.status).toEqual(current)
})
it('stops polling while inactive or hidden and re-reads on a refresh bump', async () => {
mocks.visible = false
const view = poll()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).not.toHaveBeenCalled()
mocks.visible = true
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(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 () => {
let answer: (value: AiVaultSearchStatus) => void = () => undefined
mocks.status.mockReturnValue(
new Promise<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = poll()
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
await act(async () => {
answer({ ...current, phase: 'indexing', filesDue: 2 })
})
const beforeUnmount = mocks.status.mock.calls.length
view.unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
})
it('marks the host too old on a host-too-old rejection and stops polling', async () => {
mocks.status.mockRejectedValue(new Error('Error invoking remote method: host-too-old'))
const { result } = poll()
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.hostTooOld).toBe(true)
expect(result.current.failed).toBe(true)
const calls = mocks.status.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
expect(mocks.status.mock.calls.length).toBe(calls)
})
@@ -0,0 +1,82 @@
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 { isHostTooOldError, sessionSearchPollIntervalMs } from './session-history-status-copy'
export type SessionSearchStatusRead = {
status: AiVaultSearchStatus | null
failed: boolean
/** The host answered that it has no session search at all; polling stops. */
hostTooOld: 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 [hostTooOld, setHostTooOld] = useState(false)
const intervalMs = sessionSearchPollIntervalMs(status)
const adopt = useCallback((next: AiVaultSearchStatus) => {
setStatus(next)
setFailed(false)
}, [])
useEffect(() => {
if (!active) {
setFailed(false)
return
}
if (!visible || hostTooOld) {
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 (error) {
if (!disposed) {
setStatus(null)
setFailed(true)
if (isHostTooOldError(error)) {
setHostTooOld(true)
}
}
} finally {
inFlight = false
}
}
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
return () => {
disposed = true
stopPolling()
}
}, [executionHostId, active, visible, refresh, intervalMs, hostTooOld])
return { status, failed, hostTooOld, adopt }
}
@@ -71,18 +71,18 @@ export function buildWorkflowSettingsSections(
},
{
id: 'session-history',
title: translate('sessionHistory.settings.title', 'Agent Session History'),
title: translate('sessionHistory.settings.title', 'Agent Session Search'),
description: translate(
'sessionHistory.settings.description',
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
'Search everything your agents have said and done, on this computer and on any paired Orca server.'
),
icon: History,
searchEntries: [
{
title: translate('sessionHistory.settings.enable', 'Enable session history search'),
title: translate('sessionHistory.settings.indexComputers', 'Search inside sessions'),
description: translate(
'sessionHistory.settings.searchDescription',
'Transcript indexing, index status and delete index.'
'Turn on session search for this computer and paired servers, or clear search data.'
)
}
],
+38 -28
View File
@@ -17900,37 +17900,47 @@
},
"sessionHistory": {
"status": {
"checking": "Checking index…",
"off": "Search is off. Any existing index copy is kept.",
"error": "Could not read index status. Retrying…",
"unavailable": "Index is not ready or the search service is unavailable.",
"title": "Index status",
"firstScan": "Indexing… {{indexed}} files so far",
"progress": "Indexing · {{percent}}% · {{indexed}} of {{total}} files",
"upToDate": "Up to date · {{indexed}} files indexed",
"unreadable": "{{failed}} files could not be read and will be retried.",
"stopHint": "Turn off search to stop. Progress is kept and resumes when you turn it back on.",
"roots": "Unverified source roots: {{roots}}"
"checking": "Checking…",
"error": "Could not check status. Retrying…",
"unavailable": "Search is not available on this computer right now.",
"unreadable": "{{failed}} sessions could not be read and will be retried.",
"roots": "{{roots}} session folders could not be checked.",
"searchable": "{{sessions}} sessions · {{messages}} messages searchable",
"searchableSessions": "{{sessions}} sessions searchable",
"catchingUp": "{{indexed}} of {{total}} sessions · {{messages}} messages searchable",
"catchingUpSessions": "{{indexed}} of {{total}} sessions searchable"
},
"settings": {
"saveError": "Could not save session search settings. Try again.",
"enableTitle": "Start indexing agent sessions?",
"enableConsent": "Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.",
"enableConfirm": "Start indexing",
"saveError": "Could not save. Try again.",
"advanced": "Advanced",
"deleteIndexCopy": "Delete index copy",
"deleteTitle": "Delete this computers search index?",
"deleteEnabled": "Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.",
"deleteDisabled": "Remove the search index from this computer. Original transcripts are not touched. Search stays off.",
"delete": "Delete index",
"cleared": "Search index cleared. Original transcripts were kept.",
"clearError": "Could not clear the index. Try again.",
"enable": "Enable session history search",
"webUnsupported": "Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.",
"consent": "Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.",
"title": "Agent Session History",
"description": "Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.",
"searchDescription": "Transcript indexing, index status and delete index."
"deleteIndexCopy": "Clear search data",
"deleteTitle": "Clear search data on this computer?",
"deleteEnabled": "Turns off search and removes the searchable copy from this computer. Your agent sessions are not affected.",
"deleteDisabled": "Removes the searchable copy from this computer. Your agent sessions are not affected.",
"delete": "Clear",
"cleared": "Search data cleared.",
"clearError": "Could not clear search data. Try again.",
"webUnsupported": "Turn on session search from the Orca desktop app on that computer.",
"title": "Agent Session Search",
"description": "Search everything your agents have said and done, on this computer and on any paired Orca server.",
"searchDescription": "Turn on session search for this computer and paired servers, or clear search data.",
"indexComputers": "Search inside sessions",
"computersConsent": "Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.",
"serverVersion": "Orca v{{version}}",
"rowSwitchLabel": "Search sessions on {{host}}",
"serverOffline": "Offline",
"serverTooOld": "Needs a newer version of Orca.",
"updateServer": "Update server",
"serverToggleError": "Could not change session search on {{host}}. Try again.",
"clearedAndTurnedOff": "Search turned off and search data cleared.",
"thisComputer": "This computer",
"remoteServers": "Orca remote servers",
"showMore": "Show {{count}} more",
"showFewer": "Show fewer",
"openInSidebar": "Open in the sidebar",
"openInSidebarCopy": "Type what you remember, or ask an agent: “find the session where we fixed the login timeout.”",
"open": "Open",
"enableOnAll": "Enable on all computers"
}
},
"aiVault": {
@@ -12,6 +12,8 @@ export type RightSidebarState = {
rightSidebarTab: ActiveRightSidebarTab
rightSidebarExplorerView: RightSidebarExplorerView
rightSidebarRouteRequestId: number
/** Set to ask the Agent Session Search panel to widen to all computers and focus its box. */
aiVaultSearchFocusRequested: boolean
rightSidebarTabByWorktree: Record<string, ActiveRightSidebarTab>
rightSidebarExplorerViewByWorktree: Record<string, RightSidebarExplorerView>
activityBarPosition: ActivityBarPosition
@@ -25,6 +27,8 @@ export type RightSidebarState = {
query?: string | null
includePattern?: string | null
}) => void
showAiVaultSearch: () => void
clearAiVaultSearchFocusRequest: () => void
setActivityBarPosition: (position: ActivityBarPosition) => void
}
@@ -35,6 +39,7 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
rightSidebarRouteRequestId: 0,
aiVaultSearchFocusRequested: false,
rightSidebarTabByWorktree: {},
rightSidebarExplorerViewByWorktree: {},
activityBarPosition: 'top',
@@ -124,6 +129,15 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
}
}
}),
// Settings sends the user here; the panel owns scope, so this asks rather than writes.
showAiVaultSearch: () =>
set((s) => ({
rightSidebarOpen: true,
rightSidebarTab: 'vault' as const,
rightSidebarRouteRequestId: s.rightSidebarRouteRequestId + 1,
aiVaultSearchFocusRequested: true
})),
clearAiVaultSearchFocusRequest: () => set({ aiVaultSearchFocusRequested: false }),
setActivityBarPosition: (position) => set({ activityBarPosition: position })
}
}
+2
View File
@@ -111,6 +111,8 @@ export const AiVaultSearchStatusSchema = z.object({
filesIndexed: z.number().int().nonnegative(),
filesDue: z.number().int().nonnegative(),
filesFailed: z.number().int().nonnegative(),
// Optional: a host that predates this field degrades to a session count only.
messagesIndexed: z.number().int().nonnegative().optional(),
// `root` is a host path, withheld over the relay; the array length is the count.
degradedRoots: z.array(z.object({ root: z.string().optional(), reason: z.string() })),
lastReconcileAt: z.number().nullable(),