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

This commit is contained in:
Jinwoo-H
2026-09-21 03:25:24 -04:00
parent 87379b16f4
commit 4793bd2ea2
14 changed files with 175 additions and 104 deletions
@@ -173,7 +173,7 @@ it('shows the whole history and no offer while the box is empty', async () => {
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 shown · 2 recent')).toBeTruthy()
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()
@@ -39,14 +39,10 @@ import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import {
AiVaultResultCountLabel,
AiVaultSessionListBar,
AiVaultShownCountLabel
AiVaultSessionCountLabel
} from './AiVaultSessionListBar'
import {
aiVaultBrowseSortAriaLabel,
aiVaultBrowseSortOptions,
aiVaultSearchSortAriaLabel,
aiVaultSearchSortOptions
} from './ai-vault-sort-options'
import { aiVaultBrowseSortMenu, aiVaultSearchSortMenu } from './ai-vault-sort-options'
import { AiVaultShowMoreSessionsRow } from './AiVaultSessionLimitMenu'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
import {
@@ -342,18 +338,19 @@ export default function AiVaultPanel(): React.JSX.Element {
<AiVaultSessionListBar
label={<AiVaultResultCountLabel count={filteredSessions.length} />}
value={searchSort}
options={aiVaultSearchSortOptions()}
sortAriaLabel={aiVaultSearchSortAriaLabel}
menu={aiVaultSearchSortMenu()}
onChange={setSearchSort}
/>
) : (
<AiVaultSessionListBar
label={
<AiVaultShownCountLabel shown={filteredSessions.length} recent={sessions.length} />
<AiVaultSessionCountLabel
shown={filteredSessions.length}
loaded={sessions.length}
/>
}
value={sort}
options={aiVaultBrowseSortOptions()}
sortAriaLabel={aiVaultBrowseSortAriaLabel}
menu={aiVaultBrowseSortMenu()}
onChange={setSort}
/>
))}
@@ -408,6 +405,13 @@ export default function AiVaultPanel(): React.JSX.Element {
onRequestDelete={(session) => void requestDelete(session)}
/>
)}
{!searching && (
<AiVaultShowMoreSessionsRow
loaded={sessions.length}
sessionLimit={sessionLimit}
onSessionLimitChange={setSessionLimit}
/>
)}
</AiVaultPanelSearch>
{launchActions.continuationRequest && (
<AgentSessionContinuationDialog
@@ -0,0 +1,34 @@
// @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 './AiVaultSessionLimitMenu'
afterEach(cleanup)
it('steps the history depth up once the scan filled it', async () => {
const onSessionLimitChange = vi.fn()
render(
<AiVaultShowMoreSessionsRow
loaded={250}
sessionLimit={250}
onSessionLimitChange={onSessionLimitChange}
/>
)
await userEvent.setup().click(screen.getByRole('button', { name: 'Show more sessions' }))
expect(onSessionLimitChange).toHaveBeenCalledWith(500)
})
it('stays hidden while the scan has room or is already unlimited', () => {
render(
<AiVaultShowMoreSessionsRow loaded={12} sessionLimit={250} onSessionLimitChange={vi.fn()} />
)
render(
<AiVaultShowMoreSessionsRow
loaded={5000}
sessionLimit="unlimited"
onSessionLimitChange={vi.fn()}
/>
)
expect(screen.queryByRole('button')).toBeNull()
})
@@ -7,13 +7,43 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import {
AI_VAULT_SESSION_LIMITS,
DEFAULT_AI_VAULT_SESSION_LIMIT,
nextAiVaultSessionLimit,
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,
sessionLimit,
onSessionLimitChange
}: {
loaded: number
sessionLimit: AiVaultSessionLimit
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
}): React.JSX.Element | null {
const next = nextAiVaultSessionLimit(sessionLimit)
if (next === null || typeof sessionLimit !== 'number' || loaded < sessionLimit) {
return null
}
return (
<div className="border-t border-sidebar-border p-2">
<Button
className="w-full"
variant="ghost"
size="xs"
onClick={() => onSessionLimitChange(next)}
>
{translate('sessionSearch.panel.showMoreSessions', 'Show more sessions')}
</Button>
</div>
)
}
export function AiVaultSessionLimitMenu({
sessionLimit,
onSessionLimitChange
@@ -2,16 +2,11 @@
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import {
aiVaultBrowseSortAriaLabel,
aiVaultBrowseSortOptions,
aiVaultSearchSortAriaLabel,
aiVaultSearchSortOptions
} from './ai-vault-sort-options'
import { aiVaultBrowseSortMenu, aiVaultSearchSortMenu } from './ai-vault-sort-options'
import {
AiVaultResultCountLabel,
AiVaultSessionListBar,
AiVaultShownCountLabel
AiVaultSessionCountLabel
} from './AiVaultSessionListBar'
afterEach(cleanup)
@@ -21,8 +16,7 @@ it('reports how many hits are shown and which order produced them', () => {
<AiVaultSessionListBar
label={<AiVaultResultCountLabel count={1} />}
value="relevance"
options={aiVaultSearchSortOptions()}
sortAriaLabel={aiVaultSearchSortAriaLabel}
menu={aiVaultSearchSortMenu()}
onChange={vi.fn()}
/>
)
@@ -33,8 +27,7 @@ it('reports how many hits are shown and which order produced them', () => {
<AiVaultSessionListBar
label={<AiVaultResultCountLabel count={20} />}
value="newest"
options={aiVaultSearchSortOptions()}
sortAriaLabel={aiVaultSearchSortAriaLabel}
menu={aiVaultSearchSortMenu()}
onChange={vi.fn()}
/>
)
@@ -45,18 +38,28 @@ it('reports how many hits are shown and which order produced them', () => {
it('reports how much of the browsed history is shown and its order', () => {
render(
<AiVaultSessionListBar
label={<AiVaultShownCountLabel shown={4} recent={12} />}
label={<AiVaultSessionCountLabel shown={4} loaded={12} />}
value="created"
options={aiVaultBrowseSortOptions()}
sortAriaLabel={aiVaultBrowseSortAriaLabel}
menu={aiVaultBrowseSortMenu()}
onChange={vi.fn()}
/>
)
expect(screen.getByText('4 shown · 12 recent')).toBeTruthy()
expect(screen.getByText('4 shown')).toBeTruthy()
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 shown={12} loaded={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 })
@@ -64,8 +67,7 @@ it('hands the picked search order back to the caller', async () => {
<AiVaultSessionListBar
label={<AiVaultResultCountLabel count={20} />}
value="relevance"
options={aiVaultSearchSortOptions()}
sortAriaLabel={aiVaultSearchSortAriaLabel}
menu={aiVaultSearchSortMenu()}
onChange={onChange}
/>
)
@@ -81,10 +83,9 @@ it('hands the picked browse order back to the caller', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(
<AiVaultSessionListBar
label={<AiVaultShownCountLabel shown={4} recent={12} />}
label={<AiVaultSessionCountLabel shown={4} loaded={12} />}
value="updated"
options={aiVaultBrowseSortOptions()}
sortAriaLabel={aiVaultBrowseSortAriaLabel}
menu={aiVaultBrowseSortMenu()}
onChange={onChange}
/>
)
@@ -10,7 +10,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import type { AiVaultSortOption } from './ai-vault-sort-options'
import type { AiVaultSortMenu } from './ai-vault-sort-options'
/** Left-hand label while searching: how many hits the list is showing. */
export function AiVaultResultCountLabel({ count }: { count: number }): React.JSX.Element {
@@ -23,31 +23,29 @@ export function AiVaultResultCountLabel({ count }: { count: number }): React.JSX
)
}
/** Left-hand label while browsing: how much of the scanned history the list is showing. */
export function AiVaultShownCountLabel({
/** Left-hand label while browsing: the count, and how much of the scan filters hid. */
export function AiVaultSessionCountLabel({
shown,
recent
loaded
}: {
shown: number
recent: number
loaded: number
}): React.JSX.Element {
if (shown !== loaded) {
return (
<>
{translate('sessionSearch.panel.sessionsOfLoaded', '{{value0}} of {{value1}} sessions', {
value0: shown,
value1: loaded
})}
</>
)
}
return (
<>
{/* Why: below 300px the bar competes with the sort menu, so compact copy prevents overlap. */}
<span className="@max-[300px]/ai-vault:hidden">
{translate(
'auto.components.right.sidebar.AiVaultPanel.shownRecent',
'{{value0}} shown · {{value1}} recent',
{ value0: shown, value1: recent }
)}
</span>
<span className="hidden @max-[300px]/ai-vault:inline">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionsShownCompact',
'{{value0}} shown',
{ value0: shown }
)}
</span>
{shown === 1
? translate('sessionSearch.panel.sessionsOne', '{{count}} session', { count: shown })
: translate('sessionSearch.panel.sessionsOther', '{{count}} sessions', { count: shown })}
</>
)
}
@@ -59,17 +57,15 @@ export function AiVaultShownCountLabel({
export function AiVaultSessionListBar<Value extends string>({
label,
value,
options,
sortAriaLabel,
menu,
onChange
}: {
label: ReactNode
value: Value
options: readonly AiVaultSortOption<Value>[]
sortAriaLabel: (selectedLabel: string) => string
menu: AiVaultSortMenu<Value>
onChange: (value: Value) => void
}): React.JSX.Element {
const selected = options.find((option) => option.value === value)
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">
@@ -81,7 +77,7 @@ export function AiVaultSessionListBar<Value extends string>({
variant="ghost"
size="xs"
className="shrink-0"
aria-label={sortAriaLabel(selected?.label ?? '')}
aria-label={menu.ariaLabel(selected?.label ?? '')}
>
{selected?.label}
<ChevronDown className="text-muted-foreground" />
@@ -92,13 +88,13 @@ export function AiVaultSessionListBar<Value extends string>({
value={value}
// Radix hands back a bare string; the option list is what narrows it.
onValueChange={(next) => {
const picked = options.find((option) => option.value === next)
const picked = menu.options.find((option) => option.value === next)
if (picked) {
onChange(picked.value)
}
}}
>
{options.map((option) => (
{menu.options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
@@ -9,3 +9,8 @@ export function normalizeAiVaultSessionLimit(value: unknown): AiVaultSessionLimi
? (value as AiVaultSessionLimit)
: DEFAULT_AI_VAULT_SESSION_LIMIT
}
/** The next History depth step, or null once the scan is already unlimited. */
export function nextAiVaultSessionLimit(limit: AiVaultSessionLimit): AiVaultSessionLimit | null {
return AI_VAULT_SESSION_LIMITS[AI_VAULT_SESSION_LIMITS.indexOf(limit) + 1] ?? null
}
@@ -3,37 +3,46 @@ import type { AiVaultSearchSort, AiVaultSort } from '../../../../shared/ai-vault
export type AiVaultSortOption<Value extends string> = { value: Value; label: string }
export function aiVaultBrowseSortOptions(): readonly AiVaultSortOption<AiVaultSort>[] {
return [
{
value: 'updated',
label: translate(
'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
'Last updated'
)
},
{
value: 'created',
label: translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')
}
]
/** 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 aiVaultSearchSortOptions(): readonly AiVaultSortOption<AiVaultSearchSort>[] {
return [
{ value: 'relevance', label: translate('sessionSearch.panel.sortRelevance', 'Most relevant') },
{ value: 'newest', label: translate('sessionSearch.panel.sortNewest', 'Newest') }
]
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 aiVaultBrowseSortAriaLabel(selectedLabel: string): string {
return translate('sessionSearch.panel.sortSessionsAriaLabel', 'Sort sessions: {{value0}}', {
value0: selectedLabel
})
}
export function aiVaultSearchSortAriaLabel(selectedLabel: string): string {
return translate('sessionSearch.panel.sortResultsAriaLabel', 'Sort results: {{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
})
}
}
+5 -3
View File
@@ -13201,8 +13201,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",
@@ -18021,7 +18019,11 @@
"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.",
"sortSessionsAriaLabel": "Sort sessions: {{value0}}"
"sortSessionsAriaLabel": "Sort sessions: {{value0}}",
"sessionsOne": "{{count}} session",
"sessionsOther": "{{count}} sessions",
"sessionsOfLoaded": "{{value0}} of {{value1}} sessions",
"showMoreSessions": "Show more sessions"
}
},
"sessionHistory": {
-2
View File
@@ -11376,7 +11376,6 @@
"localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.",
"agentSessionQueued": "Sesión de {{value0}} en cola",
"sessionHistory": "Historial de sesiones de agentes",
"shownRecent": "{{value0}} mostradas · {{value1}} recientes",
"resumePastSessions": "Reanudar sesiones anteriores",
"refreshSessionHistory": "Actualizar historial de sesiones",
"searchSessions": "Buscar sesiones",
@@ -11389,7 +11388,6 @@
"sessionId": "ID de sesión",
"logPath": "Ruta del log",
"agents": "Agentes",
"sessionsShownCompact": "{{value0}} mostradas",
"originalPaneUnavailable": "El panel original ya no está disponible.",
"worktreeUnavailable": "El worktree ya no está disponible.",
"openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.",
-2
View File
@@ -12503,8 +12503,6 @@
"agentSessionQueued": "Session {{value0}} en file d'attente",
"sessionHistory": "Historique des sessions d'agent",
"agents": "Agents",
"shownRecent": "{{value0}} affichées · {{value1}} récentes",
"sessionsShownCompact": "{{value0}} affichées",
"resumePastSessions": "Reprendre les sessions passées",
"refreshSessionHistory": "Actualiser l'historique des sessions",
"searchSessions": "Rechercher des sessions",
-2
View File
@@ -11376,7 +11376,6 @@
"localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。",
"agentSessionQueued": "{{value0}} 件のセッションが順番待ち",
"sessionHistory": "Agent セッション履歴",
"shownRecent": "{{value0}}表示 · {{value1}}最近",
"resumePastSessions": "過去のセッションを再開",
"refreshSessionHistory": "セッション履歴を更新",
"searchSessions": "セッションを検索",
@@ -11389,7 +11388,6 @@
"sessionId": "セッション ID",
"logPath": "ログパス",
"agents": "Agent",
"sessionsShownCompact": "{{value0}} 件表示",
"originalPaneUnavailable": "元のペインは利用できなくなりました。",
"worktreeUnavailable": "ワークツリーは利用できなくなりました。",
"openSupportedWorkspace": "セッションを再開する前に、ワークスペースを開いてください。",
-2
View File
@@ -11418,7 +11418,6 @@
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
"agentSessionQueued": "{{value0}} 세션이 대기열에 추가됨",
"sessionHistory": "Agent 세션 기록",
"shownRecent": "{{value0}}개 표시 · 최근 {{value1}}개",
"resumePastSessions": "이전 세션 재개",
"refreshSessionHistory": "세션 기록 새로고침",
"searchSessions": "세션 검색",
@@ -11431,7 +11430,6 @@
"sessionId": "세션 ID",
"logPath": "로그 경로",
"agents": "에이전트",
"sessionsShownCompact": "{{value0}} 표시됨",
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.",
"openSupportedWorkspace": "세션을 재개하기 전에 워크스페이스를 여세요.",
-2
View File
@@ -11456,7 +11456,6 @@
"localWorkspacesOnly": "从历史记录恢复仅适用于本地工作区。",
"agentSessionQueued": "{{value0}} 会话已排队",
"sessionHistory": "智能体会话历史",
"shownRecent": "已显示 {{value0}} 项 · 最近 {{value1}} 项",
"resumePastSessions": "恢复过往会话",
"refreshSessionHistory": "刷新会话历史",
"searchSessions": "搜索会话",
@@ -11469,7 +11468,6 @@
"sessionId": "会话 ID",
"logPath": "日志路径",
"agents": "智能体",
"sessionsShownCompact": "已显示 {{value0}} 个",
"originalPaneUnavailable": "原始面板不再可用。",
"worktreeUnavailable": "工作树不再可用。",
"openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。",