mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
+33
-1
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user