mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Merge pull request #66747 from NousResearch/perf/desktop-hot-paths
perf(desktop): cut startup serialization and per-turn REST amplification
This commit is contained in:
commit
73e32f37e7
7 changed files with 448 additions and 26 deletions
|
|
@ -7655,12 +7655,20 @@ function createWindow() {
|
|||
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||||
}
|
||||
|
||||
// Start the Python backend NOW, in parallel with the renderer load — not on
|
||||
// did-finish-load. The backend cold boot (spawn → port announce → /api/status)
|
||||
// is the dominant startup cost, and serializing it behind Chromium's load
|
||||
// added the whole renderer load time to first-usable-composer. The promise is
|
||||
// shared (backendConnectionState), so the renderer's getConnection() joins
|
||||
// this in-flight boot instead of duplicating it; early boot-progress events
|
||||
// the renderer misses are recovered by its getBootProgress() pull on mount.
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
// Zoom restore is handled by wireCommonWindowHandlers (shared with session
|
||||
// windows); no need to reapply it here.
|
||||
broadcastBootProgress()
|
||||
sendWindowStateChanged()
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -284,10 +284,14 @@ export function useGatewayBoot({
|
|||
return
|
||||
}
|
||||
|
||||
// Same shape as boot(): profile first (session scope depends on it),
|
||||
// then the independent fetches concurrently.
|
||||
await adoptPrimaryProfile()
|
||||
await seedDefaultCwd()
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
await Promise.all([
|
||||
seedDefaultCwd(),
|
||||
callbacksRef.current.refreshHermesConfig().catch(() => undefined),
|
||||
callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
])
|
||||
completeDesktopBoot()
|
||||
bootCompleted = true
|
||||
} catch (err) {
|
||||
|
|
@ -460,6 +464,11 @@ export function useGatewayBoot({
|
|||
return
|
||||
}
|
||||
|
||||
// Profile adoption must land first: refreshSessions scopes its fetch by
|
||||
// $profileScope ← $activeGatewayProfile. The remaining three fetches
|
||||
// (cwd seed, config, sessions) are independent REST calls — running
|
||||
// them serially added their sum to time-to-populated-sidebar when only
|
||||
// the max is needed.
|
||||
await adoptPrimaryProfile()
|
||||
|
||||
setDesktopBootStep({
|
||||
|
|
@ -467,20 +476,17 @@ export function useGatewayBoot({
|
|||
message: translateNow('boot.steps.loadingSettings'),
|
||||
progress: 97
|
||||
})
|
||||
await seedDefaultCwd()
|
||||
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
await Promise.all([
|
||||
seedDefaultCwd(),
|
||||
callbacksRef.current.refreshHermesConfig(),
|
||||
callbacksRef.current.refreshSessions()
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.sessions',
|
||||
message: translateNow('boot.steps.loadingSessions'),
|
||||
progress: 99
|
||||
})
|
||||
await callbacksRef.current.refreshSessions()
|
||||
completeDesktopBoot()
|
||||
bootCompleted = true
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useRef } from 'react'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
|
|
@ -26,6 +26,8 @@ import { followActiveSessionCwd } from '@/store/projects'
|
|||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
|
|
@ -77,6 +79,7 @@ interface GatewayEventDeps {
|
|||
queryClient: QueryClient
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
sessionInterrupted: (sessionId: string) => boolean
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
|
|
@ -105,12 +108,48 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
queryClient,
|
||||
refreshHermesConfig,
|
||||
sessionInterrupted,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
} = deps
|
||||
|
||||
const unscopedStreamSessionIdRef = useRef<string | null>(null)
|
||||
|
||||
// session.info arrives in bursts (agent build ready + turn end + title /
|
||||
// MCP / compress edges within the same second). Each used to fire its own
|
||||
// refreshHermesConfig — two REST calls (config + defaults) per event, per
|
||||
// turn, including for BACKGROUND sessions whose values the fetch can't even
|
||||
// apply. Coalesce to one trailing fetch per burst; the caller gates on
|
||||
// `apply` so background traffic doesn't schedule anything.
|
||||
const configRefreshTimerRef = useRef<null | number>(null)
|
||||
|
||||
const scheduleConfigRefresh = useCallback(() => {
|
||||
if (configRefreshTimerRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
void refreshHermesConfig()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
configRefreshTimerRef.current = window.setTimeout(() => {
|
||||
configRefreshTimerRef.current = null
|
||||
void refreshHermesConfig()
|
||||
}, 300)
|
||||
}, [refreshHermesConfig])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(configRefreshTimerRef.current)
|
||||
configRefreshTimerRef.current = null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return useCallback(
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
|
|
@ -151,6 +190,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
// The backend stamps model/provider (as strings) on EVERY session.info,
|
||||
// so the presence flags above are true on every heartbeat/turn edge —
|
||||
// fine for the cheap atom writes below (nanostores skips identical
|
||||
// values), but they also drove queryClient.invalidateQueries, refetching
|
||||
// the model-options provider catalog once or twice per turn for a model
|
||||
// that never changed. Only a genuine VALUE change (vs the session's own
|
||||
// cached runtime state, captured before the state patch below applies;
|
||||
// composer atoms as the fallback for an uncached session) invalidates.
|
||||
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
|
||||
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
|
||||
const providerValueChanged =
|
||||
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
|
||||
|
||||
// Config is profile-scoped, but session.info also arrives for background
|
||||
// sessions. Only an active-session event from the currently active
|
||||
|
|
@ -292,11 +343,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
// Config refetch is only meaningful for the foreground context —
|
||||
// everything refreshHermesConfig applies is either active-session
|
||||
// guarded or a composer/global pref. Background sessions' heartbeats
|
||||
// used to trigger it too (two REST calls each, every turn).
|
||||
scheduleConfigRefresh()
|
||||
}
|
||||
|
||||
void refreshHermesConfig()
|
||||
|
||||
if (modelChanged || providerChanged) {
|
||||
if (modelValueChanged || providerValueChanged) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
|
||||
})
|
||||
|
|
@ -755,8 +809,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
lastCwdInfoSessionRef,
|
||||
nativeSubagentSessionsRef,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
scheduleConfigRefresh,
|
||||
sessionInterrupted,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
]
|
||||
|
|
|
|||
|
|
@ -131,6 +131,46 @@ export function useMessageStream({
|
|||
[updateSessionState]
|
||||
)
|
||||
|
||||
// Turn-complete triggers a full sidebar refresh (recents + cron + messaging
|
||||
// REST fan-out, each scanning profile state.dbs server-side) plus a
|
||||
// cross-window broadcast that makes every other window do the same. Parallel
|
||||
// tiles / multi-window finishing near-simultaneously used to multiply that.
|
||||
// Coalesce completions into one trailing refresh per burst — a ~300ms title
|
||||
// lag is invisible; the redundant aggregator scans are not.
|
||||
const sessionsRefreshTimerRef = useRef<null | number>(null)
|
||||
|
||||
const scheduleSessionsRefresh = useCallback(() => {
|
||||
if (sessionsRefreshTimerRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
sessionsRefreshTimerRef.current = null
|
||||
void refreshSessions().catch(() => undefined)
|
||||
// Sync freshly-titled rows to other windows (e.g. main, when the turn
|
||||
// ran in the pop-out).
|
||||
broadcastSessionsChanged()
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionsRefreshTimerRef.current = window.setTimeout(run, 300)
|
||||
}, [refreshSessions])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (sessionsRefreshTimerRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(sessionsRefreshTimerRef.current)
|
||||
sessionsRefreshTimerRef.current = null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(0)
|
||||
|
|
@ -444,10 +484,7 @@ export function useMessageStream({
|
|||
}
|
||||
})
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
// Sync the freshly-titled row to other windows (e.g. main, when the turn
|
||||
// ran in the pop-out).
|
||||
broadcastSessionsChanged()
|
||||
scheduleSessionsRefresh()
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
|
|
@ -464,7 +501,7 @@ export function useMessageStream({
|
|||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
},
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
|
|
@ -526,6 +563,7 @@ export function useMessageStream({
|
|||
queryClient,
|
||||
refreshHermesConfig,
|
||||
sessionInterrupted,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
// Per-turn REST amplification guards: session.info must not refetch config for
|
||||
// background sessions nor invalidate the model-options catalog when the model
|
||||
// string is merely PRESENT (the backend stamps it on every event) rather than
|
||||
// actually changed. message.complete must coalesce sidebar refreshes.
|
||||
|
||||
const ACTIVE_SID = 'session-active'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
|
||||
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
|
||||
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
|
||||
queryClient = new QueryClient()
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('session.info config refetch gating', () => {
|
||||
it('coalesces active-session bursts into one trailing config fetch', async () => {
|
||||
// Mount under real timers (waitFor), then freeze time for the debounce.
|
||||
await mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', running: false })
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', title: 't' })
|
||||
|
||||
expect(refreshHermesConfig).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
})
|
||||
|
||||
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never fetches config for a background session heartbeat', async () => {
|
||||
await mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
sessionInfo('session-background', { model: 'm1', running: true })
|
||||
sessionInfo('session-background', { model: 'm1', running: false })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
})
|
||||
|
||||
expect(refreshHermesConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.info model-options invalidation gating', () => {
|
||||
it('skips invalidation when model/provider merely restate the known values', async () => {
|
||||
await mountStream()
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
// Seed the session's cached runtime state.
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
|
||||
invalidate.mockClear()
|
||||
|
||||
// Turn-end heartbeat restating the same model/provider — the pre-fix path
|
||||
// invalidated (and refetched the provider catalog) on every one of these.
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: false })
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates when the session model actually changes', async () => {
|
||||
await mountStream()
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
|
||||
invalidate.mockClear()
|
||||
|
||||
sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true })
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('message.complete sidebar refresh coalescing', () => {
|
||||
it('collapses near-simultaneous completions into one refresh', async () => {
|
||||
await mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
|
||||
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
|
||||
|
||||
expect(refreshSessions).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
})
|
||||
|
||||
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session'
|
||||
|
||||
import { useSessionListActions } from './use-session-list-actions'
|
||||
|
||||
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
|
||||
// cross-window broadcast, reconnect) must not replace $sessions' array
|
||||
// identity — that identity is the dependency for every sidebar memo — and
|
||||
// must not flicker the loading flag over an already-populated list.
|
||||
|
||||
const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
|
||||
({
|
||||
ended_at: null,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1000,
|
||||
message_count: 3,
|
||||
model: 'm',
|
||||
output_tokens: 0,
|
||||
preview: 'hey',
|
||||
profile: 'default',
|
||||
source: 'desktop',
|
||||
started_at: 900,
|
||||
title: `Chat ${id}`,
|
||||
...over
|
||||
}) as SessionInfo
|
||||
|
||||
const listAllProfileSessions = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCronJobs: vi.fn(async () => []),
|
||||
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args)
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
listAllProfileSessions.mockReset()
|
||||
setSessions([])
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSessions([])
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
|
||||
describe('refreshSessions identity + loading hygiene', () => {
|
||||
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
|
||||
const rows = [row('a'), row('b')]
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } })
|
||||
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
const first = $sessions.get()
|
||||
expect(first.map(s => s.id)).toEqual(['a', 'b'])
|
||||
|
||||
// Second refresh returns fresh (but equal) row objects, as the API does.
|
||||
listAllProfileSessions.mockResolvedValue({
|
||||
sessions: [row('a'), row('b')],
|
||||
total: 2,
|
||||
profile_totals: { default: 2 }
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
expect($sessions.get()).toBe(first)
|
||||
})
|
||||
|
||||
it('swaps the array when rows actually changed', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
const first = $sessions.get()
|
||||
|
||||
listAllProfileSessions.mockResolvedValue({
|
||||
sessions: [row('a', { last_active: 2000, title: 'Renamed' })],
|
||||
total: 1,
|
||||
profile_totals: {}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
expect($sessions.get()).not.toBe(first)
|
||||
expect($sessions.get()[0].title).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('does not flicker the loading flag over a populated list', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
const loadingStates: boolean[] = []
|
||||
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
off()
|
||||
// Only the initial subscribe emission — no true/false churn per refresh.
|
||||
expect(loadingStates).toEqual([false])
|
||||
})
|
||||
|
||||
it('still shows loading for the initial (empty-list) fetch', async () => {
|
||||
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
|
||||
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
|
||||
|
||||
const loadingStates: boolean[] = []
|
||||
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
off()
|
||||
expect(loadingStates).toEqual([false, true, false])
|
||||
})
|
||||
})
|
||||
|
|
@ -153,7 +153,15 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
|||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
refreshSessionsRequestRef.current = requestId
|
||||
setSessionsLoading(true)
|
||||
// The loading flag exists to drive the initial skeletons (they only render
|
||||
// while the list is empty). Turn-complete / reconnect refreshes over a
|
||||
// populated list used to flip it true→false anyway, churning every
|
||||
// $sessionsLoading subscriber twice per turn for no visible change.
|
||||
const showLoading = $sessions.get().length === 0
|
||||
|
||||
if (showLoading) {
|
||||
setSessionsLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
|
|
@ -176,12 +184,27 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
|||
})
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
|
||||
// Signature-gate the swap (same pattern as cron/messaging): a refresh
|
||||
// that returns content-identical rows must keep the previous array
|
||||
// identity, or every sidebar memo keyed on $sessions recomputes and the
|
||||
// whole list re-renders once per turn/broadcast for nothing.
|
||||
setSessions(prev => {
|
||||
const next = mergeSessionPage(prev, result.sessions, sessionsToKeep())
|
||||
|
||||
return sameCronSignature(prev, next) ? prev : next
|
||||
})
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
setSessionProfileTotals(result.profile_totals ?? {})
|
||||
setSessionProfileTotals(prev => {
|
||||
const next = result.profile_totals ?? {}
|
||||
const prevKeys = Object.keys(prev)
|
||||
|
||||
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
|
||||
? prev
|
||||
: next
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
if (showLoading && refreshSessionsRequestRef.current === requestId) {
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue