mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "グループ",
|
||||
|
||||
@@ -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": "그룹",
|
||||
|
||||
@@ -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": "分组",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user