mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(session-search): add ranked history panel search and consent
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user