diff --git a/src/main/ai-vault-search/session-search-hit-ranking.test.ts b/src/main/ai-vault-search/session-search-hit-ranking.test.ts
index 54919bace0f..63df3c0e77b 100644
--- a/src/main/ai-vault-search/session-search-hit-ranking.test.ts
+++ b/src/main/ai-vault-search/session-search-hit-ranking.test.ts
@@ -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) => {
diff --git a/src/main/ai-vault-search/session-search-hit-ranking.ts b/src/main/ai-vault-search/session-search-hit-ranking.ts
index 364858ea650..2172113074a 100644
--- a/src/main/ai-vault-search/session-search-hit-ranking.ts
+++ b/src/main/ai-vault-search/session-search-hit-ranking.ts
@@ -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
}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx
index 7eed10ad14b..92424db0506 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx
@@ -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[] }) => (
-
+ AiVaultSessionVirtualList: ({ groups }: { groups: readonly AiVaultSessionListGroup[] }) => (
+ // A null label is what tells the real list to render this group's rows without a header.
+ group.label ?? '(untitled)').join('|')}>
{groups.flatMap((group) =>
group.sessions.map((session) => {session.title} )
)}
@@ -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)')
+ )
})
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index a51c813e77b..405f3101f27 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -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(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>(() => new Set())
- const userChangedScopeRef = useRef(false)
- const preferredScopeRef = useRef(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 && }
+ {searching
+ ? filteredSessions.length > 0 && (
+
+ )
+ : sessions.length > 0 && (
+
+ )}
{(!searching || sessions.length > 0 || search.loading) && (
void requestDelete(session)}
/>
)}
+ {!searching && (
+
+ )}
{launchActions.continuationRequest && (
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 && (
<>
-
-
- {translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')}
-
- onSortChange(value as AiVaultSort)}
- >
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
- 'Last updated'
- )}
-
-
-
- {translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')}
-
-
{translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx
index 75561d56f46..1fb3b702c2a 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx
@@ -21,8 +21,6 @@ function header(focusSearchRequestId: number) {
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({
- {searching ? (
- translate('sessionSearch.panel.indexedHistory', 'Indexed history · best matches')
- ) : hasScanResult ? (
- <>
-
- {translate(
- 'auto.components.right.sidebar.AiVaultPanel.shownRecent',
- '{{value0}} shown · {{value1}} recent',
- { value0: shownCount, value1: sessionCount }
- )}
-
-
- {translate(
- 'auto.components.right.sidebar.AiVaultPanel.sessionsShownCompact',
- '{{value0}} shown',
- { value0: shownCount }
- )}
-
- >
- ) : (
- 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'
+ )}
@@ -137,14 +105,12 @@ export function AiVaultPanelHeader({
- onSessionLimitChange(
- value === 'unlimited' ? 'unlimited' : (Number(value) as AiVaultSessionLimit)
- )
+ onSessionLimitChange(value === 'unlimited' ? 'unlimited' : Number(value))
}
>
{AI_VAULT_SESSION_LIMITS.map((limit) => (
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx
new file mode 100644
index 00000000000..1fb2605722a
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx
@@ -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(
+
+ )
+ expect(screen.getByText('1 result')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Sort results: Most relevant' })).toBeTruthy()
+
+ rerender(
+
+ )
+ 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(
+
+ )
+ 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(
+
+ )
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Sort sessions: Last updated' }))
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Created' }))
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('created')
+})
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.tsx
new file mode 100644
index 00000000000..b8708160810
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionListBar.tsx
@@ -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({
+ label,
+ value,
+ menu,
+ onChange
+}: {
+ label: string
+ value: Value
+ menu: AiVaultSortMenu
+ onChange: (value: Value) => void
+}): React.JSX.Element {
+ const selected = menu.options.find((option) => option.value === value)
+ return (
+
+
+ {label}
+
+
+
+
+ {selected?.label}
+
+
+
+
+ {
+ const picked = menu.options.find((option) => option.value === next)
+ if (picked) {
+ onChange(picked.value)
+ }
+ }}
+ >
+ {menu.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
index 8fd88adb9ad..ffaf577183b 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
@@ -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
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 })
}
diff --git a/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx
new file mode 100644
index 00000000000..105c021d5d2
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx
@@ -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(
+
+ )
+ 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(
+
+ )
+ 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(
+
+ )
+ expect(screen.queryByRole('button')).toBeNull()
+})
+
+it('stays hidden while the scan has room or is already unlimited', () => {
+ render(
+
+ )
+ render(
+
+ )
+ expect(screen.queryByRole('button')).toBeNull()
+})
+
+it('stays hidden until the first scan reports the depth it ran at', () => {
+ render(
+
+ )
+ expect(screen.queryByRole('button')).toBeNull()
+})
diff --git a/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.tsx
new file mode 100644
index 00000000000..c994fa30af6
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.tsx
@@ -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 (
+
+ onSessionLimitChange(sessionLimit + AI_VAULT_SESSION_LIMIT_STEP)}
+ >
+ {loading
+ ? translate('sessionSearch.panel.loadingMoreSessions', 'Loading more sessions…')
+ : translate('sessionSearch.panel.showMoreSessions', 'Show more sessions')}
+
+
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts b/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts
new file mode 100644
index 00000000000..4702b924b39
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-applied-scan.ts
@@ -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(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 }
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx
index 34e8cd89e98..2da2a191fb2 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx
@@ -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()
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts
index 4bb4a678b77..2069ad1840c 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts
@@ -1,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(
() =>
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]
)
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-limit.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-limit.ts
index 8d857cd00d0..7cc212114a2 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-limit.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-limit.ts
@@ -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
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
index 389acac8579..b8787af49f9 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
@@ -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
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(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(null)
@@ -118,7 +122,6 @@ export function useAiVaultSessionRefresh(
)}\n${sessionLimitRef.current}`,
[]
)
-
const refresh = useCallback(
async (args: AiVaultRefreshArgs = {}): Promise => {
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 }
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-sort-options.ts b/src/renderer/src/components/right-sidebar/ai-vault-sort-options.ts
new file mode 100644
index 00000000000..0bf9d9e48ef
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ai-vault-sort-options.ts
@@ -0,0 +1,48 @@
+import { translate } from '@/i18n/i18n'
+import type { AiVaultSearchSort, AiVaultSort } from '../../../../shared/ai-vault-types'
+
+export type AiVaultSortOption = { value: Value; label: string }
+
+/** One mode's sort menu: its choices, and the accessible name of the trigger showing one. */
+export type AiVaultSortMenu = {
+ options: readonly AiVaultSortOption[]
+ ariaLabel: (selectedLabel: string) => string
+}
+
+export function aiVaultBrowseSortMenu(): AiVaultSortMenu {
+ 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 {
+ 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
+ })
+ }
+}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts
index 512d7a347a3..9b9f3ca1906 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts
@@ -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)
})
})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.ts
index 0c4128a0188..82c48ebad4e 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-view-defaults.ts
@@ -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)
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts
index aac72fe237b..018503fa68c 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts
@@ -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
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts
index 8b9c10c53e6..1af2d52d461 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts
@@ -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'
diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts
new file mode 100644
index 00000000000..2865b84d74a
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts
@@ -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(DEFAULT_AI_VAULT_SCOPE)
+ const userChangedScopeRef = useRef(false)
+ const preferredScopeRef = useRef(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 }
+}
diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx
index 5b33d795159..4ab4554b3c7 100644
--- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx
+++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx
@@ -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()
diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts
index a2dcfcba1ee..39c4e8e9853 100644
--- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts
+++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts
@@ -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(
diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts
index e87588f0015..b47f60dc605 100644
--- a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts
+++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts
@@ -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,
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 4642249580f..7ac08d6d289 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index cfccde75280..e2231a6707c 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 44094298d85..9e4f221980a 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 0a3b2ee6c86..378fdc8bf75 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -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": "グループ",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 91aa9d8d74b..c05e94a0eb0 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -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": "그룹",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index f460f007b2c..7c6370ff46f 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -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": "分组",
diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts
index 377ee632e3e..15733400234 100644
--- a/src/shared/ai-vault-search-contract.ts
+++ b/src/shared/ai-vault-search-contract.ts
@@ -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.
diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts
index 5202464dfdf..24567e82fbc 100644
--- a/src/shared/ai-vault-types.ts
+++ b/src/shared/ai-vault-types.ts
@@ -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 = {