diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx new file mode 100644 index 00000000000..7eed10ad14b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultSessionGroup } from './ai-vault-session-filters' + +const mockState: { + settings: { aiVaultSearch?: { enabled: boolean } } + runtimeEnvironments: never[] + folderWorkspaces: Record + projectGroups: never[] + repos: never[] + worktreesByRepo: Record +} = { + settings: {}, + runtimeEnvironments: [], + folderWorkspaces: {}, + projectGroups: [], + repos: [], + worktreesByRepo: {} +} +const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => { + mockState.settings = next +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((select: (state: typeof mockState) => unknown) => select(mockState), { + getState: () => ({ ...mockState, updateSettingsOrThrow }) + }) +})) +vi.mock('@/store/selectors', () => ({ + useActiveRepo: () => null, + useActiveWorktree: () => null, + useActiveWorktreeId: () => null, + useAllWorktrees: () => [], + useProjectHostSetupProjection: () => ({ projects: [], setups: [] }), + useRepos: () => [] +})) + +const sessions: AiVaultSession[] = [ + vaultSession('claude:1', 'Fix the foo pipeline'), + vaultSession('claude:2', 'Rename the bar widget') +] +vi.mock('./ai-vault-session-refresh', () => ({ + useAiVaultSessionRefresh: () => ({ + error: null, + loading: false, + refresh: vi.fn(), + scanResult: { sessions, issues: [], scannedAt: '2026-05-01T10:10:00.000Z' }, + sessions + }) +})) +vi.mock('./ai-vault-session-launch-actions', () => ({ + useAiVaultSessionLaunchActions: () => ({ + buildResumeStartup: vi.fn(), + copyResumeCommand: vi.fn(), + handleResume: vi.fn(), + handleResumeInNewChat: vi.fn(), + handleContinueInNewSession: vi.fn(), + continuationRequest: null, + handleContinuationDialogOpenChange: vi.fn() + }) +})) +vi.mock('./ai-vault-original-pane-actions', () => ({ + useAiVaultOriginalPaneActions: () => ({ + getOriginalPaneTarget: vi.fn(), + getSessionLiveState: vi.fn(), + jumpToOriginalPane: vi.fn(), + jumpToWorktree: vi.fn() + }) +})) +vi.mock('./ai-vault-session-delete-action', () => ({ + useAiVaultSessionDeleteAction: () => vi.fn() +})) +// The virtualizer measures a zero-height viewport under happy-dom; the rows it would +// choose are exactly the grouped sessions, so render those instead. +vi.mock('./AiVaultSessionVirtualList', () => ({ + AiVaultSessionVirtualList: ({ groups }: { groups: readonly AiVaultSessionGroup[] }) => ( + + ) +})) + +const searchSessions = vi.fn() + +function vaultSession(id: string, title: string): AiVaultSession { + return { + id, + executionHostId: 'local', + agent: 'claude', + sessionId: id, + title, + cwd: '/Users/ada/repo', + branch: null, + model: null, + filePath: `/Users/ada/.claude/${id}.jsonl`, + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume', + subagent: null + } +} + +beforeEach(() => { + mockState.settings = {} + searchSessions.mockReset().mockResolvedValue({ + kind: 'results', + hits: [], + page: { cursor: null, hasMore: false }, + generation: 1, + durationMs: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { searchSessions }, ui: { writeClipboardText: vi.fn() } } + }) +}) +afterEach(cleanup) + +async function typeQuery(text: string) { + // Imported here, not at the top: the hoisted mock factories close over `mockState` and `sessions`. + const { default: AiVaultPanel } = await import('./AiVaultPanel') + render() + if (text) { + await userEvent.type(screen.getByLabelText('Search sessions'), text) + } +} + +it('offers indexing above the title-filtered history instead of hiding every session', async () => { + await typeQuery('foo') + + expect(screen.getByRole('status').textContent).toContain('Enable full-text search?') + expect(screen.getByRole('button', { name: 'Enable' })).toBeTruthy() + expect(screen.getByText('Fix the foo pipeline')).toBeTruthy() + expect(screen.queryByText('Rename the bar widget')).toBeNull() + expect(searchSessions).not.toHaveBeenCalled() +}) + +it('switches to index search with the same query once indexing is enabled', async () => { + await typeQuery('foo') + + await userEvent.click(screen.getByRole('button', { name: 'Enable' })) + + expect(updateSettingsOrThrow).toHaveBeenCalledWith({ + aiVaultSearch: { enabled: true, historyDays: null } + }) + await waitFor(() => + expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local') + ) + expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull() + // The index answered with no hits, so the title filter's own row must not linger. + await waitFor(() => expect(screen.queryByText('Fix the foo pipeline')).toBeNull()) +}) + +it('shows the whole history and no offer while the box is empty', async () => { + await typeQuery('') + + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull() + expect(screen.getByText('Fix the foo pipeline')).toBeTruthy() + expect(screen.getByText('Rename the bar widget')).toBeTruthy() + expect(searchSessions).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 01451c05cd8..a51c813e77b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -362,11 +362,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) : null} {!searching && } - setQuery('')} - > + {(!searching || sessions.length > 0 || search.loading) && ( = {}): PanelSearch { sessions: [], searchHits: new Map(), searching: true, - localConsent: false, + hasQuery: true, + needsLocalConsent: false, host: null, resetKey: 'all', ...overrides @@ -35,7 +36,7 @@ function panelSearch(overrides: Partial = {}): PanelSearch { function renderPanel(search: PanelSearch) { return render( - +
results
) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx index 9e879df5ed8..d7294bcf304 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx @@ -55,15 +55,13 @@ function describeSkippedHosts(hosts: readonly AiVaultSearchHostOutcome[]): strin export function AiVaultPanelSearch({ search, noAgents, - onDismiss, children }: { search: ReturnType noAgents: boolean - onDismiss: () => void children: ReactNode }) { - const { localConsent, response, error, loading, retry: onRetry } = search + const { needsLocalConsent, response, error, loading, retry: onRetry } = search const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(false) async function enable() { @@ -81,9 +79,13 @@ export function AiVaultPanelSearch({ setSaving(false) } } + // The consent card is an offer above the legacy title filter's own results, not a wall. + if (!search.hasQuery) { + return children + } const unavailable = response?.kind === 'unavailable' ? response.reason : null let message: string | null = null - if (localConsent) { + if (needsLocalConsent) { 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.' @@ -135,9 +137,6 @@ export function AiVaultPanelSearch({ ) } } - if (!search.searching) { - return children - } if (response?.kind === 'results' && search.hits.length === 0) { message = translate( 'sessionSearch.panel.noMatches', @@ -155,7 +154,7 @@ export function AiVaultPanelSearch({ > {message &&

{message}

} {skippedHosts &&

{skippedHosts}

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

@@ -165,14 +164,9 @@ export function AiVaultPanelSearch({ )}

)} -
- - -
+ ) : !noAgents && (error || 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 92cb800d945..5b33d795159 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 @@ -9,9 +9,10 @@ import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/exe import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' +const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} vi.mock('@/store', () => ({ - useAppStore: (select: (state: { settings: undefined }) => unknown) => - select({ settings: undefined }) + useAppStore: (select: (state: { settings: typeof mockSettings }) => unknown) => + select({ settings: mockSettings }) })) const ALL_AGENTS = ['codex' as const] @@ -35,6 +36,7 @@ beforeEach(() => { value: { aiVault: { searchSessions } } }) searchSessions.mockReset().mockResolvedValue(empty) + delete mockSettings.aiVaultSearch }) afterEach(() => vi.useRealTimers()) async function debounce() { @@ -267,3 +269,44 @@ it('restarts page one under the all scope when the merged cursor goes stale', as expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local']) unmount() }) + +it('leaves the box as the legacy title filter while local indexing consent is pending', async () => { + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).not.toHaveBeenCalled() + expect(result.current.searching).toBe(false) + expect(result.current.hasQuery).toBe(true) + expect(result.current.needsLocalConsent).toBe(true) + expect(result.current.loading).toBe(false) + expect(result.current.sessions).toEqual([]) + unmount() +}) + +it('searches the local index with the same query once consent is on', async () => { + mockSettings.aiVaultSearch = { enabled: true } + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).toHaveBeenCalledExactlyOnceWith( + { ...ALL_REQUEST, cursor: undefined }, + 'local' + ) + expect(result.current.searching).toBe(true) + expect(result.current.hasQuery).toBe(true) + expect(result.current.needsLocalConsent).toBe(false) + unmount() +}) + +it('is neither searching nor holding a query for a blank box', async () => { + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch(' ', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).not.toHaveBeenCalled() + expect(result.current.searching).toBe(false) + expect(result.current.hasQuery).toBe(false) + 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 cec0580531d..a2dcfcba1ee 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 @@ -146,20 +146,24 @@ export function useAiVaultPanelSearch( 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 trimmed = query.trim() + const hasQuery = trimmed.length > 0 + const needsLocalConsent = + executionHostScope === 'local' && !isWebClientLocation() && !policy.enabled + // Until indexing is on the box is still the legacy title filter, not index search. + const searching = hasQuery && !needsLocalConsent // `within` is memoized by the caller; a fresh object per render would restart // the search on every render and never let one settle. const request = useMemo( () => - searching && scope && !localConsent && agents.length > 0 + searching && scope && agents.length > 0 ? { - query: query.trim(), + query: trimmed, filters: { agents: [...agents] }, ...(within ? { within } : {}) } : null, - [searching, scope, localConsent, agents, query, within] + [searching, scope, agents, trimmed, within] ) const search = useAiVaultSearch(request, scope, JSON.stringify(policy)) const sessions = useMemo( @@ -181,7 +185,8 @@ export function useAiVaultPanelSearch( sessions, searchHits, searching, - localConsent, + hasQuery, + needsLocalConsent, host, resetKey: JSON.stringify([scope, request]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 34b3b0c806f..b6953e3f464 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17897,7 +17897,6 @@ "truncated": "Some results or matching text were limited. Narrow your search for more precise results.", "enableFailed": "Could not enable search. Try again.", "enable": "Enable", - "notNow": "Not now", "retry": "Try again", "scopeUnknown": "This computer does not have this workspace or project. Switch the scope to All to search everything on it." }