From b10226a7ed871461a0860e02bf4e27bae05ca6a9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:45:43 -0400 Subject: [PATCH] fix(session-search): keep the title filter while full-text search is off (#21550) * fix(session-search): keep the title filter while full-text search is off The search box switched to index search the moment the query was non-blank, so on a computer with indexing off the panel showed only the consent card and hid every session. Derive the mode once in useAiVaultPanelSearch: while local consent is pending the box stays the legacy title filter and the consent card becomes an offer above the filtered results. * test(session-search): cover the legacy title filter while indexing is off The panel test fails on the pre-fix code with no session rows rendered. * refactor(session-search): drop Not now and name the query flag queried The dismiss button's only remaining job was wiping the user's live title filter. Keep localConsent and expose queried so the panel reads one flag per fact, and assert the post-enable and empty-box states. * refactor(session-search): guard first and name the search-mode flags for what they mean (cherry picked from commit 084e101328fef77a2f0997375333939b59ead4d4) --- .../AiVaultPanel.legacy-filter.test.tsx | 175 ++++++++++++++++++ .../components/right-sidebar/AiVaultPanel.tsx | 6 +- .../right-sidebar/AiVaultPanelSearch.test.tsx | 5 +- .../right-sidebar/AiVaultPanelSearch.tsx | 26 +-- .../use-ai-vault-search.test.tsx | 47 ++++- .../right-sidebar/use-ai-vault-search.ts | 17 +- src/renderer/src/i18n/locales/en.json | 1 - 7 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx 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." }