feat(session-search): add ranked history panel search and consent

This commit is contained in:
Jinwoo-H
2026-09-14 14:05:47 -04:00
parent c6a7216984
commit d0f2348cb7
20 changed files with 1237 additions and 187 deletions
@@ -10,7 +10,7 @@ import {
useProjectHostSetupProjection,
useRepos
} from '@/store/selectors'
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import { useAiVaultPanelSessions } from './ai-vault-session-filters'
import {
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
@@ -52,6 +52,8 @@ import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-op
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action'
import { useAiVaultPanelSearch } from './use-ai-vault-search'
import { AiVaultPanelSearch } from './AiVaultPanelSearch'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktreeId = useActiveWorktreeId()
@@ -145,11 +147,21 @@ export default function AiVaultPanel(): React.JSX.Element {
}),
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
)
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(
scopePaths,
executionHostScope,
sessionLimit
const {
error,
loading,
refresh,
scanResult,
sessions: history
} = useAiVaultSessionRefresh(scopePaths, executionHostScope, sessionLimit)
const search = useAiVaultPanelSearch(
query,
agents,
scope === 'all' ? undefined : scope === 'workspace' ? activeWorktreePaths : scopePaths,
executionHostScope
)
const { searching, searchHits } = search
const sessions = searching ? search.sessions : history
// Deliberately blind to the active repo/worktree: rebuilding these session
// maps on every worktree switch is what made switching visibly slow (#10841 era).
const sessionProjectById = useMemo(
@@ -216,41 +228,17 @@ export default function AiVaultPanel(): React.JSX.Element {
}
}, [activeProjectKey, activeWorktreePath, scope])
const filteredSessions = useMemo(
() =>
filterAiVaultSessions(sessions, {
query,
agents,
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
}),
[
activeProjectKey,
activeWorktreePaths,
agents,
hideEmptySessions,
projectLabelByKey,
query,
scope,
sessionProjectById,
sessions,
sort
]
)
const groups = useMemo(
() =>
groupAiVaultSessions(filteredSessions, group, {
sessionProjectById,
projectLabelByKey
}),
[filteredSessions, group, projectLabelByKey, sessionProjectById]
)
const { filteredSessions, groups } = useAiVaultPanelSessions(sessions, searching, group, {
query,
agents,
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
})
const copyText = useCallback(async (text: string, label: string): Promise<void> => {
await window.api.ui.writeClipboardText(text)
@@ -323,13 +311,14 @@ export default function AiVaultPanel(): React.JSX.Element {
})
}, [])
const requestDelete = useAiVaultSessionDeleteAction({ refresh })
const requestDelete = useAiVaultSessionDeleteAction({ refresh, onDeleted: search.onDeleted })
return (
<div className="@container/ai-vault flex h-full min-h-0 flex-col bg-sidebar">
<AiVaultPanelHeader
query={query}
loading={loading}
searching={searching}
loading={searching ? search.loading : loading}
shownCount={filteredSessions.length}
sessionCount={sessions.length}
hasScanResult={Boolean(scanResult)}
@@ -354,63 +343,72 @@ export default function AiVaultPanel(): React.JSX.Element {
onHideEmptySessionsChange={setHideEmptySessions}
onSessionLimitChange={setSessionLimit}
onReset={resetViewOptions}
onRefresh={() => void refresh({ force: true })}
onRefresh={() => (searching ? search.retry() : void refresh({ force: true }))}
/>
{error ? (
{!searching && error ? (
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
{error}
</div>
) : null}
<AiVaultScanIssueBanners scanResult={scanResult} />
<AiVaultSessionVirtualList
groups={groups}
collapsedGroups={collapsedGroups}
loading={loading}
sessionsCount={sessions.length}
filteredSessionsCount={filteredSessions.length}
noAgentsSelected={agents.length === 0}
error={error}
vaultScope={scope}
buildResumeStartup={launchActions.buildResumeStartup}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
getWorktreeInfo={getSessionWorktreeInfo}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
onResume={launchActions.handleResume}
getSessionResumeInChat={getSessionResumeInChat}
onContinueInNewSession={launchActions.handleContinueInNewSession}
onResumeInNewChat={launchActions.handleResumeInNewChat}
onCopyResume={(session, worktreeId) =>
void launchActions.copyResumeCommand(session, worktreeId)
}
onCopyId={(session) =>
void copyText(
session.sessionId,
translate('auto.components.right.sidebar.AiVaultPanel.sessionId', 'Session ID')
)
}
onCopyPath={(session) =>
void copyText(
session.filePath,
translate('auto.components.right.sidebar.AiVaultPanel.logPath', 'Log path')
)
}
onOpenLog={(session) => void openAiVaultSessionLogInOrca(session)}
onRevealLog={(session) => void window.api.shell.openPath(session.filePath)}
onOpenCwd={(session) => {
if (session.cwd) {
void window.api.shell.openPath(session.cwd)
}
}}
onRequestDelete={(session) => void requestDelete(session)}
/>
{!searching && <AiVaultScanIssueBanners scanResult={scanResult} />}
<AiVaultPanelSearch
search={search}
noAgents={agents.length === 0}
onDismiss={() => setQuery('')}
>
{(!searching || sessions.length > 0 || search.loading) && (
<AiVaultSessionVirtualList
key={searching ? search.resetKey : 'history'}
searchHits={searching ? searchHits : undefined}
groups={groups}
collapsedGroups={collapsedGroups}
loading={searching ? search.loading : loading}
sessionsCount={sessions.length}
filteredSessionsCount={filteredSessions.length}
noAgentsSelected={agents.length === 0}
error={error}
vaultScope={scope}
buildResumeStartup={launchActions.buildResumeStartup}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
getWorktreeInfo={getSessionWorktreeInfo}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
onResume={launchActions.handleResume}
getSessionResumeInChat={getSessionResumeInChat}
onContinueInNewSession={launchActions.handleContinueInNewSession}
onResumeInNewChat={launchActions.handleResumeInNewChat}
onCopyResume={(session, worktreeId) =>
void launchActions.copyResumeCommand(session, worktreeId)
}
onCopyId={(session) =>
void copyText(
session.sessionId,
translate('auto.components.right.sidebar.AiVaultPanel.sessionId', 'Session ID')
)
}
onCopyPath={(session) =>
void copyText(
session.filePath,
translate('auto.components.right.sidebar.AiVaultPanel.logPath', 'Log path')
)
}
onOpenLog={(session) => void openAiVaultSessionLogInOrca(session)}
onRevealLog={(session) => void window.api.shell.openPath(session.filePath)}
onOpenCwd={(session) => {
if (session.cwd) {
void window.api.shell.openPath(session.cwd)
}
}}
onRequestDelete={(session) => void requestDelete(session)}
/>
)}
</AiVaultPanelSearch>
{launchActions.continuationRequest && (
<AgentSessionContinuationDialog
open
@@ -204,6 +204,7 @@ export function VaultHostScopeMenu({
}
export function VaultViewMenu({
searching = false,
agents,
sort,
group,
@@ -218,6 +219,7 @@ export function VaultViewMenu({
onSessionLimitChange,
onReset
}: {
searching?: boolean
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
@@ -313,62 +315,66 @@ export function VaultViewMenu({
{agentLabel(agent)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sort}
onValueChange={(value) => onSortChange(value as AiVaultSort)}
>
<DropdownMenuRadioItem value="updated">
<Clock3 className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
'Last updated'
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
<Calendar className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={group}
onValueChange={(value) => onGroupChange(value as AiVaultGroup)}
>
<DropdownMenuRadioItem value="project">
<PanelsTopLeft className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="folder">
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="agent">
<ArchiveRestore className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={hideEmptySessions}
onCheckedChange={(checked) => onHideEmptySessionsChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions',
'Hide empty sessions'
)}
</DropdownMenuCheckboxItem>
<AiVaultSessionLimitMenu
sessionLimit={sessionLimit}
onSessionLimitChange={onSessionLimitChange}
/>
{!searching && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sort}
onValueChange={(value) => onSortChange(value as AiVaultSort)}
>
<DropdownMenuRadioItem value="updated">
<Clock3 className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
'Last updated'
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
<Calendar className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={group}
onValueChange={(value) => onGroupChange(value as AiVaultGroup)}
>
<DropdownMenuRadioItem value="project">
<PanelsTopLeft className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="folder">
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="agent">
<ArchiveRestore className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={hideEmptySessions}
onCheckedChange={(checked) => onHideEmptySessionsChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions',
'Hide empty sessions'
)}
</DropdownMenuCheckboxItem>
<AiVaultSessionLimitMenu
sessionLimit={sessionLimit}
onSessionLimitChange={onSessionLimitChange}
/>
</>
)}
{adjustmentCount > 0 ? (
<>
<DropdownMenuSeparator />
@@ -13,6 +13,7 @@ import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
type AiVaultPanelHeaderProps = {
searching?: boolean
query: string
loading: boolean
shownCount: number
@@ -44,6 +45,7 @@ type AiVaultPanelHeaderProps = {
export function AiVaultPanelHeader({
query,
searching = false,
loading,
shownCount,
sessionCount,
@@ -88,7 +90,9 @@ export function AiVaultPanelHeader({
</span>
</div>
<div className="truncate text-[11px] text-muted-foreground">
{hasScanResult ? (
{searching ? (
translate('sessionSearch.panel.indexedHistory', 'Indexed history · best matches')
) : hasScanResult ? (
<>
<span className="@max-[300px]/ai-vault:hidden">
{translate(
@@ -120,6 +124,7 @@ export function AiVaultPanelHeader({
onExecutionHostScopeChange={onExecutionHostScopeChange}
/>
<VaultViewMenu
searching={searching}
agents={agents}
sort={sort}
group={group}
@@ -175,6 +180,16 @@ export function AiVaultPanelHeader({
'Search sessions'
)}
className="min-w-0 flex-1 bg-transparent py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/50"
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanel.searchSessions',
'Search sessions'
)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation()
onQueryChange('')
}
}}
spellCheck={false}
/>
{loading ? <LoaderCircle className="size-3 animate-spin text-muted-foreground" /> : null}
@@ -0,0 +1,156 @@
import { useState, type ReactNode } from 'react'
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 { useAiVaultPanelSearch } from './use-ai-vault-search'
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 allHosts = !search.host
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState(false)
async function enable() {
setSaving(true)
setSaveError(false)
try {
const store = useAppStore.getState()
await store.updateSettingsOrThrow({
aiVaultSearch: { ...resolveAiVaultSearchSettings(store.settings), enabled: true }
})
onRetry()
} catch {
setSaveError(true)
} finally {
setSaving(false)
}
}
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) {
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.'
)
} else if (noAgents) {
message = translate(
'auto.components.right.sidebar.AiVaultPanel.noAgentsSelected',
'No agents selected'
)
} else if (unavailable === 'disabled') {
message = translate(
'sessionSearch.panel.remoteDisabled',
'Search is disabled on this computer. Enable transcript indexing on that computer to search its sessions.'
)
} else if (unavailable === 'not-ready') {
message = translate(
'sessionSearch.panel.notReady',
'The search index is not ready yet. Try again shortly.'
)
} else if (unavailable === 'no-service') {
message = translate(
'sessionSearch.panel.noService',
'Search is unavailable on this computer. It may need an Orca update or a runtime with search support.'
)
} else if (error) {
message = translate(
'sessionSearch.panel.failed',
'Could not search this computer. Check its connection and try again.'
)
} else if (response?.kind === 'stale-cursor' || response?.kind === 'malformed-cursor') {
message = translate(
'sessionSearch.panel.changed',
'The index changed while searching. Search again for current results.'
)
} else if (response?.kind === 'results') {
if (
response.truncated.candidates ||
response.truncated.query ||
response.truncated.snippets > 0
) {
message = translate(
'sessionSearch.panel.truncated',
'Some results or matching text were limited. Narrow your search for more precise results.'
)
}
}
if (!search.searching) {
return children
}
if (response?.kind === 'results' && search.hits.length === 0) {
message = translate(
'sessionSearch.panel.noMatches',
'No matching sessions in the indexed history. Try another query or scope.'
)
}
return (
<>
{message && (
<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 ? (
<>
{saveError && (
<p className="text-destructive">
{translate(
'sessionSearch.panel.enableFailed',
'Could not enable search. Try again.'
)}
</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>
</>
) : !allHosts &&
!noAgents &&
(error ||
unavailable ||
response?.kind === 'stale-cursor' ||
response?.kind === 'malformed-cursor') ? (
<Button size="xs" variant="outline" disabled={loading} onClick={onRetry}>
{translate('sessionSearch.panel.retry', 'Try again')}
</Button>
) : null}
</div>
)}
{children}
{response?.kind === 'results' && response.page.hasMore && (
<div className="border-t border-sidebar-border p-2">
<Button
className="w-full"
variant="ghost"
size="xs"
disabled={loading}
onClick={search.loadMore}
>
{translate('sessionSearch.panel.loadMore', 'Load more matches')}
</Button>
</div>
)}
</>
)
}
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { searchHit } from '../../../../shared/ai-vault-search-test-fixture'
import { AiVaultSearchEvidence } from './AiVaultSearchEvidence'
afterEach(cleanup)
describe('AiVaultSearchEvidence', () => {
it('renders marker text as React content and highlights only paired markers', () => {
const hit = {
...searchHit(),
evidence: {
role: 'user' as const,
timestamp: null,
snippet: '<script>safe</script> [[needle]] unmatched [['
}
}
const { container } = render(<AiVaultSearchEvidence hit={hit} />)
expect(screen.getByText('You')).toBeTruthy()
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('mark')?.textContent).toBe('needle')
expect(container.textContent).toContain('<script>safe</script> needle unmatched [[')
})
it('describes metadata matches and missing transcript sources honestly', () => {
render(
<AiVaultSearchEvidence
hit={{ ...searchHit(), evidence: null, source: { presence: 'missing' } }}
/>
)
expect(screen.getByText('Match in session metadata')).toBeTruthy()
expect(screen.getByText('Transcript is no longer available')).toBeTruthy()
})
})
@@ -0,0 +1,68 @@
import type React from 'react'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import { translate } from '@/i18n/i18n'
import { conversationRoleLabel } from './ai-vault-session-row-display'
export function highlightedSearchSnippet(snippet: string): React.ReactNode[] {
const parts: React.ReactNode[] = []
const marker = /\[\[([\s\S]*?)\]\]/g
let offset = 0
for (const match of snippet.matchAll(marker)) {
const index = match.index
if (index > offset) {
parts.push(snippet.slice(offset, index))
}
parts.push(
<mark
key={`${index}:${match[1]}`}
className="rounded-sm bg-sidebar-accent px-0.5 font-medium text-sidebar-accent-foreground"
>
{match[1]}
</mark>
)
offset = index + match[0].length
}
if (offset < snippet.length) {
parts.push(snippet.slice(offset))
}
return parts
}
export function AiVaultSearchEvidence({ hit }: { hit: AiVaultSearchHit }): React.JSX.Element {
const evidence = hit.evidence
let availability: string | null = null
if (hit.source.presence === 'unverifiable') {
availability = translate(
'auto.components.right.sidebar.AiVaultSearchEvidence.sourceUnverifiable',
'Transcript availability could not be verified'
)
} else if (hit.source.presence === 'missing') {
availability = translate(
'auto.components.right.sidebar.AiVaultSearchEvidence.sourceMissing',
'Transcript is no longer available'
)
}
return (
<div className="mt-0.5 min-w-0 text-[12px] leading-4 text-muted-foreground">
{evidence ? (
<div className="line-clamp-2">
<span className="font-medium text-foreground/80">
{conversationRoleLabel(evidence.role)}
</span>
<span>: {highlightedSearchSnippet(evidence.snippet)}</span>
</div>
) : (
<div className="line-clamp-1">
{translate(
'auto.components.right.sidebar.AiVaultSearchEvidence.metadataMatch',
'Match in session metadata'
)}
</div>
)}
{availability ? <div className="line-clamp-1 text-[11px]">{availability}</div> : null}
</div>
)
}
@@ -46,7 +46,7 @@ export function SessionActionMenuItems({
// empty conversation would contradict the "not saved" state.
onCopyResume?: () => void
onCopyId: () => void
onCopyPath: () => void
onCopyPath?: () => void
onOpenLog?: () => void
onRevealLog?: () => void
onOpenCwd?: () => void
@@ -156,9 +156,14 @@ export function SessionActionMenuItems({
'Copy Session ID'
)}
</Item>
<Item onSelect={onCopyPath}>
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
</Item>
{onCopyPath ? (
<Item onSelect={onCopyPath}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyLogPath',
'Copy Log Path'
)}
</Item>
) : null}
<Separator />
{deleteBlockedReason ? (
<Tooltip>
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import { searchHit } from '../../../../shared/ai-vault-search-test-fixture'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import { VaultSessionRow } from './AiVaultSessionRow'
const session = {
@@ -58,6 +60,7 @@ afterEach(() => {
function renderRow(
overrides: {
searchHit?: AiVaultSearchHit
detailsExpanded?: boolean
worktreeInfo?: AiVaultSessionWorktreeInfo | null
onToggleDetails?: () => void
@@ -68,6 +71,7 @@ function renderRow(
<TooltipProvider>
<VaultSessionRow
session={session}
searchHit={overrides.searchHit}
liveState={null}
resumeStartup={{ command: 'gemini --resume sess-1' }}
realHomeResumeStartup={{ command: 'gemini --resume sess-1' }}
@@ -159,3 +163,8 @@ describe('VaultSessionRow agent metadata line', () => {
expect(container.querySelectorAll(`[title="${worktreeInfo.label}"]`)).toHaveLength(1)
})
})
it('keeps matching evidence visible in expanded search rows', () => {
const { container } = renderRow({ detailsExpanded: true, searchHit: searchHit() })
expect(container.querySelector('mark')?.textContent).toBe('needle')
})
@@ -23,6 +23,8 @@ import {
SessionMetadata
} from './ai-vault-session-row-display'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import { AiVaultSearchEvidence } from './AiVaultSearchEvidence'
export function VaultSessionRow({
session,
@@ -50,7 +52,8 @@ export function VaultSessionRow({
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
onRequestDelete,
searchHit
}: {
session: AiVaultSession
liveState: AgentStatusState | null
@@ -73,18 +76,24 @@ export function VaultSessionRow({
onResumeInNewTab: () => void
onCopyResume?: () => void
onCopyId: () => void
onCopyPath: () => void
onCopyPath?: () => void
onOpenLog?: () => void
onRevealLog?: () => void
onOpenCwd?: () => void
onRequestDelete: (session: AiVaultSession) => void
onRequestDelete?: (session: AiVaultSession) => void
searchHit?: AiVaultSearchHit
}) {
const updatedAt = session.updatedAt ?? session.modifiedAt
const detailsId = getSessionDetailsId(session.id)
const latestTurn = latestSessionConversationTurn(session)
// Computed once so the dropdown menu and the context menu never disagree.
const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session)
const requestDelete = (): void => onRequestDelete(session)
const deleteBlockedReason = onRequestDelete
? aiVaultSessionDeleteBlockedReason(session)
: translate(
'auto.components.right.sidebar.AiVaultSearchEvidence.sourceActionsUnavailable',
'The transcript source is unavailable.'
)
const requestDelete = (): void => onRequestDelete?.(session)
const detailsTooltip = detailsExpanded
? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details')
: translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details')
@@ -186,7 +195,8 @@ export function VaultSessionRow({
onRequestDelete={requestDelete}
/>
</div>
{!detailsExpanded ? (
{searchHit ? <AiVaultSearchEvidence hit={searchHit} /> : null}
{!detailsExpanded && !searchHit ? (
<div className="mt-0.5 min-w-0 line-clamp-2 text-[12px] leading-4 text-muted-foreground">
{latestTurn ? (
<>
@@ -21,6 +21,7 @@ import {
} from './ai-vault-virtual-rows'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import { AiVaultVirtualRow, type AiVaultListRow } from './AiVaultVirtualRow'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
@@ -53,7 +54,8 @@ export function AiVaultSessionVirtualList({
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
onRequestDelete,
searchHits
}: {
groups: readonly AiVaultSessionGroup[]
collapsedGroups: ReadonlySet<string>
@@ -83,6 +85,7 @@ export function AiVaultSessionVirtualList({
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
searchHits?: ReadonlyMap<string, AiVaultSearchHit>
}): React.JSX.Element {
const listScrollRef = useRef<HTMLDivElement>(null)
const stickyRangeStartIndexRef = useRef(0)
@@ -201,6 +204,7 @@ export function AiVaultSessionVirtualList({
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
vaultScope={vaultScope}
searchHits={searchHits}
buildResumeStartup={buildResumeStartup}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
@@ -23,6 +23,8 @@ import {
} from './ai-vault-session-path-actions'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import { canResumeAiVaultSearchHit, hasAiVaultSearchHitPath } from './ai-vault-search-session'
export type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
@@ -57,7 +59,8 @@ export function AiVaultVirtualRow({
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
onRequestDelete,
searchHits
}: {
row: AiVaultListRow | undefined
index: number
@@ -88,6 +91,7 @@ export function AiVaultVirtualRow({
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
searchHits?: ReadonlyMap<string, AiVaultSearchHit>
}): React.JSX.Element | null {
if (!row) {
return null
@@ -124,6 +128,20 @@ export function AiVaultVirtualRow({
// identities that have no single file to open, while Reveal/CWD stay on the
// existing local-path gate.
const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session)
const searchHit = row.type === 'session' ? searchHits?.get(row.session.id) : undefined
const searchResumeAllowed = searchHit ? canResumeAiVaultSearchHit(searchHit) : true
const searchPathAllowed = searchHit ? hasAiVaultSearchHitPath(searchHit) : true
const resumeStartup =
row.type === 'session' && searchResumeAllowed
? buildResumeStartup(row.session, resumeState?.worktreeId)
: { command: '' }
const visibleResumeActions =
searchResumeAllowed && resumeActions
? resumeActions
: {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
return (
<div
@@ -144,23 +162,20 @@ export function AiVaultVirtualRow({
) : (
<VaultSessionRow
session={row.session}
searchHit={searchHit}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
realHomeResumeStartup={buildResumeStartup(
{ ...row.session, codexHome: null },
resumeState?.worktreeId
)}
resumeStartup={resumeStartup}
realHomeResumeStartup={
searchResumeAllowed
? buildResumeStartup({ ...row.session, codexHome: null }, resumeState?.worktreeId)
: resumeStartup
}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeGating.resumeDisabled}
resumeDisabled={!searchResumeAllowed || resumeGating.resumeDisabled}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
resumeActions={visibleResumeActions}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
@@ -173,38 +188,46 @@ export function AiVaultVirtualRow({
}
}}
onContinueInNewSession={
continuationWorktreeId
searchResumeAllowed && continuationWorktreeId
? () => onContinueInNewSession(row.session, continuationWorktreeId)
: undefined
}
onResumeInNewChat={
resumeInChat?.available
searchResumeAllowed && resumeInChat?.available
? () => onResumeInNewChat(row.session, resumeInChat.workspaceId)
: undefined
}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
if (searchResumeAllowed && resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
if (searchResumeAllowed && resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={
resumeGating.canCopyResumeCommand
searchResumeAllowed && resumeGating.canCopyResumeCommand
? () => onCopyResume(row.session, resumeState?.worktreeId)
: undefined
}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined}
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
onOpenCwd={
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
onCopyPath={searchPathAllowed ? () => onCopyPath(row.session) : undefined}
onOpenLog={
searchPathAllowed && canOpenLogInOrca ? () => onOpenLog(row.session) : undefined
}
onRequestDelete={onRequestDelete}
onRevealLog={
searchPathAllowed && canOpenLocalSessionPaths
? () => onRevealLog(row.session)
: undefined
}
onOpenCwd={
searchPathAllowed && canOpenLocalSessionPaths && row.session.cwd
? () => onOpenCwd(row.session)
: undefined
}
onRequestDelete={searchPathAllowed ? onRequestDelete : undefined}
/>
)}
</div>
@@ -80,7 +80,7 @@ export function SessionRowTrailingActions({
onResumeInNewChat?: () => void
onCopyResume?: () => void
onCopyId: () => void
onCopyPath: () => void
onCopyPath?: () => void
onOpenLog?: () => void
onRevealLog?: () => void
onOpenCwd?: () => void
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { searchHit } from '../../../../shared/ai-vault-search-test-fixture'
import {
aiVaultSearchHitToSession,
canResumeAiVaultSearchHit,
hasAiVaultSearchHitPath
} from './ai-vault-search-session'
describe('aiVaultSearchHitToSession', () => {
it('uses the selected execution host and only host-provided source fields', () => {
const hit = { ...searchHit(), executionHostId: 'local' }
const session = aiVaultSearchHitToSession(hit, 'ssh:paired-host')
expect(session.executionHostId).toBe('ssh:paired-host')
expect(session.id).toBe('ssh:paired-host:codex:host-session:/host/transcript.jsonl')
expect(session.filePath).toBe('/host/transcript.jsonl')
expect(session.resumeCommand).toBe('host-resume-command')
expect(session.previewMessages).toEqual([])
})
it('does not invent a path or resume command when transport withholds them', () => {
const hit = {
...searchHit(),
source: { presence: 'unverifiable' as const },
resumeCommand: undefined
}
const session = aiVaultSearchHitToSession(hit, 'runtime:cloud')
expect(session.filePath).toBe('')
expect(session.resumeCommand).toBe('')
expect(canResumeAiVaultSearchHit(hit)).toBe(false)
expect(hasAiVaultSearchHitPath(hit)).toBe(false)
})
it('gates resume and path actions on their own source fields', () => {
const withoutResume = { ...searchHit(), resumeCommand: undefined }
expect(canResumeAiVaultSearchHit(withoutResume)).toBe(false)
expect(hasAiVaultSearchHitPath(withoutResume)).toBe(true)
})
})
@@ -0,0 +1,42 @@
import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
export function aiVaultSearchHitToSession(
hit: AiVaultSearchHit,
executionHostId: ExecutionHostId
): AiVaultSession {
const filePath = hit.source.filePath ?? ''
const timestamp = hit.updatedAt ?? ''
return {
id: `${executionHostId}:${hit.agent}:${hit.sessionId}:${filePath}`,
executionHostId,
agent: hit.agent,
sessionId: hit.sessionId,
title: hit.title,
cwd: hit.cwd,
branch: hit.branch,
model: null,
filePath,
codexHome: hit.source.codexHome ?? null,
createdAt: null,
updatedAt: hit.updatedAt,
modifiedAt: timestamp,
messageCount: hit.messageCount,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: hit.resumeCommand ?? '',
subagent: null
}
}
export function canResumeAiVaultSearchHit(hit: AiVaultSearchHit): boolean {
return hit.source.presence === 'present' && hit.resumeCommand !== undefined
}
export function hasAiVaultSearchHitPath(hit: AiVaultSearchHit): boolean {
return hit.source.presence === 'present' && hit.source.filePath !== undefined
}
@@ -10,9 +10,11 @@ import { agentLabel } from './ai-vault-session-filters'
* refresh so the row goes away immediately.
*/
export function useAiVaultSessionDeleteAction({
refresh
refresh,
onDeleted
}: {
refresh: (options: { force: boolean }) => Promise<void>
onDeleted?: (session: AiVaultSession) => void
}): (session: AiVaultSession) => Promise<void> {
const confirm = useConfirmationDialog()
@@ -49,6 +51,7 @@ export function useAiVaultSessionDeleteAction({
// main-side detail, not something to surface raw.
throw new Error(result.outcome)
}
onDeleted?.(session)
toast.success(
translate('auto.components.right.sidebar.AiVaultPanel.sessionDeleted', 'Session deleted')
)
@@ -64,6 +67,6 @@ export function useAiVaultSessionDeleteAction({
)
}
},
[confirm, refresh]
[confirm, refresh, onDeleted]
)
}
@@ -1,3 +1,11 @@
import { useMemo } from 'react'
import { translate } from '@/i18n/i18n'
import type { AiVaultSession, AiVaultGroup } from '../../../../shared/ai-vault-types'
import {
filterAiVaultSessions,
groupAiVaultSessions,
type AiVaultSessionFilterState
} from '../../../../shared/ai-vault-session-filters'
// Why: the pure filter/group/query core now lives in /shared so the mobile
// package can reuse it (Metro can't import renderer). Re-export for renderer
// import parity. Not a byte-for-byte move: tokenizeQuery gained quoted
@@ -17,3 +25,66 @@ export {
isAiVaultSessionFilterQueryTooLarge,
parseVaultQuery
} from '../../../../shared/ai-vault-session-filters'
export function useAiVaultPanelSessions(
sessions: readonly AiVaultSession[],
searching: boolean,
group: AiVaultGroup,
{
query,
agents,
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
}: AiVaultSessionFilterState
) {
const filteredSessions = useMemo(
() =>
searching
? sessions
: filterAiVaultSessions(sessions, {
query,
agents,
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
}),
[
searching,
sessions,
query,
agents,
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
]
)
const groups = useMemo(
() =>
searching
? filteredSessions.length === 0
? []
: [
{
key: 'search-results',
label: translate('sessionSearch.panel.rankedResults', 'Best matches'),
sessions: [...filteredSessions]
}
]
: groupAiVaultSessions(filteredSessions, group, { sessionProjectById, projectLabelByKey }),
[searching, filteredSessions, group, projectLabelByKey, sessionProjectById]
)
return { filteredSessions, groups }
}
@@ -0,0 +1,211 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
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'
const searchSessions =
vi.fn<(request: AiVaultSearchRequest, host?: ExecutionHostId) => Promise<AiVaultSearchResponse>>()
const empty: AiVaultSearchResponse = {
kind: 'results',
hits: [],
page: { cursor: null, hasMore: false },
generation: 1,
durationMs: 1,
truncated: { candidates: false, snippets: 0, query: false, freshness: false }
}
beforeEach(() => {
vi.useFakeTimers()
Object.defineProperty(window, 'api', {
configurable: true,
value: { aiVault: { searchSessions } }
})
searchSessions.mockReset().mockResolvedValue(empty)
})
afterEach(() => vi.useRealTimers())
async function debounce() {
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
}
it('debounces, skips empty/disabled requests, and never substitutes local for an unknown host', async () => {
const initialProps: { request: AiVaultSearchRequest | null; host: ExecutionHostId | null } = {
request: null,
host: null
}
const { rerender, unmount } = renderHook(
({ request, host }: { request: AiVaultSearchRequest | null; host: ExecutionHostId | null }) =>
useAiVaultSearch(request, host, ''),
{ initialProps }
)
await debounce()
expect(searchSessions).not.toHaveBeenCalled()
rerender({ request: { query: 'old' }, host: 'ssh:remote' })
rerender({ request: { query: 'latest' }, host: 'ssh:remote' })
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
{ query: 'latest', cursor: undefined },
'ssh:remote'
)
unmount()
})
it('hides old-host results immediately and ignores late success and failure after switching', async () => {
let resolveOld: (value: AiVaultSearchResponse) => void = () => {}
searchSessions.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
})
)
const request = { query: 'needle' }
const { result, rerender, unmount } = renderHook(
({ host }: { host: ExecutionHostId }) => useAiVaultSearch(request, host, ''),
{ initialProps: { host: 'local' } }
)
await debounce()
rerender({ host: 'ssh:remote' })
expect(result.current.response).toBeNull()
await debounce()
await act(async () => resolveOld({ kind: 'unavailable', reason: 'disabled' }))
expect(result.current.response).toEqual(empty)
expect(searchSessions.mock.calls.map((call) => call[1])).toEqual(['local', 'ssh:remote'])
unmount()
})
it('refuses late responses after unmount and cancels a pending debounce', async () => {
const request = { query: 'needle' }
const { unmount } = renderHook(() => useAiVaultSearch(request, 'local', ''))
unmount()
await debounce()
expect(searchSessions).not.toHaveBeenCalled()
})
it('restarts page one after stale cursors without looping on a changing index', async () => {
searchSessions.mockResolvedValueOnce({ ...empty, page: { cursor: 'page-2', hasMore: true } })
const request = { query: 'needle', filters: { agents: ['claude' as const] } }
const { result, unmount } = renderHook(() => useAiVaultSearch(request, 'runtime:owner', ''))
await debounce()
searchSessions
.mockResolvedValueOnce({ kind: 'stale-cursor', generation: 2 })
.mockResolvedValueOnce({ kind: 'stale-cursor', generation: 3 })
act(() => result.current.loadMore())
await debounce()
expect(searchSessions.mock.calls[1]).toEqual([{ ...request, cursor: 'page-2' }, 'runtime:owner'])
expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner'])
expect(searchSessions).toHaveBeenCalledTimes(3)
expect(result.current.response?.kind).toBe('stale-cursor')
unmount()
})
it('keeps transport errors and unavailable reasons distinct and retries after consent changes', async () => {
searchSessions.mockRejectedValueOnce(new Error('offline'))
const request = { query: 'needle' }
const { result, rerender, unmount } = renderHook(
({ policy }) => useAiVaultSearch(request, 'local', policy),
{ initialProps: { policy: 'disabled' } }
)
await debounce()
expect(result.current.error).toBe(true)
searchSessions.mockResolvedValueOnce({ kind: 'unavailable', reason: 'no-service' })
act(() => result.current.retry())
await debounce()
expect(result.current.error).toBe(false)
expect(result.current.response).toEqual({ kind: 'unavailable', reason: 'no-service' })
rerender({ policy: 'enabled' })
expect(result.current.response).toBeNull()
await debounce()
expect(result.current.response?.kind).toBe('results')
unmount()
})
it('discards pagination when a host is left and revisited, and replaces stale pages', async () => {
const first = searchResults()
searchSessions.mockResolvedValueOnce({ ...first, page: { cursor: 'next', hasMore: true } })
const request = { query: 'needle' }
const { result, rerender, unmount } = renderHook(
({ host }: { host: ExecutionHostId }) => useAiVaultSearch(request, host, ''),
{ initialProps: { host: 'local' } }
)
await debounce()
searchSessions
.mockResolvedValueOnce({ kind: 'stale-cursor', generation: 8 })
.mockResolvedValueOnce({ ...first, hits: [{ ...first.hits[0], sessionId: 'replacement' }] })
act(() => {
result.current.loadMore()
result.current.loadMore()
})
await debounce()
expect(result.current.hits.map((hit) => hit.sessionId)).toEqual(['replacement'])
expect(searchSessions).toHaveBeenCalledTimes(3)
rerender({ host: 'ssh:other' })
await debounce()
rerender({ host: 'local' })
await debounce()
expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local'])
unmount()
})
it('ignores a late failure for a superseded query', async () => {
let rejectOld: (error: Error) => void = () => {}
searchSessions.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectOld = reject
})
)
const { result, rerender, unmount } = renderHook(
({ request }) => useAiVaultSearch(request, 'local', ''),
{ initialProps: { request: { query: 'old' } } }
)
await debounce()
rerender({ request: { query: 'new' } })
await debounce()
await act(async () => rejectOld(new Error('offline')))
expect(result.current.error).toBe(false)
expect(result.current.response).toEqual(empty)
unmount()
})
it('does not revive old results or cursors before debounce when returning from an invalid host', async () => {
const request = { query: 'needle' }
searchSessions.mockResolvedValueOnce({
...searchResults(),
page: { cursor: 'obsolete', hasMore: true }
})
const initialProps: { host: ExecutionHostId | null } = { host: 'local' }
const { result, rerender, unmount } = renderHook(
({ host }) => useAiVaultSearch(host ? request : null, host, ''),
{ initialProps }
)
await debounce()
expect(result.current.hits.length).toBe(1)
rerender({ host: null })
rerender({ host: 'local' })
expect(result.current.hits).toEqual([])
expect(result.current.response).toBeNull()
expect(result.current.loading).toBe(true)
act(() => result.current.loadMore())
expect(searchSessions).toHaveBeenCalledTimes(1)
await debounce()
expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local'])
unmount()
})
it('removes a confirmed-deleted hit without re-querying a potentially stale index', async () => {
const response = searchResults()
searchSessions.mockResolvedValue(response)
const request = { query: 'needle' }
const { result, unmount } = renderHook(() => useAiVaultSearch(request, 'local', ''))
await debounce()
act(() => result.current.removeHit(response.hits[0]))
expect(result.current.hits).toEqual([])
expect(searchSessions).toHaveBeenCalledTimes(1)
unmount()
})
@@ -0,0 +1,174 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
AiVaultSearchHit,
AiVaultSearchRequest,
AiVaultSearchResponse
} from '../../../../shared/ai-vault-search-types'
import {
parseExecutionHostId,
type ExecutionHostId,
type ExecutionHostScope
} from '../../../../shared/execution-host'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '@/store'
import { aiVaultSearchHitToSession } from './ai-vault-search-session'
type SearchIdentity = {
request: AiVaultSearchRequest | null
host: ExecutionHostId | null
policyKey: string
revision: number
}
type SearchPage = {
identity: SearchIdentity
hits: AiVaultSearchHit[]
response: AiVaultSearchResponse | null
error: boolean
loading: boolean
}
export function useAiVaultSearch(
request: AiVaultSearchRequest | null,
host: ExecutionHostId | 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]
)
useEffect(() => {
const { request, host } = identity
if (!request || !host) {
return
}
let cancelled = false
let pending = false
async function run(cursor?: string) {
if (pending || cancelled || !request || !host) {
return
}
pending = true
setPage((previous) => ({
identity,
hits: cursor && previous?.identity === identity ? previous.hits : [],
response: null,
error: false,
loading: true
}))
try {
let response = await window.api.aiVault.searchSessions({ ...request, cursor }, host)
let append = Boolean(cursor)
if (cancelled) {
return
}
if (response.kind === 'stale-cursor') {
append = false
response = await window.api.aiVault.searchSessions(request, host)
}
if (cancelled) {
return
}
setPage((previous) => ({
identity,
hits:
response.kind === 'results'
? [
...(append && previous?.identity === identity ? previous.hits : []),
...response.hits
]
: [],
response,
error: false,
loading: false
}))
} catch {
if (!cancelled) {
setPage({ identity, hits: [], response: null, error: true, loading: false })
}
} finally {
pending = false
}
}
loadPage.current = (cursor) => void run(cursor)
const timer = setTimeout(() => void run(), 250)
return () => {
cancelled = true
loadPage.current = null
clearTimeout(timer)
}
}, [identity])
const current = page?.identity === identity ? page : null
return {
hits: current?.hits ?? [],
response: current?.response ?? null,
error: current?.error ?? false,
loading: Boolean(request && host && (!current || current.loading)),
removeHit: (hit: AiVaultSearchHit) =>
setPage((previous) =>
previous?.identity === identity
? { ...previous, hits: previous.hits.filter((entry) => entry !== hit) }
: previous
),
retry: () => setRevision((value) => value + 1),
loadMore: () => {
if (current?.response?.kind === 'results' && current.response.page.cursor) {
loadPage.current?.(current.response.page.cursor)
}
}
}
}
export function useAiVaultPanelSearch(
query: string,
agents: readonly AiVaultAgent[],
paths: readonly string[] | undefined,
executionHostScope: ExecutionHostScope
) {
const settings = useAppStore((state) => state.settings?.aiVaultSearch)
const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings })
const host = parseExecutionHostId(executionHostScope)?.id ?? null
const searching = query.trim().length > 0
const localConsent = executionHostScope === 'local' && !isWebClientLocation() && !policy.enabled
const request = useMemo(
() =>
searching && host && !localConsent && agents.length > 0
? {
query: query.trim(),
filters: { agents: [...agents], ...(paths ? { scopePaths: [...paths] } : {}) }
}
: null,
[searching, host, localConsent, agents, query, paths]
)
const search = useAiVaultSearch(request, host, JSON.stringify(policy))
const sessions = useMemo(
() => (host ? search.hits.map((hit) => aiVaultSearchHitToSession(hit, host)) : []),
[search.hits, host]
)
const searchHits = useMemo(
() => new Map(sessions.map((session, index) => [session.id, search.hits[index]])),
[sessions, search.hits]
)
return {
...search,
onDeleted: (session: AiVaultSession) => {
const hit = searchHits.get(session.id)
if (hit) {
search.removeHit(hit)
}
},
sessions,
searchHits,
searching,
localConsent,
host,
resetKey: JSON.stringify([host, request])
}
}
+26
View File
@@ -13349,6 +13349,12 @@
"90cabf6cfc": "Stack this PR above #{{value0}}",
"ff81473a57": "Creates a GitHub Stack or extends the parent's existing stack.",
"29732f2fb0": "new PR"
},
"AiVaultSearchEvidence": {
"sourceUnverifiable": "Transcript availability could not be verified",
"sourceMissing": "Transcript is no longer available",
"metadataMatch": "Match in session metadata",
"sourceActionsUnavailable": "The transcript source is unavailable."
}
}
},
@@ -17875,5 +17881,25 @@
"stopped": "stopped",
"finished": "finished"
}
},
"sessionSearch": {
"panel": {
"rankedResults": "Best matches",
"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.",
"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.",
"noService": "Search is unavailable on this computer. It may need an Orca update or a runtime with search support.",
"failed": "Could not search this computer. Check its connection and try again.",
"changed": "The index changed while searching. Search again for current results.",
"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"
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
test('panel consent enables real local transcript search; clearing restores history', async ({
electronApp,
orcaPage,
seededRepoPath
}, testInfo) => {
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
const directory = path.join(home, '.claude', 'projects', '-synthetic-pr7')
mkdirSync(directory, { recursive: true })
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
writeFileSync(
path.join(directory, `${sessionId}.jsonl`),
`${[
{
type: 'user',
sessionId,
cwd: seededRepoPath,
timestamp: new Date().toISOString(),
message: { role: 'user', content: 'Synthetic panel transcript' }
},
{
type: 'assistant',
sessionId,
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: 'The nebulariver implementation handles <script>literal text</script> safely.'
}
}
]
.map((record) => JSON.stringify(record))
.join('\n')}\n`
)
await orcaPage.evaluate(() => {
const state = window.__store?.getState()
state?.setRightSidebarOpen(true)
state?.setRightSidebarTab('vault')
state?.setRightSidebarWidth(400)
})
await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click()
await orcaPage.getByRole('radio', { name: 'All', exact: true }).click()
const input = orcaPage.getByRole('textbox', { name: 'Search sessions', exact: true })
await input.fill('nebulariver')
await expect(orcaPage.getByText('Enable full-text search?', { exact: false })).toBeVisible()
const cdp = await orcaPage.context().newCDPSession(orcaPage)
async function screenshot(name: string) {
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' })
const screenshotPath = testInfo.outputPath(name)
writeFileSync(screenshotPath, Buffer.from(data, 'base64'))
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' })
}
await screenshot('consent.png')
await orcaPage.getByRole('button', { name: 'Not now', exact: true }).click()
await expect(input).toHaveValue('')
await input.fill('nebulariver')
await orcaPage.getByRole('button', { name: 'Enable', exact: true }).click()
await expect(orcaPage.locator('mark').filter({ hasText: 'nebulariver' })).toBeVisible({
timeout: 30_000
})
expect(
await orcaPage.evaluate(
async () => (await window.api.aiVault.searchStatus('local')).filesIndexed
)
).toBeGreaterThan(0)
await expect(orcaPage.getByText('Synthetic panel transcript', { exact: true })).toBeVisible()
await screenshot('results.png')
await orcaPage.getByTitle('Drag to resume in a new tab', { exact: true }).click()
await expect(orcaPage.locator('mark').filter({ hasText: 'nebulariver' })).toBeVisible()
await orcaPage.getByTitle('Drag to resume in a new tab', { exact: true }).click()
const title = orcaPage.getByText('Synthetic panel transcript', { exact: true })
await expect(title).toHaveAttribute('draggable', 'true')
const drag = await title.evaluate((element) => {
const dataTransfer = new DataTransfer()
element.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer }))
const payload = dataTransfer.getData('application/x-orca-ai-vault-session')
element.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer }))
return payload
})
expect(JSON.parse(drag)).toMatchObject({ sessionId, sessionExecutionHostId: 'local' })
await title.click({ button: 'right' })
await expect(
orcaPage.getByRole('menuitem', { name: 'Copy Session ID', exact: true })
).toBeVisible()
await orcaPage.keyboard.press('Escape')
await expect(orcaPage.locator('[role="menu"]')).toHaveCount(0)
await orcaPage.evaluate(async () => {
await window.__store?.getState().updateSettingsOrThrow({ theme: 'dark' })
window.__store?.getState().setRightSidebarWidth(280)
})
await expect(orcaPage.locator('html')).toHaveClass(/dark/)
await screenshot('results-dark-narrow.png')
await title.click({ button: 'right' })
await orcaPage.getByRole('menuitem', { name: 'Delete', exact: true }).click()
await orcaPage.getByRole('button', { name: 'Delete', exact: true }).click()
await expect(title).toHaveCount(0)
await expect(orcaPage.locator('mark')).toHaveCount(0)
await input.fill('nothingmatchesprseven')
await expect(
orcaPage.getByText('No matching sessions in the indexed history.', { exact: false })
).toBeVisible()
await screenshot('empty.png')
await input.press('Escape')
await expect(input).toHaveValue('')
await expect(orcaPage.getByText('Indexed history · best matches', { exact: false })).toHaveCount(
0
)
await cdp.detach()
})
test('panel renders transport failure and unavailable reasons without a local fallback', async ({
electronApp,
orcaPage
}, testInfo) => {
await orcaPage.evaluate(async () => {
await window.__store
?.getState()
.updateSettingsOrThrow({ aiVaultSearch: { enabled: true, historyDays: null } })
window.__store?.getState().setRightSidebarOpen(true)
window.__store?.getState().setRightSidebarTab('vault')
})
await electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('aiVault:searchSessions')
ipcMain.handle('aiVault:searchSessions', () => {
throw new Error('Synthetic transport failure')
})
})
const input = orcaPage.getByRole('textbox', { name: 'Search sessions', exact: true })
await input.fill('needle')
await expect(
orcaPage.getByText('Could not search this computer.', { exact: false })
).toBeVisible()
await orcaPage.screenshot({ path: testInfo.outputPath('failure.png') })
for (const reason of ['disabled', 'not-ready', 'no-service'] as const) {
await electronApp.evaluate(({ ipcMain }, value) => {
ipcMain.removeHandler('aiVault:searchSessions')
ipcMain.handle('aiVault:searchSessions', () => ({ kind: 'unavailable', reason: value }))
}, reason)
await orcaPage.getByRole('button', { name: 'Try again', exact: true }).click()
const copy =
reason === 'disabled'
? 'Search is disabled on this computer.'
: reason === 'not-ready'
? 'The search index is not ready yet.'
: 'Search is unavailable on this computer.'
await expect(orcaPage.getByText(copy, { exact: false })).toBeVisible()
}
})