diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 624a9caa778..cf82217ec17 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -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 /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx new file mode 100644 index 00000000000..27e9e1cab99 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx @@ -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 + +function panelSearch(overrides: Partial = {}): 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( + +
results
+
+ ) +} + +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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx index 7ada6fb8189..b125bf263ad 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx @@ -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) && (
-

{message}

- {localConsent && !allHosts ? ( + {message &&

{message}

} + {skippedHosts &&

{skippedHosts}

} + {localConsent ? ( <> {saveError && (

@@ -125,8 +155,7 @@ export function AiVaultPanelSearch({

- ) : !allHosts && - !noAgents && + ) : !noAgents && (error || unavailable || response?.kind === 'stale-cursor' || diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index 5f59540c95c..92cb800d945 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -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>() + vi.fn< + (request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise + >() 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() +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index 01ab8b4c6e3..2e0672c6543 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -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(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]) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 61191d508eb..0cc57e219b9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index eb28193e272..d87e88d63bc 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -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['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 || diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index a9f5980cf68..215ffc928f5 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -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'