mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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 084e101328)
This commit is contained in:
@@ -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<string, never>
|
||||
projectGroups: never[]
|
||||
repos: never[]
|
||||
worktreesByRepo: Record<string, never>
|
||||
} = {
|
||||
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[] }) => (
|
||||
<ul>
|
||||
{groups.flatMap((group) =>
|
||||
group.sessions.map((session) => <li key={session.id}>{session.title}</li>)
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}))
|
||||
|
||||
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(<AiVaultPanel />)
|
||||
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()
|
||||
})
|
||||
@@ -362,11 +362,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
) : null}
|
||||
|
||||
{!searching && <AiVaultScanIssueBanners scanResult={scanResult} />}
|
||||
<AiVaultPanelSearch
|
||||
search={search}
|
||||
noAgents={agents.length === 0}
|
||||
onDismiss={() => setQuery('')}
|
||||
>
|
||||
<AiVaultPanelSearch search={search} noAgents={agents.length === 0}>
|
||||
{(!searching || sessions.length > 0 || search.loading) && (
|
||||
<AiVaultSessionVirtualList
|
||||
key={searching ? search.resetKey : 'history'}
|
||||
|
||||
@@ -26,7 +26,8 @@ function panelSearch(overrides: Partial<PanelSearch> = {}): 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> = {}): PanelSearch {
|
||||
|
||||
function renderPanel(search: PanelSearch) {
|
||||
return render(
|
||||
<AiVaultPanelSearch search={search} noAgents={false} onDismiss={vi.fn()}>
|
||||
<AiVaultPanelSearch search={search} noAgents={false}>
|
||||
<div>results</div>
|
||||
</AiVaultPanelSearch>
|
||||
)
|
||||
|
||||
@@ -55,15 +55,13 @@ function describeSkippedHosts(hosts: readonly AiVaultSearchHostOutcome[]): strin
|
||||
export function AiVaultPanelSearch({
|
||||
search,
|
||||
noAgents,
|
||||
onDismiss,
|
||||
children
|
||||
}: {
|
||||
search: ReturnType<typeof useAiVaultPanelSearch>
|
||||
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 && <p>{message}</p>}
|
||||
{skippedHosts && <p>{skippedHosts}</p>}
|
||||
{localConsent ? (
|
||||
{needsLocalConsent ? (
|
||||
<>
|
||||
{saveError && (
|
||||
<p className="text-destructive">
|
||||
@@ -165,14 +164,9 @@ export function AiVaultPanelSearch({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="xs" disabled={saving} onClick={() => void enable()}>
|
||||
{translate('sessionSearch.panel.enable', 'Enable')}
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" disabled={saving} onClick={onDismiss}>
|
||||
{translate('sessionSearch.panel.notNow', 'Not now')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="xs" disabled={saving} onClick={() => void enable()}>
|
||||
{translate('sessionSearch.panel.enable', 'Enable')}
|
||||
</Button>
|
||||
</>
|
||||
) : !noAgents &&
|
||||
(error ||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user