feat(session-search): sort search results by newest, and break relevance ties by recency (#21863)

* feat(session-search): sort search results by newest, and break relevance ties by recency

Results were ordered by match score alone with the session id as the
tiebreak, so equally good matches came out in an arbitrary order and
nothing ever favoured recent work. The Sort menu now offers Most relevant
and Newest while the box has text; the engine already knew both orders and
the all-computers merge already honoured the newest one, so only the panel
had to ask. Under Most relevant, equal scores now go to the newer session.
The choice persists with the other view options, separately from the
list's own Last updated / Created sort.

* fix(session-search): label results by the order they are in

The header subtitle and the results group said "best matches" whichever
sort was chosen; under Newest they now say so. The panel's scope state and
its two context effects move to use-ai-vault-panel-scope.ts, which keeps
the panel under the line cap and gives that behaviour a name.

* feat(session-search): move search sort onto a results bar above the hits

Search mode gets a bar in the group header's place: the hit count on the
left, a ghost menu button on the right that names the current order and
opens the two-item radio group. The filter menu's Sort section keeps one
meaning again (Last updated / Created), the header subtitle stops
reporting sort, and search rows run flat with no group header.

* style(session-search): drop the icons from the results-bar sort menu and match its text size

* feat(session-search): one sort bar above the list in both modes

Filters stay behind the header filter icon; sort moves onto the bar
directly above the session list, in browse mode as well as search.
The bar is mode-agnostic: it takes a label, the selected value, a typed
option list, and a callback, and the panel configures it twice.

- rename AiVaultSearchResultsBar to AiVaultSessionListBar and generalize it
- add ai-vault-sort-options for the two option lists and their aria labels
- drop the Sort section from the filter menu and stop counting sort in the badge
- header subtitle now reads "Indexed history" in both modes

* feat(session-search): count sessions plainly and offer Show more when the scan fills its depth

* fix(session-search): step history depth 250 at a time and keep Show more visible while the rescan runs

* style(session-search): let the sort menu hug its two options

* fix(session-search): show more reads the depth its rows came from

The row inferred "a deeper rescan is running" from the selected depth minus one
page, which at the default depth is zero, so every foreground scan with at least
one session painted a disabled "Loading more sessions…" footer the scan had room
for.

The scan now publishes the depth it ran at beside its sessions, and the row
compares the two: it survives the rescan because that depth trails the selected
one until the deeper scan lands. Drops the stepping arithmetic and
nextAiVaultSessionLimit, and moves the row out of the menu file it was sharing.

* refactor(session-search): an untitled group is what hides a header

Search mode said "no group headers" twice, in two files, both keyed off the same
flag: an empty label in the filters hook and a hideGroupHeaders prop on the list.
The label is now the only fact. A null label means the group has no header of
its own, the list renders its rows flat, and the prop is gone.

The shared group type keeps its string label so the mobile sections that map it
are untouched; the nullable label is the renderer list's own type.

* refactor(session-search): plain labels, and a browse bar that can report zero

Three small simplifications around the list bar:

- The browse bar is guarded on the loaded history rather than the filtered rows,
  so "0 of 250 sessions" can actually appear when filters hide everything and
  the sort control stays reachable. Search keeps its own guard.
- The two count labels were components whose whole body was a ternary over
  translate; they are functions returning a string, and the bar's label prop is
  a string.
- The persistence guards stop being exported with no caller outside the file,
  and the search-sort guard reads the AI_VAULT_SEARCH_SORTS list instead of
  respelling the union.
This commit is contained in:
Jinwoo Hong
2026-09-21 14:11:40 -04:00
committed by GitHub
parent 663d670878
commit f07bf8544c
35 changed files with 735 additions and 198 deletions
@@ -39,6 +39,15 @@ describe('order', () => {
expect(rankSessionHits(sessions, scores, 'newest').map((e) => e.session.id)).toEqual([2, 1])
})
it('hands a relevance tie to the newer session before falling back to id', () => {
const sessions = [
session(1, { updated_at: '2026-09-01T00:00:00.000Z' }),
session(2, { updated_at: '2026-09-09T00:00:00.000Z' })
]
const scores = matches(match(1, 5), match(2, 5))
expect(rankSessionHits(sessions, scores, 'relevance').map((e) => e.session.id)).toEqual([2, 1])
})
it.each(['relevance', 'newest'] as const)(
'breaks a %s tie by session, whatever order retrieval handed them over in',
(sort) => {
@@ -65,11 +65,12 @@ export function rankSessionHits(
)
// Why a total order and not just the key: a cursor is an offset into this
// list, so two entries that tie must not be free to swap between pages.
// Newer first among equal scores, so relevance never hands ties to whichever id is lower.
scored.sort(
(left, right) =>
(sort === 'newest'
? (right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '')
: right.score - left.score) || left.session.id - right.session.id
(sort === 'newest' ? 0 : right.score - left.score) ||
(right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '') ||
left.session.id - right.session.id
)
return scored
}
@@ -3,7 +3,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultSessionListGroup } from './ai-vault-session-filters'
const mockState: {
settings: { aiVaultSearch?: { enabled: boolean } }
@@ -76,8 +76,9 @@ vi.mock('./ai-vault-session-delete-action', () => ({
// The virtualizer measures a zero-height viewport under happy-dom; the rows it would
// choose are exactly the grouped sessions, so render those instead.
vi.mock('./AiVaultSessionVirtualList', () => ({
AiVaultSessionVirtualList: ({ groups }: { groups: readonly AiVaultSessionGroup[] }) => (
<ul>
AiVaultSessionVirtualList: ({ groups }: { groups: readonly AiVaultSessionListGroup[] }) => (
// A null label is what tells the real list to render this group's rows without a header.
<ul data-group-labels={groups.map((group) => group.label ?? '(untitled)').join('|')}>
{groups.flatMap((group) =>
group.sessions.map((session) => <li key={session.id}>{session.title}</li>)
)}
@@ -172,4 +173,87 @@ it('shows the whole history and no offer while the box is empty', async () => {
expect(screen.getByText('Fix the foo pipeline')).toBeTruthy()
expect(screen.getByText('Rename the bar widget')).toBeTruthy()
expect(searchSessions).not.toHaveBeenCalled()
// The same bar serves both modes; browsing it reports the history counts and its own sort.
expect(screen.getByText('2 sessions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sort sessions: Last updated' })).toBeTruthy()
expect(screen.queryByText('2 results')).toBeNull()
expect(screen.queryByRole('button', { name: /^Sort results:/ })).toBeNull()
})
it('keeps sort off the filter menu, which is filters only', async () => {
await typeQuery('')
await userEvent.click(screen.getByRole('button', { name: 'Session History view options' }))
expect(await screen.findByRole('menuitem', { name: 'Select all' })).toBeTruthy()
expect(screen.queryByText('Sort')).toBeNull()
expect(screen.queryByRole('menuitemradio', { name: 'Last updated' })).toBeNull()
expect(screen.queryByRole('menuitemradio', { name: 'Created' })).toBeNull()
})
it('puts the hit count and the search sort on the same bar once the index answers', async () => {
mockState.settings = { aiVaultSearch: { enabled: true } }
searchSessions.mockResolvedValue({
kind: 'results',
hits: [
{
agent: 'claude',
sessionId: 'claude:1',
title: 'Fix the foo pipeline',
cwd: '/Users/ada/repo',
branch: null,
updatedAt: '2026-05-01T10:10:00.000Z',
messageCount: 4,
score: 1,
source: { presence: 'present', filePath: '/Users/ada/.claude/claude:1.jsonl' },
evidence: null
}
],
page: { cursor: null, hasMore: false },
generation: 1,
durationMs: 1,
truncated: { candidates: false, snippets: 0, query: false, freshness: false }
})
await typeQuery('foo')
await waitFor(() => expect(screen.getByText('1 result')).toBeTruthy())
expect(screen.getByRole('button', { name: 'Sort results: Most relevant' })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Sort sessions:/ })).toBeNull()
})
it('hands the list one untitled group while searching, and titled groups while browsing', async () => {
mockState.settings = { aiVaultSearch: { enabled: true } }
searchSessions.mockResolvedValue({
kind: 'results',
hits: [
{
agent: 'claude',
sessionId: 'claude:1',
title: 'Fix the foo pipeline',
cwd: '/Users/ada/repo',
branch: null,
updatedAt: '2026-05-01T10:10:00.000Z',
messageCount: 4,
score: 1,
source: { presence: 'present', filePath: '/Users/ada/.claude/claude:1.jsonl' },
evidence: null
}
],
page: { cursor: null, hasMore: false },
generation: 1,
durationMs: 1,
truncated: { candidates: false, snippets: 0, query: false, freshness: false }
})
await typeQuery('')
const browsing = screen.getByRole('list').getAttribute('data-group-labels')
expect(browsing).not.toContain('(untitled)')
expect(browsing).toBeTruthy()
await userEvent.type(screen.getByLabelText('Search sessions'), 'foo')
await waitFor(() =>
expect(screen.getByRole('list').getAttribute('data-group-labels')).toBe('(untitled)')
)
})
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
@@ -15,11 +15,6 @@ import {
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { countAiVaultViewAdjustments } from './ai-vault-view-defaults'
import {
buildAiVaultProjectContext,
@@ -38,9 +33,16 @@ import {
} from './ai-vault-session-worktree'
import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import {
aiVaultResultCountLabel,
AiVaultSessionListBar,
aiVaultSessionCountLabel
} from './AiVaultSessionListBar'
import { aiVaultBrowseSortMenu, aiVaultSearchSortMenu } from './ai-vault-sort-options'
import { AiVaultShowMoreSessionsRow } from './AiVaultShowMoreSessionsRow'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
import {
@@ -48,6 +50,7 @@ import {
buildRuntimeAiVaultHostScopeOptions,
useAiVaultExecutionHostScope
} from './ai-vault-host-scope'
import { useAiVaultPanelScope } from './use-ai-vault-panel-scope'
import { useAiVaultSearchFocusRequest } from './use-ai-vault-search-focus-request'
import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options'
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
@@ -79,14 +82,15 @@ export default function AiVaultPanel(): React.JSX.Element {
useAiVaultOriginalPaneActions()
const [query, setQuery] = useState('')
// Why: scope depends on current workspace/project availability, so only stable view options persist.
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const {
agents,
sort,
searchSort,
group,
hideEmptySessions,
sessionLimit,
setSort,
setSearchSort,
setGroup,
setHideEmptySessions,
setSessionLimit,
@@ -95,8 +99,6 @@ export default function AiVaultPanel(): React.JSX.Element {
resetViewOptions
} = usePersistedAiVaultViewOptions()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const runtimeHostOptions = useMemo(
() => buildRuntimeAiVaultHostScopeOptions(runtimeEnvironments),
@@ -139,6 +141,10 @@ export default function AiVaultPanel(): React.JSX.Element {
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos]
)
const activeProjectKey = projectScopeContext.activeProjectKey
const { scope, handleScopeChange } = useAiVaultPanelScope({
activeProjectKey,
activeWorktreePath
})
const projectLabelByKey = projectScopeContext.projectLabelByKey
// Sent to the scanner so scoped views surface sessions older than the global cap.
const scopePaths = useMemo(
@@ -154,7 +160,8 @@ export default function AiVaultPanel(): React.JSX.Element {
loading,
refresh,
scanResult,
sessions: history
sessions: history,
loadedSessionLimit
} = useAiVaultSessionRefresh(scopePaths, executionHostScope, sessionLimit)
// Why an identity and not paths: a project's worktrees are the host's to
// enumerate, and a repo with hundreds of them has no path list a request can carry.
@@ -163,7 +170,7 @@ export default function AiVaultPanel(): React.JSX.Element {
aiVaultSearchScopeIdentity({ scope, activeWorktreeId: activeWorktree?.id, activeProjectKey }),
[activeProjectKey, activeWorktree?.id, scope]
)
const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope)
const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort)
const { searching, searchHits } = search
const sessions = searching ? search.sessions : history
// Deliberately blind to the active repo/worktree: rebuilding these session
@@ -201,37 +208,11 @@ export default function AiVaultPanel(): React.JSX.Element {
})
const viewAdjustmentCount = countAiVaultViewAdjustments({
agents,
sort,
group,
hideEmptySessions,
sessionLimit
})
// Workspace is the preferred default, but unavailable context still falls back to All.
useEffect(() => {
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
activeProjectKey,
activeWorktreePath
})
if (normalizedScope !== scope) {
setScope(normalizedScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
const restorableScope = getRestorableAiVaultScope({
scope,
activeProjectKey,
activeWorktreePath,
preferredScope: preferredScopeRef.current,
userChangedScope: userChangedScopeRef.current
})
if (restorableScope) {
setScope(restorableScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
const { filteredSessions, groups } = useAiVaultPanelSessions(sessions, searching, group, {
query,
agents,
@@ -296,12 +277,6 @@ export default function AiVaultPanel(): React.JSX.Element {
[effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings]
)
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
preferredScopeRef.current = nextScope
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
setScope(nextScope)
}, [])
// Settings asks for "everything, ready to type".
const focusSearchRequestId = useAiVaultSearchFocusRequest(
useCallback(() => handleScopeChange('all'), [handleScopeChange])
@@ -327,8 +302,6 @@ export default function AiVaultPanel(): React.JSX.Element {
query={query}
searching={searching}
loading={searching ? search.loading : loading}
shownCount={filteredSessions.length}
sessionCount={sessions.length}
hasScanResult={Boolean(scanResult)}
activeWorktreePath={activeWorktreePath}
activeProjectKey={activeProjectKey}
@@ -336,7 +309,6 @@ export default function AiVaultPanel(): React.JSX.Element {
executionHostScope={executionHostScope}
hostScopeOptions={hostScopeOptions}
agents={agents}
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
sessionLimit={sessionLimit}
@@ -347,7 +319,6 @@ export default function AiVaultPanel(): React.JSX.Element {
onExecutionHostScopeChange={onExecutionHostScopeChange}
onAgentEnabledChange={setAgentEnabled}
onAllAgentsEnabledChange={setAllAgentsEnabled}
onSortChange={setSort}
onGroupChange={setGroup}
onHideEmptySessionsChange={setHideEmptySessions}
onSessionLimitChange={setSessionLimit}
@@ -363,6 +334,23 @@ export default function AiVaultPanel(): React.JSX.Element {
{!searching && <AiVaultScanIssueBanners scanResult={scanResult} />}
<AiVaultPanelSearch search={search} noAgents={agents.length === 0}>
{searching
? filteredSessions.length > 0 && (
<AiVaultSessionListBar
label={aiVaultResultCountLabel(filteredSessions.length)}
value={searchSort}
menu={aiVaultSearchSortMenu()}
onChange={setSearchSort}
/>
)
: sessions.length > 0 && (
<AiVaultSessionListBar
label={aiVaultSessionCountLabel(filteredSessions.length, sessions.length)}
value={sort}
menu={aiVaultBrowseSortMenu()}
onChange={setSort}
/>
)}
{(!searching || sessions.length > 0 || search.loading) && (
<AiVaultSessionVirtualList
key={searching ? search.resetKey : 'history'}
@@ -413,6 +401,15 @@ export default function AiVaultPanel(): React.JSX.Element {
onRequestDelete={(session) => void requestDelete(session)}
/>
)}
{!searching && (
<AiVaultShowMoreSessionsRow
loaded={sessions.length}
loadedSessionLimit={loadedSessionLimit}
loading={loading}
sessionLimit={sessionLimit}
onSessionLimitChange={setSessionLimit}
/>
)}
</AiVaultPanelSearch>
{launchActions.continuationRequest && (
<AgentSessionContinuationDialog
@@ -1,9 +1,7 @@
import type React from 'react'
import {
ArchiveRestore,
Calendar,
ChevronRight,
Clock3,
FolderOpen,
ListFilter,
PanelsTopLeft,
@@ -28,8 +26,7 @@ import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultScope,
type AiVaultSort
type AiVaultScope
} from '../../../../shared/ai-vault-types'
import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shared/execution-host'
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
@@ -206,14 +203,12 @@ export function VaultHostScopeMenu({
export function VaultViewMenu({
searching = false,
agents,
sort,
group,
hideEmptySessions,
sessionLimit,
adjustmentCount,
onAgentEnabledChange,
onAllAgentsEnabledChange,
onSortChange,
onGroupChange,
onHideEmptySessionsChange,
onSessionLimitChange,
@@ -221,14 +216,12 @@ export function VaultViewMenu({
}: {
searching?: boolean
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
adjustmentCount: number
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
onAllAgentsEnabledChange: (enabled: boolean) => void
onSortChange: (sort: AiVaultSort) => void
onGroupChange: (group: AiVaultGroup) => void
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
@@ -317,26 +310,6 @@ export function VaultViewMenu({
))}
{!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')}
@@ -21,8 +21,6 @@ function header(focusSearchRequestId: number) {
<AiVaultPanelHeader
query=""
loading={false}
shownCount={0}
sessionCount={0}
hasScanResult={false}
activeWorktreePath={null}
activeProjectKey={null}
@@ -30,7 +28,6 @@ function header(focusSearchRequestId: number) {
executionHostScope="local"
hostScopeOptions={[]}
agents={[]}
sort="updated"
group="project"
hideEmptySessions={false}
sessionLimit={250}
@@ -41,7 +38,6 @@ function header(focusSearchRequestId: number) {
onExecutionHostScopeChange={vi.fn()}
onAgentEnabledChange={vi.fn()}
onAllAgentsEnabledChange={vi.fn()}
onSortChange={vi.fn()}
onGroupChange={vi.fn()}
onHideEmptySessionsChange={vi.fn()}
onSessionLimitChange={vi.fn()}
@@ -2,12 +2,7 @@ import { useEffect, useRef } from 'react'
import { LoaderCircle, RefreshCw, Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type {
AiVaultAgent,
AiVaultGroup,
AiVaultScope,
AiVaultSort
} from '../../../../shared/ai-vault-types'
import type { AiVaultAgent, AiVaultGroup, AiVaultScope } from '../../../../shared/ai-vault-types'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
@@ -17,8 +12,6 @@ type AiVaultPanelHeaderProps = {
searching?: boolean
query: string
loading: boolean
shownCount: number
sessionCount: number
hasScanResult: boolean
activeWorktreePath: string | null
activeProjectKey: string | null
@@ -26,7 +19,6 @@ type AiVaultPanelHeaderProps = {
executionHostScope: ExecutionHostScope
hostScopeOptions: readonly AiVaultHostScopeOption[]
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
@@ -38,7 +30,6 @@ type AiVaultPanelHeaderProps = {
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
onAllAgentsEnabledChange: (enabled: boolean) => void
onSortChange: (sort: AiVaultSort) => void
onGroupChange: (group: AiVaultGroup) => void
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
@@ -50,8 +41,6 @@ export function AiVaultPanelHeader({
query,
searching = false,
loading,
shownCount,
sessionCount,
hasScanResult,
activeWorktreePath,
activeProjectKey,
@@ -59,7 +48,6 @@ export function AiVaultPanelHeader({
executionHostScope,
hostScopeOptions,
agents,
sort,
group,
hideEmptySessions,
sessionLimit,
@@ -70,7 +58,6 @@ export function AiVaultPanelHeader({
onExecutionHostScopeChange,
onAgentEnabledChange,
onAllAgentsEnabledChange,
onSortChange,
onGroupChange,
onHideEmptySessionsChange,
onSessionLimitChange,
@@ -101,31 +88,12 @@ export function AiVaultPanelHeader({
</span>
</div>
<div className="truncate text-[11px] text-muted-foreground">
{searching ? (
translate('sessionSearch.panel.indexedHistory', 'Indexed history · best matches')
) : hasScanResult ? (
<>
<span className="@max-[300px]/ai-vault:hidden">
{translate(
'auto.components.right.sidebar.AiVaultPanel.shownRecent',
'{{value0}} shown · {{value1}} recent',
{ value0: shownCount, value1: sessionCount }
)}
</span>
<span className="hidden @max-[300px]/ai-vault:inline">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionsShownCompact',
'{{value0}} shown',
{ value0: shownCount }
)}
</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultPanel.resumePastSessions',
'Resume past sessions'
)
)}
{searching || hasScanResult
? translate('sessionSearch.panel.indexedHistory', 'Indexed history')
: translate(
'auto.components.right.sidebar.AiVaultPanel.resumePastSessions',
'Resume past sessions'
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
@@ -137,14 +105,12 @@ export function AiVaultPanelHeader({
<VaultViewMenu
searching={searching}
agents={agents}
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
sessionLimit={sessionLimit}
adjustmentCount={adjustmentCount}
onAgentEnabledChange={onAgentEnabledChange}
onAllAgentsEnabledChange={onAllAgentsEnabledChange}
onSortChange={onSortChange}
onGroupChange={onGroupChange}
onHideEmptySessionsChange={onHideEmptySessionsChange}
onSessionLimitChange={onSessionLimitChange}
@@ -45,9 +45,7 @@ export function AiVaultSessionLimitMenu({
<DropdownMenuRadioGroup
value={String(sessionLimit)}
onValueChange={(value) =>
onSessionLimitChange(
value === 'unlimited' ? 'unlimited' : (Number(value) as AiVaultSessionLimit)
)
onSessionLimitChange(value === 'unlimited' ? 'unlimited' : Number(value))
}
>
{AI_VAULT_SESSION_LIMITS.map((limit) => (
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { aiVaultBrowseSortMenu, aiVaultSearchSortMenu } from './ai-vault-sort-options'
import {
aiVaultResultCountLabel,
AiVaultSessionListBar,
aiVaultSessionCountLabel
} from './AiVaultSessionListBar'
afterEach(cleanup)
it('reports how many hits are shown and which order produced them', () => {
const { rerender } = render(
<AiVaultSessionListBar
label={aiVaultResultCountLabel(1)}
value="relevance"
menu={aiVaultSearchSortMenu()}
onChange={vi.fn()}
/>
)
expect(screen.getByText('1 result')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sort results: Most relevant' })).toBeTruthy()
rerender(
<AiVaultSessionListBar
label={aiVaultResultCountLabel(20)}
value="newest"
menu={aiVaultSearchSortMenu()}
onChange={vi.fn()}
/>
)
expect(screen.getByText('20 results')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sort results: Newest' })).toBeTruthy()
})
it('reports how much of the browsed history is shown and its order', () => {
render(
<AiVaultSessionListBar
label={aiVaultSessionCountLabel(4, 12)}
value="created"
menu={aiVaultBrowseSortMenu()}
onChange={vi.fn()}
/>
)
expect(screen.getByText('4 of 12 sessions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sort sessions: Created' })).toBeTruthy()
})
it('counts plainly when filters hide nothing', () => {
render(
<AiVaultSessionListBar
label={aiVaultSessionCountLabel(12, 12)}
value="updated"
menu={aiVaultBrowseSortMenu()}
onChange={vi.fn()}
/>
)
expect(screen.getByText('12 sessions')).toBeTruthy()
})
it('hands the picked search order back to the caller', async () => {
const onChange = vi.fn()
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(
<AiVaultSessionListBar
label={aiVaultResultCountLabel(20)}
value="relevance"
menu={aiVaultSearchSortMenu()}
onChange={onChange}
/>
)
await user.click(screen.getByRole('button', { name: 'Sort results: Most relevant' }))
await user.click(await screen.findByRole('menuitemradio', { name: 'Newest' }))
expect(onChange).toHaveBeenCalledExactlyOnceWith('newest')
})
it('hands the picked browse order back to the caller', async () => {
const onChange = vi.fn()
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(
<AiVaultSessionListBar
label={aiVaultSessionCountLabel(4, 12)}
value="updated"
menu={aiVaultBrowseSortMenu()}
onChange={onChange}
/>
)
await user.click(screen.getByRole('button', { name: 'Sort sessions: Last updated' }))
await user.click(await screen.findByRole('menuitemradio', { name: 'Created' }))
expect(onChange).toHaveBeenCalledExactlyOnceWith('created')
})
@@ -0,0 +1,88 @@
import type React from 'react'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import type { AiVaultSortMenu } from './ai-vault-sort-options'
/** Left-hand label while searching: how many hits the list is showing. */
export function aiVaultResultCountLabel(count: number): string {
return count === 1
? translate('sessionSearch.panel.resultsOne', '{{count}} result', { count })
: translate('sessionSearch.panel.resultsOther', '{{count}} results', { count })
}
/** Left-hand label while browsing: the count, and how much of the scan filters hid. */
export function aiVaultSessionCountLabel(shown: number, loaded: number): string {
if (shown !== loaded) {
return translate('sessionSearch.panel.sessionsOfLoaded', '{{value0}} of {{value1}} sessions', {
value0: shown,
value1: loaded
})
}
return shown === 1
? translate('sessionSearch.panel.sessionsOne', '{{count}} session', { count: shown })
: translate('sessionSearch.panel.sessionsOther', '{{count}} sessions', { count: shown })
}
/**
* The bar above the session list: what the list is showing on the left, the order that
* produced it on the right — the one place sort is both reported and changed.
*/
export function AiVaultSessionListBar<Value extends string>({
label,
value,
menu,
onChange
}: {
label: string
value: Value
menu: AiVaultSortMenu<Value>
onChange: (value: Value) => void
}): React.JSX.Element {
const selected = menu.options.find((option) => option.value === value)
return (
<div className="flex h-8 shrink-0 items-center gap-2 border-y border-sidebar-border bg-sidebar-accent/60 pl-3 pr-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold tabular-nums text-foreground">
{label}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="shrink-0"
aria-label={menu.ariaLabel(selected?.label ?? '')}
>
{selected?.label}
<ChevronDown className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-0">
<DropdownMenuRadioGroup
value={value}
// Radix hands back a bare string; the option list is what narrows it.
onValueChange={(next) => {
const picked = menu.options.find((option) => option.value === next)
if (picked) {
onChange(picked.value)
}
}}
>
{menu.options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -7,7 +7,7 @@ import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { translate } from '@/i18n/i18n'
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list/viewport/virtual-rows'
import { EmptyState, SessionLoadingState } from './AiVaultSessionListStates'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultSessionListGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import type {
AiVaultSessionResumeActions,
@@ -58,7 +58,7 @@ export function AiVaultSessionVirtualList({
onRequestDelete,
searchHits
}: {
groups: readonly AiVaultSessionGroup[]
groups: readonly AiVaultSessionListGroup[]
collapsedGroups: ReadonlySet<string>
loading: boolean
sessionsCount: number
@@ -96,8 +96,11 @@ export function AiVaultSessionVirtualList({
const vaultRows = useMemo(() => {
const rows: AiVaultListRow[] = []
for (const sessionGroup of groups) {
rows.push({ type: 'group', group: sessionGroup })
if (!collapsedGroups.has(sessionGroup.key)) {
const label = sessionGroup.label
if (label !== null) {
rows.push({ type: 'group', group: { ...sessionGroup, label } })
}
if (label === null || !collapsedGroups.has(sessionGroup.key)) {
for (const session of sessionGroup.sessions) {
rows.push({ type: 'session', groupKey: sessionGroup.key, session })
}
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { AiVaultShowMoreSessionsRow } from './AiVaultShowMoreSessionsRow'
afterEach(cleanup)
it('steps the history depth up by one page once the scan filled it', async () => {
const onSessionLimitChange = vi.fn()
render(
<AiVaultShowMoreSessionsRow
loaded={500}
loadedSessionLimit={500}
loading={false}
sessionLimit={500}
onSessionLimitChange={onSessionLimitChange}
/>
)
await userEvent.setup().click(screen.getByRole('button', { name: 'Show more sessions' }))
expect(onSessionLimitChange).toHaveBeenCalledWith(750)
})
it('stays put in a loading state while the deeper rescan runs', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={250}
loadedSessionLimit={250}
loading
sessionLimit={500}
onSessionLimitChange={vi.fn()}
/>
)
const button = screen.getByRole('button', { name: 'Loading more sessions…' })
expect(button.hasAttribute('disabled')).toBe(true)
})
// Why: the old rule inferred "stepping" from the selected depth minus a page, which at the
// default depth is zero, so every foreground rescan claimed more history was coming.
it('stays hidden during a foreground rescan the scan had room for', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={40}
loadedSessionLimit={250}
loading
sessionLimit={250}
onSessionLimitChange={vi.fn()}
/>
)
expect(screen.queryByRole('button')).toBeNull()
})
it('stays hidden while the scan has room or is already unlimited', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={12}
loadedSessionLimit={250}
loading={false}
sessionLimit={250}
onSessionLimitChange={vi.fn()}
/>
)
render(
<AiVaultShowMoreSessionsRow
loaded={5000}
loadedSessionLimit="unlimited"
loading={false}
sessionLimit="unlimited"
onSessionLimitChange={vi.fn()}
/>
)
expect(screen.queryByRole('button')).toBeNull()
})
it('stays hidden until the first scan reports the depth it ran at', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={0}
loadedSessionLimit={null}
loading
sessionLimit={250}
onSessionLimitChange={vi.fn()}
/>
)
expect(screen.queryByRole('button')).toBeNull()
})
@@ -0,0 +1,45 @@
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { AI_VAULT_SESSION_LIMIT_STEP, type AiVaultSessionLimit } from './ai-vault-session-limit'
/** Footer row once the scan filled its History depth; steps the same setting the menu edits. */
export function AiVaultShowMoreSessionsRow({
loaded,
loadedSessionLimit,
loading,
sessionLimit,
onSessionLimitChange
}: {
loaded: number
/** The depth those rows came from: still the old one while a deeper rescan runs. */
loadedSessionLimit: AiVaultSessionLimit | null
loading: boolean
sessionLimit: AiVaultSessionLimit
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
}): React.JSX.Element | null {
if (sessionLimit === 'unlimited' || loaded === 0) {
return null
}
if (
loadedSessionLimit === null ||
loadedSessionLimit === 'unlimited' ||
loaded < loadedSessionLimit
) {
return null
}
return (
<div className="border-t border-sidebar-border p-2">
<Button
className="w-full"
variant="ghost"
size="xs"
disabled={loading}
onClick={() => onSessionLimitChange(sessionLimit + AI_VAULT_SESSION_LIMIT_STEP)}
>
{loading
? translate('sessionSearch.panel.loadingMoreSessions', 'Loading more sessions…')
: translate('sessionSearch.panel.showMoreSessions', 'Show more sessions')}
</Button>
</div>
)
}
@@ -0,0 +1,27 @@
import { useCallback, useState } from 'react'
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
import { applyPublishedAiVaultList } from './ai-vault-session-identity'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
// One object so a session count is never paired with a depth its scan never ran at.
export type AiVaultAppliedScan = { result: AiVaultListResult; limit: AiVaultSessionLimit }
/** The scan the panel is showing, together with the History depth it ran at. */
export function useAppliedAiVaultScan(): {
scan: AiVaultAppliedScan | null
applyScan: (published: AiVaultListResult, limit: AiVaultSessionLimit) => void
} {
const [scan, setScan] = useState<AiVaultAppliedScan | null>(null)
// Identity-preserving like the plain setter was, so an unchanged republish still bails out.
const applyScan = useCallback((published: AiVaultListResult, limit: AiVaultSessionLimit) => {
applyPublishedAiVaultList(published, (update) =>
setScan((prev) => {
const result = update(prev?.result ?? null)
return prev !== null && prev.result === result && prev.limit === limit
? prev
: { result, limit }
})
)
}, [])
return { scan, applyScan }
}
@@ -85,7 +85,7 @@ describe('the panel hook under a scope identity', () => {
it('sends the identity and no path list', async () => {
const within = { kind: 'project', projectKey: 'repo:repo-1' } as const
const { unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', AGENTS, within, 'ssh:build-box')
useAiVaultPanelSearch('needle', AGENTS, within, 'ssh:build-box', 'relevance')
)
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
@@ -97,7 +97,7 @@ describe('the panel hook under a scope identity', () => {
it('does not restart the search while the identity holds', async () => {
const { rerender, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', AGENTS, WORKSPACE, 'ssh:build-box')
useAiVaultPanelSearch('needle', AGENTS, WORKSPACE, 'ssh:build-box', 'relevance')
)
await debounce()
rerender()
@@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { translate } from '@/i18n/i18n'
import type { AiVaultSession, AiVaultGroup } from '../../../../shared/ai-vault-types'
import type { AiVaultGroup, AiVaultSession } from '../../../../shared/ai-vault-types'
import {
filterAiVaultSessions,
groupAiVaultSessions,
@@ -26,6 +25,13 @@ export {
parseVaultQuery
} from '../../../../shared/ai-vault-session-filters'
/** What the list renders: a null label is a group of rows with no header of its own. */
export type AiVaultSessionListGroup = {
key: string
label: string | null
sessions: AiVaultSession[]
}
export function useAiVaultPanelSessions(
sessions: readonly AiVaultSession[],
searching: boolean,
@@ -71,18 +77,13 @@ export function useAiVaultPanelSessions(
hideEmptySessions
]
)
const groups = useMemo(
const groups = useMemo<AiVaultSessionListGroup[]>(
() =>
searching
? filteredSessions.length === 0
? []
: [
{
key: 'search-results',
label: translate('sessionSearch.panel.rankedResults', 'Best matches'),
sessions: [...filteredSessions]
}
]
: // The results bar above the list carries the count, so this group only holds rows.
[{ key: 'search-results', label: null, sessions: [...filteredSessions] }]
: groupAiVaultSessions(filteredSessions, group, { sessionProjectById, projectLabelByKey }),
[searching, filteredSessions, group, projectLabelByKey, sessionProjectById]
)
@@ -1,11 +1,16 @@
/** Presets the History depth menu offers; Show more steps past them 250 at a time. */
export const AI_VAULT_SESSION_LIMITS = [250, 500, 1000, 'unlimited'] as const
export const AI_VAULT_SESSION_LIMIT_STEP = 250
export type AiVaultSessionLimit = (typeof AI_VAULT_SESSION_LIMITS)[number]
export type AiVaultSessionLimit = number | 'unlimited'
export const DEFAULT_AI_VAULT_SESSION_LIMIT: AiVaultSessionLimit = 250
export function normalizeAiVaultSessionLimit(value: unknown): AiVaultSessionLimit {
return AI_VAULT_SESSION_LIMITS.includes(value as AiVaultSessionLimit)
? (value as AiVaultSessionLimit)
if (value === 'unlimited') {
return value
}
return typeof value === 'number' && value > 0 && value % AI_VAULT_SESSION_LIMIT_STEP === 0
? value
: DEFAULT_AI_VAULT_SESSION_LIMIT
}
@@ -13,7 +13,8 @@ import {
import { useAppStore } from '@/store'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
import { applyPublishedAiVaultList, EMPTY_AI_VAULT_SESSIONS } from './ai-vault-session-identity'
import { EMPTY_AI_VAULT_SESSIONS } from './ai-vault-session-identity'
import { useAppliedAiVaultScan } from './ai-vault-applied-scan'
import {
aiVaultSessionResultCacheKey,
cacheAiVaultSessionResult,
@@ -84,8 +85,11 @@ export function useAiVaultSessionRefresh(
refresh: (args?: AiVaultRefreshArgs) => Promise<void>
scanResult: AiVaultListResult | null
sessions: readonly AiVaultSession[]
/** The depth the sessions on screen came from, which trails the selected one during a rescan. */
loadedSessionLimit: AiVaultSessionLimit | null
} {
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const { scan, applyScan } = useAppliedAiVaultScan()
const scanResult = scan?.result ?? null
const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -118,7 +122,6 @@ export function useAiVaultSessionRefresh(
)}\n${sessionLimitRef.current}`,
[]
)
const refresh = useCallback(
async (args: AiVaultRefreshArgs = {}): Promise<void> => {
const hostScope = executionHostScopeRef.current
@@ -137,7 +140,7 @@ export function useAiVaultSessionRefresh(
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
setError(null)
publicationGateRef.current.publish(cachedResult, (published) => {
applyPublishedAiVaultList(published, setScanResult)
applyScan(published, selectedLimit)
})
setLoading(false)
return
@@ -211,7 +214,7 @@ export function useAiVaultSessionRefresh(
})
publicationGateRef.current.publish(result, (published) => {
if (mountedRef.current && scanKey === currentScanScopeKey()) {
applyPublishedAiVaultList(published, setScanResult)
applyScan(published, selectedLimit)
}
})
} catch (err) {
@@ -244,7 +247,7 @@ export function useAiVaultSessionRefresh(
// Deps intentionally avoid changing scope values: refresh reads them
// through refs and recurses on itself, so its identity must stay stable.
},
[currentScanScopeKey]
[applyScan, currentScanScopeKey]
)
// Forced rescans triggered by new agent sessions run
@@ -359,5 +362,5 @@ export function useAiVaultSessionRefresh(
requestForcedRescan()
}, [agentSessionIdsKey, requestForcedRescan])
return { error, loading, refresh, scanResult, sessions }
return { error, loading, refresh, scanResult, sessions, loadedSessionLimit: scan?.limit ?? null }
}
@@ -0,0 +1,48 @@
import { translate } from '@/i18n/i18n'
import type { AiVaultSearchSort, AiVaultSort } from '../../../../shared/ai-vault-types'
export type AiVaultSortOption<Value extends string> = { value: Value; label: string }
/** One mode's sort menu: its choices, and the accessible name of the trigger showing one. */
export type AiVaultSortMenu<Value extends string> = {
options: readonly AiVaultSortOption<Value>[]
ariaLabel: (selectedLabel: string) => string
}
export function aiVaultBrowseSortMenu(): AiVaultSortMenu<AiVaultSort> {
return {
options: [
{
value: 'updated',
label: translate(
'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
'Last updated'
)
},
{
value: 'created',
label: translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')
}
],
ariaLabel: (selectedLabel) =>
translate('sessionSearch.panel.sortSessionsAriaLabel', 'Sort sessions: {{value0}}', {
value0: selectedLabel
})
}
}
export function aiVaultSearchSortMenu(): AiVaultSortMenu<AiVaultSearchSort> {
return {
options: [
{
value: 'relevance',
label: translate('sessionSearch.panel.sortRelevance', 'Most relevant')
},
{ value: 'newest', label: translate('sessionSearch.panel.sortNewest', 'Newest') }
],
ariaLabel: (selectedLabel) =>
translate('sessionSearch.panel.sortResultsAriaLabel', 'Sort results: {{value0}}', {
value0: selectedLabel
})
}
}
@@ -3,8 +3,7 @@ import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types'
import {
countAiVaultViewAdjustments,
DEFAULT_AI_VAULT_GROUP,
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
DEFAULT_AI_VAULT_SORT
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
} from './ai-vault-view-defaults'
import { DEFAULT_AI_VAULT_SESSION_LIMIT } from './ai-vault-session-limit'
@@ -17,7 +16,6 @@ describe('ai-vault-view-defaults', () => {
expect(
countAiVaultViewAdjustments({
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
@@ -29,7 +27,6 @@ describe('ai-vault-view-defaults', () => {
expect(
countAiVaultViewAdjustments({
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: true,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
@@ -41,7 +38,6 @@ describe('ai-vault-view-defaults', () => {
expect(
countAiVaultViewAdjustments({
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: false,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
@@ -56,7 +52,6 @@ describe('ai-vault-view-defaults', () => {
expect(
countAiVaultViewAdjustments({
agents: swapped,
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
@@ -68,11 +63,10 @@ describe('ai-vault-view-defaults', () => {
expect(
countAiVaultViewAdjustments({
agents: ['claude'],
sort: 'created',
group: 'agent',
hideEmptySessions: true,
sessionLimit: 1000
})
).toBe(5)
).toBe(4)
})
})
@@ -2,6 +2,7 @@ import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultSearchSort,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
@@ -10,11 +11,12 @@ import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-v
// on one constant so a default flip cannot leave Reset pointing at the old value.
export const DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS = false
export const DEFAULT_AI_VAULT_SORT: AiVaultSort = 'updated'
export const DEFAULT_AI_VAULT_SEARCH_SORT: AiVaultSearchSort = 'relevance'
export const DEFAULT_AI_VAULT_GROUP: AiVaultGroup = 'project'
// Sorts are uncounted: the bar shows them, so neither is a hidden adjustment.
export function countAiVaultViewAdjustments(options: {
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
@@ -24,7 +26,6 @@ export function countAiVaultViewAdjustments(options: {
const allAgentsEnabled = AI_VAULT_AGENTS.every((agent) => options.agents.includes(agent))
return (
(allAgentsEnabled ? 0 : 1) +
(options.sort === DEFAULT_AI_VAULT_SORT ? 0 : 1) +
(options.group === DEFAULT_AI_VAULT_GROUP ? 0 : 1) +
(options.hideEmptySessions === DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS ? 0 : 1) +
(options.sessionLimit === DEFAULT_AI_VAULT_SESSION_LIMIT ? 0 : 1)
@@ -32,6 +32,7 @@ describe('AI Vault view option persistence', () => {
).toEqual({
disabledAgents: ['codex'],
sort: 'updated',
searchSort: 'relevance',
group: 'agent',
hideEmptySessions: false,
sessionLimit: 250
@@ -50,6 +51,7 @@ describe('AI Vault view option persistence', () => {
).toEqual({
disabledAgents: [],
sort: 'updated',
searchSort: 'relevance',
group: 'project',
hideEmptySessions: false,
sessionLimit: 250
@@ -58,6 +60,7 @@ describe('AI Vault view option persistence', () => {
normalizeAiVaultViewOptions({
disabledAgents: [],
sort: 'created',
searchSort: 'newest',
group: 'folder',
hideEmptySessions: true,
sessionLimit: 1000
@@ -65,11 +68,13 @@ describe('AI Vault view option persistence', () => {
).toEqual({
disabledAgents: [],
sort: 'created',
searchSort: 'newest',
group: 'folder',
hideEmptySessions: true,
sessionLimit: 1000
})
expect(normalizeAiVaultViewOptions({ group: 'agent' }).group).toBe('agent')
expect(normalizeAiVaultViewOptions({ searchSort: 'oldest' }).searchSort).toBe('relevance')
expect(normalizeAiVaultViewOptions({ sessionLimit: 'unlimited' }).sessionLimit).toBe(
'unlimited'
)
@@ -122,6 +127,7 @@ describe('AI Vault view option persistence', () => {
{
disabledAgents: ['codex'],
sort: 'created',
searchSort: 'newest',
group: 'folder',
hideEmptySessions: true,
sessionLimit: 500
@@ -134,6 +140,7 @@ describe('AI Vault view option persistence', () => {
JSON.stringify({
disabledAgents: ['codex'],
sort: 'created',
searchSort: 'newest',
group: 'folder',
hideEmptySessions: true,
sessionLimit: 500
@@ -1,12 +1,15 @@
import {
AI_VAULT_AGENTS,
AI_VAULT_SEARCH_SORTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultSearchSort,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
import {
DEFAULT_AI_VAULT_GROUP,
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
DEFAULT_AI_VAULT_SEARCH_SORT,
DEFAULT_AI_VAULT_SORT
} from './ai-vault-view-defaults'
import {
@@ -20,6 +23,7 @@ export const AI_VAULT_VIEW_OPTIONS_STORAGE_KEY = 'orca.aiVault.viewOptions.v1'
export type AiVaultViewOptions = {
disabledAgents: AiVaultAgent[]
sort: AiVaultSort
searchSort: AiVaultSearchSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
@@ -34,6 +38,7 @@ export function createDefaultAiVaultViewOptions(): AiVaultViewOptions {
return {
disabledAgents: [],
sort: DEFAULT_AI_VAULT_SORT,
searchSort: DEFAULT_AI_VAULT_SEARCH_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
@@ -49,6 +54,10 @@ function isAiVaultSort(value: unknown): value is AiVaultSort {
return value === 'updated' || value === 'created'
}
function isAiVaultSearchSort(value: unknown): value is AiVaultSearchSort {
return AI_VAULT_SEARCH_SORTS.some((sort) => sort === value)
}
function isAiVaultGroup(value: unknown): value is AiVaultGroup {
return value === 'project' || value === 'folder' || value === 'agent'
}
@@ -66,6 +75,9 @@ export function normalizeAiVaultViewOptions(value: unknown): AiVaultViewOptions
return {
disabledAgents,
sort: isAiVaultSort(record.sort) ? record.sort : DEFAULT_AI_VAULT_SORT,
searchSort: isAiVaultSearchSort(record.searchSort)
? record.searchSort
: DEFAULT_AI_VAULT_SEARCH_SORT,
group: isAiVaultGroup(record.group) ? record.group : DEFAULT_AI_VAULT_GROUP,
hideEmptySessions:
typeof record.hideEmptySessions === 'boolean'
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
/**
* The panel's scope, kept honest against the active context: an unavailable
* Workspace or Project falls back to All, and the user's choice returns when
* its context does.
*/
export function useAiVaultPanelScope({
activeProjectKey,
activeWorktreePath
}: {
activeProjectKey: string | null
activeWorktreePath: string | null
}): { scope: AiVaultScope; handleScopeChange: (scope: AiVaultScope) => void } {
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
useEffect(() => {
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
activeProjectKey,
activeWorktreePath
})
if (normalizedScope !== scope) {
setScope(normalizedScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
const restorableScope = getRestorableAiVaultScope({
scope,
activeProjectKey,
activeWorktreePath,
preferredScope: preferredScopeRef.current,
userChangedScope: userChangedScopeRef.current
})
if (restorableScope) {
setScope(restorableScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
preferredScopeRef.current = nextScope
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
setScope(nextScope)
}, [])
return { scope, handleScopeChange }
}
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { AiVaultSearchSort } from '../../../../shared/ai-vault-types'
import type {
AiVaultSearchRequest,
AiVaultSearchResponse
@@ -235,7 +236,7 @@ it('searches every computer at once and keeps each hit on the computer that owns
]
})
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all')
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all', 'relevance')
)
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
@@ -249,6 +250,28 @@ it('searches every computer at once and keeps each hit on the computer that owns
unmount()
})
it('sends the sort only when it is not the host default', async () => {
const initialProps: { sort: AiVaultSearchSort } = { sort: 'relevance' }
const { rerender, unmount } = renderHook(
({ sort }) => useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all', sort),
{ initialProps }
)
await debounce()
expect(searchSessions.mock.calls[0]?.[0]).toEqual({
query: 'needle',
filters: { agents: ALL_AGENTS },
cursor: undefined
})
rerender({ sort: 'newest' })
await debounce()
expect(searchSessions.mock.calls[1]?.[0]).toEqual({
query: 'needle',
filters: { agents: ALL_AGENTS, sort: 'newest' },
cursor: undefined
})
unmount()
})
it('restarts page one under the all scope when the merged cursor goes stale', async () => {
searchSessions.mockResolvedValueOnce({
...searchResults(),
@@ -256,7 +279,7 @@ it('restarts page one under the all scope when the merged cursor goes stale', as
page: { cursor: 'merged', hasMore: true }
})
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all')
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'all', 'relevance')
)
await debounce()
searchSessions
@@ -272,7 +295,7 @@ it('restarts page one under the all scope when the merged cursor goes stale', as
it('leaves the box as the legacy title filter while local indexing consent is pending', async () => {
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local')
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local', 'relevance')
)
await debounce()
expect(searchSessions).not.toHaveBeenCalled()
@@ -287,7 +310,7 @@ it('leaves the box as the legacy title filter while local indexing consent is pe
it('searches the local index with the same query once consent is on', async () => {
mockSettings.aiVaultSearch = { enabled: true }
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local')
useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local', 'relevance')
)
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
@@ -302,7 +325,7 @@ it('searches the local index with the same query once consent is on', async () =
it('is neither searching nor holding a query for a blank box', async () => {
const { result, unmount } = renderHook(() =>
useAiVaultPanelSearch(' ', ALL_AGENTS, undefined, 'local')
useAiVaultPanelSearch(' ', ALL_AGENTS, undefined, 'local', 'relevance')
)
await debounce()
expect(searchSessions).not.toHaveBeenCalled()
@@ -11,7 +11,11 @@ import {
type ExecutionHostId,
type ExecutionHostScope
} from '../../../../shared/execution-host'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import type {
AiVaultAgent,
AiVaultSearchSort,
AiVaultSession
} from '../../../../shared/ai-vault-types'
import type { AiVaultSearchScopeIdentity } from '../../../../shared/ai-vault-search-scope'
import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings'
import { isWebClientLocation } from '@/lib/web-client-location'
@@ -139,7 +143,8 @@ export function useAiVaultPanelSearch(
agents: readonly AiVaultAgent[],
/** Which scope the host resolves; undefined searches everything it has. */
within: AiVaultSearchScopeIdentity | undefined,
executionHostScope: ExecutionHostScope
executionHostScope: ExecutionHostScope,
sort: AiVaultSearchSort
) {
const settings = useAppStore((state) => state.settings?.aiVaultSearch)
const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings })
@@ -159,11 +164,12 @@ export function useAiVaultPanelSearch(
searching && scope && agents.length > 0
? {
query: trimmed,
filters: { agents: [...agents] },
// Relevance is the host's own default, so only the other order travels.
filters: { agents: [...agents], ...(sort === 'relevance' ? {} : { sort }) },
...(within ? { within } : {})
}
: null,
[searching, scope, agents, trimmed, within]
[searching, scope, agents, trimmed, within, sort]
)
const search = useAiVaultSearch(request, scope, JSON.stringify(policy))
const sessions = useMemo(
@@ -3,6 +3,7 @@ import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultSearchSort,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
import {
@@ -19,10 +20,12 @@ type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOpti
export function usePersistedAiVaultViewOptions(): {
agents: AiVaultAgent[]
sort: AiVaultSort
searchSort: AiVaultSearchSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
setSort: (sort: AiVaultSort) => void
setSearchSort: (sort: AiVaultSearchSort) => void
setGroup: (group: AiVaultGroup) => void
setHideEmptySessions: (hide: boolean) => void
setSessionLimit: (limit: AiVaultSessionLimit) => void
@@ -54,6 +57,13 @@ export function usePersistedAiVaultViewOptions(): {
updateOptions((current) => (current.sort === sort ? current : { ...current, sort })),
[updateOptions]
)
const setSearchSort = useCallback(
(searchSort: AiVaultSearchSort) =>
updateOptions((current) =>
current.searchSort === searchSort ? current : { ...current, searchSort }
),
[updateOptions]
)
const setGroup = useCallback(
(group: AiVaultGroup) =>
updateOptions((current) => (current.group === group ? current : { ...current, group })),
@@ -118,10 +128,12 @@ export function usePersistedAiVaultViewOptions(): {
return {
agents,
sort: options.sort,
searchSort: options.searchSort,
group: options.group,
hideEmptySessions: options.hideEmptySessions,
sessionLimit: options.sessionLimit,
setSort,
setSearchSort,
setGroup,
setHideEmptySessions,
setSessionLimit,
+13 -6
View File
@@ -13207,8 +13207,6 @@
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"agents": "Agents",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"sessionsShownCompact": "{{value0}} shown",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
@@ -13248,7 +13246,6 @@
"agents": "Agents",
"selectAllAgents": "Select all",
"clearAgents": "Clear",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
@@ -18008,11 +18005,15 @@
},
"sessionSearch": {
"panel": {
"rankedResults": "Best matches",
"resultsOne": "{{count}} result",
"resultsOther": "{{count}} results",
"noMatches": "No matching sessions in the indexed history. Try another query or scope.",
"loadMore": "Load more matches",
"indexedHistory": "Indexed history · best matches",
"indexedHistory": "Indexed history",
"hostsSkipped": "Not searched: {{value0}}",
"sortRelevance": "Most relevant",
"sortNewest": "Newest",
"sortResultsAriaLabel": "Sort results: {{value0}}",
"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.",
@@ -18023,7 +18024,13 @@
"enableFailed": "Could not enable search. Try again.",
"enable": "Enable",
"retry": "Try again",
"scopeUnknown": "This computer does not have this workspace or project. Switch the scope to All to search everything on it."
"scopeUnknown": "This computer does not have this workspace or project. Switch the scope to All to search everything on it.",
"sortSessionsAriaLabel": "Sort sessions: {{value0}}",
"sessionsOne": "{{count}} session",
"sessionsOther": "{{count}} sessions",
"sessionsOfLoaded": "{{value0}} of {{value1}} sessions",
"showMoreSessions": "Show more sessions",
"loadingMoreSessions": "Loading more sessions…"
}
},
"sessionHistory": {
-3
View File
@@ -11376,7 +11376,6 @@
"localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.",
"agentSessionQueued": "Sesión de {{value0}} en cola",
"sessionHistory": "Historial de sesiones de agentes",
"shownRecent": "{{value0}} mostradas · {{value1}} recientes",
"resumePastSessions": "Reanudar sesiones anteriores",
"refreshSessionHistory": "Actualizar historial de sesiones",
"searchSessions": "Buscar sesiones",
@@ -11389,7 +11388,6 @@
"sessionId": "ID de sesión",
"logPath": "Ruta del log",
"agents": "Agentes",
"sessionsShownCompact": "{{value0}} mostradas",
"originalPaneUnavailable": "El panel original ya no está disponible.",
"worktreeUnavailable": "El worktree ya no está disponible.",
"openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.",
@@ -11415,7 +11413,6 @@
"agents": "Agentes",
"selectAllAgents": "Seleccionar todo",
"clearAgents": "Limpiar",
"sort": "Ordenar",
"lastUpdated": "Última actualización",
"created": "Creado",
"group": "Agrupar",
-3
View File
@@ -12503,8 +12503,6 @@
"agentSessionQueued": "Session {{value0}} en file d'attente",
"sessionHistory": "Historique des sessions d'agent",
"agents": "Agents",
"shownRecent": "{{value0}} affichées · {{value1}} récentes",
"sessionsShownCompact": "{{value0}} affichées",
"resumePastSessions": "Reprendre les sessions passées",
"refreshSessionHistory": "Actualiser l'historique des sessions",
"searchSessions": "Rechercher des sessions",
@@ -12541,7 +12539,6 @@
"agents": "Agents",
"selectAllAgents": "Tout sélectionner",
"clearAgents": "Effacer",
"sort": "Trier",
"lastUpdated": "Dernière mise à jour",
"created": "Créé",
"group": "Regroupement",
-3
View File
@@ -11376,7 +11376,6 @@
"localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。",
"agentSessionQueued": "{{value0}} 件のセッションが順番待ち",
"sessionHistory": "Agent セッション履歴",
"shownRecent": "{{value0}}表示 · {{value1}}最近",
"resumePastSessions": "過去のセッションを再開",
"refreshSessionHistory": "セッション履歴を更新",
"searchSessions": "セッションを検索",
@@ -11389,7 +11388,6 @@
"sessionId": "セッション ID",
"logPath": "ログパス",
"agents": "Agent",
"sessionsShownCompact": "{{value0}} 件表示",
"originalPaneUnavailable": "元のペインは利用できなくなりました。",
"worktreeUnavailable": "ワークツリーは利用できなくなりました。",
"openSupportedWorkspace": "セッションを再開する前に、ワークスペースを開いてください。",
@@ -11415,7 +11413,6 @@
"agents": "Agent",
"selectAllAgents": "すべて選択",
"clearAgents": "クリア",
"sort": "並べ替え",
"lastUpdated": "最終更新",
"created": "作成日時",
"group": "グループ",
-3
View File
@@ -11418,7 +11418,6 @@
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
"agentSessionQueued": "{{value0}} 세션이 대기열에 추가됨",
"sessionHistory": "Agent 세션 기록",
"shownRecent": "{{value0}}개 표시 · 최근 {{value1}}개",
"resumePastSessions": "이전 세션 재개",
"refreshSessionHistory": "세션 기록 새로고침",
"searchSessions": "세션 검색",
@@ -11431,7 +11430,6 @@
"sessionId": "세션 ID",
"logPath": "로그 경로",
"agents": "에이전트",
"sessionsShownCompact": "{{value0}} 표시됨",
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.",
"openSupportedWorkspace": "세션을 재개하기 전에 워크스페이스를 여세요.",
@@ -11457,7 +11455,6 @@
"agents": "에이전트",
"selectAllAgents": "모두 선택",
"clearAgents": "모두 해제",
"sort": "정렬",
"lastUpdated": "마지막 업데이트",
"created": "생성됨",
"group": "그룹",
-3
View File
@@ -11456,7 +11456,6 @@
"localWorkspacesOnly": "从历史记录恢复仅适用于本地工作区。",
"agentSessionQueued": "{{value0}} 会话已排队",
"sessionHistory": "智能体会话历史",
"shownRecent": "已显示 {{value0}} 项 · 最近 {{value1}} 项",
"resumePastSessions": "恢复过往会话",
"refreshSessionHistory": "刷新会话历史",
"searchSessions": "搜索会话",
@@ -11469,7 +11468,6 @@
"sessionId": "会话 ID",
"logPath": "日志路径",
"agents": "智能体",
"sessionsShownCompact": "已显示 {{value0}} 个",
"originalPaneUnavailable": "原始面板不再可用。",
"worktreeUnavailable": "工作树不再可用。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",
@@ -11495,7 +11493,6 @@
"agents": "智能体",
"selectAllAgents": "选择全部",
"clearAgents": "清除",
"sort": "排序",
"lastUpdated": "最后更新",
"created": "创建时间",
"group": "分组",
+6 -2
View File
@@ -1,13 +1,17 @@
import { resolveSessionSearchLimit, SESSION_SEARCH_LIMIT_MAX } from './ai-vault-search-limit'
import { z } from 'zod'
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from './ai-vault-types'
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
AI_VAULT_SEARCH_SORTS
} from './ai-vault-types'
import { AiVaultSearchScopeIdentitySchema } from './ai-vault-search-scope'
export const AiVaultSearchFiltersSchema = z.object({
agents: z.array(z.enum(AI_VAULT_AGENTS)).optional(),
scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(),
since: z.string().datetime({ offset: true }).optional(),
sort: z.enum(['relevance', 'newest']).optional()
sort: z.enum(AI_VAULT_SEARCH_SORTS).optional()
})
// Strip unknown fields so legacy tier/refresh are accepted without affecting the query.
+3
View File
@@ -42,6 +42,9 @@ export function isAiVaultScanCancelledError(error: unknown): boolean {
export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number]
export type AiVaultScope = 'workspace' | 'project' | 'all'
export type AiVaultSort = 'updated' | 'created'
export const AI_VAULT_SEARCH_SORTS = ['relevance', 'newest'] as const
/** Order of full-text search results; the list above has its own `AiVaultSort`. */
export type AiVaultSearchSort = (typeof AI_VAULT_SEARCH_SORTS)[number]
export type AiVaultGroup = 'project' | 'folder' | 'agent'
export const AI_VAULT_AGENT_LABELS = {