From a7e34d5695fda152a9ca441f0a496a89359bae91 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:12:59 -0400 Subject: [PATCH] feat(session-search): add panel search and opt-in consent (PR7) (#20580) * feat(session-search): add ranked history panel search and consent * test: wait for initial session indexing before refreshing results * fix(lint): drop the type import #20898 left behind in the windowing test main's tip fails `typecheck` and `static analysis` on `NativeChatMessageList.windowing.test.tsx`: #20898 moved the growth/append suite into its own file and took the last use of `NativeChatMessage` with it, leaving the import. Every open PR reds both jobs through the merge ref, so this rides the first branch that has to merge main in. --- .../NativeChatMessageList.windowing.test.tsx | 1 - .../components/right-sidebar/AiVaultPanel.tsx | 182 ++++++++------- .../right-sidebar/AiVaultPanelControls.tsx | 118 +++++----- .../right-sidebar/AiVaultPanelHeader.tsx | 17 +- .../right-sidebar/AiVaultPanelSearch.tsx | 156 +++++++++++++ .../AiVaultSearchEvidence.test.tsx | 38 ++++ .../right-sidebar/AiVaultSearchEvidence.tsx | 68 ++++++ .../AiVaultSessionActionMenuItems.tsx | 13 +- .../right-sidebar/AiVaultSessionRow.test.tsx | 9 + .../right-sidebar/AiVaultSessionRow.tsx | 22 +- .../AiVaultSessionVirtualList.tsx | 6 +- .../right-sidebar/AiVaultVirtualRow.tsx | 71 ++++-- .../SessionRowTrailingActions.tsx | 2 +- .../ai-vault-search-session.test.ts | 41 ++++ .../right-sidebar/ai-vault-search-session.ts | 42 ++++ .../ai-vault-session-delete-action.ts | 7 +- .../right-sidebar/ai-vault-session-filters.ts | 71 ++++++ .../use-ai-vault-search.test.tsx | 211 ++++++++++++++++++ .../right-sidebar/use-ai-vault-search.ts | 174 +++++++++++++++ src/renderer/src/i18n/locales/en.json | 26 +++ tests/e2e/ai-vault-panel-search.spec.ts | 154 +++++++++++++ 21 files changed, 1241 insertions(+), 188 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-search-session.ts create mode 100644 src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/use-ai-vault-search.ts create mode 100644 tests/e2e/ai-vault-panel-search.spec.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx index e95f6cfb406..b843a6bdc5d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -9,7 +9,6 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { NativeChatMessageList } from './NativeChatMessageList' import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate' diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index a24ce03b448..d52761f877c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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 => { await window.api.ui.writeClipboardText(text) @@ -322,13 +310,14 @@ export default function AiVaultPanel(): React.JSX.Element { }) }, []) - const requestDelete = useAiVaultSessionDeleteAction({ refresh }) + const requestDelete = useAiVaultSessionDeleteAction({ refresh, onDeleted: search.onDeleted }) return (
void refresh({ force: true })} + onRefresh={() => (searching ? search.retry() : void refresh({ force: true }))} /> - {error ? ( + {!searching && error ? (
{error}
) : null} - - - - 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 && } + setQuery('')} + > + {(!searching || sessions.length > 0 || search.loading) && ( + + 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)} + /> + )} + {launchActions.continuationRequest && ( ))} - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')} - - onSortChange(value as AiVaultSort)} - > - - - {translate( - 'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated', - 'Last updated' - )} - - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')} - - - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')} - - onGroupChange(value as AiVaultGroup)} - > - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} - - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} - - - - {translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')} - - - - onHideEmptySessionsChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions', - 'Hide empty sessions' - )} - - + {!searching && ( + <> + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')} + + onSortChange(value as AiVaultSort)} + > + + + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated', + 'Last updated' + )} + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')} + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')} + + onGroupChange(value as AiVaultGroup)} + > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')} + + + + onHideEmptySessionsChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions', + 'Hide empty sessions' + )} + + + + )} {adjustmentCount > 0 ? ( <> diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 83b190a0800..a27014db482 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -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({
- {hasScanResult ? ( + {searching ? ( + translate('sessionSearch.panel.indexedHistory', 'Indexed history · best matches') + ) : hasScanResult ? ( <> {translate( @@ -120,6 +124,7 @@ export function AiVaultPanelHeader({ onExecutionHostScopeChange={onExecutionHostScopeChange} /> { + if (event.key === 'Escape') { + event.stopPropagation() + onQueryChange('') + } + }} spellCheck={false} /> {loading ? : null} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx new file mode 100644 index 00000000000..7ada6fb8189 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx @@ -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 + 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 && ( +
+

{message}

+ {localConsent && !allHosts ? ( + <> + {saveError && ( +

+ {translate( + 'sessionSearch.panel.enableFailed', + 'Could not enable search. Try again.' + )} +

+ )} +
+ + +
+ + ) : !allHosts && + !noAgents && + (error || + unavailable || + response?.kind === 'stale-cursor' || + response?.kind === 'malformed-cursor') ? ( + + ) : null} +
+ )} + {children} + {response?.kind === 'results' && response.page.hasMore && ( +
+ +
+ )} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx new file mode 100644 index 00000000000..67bcb55a353 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx @@ -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: ' [[needle]] unmatched [[' + } + } + const { container } = render() + + expect(screen.getByText('You')).toBeTruthy() + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('mark')?.textContent).toBe('needle') + expect(container.textContent).toContain(' needle unmatched [[') + }) + + it('describes metadata matches and missing transcript sources honestly', () => { + render( + + ) + + expect(screen.getByText('Match in session metadata')).toBeTruthy() + expect(screen.getByText('Transcript is no longer available')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.tsx b/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.tsx new file mode 100644 index 00000000000..1415d250556 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.tsx @@ -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( + + {match[1]} + + ) + 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 ( +
+ {evidence ? ( +
+ + {conversationRoleLabel(evidence.role)} + + : {highlightedSearchSnippet(evidence.snippet)} +
+ ) : ( +
+ {translate( + 'auto.components.right.sidebar.AiVaultSearchEvidence.metadataMatch', + 'Match in session metadata' + )} +
+ )} + {availability ?
{availability}
: null} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index 00f8da07d35..246b9d05ab6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -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' )} - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - + {onCopyPath ? ( + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', + 'Copy Log Path' + )} + + ) : null} {deleteBlockedReason ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx index ba14936aae0..808b518024b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -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 type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { VaultSessionRow } from './AiVaultSessionRow' @@ -59,6 +61,7 @@ afterEach(() => { function renderRow( overrides: { + searchHit?: AiVaultSearchHit session?: AiVaultSession subagentResume?: AiVaultSubagentResumeActions detailsExpanded?: boolean @@ -71,6 +74,7 @@ function renderRow( { }) }) +it('keeps matching evidence visible in expanded search rows', () => { + const { container } = renderRow({ detailsExpanded: true, searchHit: searchHit() }) + expect(container.querySelector('mark')?.textContent).toBe('needle') +}) + it('threads child resume through expanded parent details without resuming the parent', async () => { const child: AiVaultSession = { ...session, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 2add6113108..5f8771315a4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -24,6 +24,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, @@ -52,7 +54,8 @@ export function VaultSessionRow({ onOpenLog, onRevealLog, onOpenCwd, - onRequestDelete + onRequestDelete, + searchHit }: { session: AiVaultSession liveState: AgentStatusState | null @@ -76,18 +79,24 @@ export function VaultSessionRow({ subagentResume?: AiVaultSubagentResumeActions 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') @@ -189,7 +198,8 @@ export function VaultSessionRow({ onRequestDelete={requestDelete} />
- {!detailsExpanded ? ( + {searchHit ? : null} + {!detailsExpanded && !searchHit ? (
{latestTurn ? ( <> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 0e50e9ba89b..8fd88adb9ad 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -22,6 +22,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 @@ -54,7 +55,8 @@ export function AiVaultSessionVirtualList({ onOpenLog, onRevealLog, onOpenCwd, - onRequestDelete + onRequestDelete, + searchHits }: { groups: readonly AiVaultSessionGroup[] collapsedGroups: ReadonlySet @@ -84,6 +86,7 @@ export function AiVaultSessionVirtualList({ onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void onRequestDelete: (session: AiVaultSession) => void + searchHits?: ReadonlyMap }): React.JSX.Element { const listScrollRef = useRef(null) const stickyRangeStartIndexRef = useRef(0) @@ -203,6 +206,7 @@ export function AiVaultSessionVirtualList({ collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} vaultScope={vaultScope} + searchHits={searchHits} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getSessionLiveState={getSessionLiveState} diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx index 4ac61fab170..436f6cc5c3e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx @@ -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 }): 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 (
onToggleSessionDetails(row.session.id)} onJumpToOriginalPane={ originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined @@ -174,38 +189,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} /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 262d736661e..4e935022744 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -80,7 +80,7 @@ export function SessionRowTrailingActions({ onResumeInNewChat?: () => void onCopyResume?: () => void onCopyId: () => void - onCopyPath: () => void + onCopyPath?: () => void onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts new file mode 100644 index 00000000000..5ad44eeeeaa --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts new file mode 100644 index 00000000000..d021a36c85d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts @@ -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 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts index fcd13403a53..94ce8c74395 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts @@ -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 + onDeleted?: (session: AiVaultSession) => void }): (session: AiVaultSession) => Promise { 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] ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 0ea9338c39f..4bb4a678b77 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -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 } +} 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 new file mode 100644 index 00000000000..5f59540c95c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -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>() +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() +}) 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 new file mode 100644 index 00000000000..01ab8b4c6e3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -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(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]) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d98321730a..6ee550030ad 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13379,6 +13379,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." } } }, @@ -17845,6 +17851,26 @@ "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" + } + }, "sessionHistory": { "status": { "checking": "Checking index…", diff --git a/tests/e2e/ai-vault-panel-search.spec.ts b/tests/e2e/ai-vault-panel-search.spec.ts new file mode 100644 index 00000000000..f24bd389f4c --- /dev/null +++ b/tests/e2e/ai-vault-panel-search.spec.ts @@ -0,0 +1,154 @@ +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 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() + // Indexed searches are snapshots; enabling starts indexing independently of the panel. + await expect + .poll( + () => + orcaPage.evaluate( + async () => (await window.api.aiVault.searchStatus('local')).filesIndexed + ), + { timeout: 30_000 } + ) + .toBeGreaterThan(0) + await orcaPage.getByRole('button', { name: 'Refresh Session History', exact: true }).click() + await expect(orcaPage.locator('mark').filter({ hasText: 'nebulariver' })).toBeVisible() + 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() + } +})