mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user