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)
This commit is contained in:
Jinwoo-H
2026-09-15 17:15:08 -04:00
parent 0cc51e7ff9
commit cffdaa0e5a
8 changed files with 227 additions and 38 deletions
+3 -3
View File
@@ -23,13 +23,13 @@ import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ExecutionHostId, ExecutionHostScope } from '../../shared/execution-host'
export type AiVaultApi = {
/** Omitted host means this host; search addresses one index at a time. */
/** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */
searchSessions: (
request: AiVaultSearchRequest,
executionHostScope?: ExecutionHostId
executionHostScope?: ExecutionHostScope
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { searchResults } from '../../../../shared/ai-vault-search-test-fixture'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { AiVaultPanelSearch } from './AiVaultPanelSearch'
import type { useAiVaultPanelSearch } from './use-ai-vault-search'
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
afterEach(cleanup)
type PanelSearch = ReturnType<typeof useAiVaultPanelSearch>
function panelSearch(overrides: Partial<PanelSearch> = {}): PanelSearch {
return {
hits: [],
response: null,
error: false,
loading: false,
removeHit: vi.fn(),
retry: vi.fn(),
loadMore: vi.fn(),
onDeleted: vi.fn(),
sessions: [],
searchHits: new Map(),
searching: true,
localConsent: false,
host: null,
resetKey: 'all',
...overrides
}
}
function renderPanel(search: PanelSearch) {
return render(
<AiVaultPanelSearch search={search} noAgents={false} onDismiss={vi.fn()}>
<div>results</div>
</AiVaultPanelSearch>
)
}
describe('AiVaultPanelSearch', () => {
it('names every computer the merge could not search, with its reason', () => {
const response = searchResults()
renderPanel(
panelSearch({
hits: response.hits,
response: {
...response,
hosts: [
{ executionHostId: 'local', outcome: 'disabled' },
{ executionHostId: 'ssh:build-box', outcome: 'unreachable' },
{ executionHostId: 'runtime:cloud', outcome: 'searched' },
{ executionHostId: 'runtime:paused', outcome: 'not-ready' },
{ executionHostId: 'ssh:moved', outcome: 'stale' },
{ executionHostId: 'ssh:old', outcome: 'no-service' }
]
}
})
)
expect(screen.getByRole('status').textContent).toBe(
`Not searched: ${getExecutionHostLabel('local')} (search off) · build-box (unreachable) · paused (not ready) · moved (index changed) · old (unavailable)`
)
})
it('stays silent when every computer answered', () => {
const response = searchResults()
renderPanel(
panelSearch({
hits: response.hits,
response: {
...response,
hosts: [{ executionHostId: 'local', outcome: 'searched' }]
}
})
)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByText('results')).toBeTruthy()
})
it('no longer asks the user to choose one computer before searching', () => {
renderPanel(panelSearch({ host: null }))
expect(screen.queryByRole('status')).toBeNull()
expect(screen.queryByText(/choose one computer/i)).toBeNull()
expect(screen.getByText('results')).toBeTruthy()
})
})
@@ -3,8 +3,41 @@ import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings'
import type { AiVaultSearchHostOutcome } from '../../../../shared/ai-vault-search-types'
import { getExecutionHostLabel, parseExecutionHostId } from '../../../../shared/execution-host'
import type { useAiVaultPanelSearch } from './use-ai-vault-search'
// Short English like `getExecutionHostLabel`, which these read beside; null means the host answered.
function hostSkipReason(outcome: AiVaultSearchHostOutcome['outcome']): string | null {
switch (outcome) {
case 'searched':
return null
case 'stale':
return 'index changed'
case 'disabled':
return 'search off'
case 'not-ready':
return 'not ready'
case 'no-service':
return 'unavailable'
case 'unreachable':
return 'unreachable'
}
}
function describeSkippedHosts(hosts: readonly AiVaultSearchHostOutcome[]): string | null {
const skipped = hosts.flatMap((entry) => {
const reason = hostSkipReason(entry.outcome)
const label = getExecutionHostLabel(parseExecutionHostId(entry.executionHostId)?.id ?? null)
return reason ? [`${label} (${reason})`] : []
})
return skipped.length > 0
? translate('sessionSearch.panel.hostsSkipped', 'Not searched: {{value0}}', {
value0: skipped.join(' · ')
})
: null
}
export function AiVaultPanelSearch({
search,
noAgents,
@@ -17,7 +50,6 @@ export function AiVaultPanelSearch({
children: ReactNode
}) {
const { localConsent, response, error, loading, retry: onRetry } = search
const allHosts = !search.host
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState(false)
async function enable() {
@@ -37,12 +69,7 @@ export function AiVaultPanelSearch({
}
const unavailable = response?.kind === 'unavailable' ? response.reason : null
let message: string | null = null
if (allHosts) {
message = translate(
'sessionSearch.panel.chooseHost',
'Choose one computer to search its sessions.'
)
} else if (localConsent) {
if (localConsent) {
message = translate(
'sessionSearch.panel.consent',
'Enable full-text search? Orca builds an index on this computer from local agent transcripts, including full conversations and up to 3,072 characters per tool output. Content is not redacted. Authenticated paired clients can search it.'
@@ -98,15 +125,18 @@ export function AiVaultPanelSearch({
'No matching sessions in the indexed history. Try another query or scope.'
)
}
const skippedHosts =
response?.kind === 'results' ? describeSkippedHosts(response.hosts ?? []) : null
return (
<>
{message && (
{(message || skippedHosts) && (
<div
className="space-y-2 border-b border-sidebar-border px-3 py-3 text-xs text-muted-foreground"
role="status"
>
<p>{message}</p>
{localConsent && !allHosts ? (
{message && <p>{message}</p>}
{skippedHosts && <p>{skippedHosts}</p>}
{localConsent ? (
<>
{saveError && (
<p className="text-destructive">
@@ -125,8 +155,7 @@ export function AiVaultPanelSearch({
</Button>
</div>
</>
) : !allHosts &&
!noAgents &&
) : !noAgents &&
(error ||
unavailable ||
response?.kind === 'stale-cursor' ||
@@ -5,12 +5,21 @@ import type {
AiVaultSearchRequest,
AiVaultSearchResponse
} from '../../../../shared/ai-vault-search-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { searchResults } from '../../../../shared/ai-vault-search-test-fixture'
import { useAiVaultSearch } from './use-ai-vault-search'
import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host'
import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture'
import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search'
vi.mock('@/store', () => ({
useAppStore: (select: (state: { settings: undefined }) => unknown) =>
select({ settings: undefined })
}))
const ALL_AGENTS = ['codex' as const]
const ALL_REQUEST = { query: 'needle', filters: { agents: ['codex'] } }
const searchSessions =
vi.fn<(request: AiVaultSearchRequest, host?: ExecutionHostId) => Promise<AiVaultSearchResponse>>()
vi.fn<
(request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise<AiVaultSearchResponse>
>()
const empty: AiVaultSearchResponse = {
kind: 'results',
hits: [],
@@ -209,3 +218,52 @@ it('removes a confirmed-deleted hit without re-querying a potentially stale inde
expect(searchSessions).toHaveBeenCalledTimes(1)
unmount()
})
it('searches every computer at once and keeps each hit on the computer that owns it', async () => {
searchSessions.mockResolvedValueOnce({
...searchResults(),
hits: [
{ ...searchHit(), sessionId: 'remote', executionHostId: 'ssh:build-box' },
{ ...searchHit(), sessionId: 'unattributed' }
],
generation: 0,
hosts: [
{ executionHostId: 'local', outcome: 'searched' },
{ executionHostId: 'ssh:build-box', outcome: 'searched' }
]
})
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all')
)
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
{ ...ALL_REQUEST, cursor: undefined },
'all'
)
expect(result.current.sessions.map((session) => session.executionHostId)).toEqual([
'ssh:build-box',
'local'
])
unmount()
})
it('restarts page one under the all scope when the merged cursor goes stale', async () => {
searchSessions.mockResolvedValueOnce({
...searchResults(),
generation: 0,
page: { cursor: 'merged', hasMore: true }
})
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all')
)
await debounce()
searchSessions
.mockResolvedValueOnce({ kind: 'stale-cursor', generation: 0 })
.mockResolvedValueOnce({ ...searchResults(), generation: 0 })
act(() => result.current.loadMore())
await debounce()
expect(searchSessions.mock.calls[1]).toEqual([{ ...ALL_REQUEST, cursor: 'merged' }, 'all'])
expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all'])
expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local'])
unmount()
})
@@ -5,6 +5,8 @@ import type {
AiVaultSearchResponse
} from '../../../../shared/ai-vault-search-types'
import {
ALL_EXECUTION_HOSTS_SCOPE,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId,
type ExecutionHostScope
@@ -17,7 +19,7 @@ import { aiVaultSearchHitToSession } from './ai-vault-search-session'
type SearchIdentity = {
request: AiVaultSearchRequest | null
host: ExecutionHostId | null
scope: ExecutionHostScope | null
policyKey: string
revision: number
}
@@ -32,26 +34,26 @@ type SearchPage = {
export function useAiVaultSearch(
request: AiVaultSearchRequest | null,
host: ExecutionHostId | null,
scope: ExecutionHostScope | null,
policyKey: string
) {
const [page, setPage] = useState<SearchPage | null>(null)
const [revision, setRevision] = useState(0)
const loadPage = useRef<((cursor: string) => void) | null>(null)
const identity = useMemo(
() => ({ request, host, policyKey, revision }),
[request, host, policyKey, revision]
() => ({ request, scope, policyKey, revision }),
[request, scope, policyKey, revision]
)
useEffect(() => {
const { request, host } = identity
if (!request || !host) {
const { request, scope } = identity
if (!request || !scope) {
return
}
let cancelled = false
let pending = false
async function run(cursor?: string) {
if (pending || cancelled || !request || !host) {
if (pending || cancelled || !request || !scope) {
return
}
pending = true
@@ -63,14 +65,14 @@ export function useAiVaultSearch(
loading: true
}))
try {
let response = await window.api.aiVault.searchSessions({ ...request, cursor }, host)
let response = await window.api.aiVault.searchSessions({ ...request, cursor }, scope)
let append = Boolean(cursor)
if (cancelled) {
return
}
if (response.kind === 'stale-cursor') {
append = false
response = await window.api.aiVault.searchSessions(request, host)
response = await window.api.aiVault.searchSessions(request, scope)
}
if (cancelled) {
return
@@ -110,7 +112,7 @@ export function useAiVaultSearch(
hits: current?.hits ?? [],
response: current?.response ?? null,
error: current?.error ?? false,
loading: Boolean(request && host && (!current || current.loading)),
loading: Boolean(request && scope && (!current || current.loading)),
removeHit: (hit: AiVaultSearchHit) =>
setPage((previous) =>
previous?.identity === identity
@@ -126,6 +128,11 @@ export function useAiVaultSearch(
}
}
/** Under `all` every hit names its own host; a single-host answer belongs to the host we addressed. */
function hitExecutionHostId(hit: AiVaultSearchHit, host: ExecutionHostId | null): ExecutionHostId {
return host ?? parseExecutionHostId(hit.executionHostId)?.id ?? LOCAL_EXECUTION_HOST_ID
}
export function useAiVaultPanelSearch(
query: string,
agents: readonly AiVaultAgent[],
@@ -135,21 +142,23 @@ export function useAiVaultPanelSearch(
const settings = useAppStore((state) => state.settings?.aiVaultSearch)
const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings })
const host = parseExecutionHostId(executionHostScope)?.id ?? null
const scope: ExecutionHostScope | null =
executionHostScope === ALL_EXECUTION_HOSTS_SCOPE ? ALL_EXECUTION_HOSTS_SCOPE : host
const searching = query.trim().length > 0
const localConsent = executionHostScope === 'local' && !isWebClientLocation() && !policy.enabled
const request = useMemo(
() =>
searching && host && !localConsent && agents.length > 0
searching && scope && !localConsent && agents.length > 0
? {
query: query.trim(),
filters: { agents: [...agents], ...(paths ? { scopePaths: [...paths] } : {}) }
}
: null,
[searching, host, localConsent, agents, query, paths]
[searching, scope, localConsent, agents, query, paths]
)
const search = useAiVaultSearch(request, host, JSON.stringify(policy))
const search = useAiVaultSearch(request, scope, JSON.stringify(policy))
const sessions = useMemo(
() => (host ? search.hits.map((hit) => aiVaultSearchHitToSession(hit, host)) : []),
() => search.hits.map((hit) => aiVaultSearchHitToSession(hit, hitExecutionHostId(hit, host))),
[search.hits, host]
)
const searchHits = useMemo(
@@ -169,6 +178,6 @@ export function useAiVaultPanelSearch(
searching,
localConsent,
host,
resetKey: JSON.stringify([host, request])
resetKey: JSON.stringify([scope, request])
}
}
+1 -1
View File
@@ -17926,7 +17926,7 @@
"noMatches": "No matching sessions in the indexed history. Try another query or scope.",
"loadMore": "Load more matches",
"indexedHistory": "Indexed history · best matches",
"chooseHost": "Choose one computer to search its sessions.",
"hostsSkipped": "Not searched: {{value0}}",
"consent": "Enable full-text search? Orca builds an index on this computer from local agent transcripts, including full conversations and up to 3,072 characters per tool output. Content is not redacted. Authenticated paired clients can search it.",
"remoteDisabled": "Search is disabled on this computer. Enable transcript indexing on that computer to search its sessions.",
"notReady": "The search index is not ready yet. Try again shortly.",
@@ -18,7 +18,7 @@ import {
normalizeExecutionHostScope,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironment } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
@@ -95,7 +95,8 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
}
// An unparseable id must not normalize into the everything-scope and answer anyway.
function addressesOwnRuntime(executionHostScope: ExecutionHostId | undefined): boolean {
// `all` is a desktop-side merge; it never normalizes to this runtime, so a browser reports no-service.
function addressesOwnRuntime(executionHostScope: ExecutionHostScope | undefined): boolean {
const ownRuntimeId = toRuntimeExecutionHostId(requireActiveEnvironment().id)
return (
executionHostScope === undefined ||
@@ -68,7 +68,7 @@ describe('web session search preload compatibility', () => {
})
expect(await api.searchStatus(scope)).toEqual(unavailableSessionSearchStatus())
}
// @ts-expect-error All-host search is deliberately outside the public API.
// The desktop merges every host; a browser has one runtime and answers for that one only.
expect(await api.searchSessions({ query: 'needle' }, 'all')).toEqual({
kind: 'unavailable',
reason: 'no-service'