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.
This commit is contained in:
Jinwoo-H
2026-09-21 04:36:01 -04:00
parent e2ef72760d
commit cd8e0b2b7b
6 changed files with 115 additions and 56 deletions
@@ -7,56 +7,13 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import {
AI_VAULT_SESSION_LIMIT_STEP,
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,
loading,
sessionLimit,
onSessionLimitChange
}: {
loaded: number
loading: boolean
sessionLimit: AiVaultSessionLimit
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
}): React.JSX.Element | null {
const next = nextAiVaultSessionLimit(sessionLimit)
if (next === null || sessionLimit === 'unlimited' || loaded === 0) {
return null
}
// Why: a step raises the depth before the rescan lands, so the row must survive
// that gap in a loading state instead of vanishing until the new rows paint.
const filled = loaded >= sessionLimit
const stepping = loading && loaded >= sessionLimit - AI_VAULT_SESSION_LIMIT_STEP
if (!filled && !stepping) {
return null
}
return (
<div className="border-t border-sidebar-border p-2">
<Button
className="w-full"
variant="ghost"
size="xs"
disabled={loading}
onClick={() => onSessionLimitChange(next)}
>
{loading
? translate('sessionSearch.panel.loadingMoreSessions', 'Loading more sessions…')
: translate('sessionSearch.panel.showMoreSessions', 'Show more sessions')}
</Button>
</div>
)
}
export function AiVaultSessionLimitMenu({
sessionLimit,
onSessionLimitChange
@@ -2,7 +2,7 @@
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'
import { AiVaultShowMoreSessionsRow } from './AiVaultShowMoreSessionsRow'
afterEach(cleanup)
@@ -11,6 +11,7 @@ it('steps the history depth up by one page once the scan filled it', async () =>
render(
<AiVaultShowMoreSessionsRow
loaded={500}
loadedSessionLimit={500}
loading={false}
sessionLimit={500}
onSessionLimitChange={onSessionLimitChange}
@@ -24,6 +25,7 @@ it('stays put in a loading state while the deeper rescan runs', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={250}
loadedSessionLimit={250}
loading
sessionLimit={500}
onSessionLimitChange={vi.fn()}
@@ -33,10 +35,26 @@ it('stays put in a loading state while the deeper rescan runs', () => {
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()}
@@ -45,6 +63,7 @@ it('stays hidden while the scan has room or is already unlimited', () => {
render(
<AiVaultShowMoreSessionsRow
loaded={5000}
loadedSessionLimit="unlimited"
loading={false}
sessionLimit="unlimited"
onSessionLimitChange={vi.fn()}
@@ -52,3 +71,16 @@ it('stays hidden while the scan has room or is already unlimited', () => {
)
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 }
}
@@ -14,8 +14,3 @@ export function normalizeAiVaultSessionLimit(value: unknown): AiVaultSessionLimi
? value
: DEFAULT_AI_VAULT_SESSION_LIMIT
}
/** The next History depth, or null once the scan is already unlimited. */
export function nextAiVaultSessionLimit(limit: AiVaultSessionLimit): number | null {
return limit === 'unlimited' ? null : limit + AI_VAULT_SESSION_LIMIT_STEP
}
@@ -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 }
}