Skip to content

Commit 4225046

Browse files
Merge pull request #39 from bitloops/cli-1850-slow-session-data-update
Fix slow session data update
2 parents 4679d5e + efaf2e9 commit 4225046

2 files changed

Lines changed: 431 additions & 9 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
import type { ReactNode } from 'react'
2+
import { useEffect } from 'react'
3+
import { act, render, screen, waitFor } from '@testing-library/react'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
fetchDashboardCheckpointDetail,
7+
subscribeDashboardInteractionUpdates,
8+
} from '@/features/dashboard/graphql/fetch-dashboard-data'
9+
import type { DashboardInteractionSessionDto } from '@/features/dashboard/api-types'
10+
import { rootStoreInstance } from '@/store'
11+
import { runDashboardQueryExplorerQuery } from './run-dashboard-query'
12+
import { SessionsView } from './sessions-view'
13+
14+
vi.mock('@/components/layout/header', () => ({
15+
Header: ({ children }: { children: ReactNode }) => <div>{children}</div>,
16+
}))
17+
18+
vi.mock('@/components/layout/main', () => ({
19+
Main: ({ children }: { children: ReactNode }) => <main>{children}</main>,
20+
}))
21+
22+
vi.mock('@/components/theme-switch', () => ({
23+
ThemeSwitch: () => <div data-testid='theme-switch' />,
24+
}))
25+
26+
vi.mock('@/components/ui/use-sidebar', () => ({
27+
useSidebar: () => ({
28+
setOpen: vi.fn(),
29+
setRightOpen: vi.fn(),
30+
}),
31+
}))
32+
33+
vi.mock('@/components/ui/sidebar', () => ({
34+
Sidebar: ({ children }: { children: ReactNode }) => <aside>{children}</aside>,
35+
SidebarRail: () => null,
36+
}))
37+
38+
vi.mock('@/features/query-explorer/hooks/use-resize-width', () => ({
39+
useResizeWidth: () => [780, vi.fn()],
40+
}))
41+
42+
vi.mock('@/features/query-explorer/components/query-explorer', () => ({
43+
QueryExplorerLayout: ({
44+
leftPanel,
45+
rightPanel,
46+
}: {
47+
leftPanel: ReactNode
48+
rightPanel: ReactNode
49+
}) => (
50+
<div>
51+
<div>{leftPanel}</div>
52+
<div>{rightPanel}</div>
53+
</div>
54+
),
55+
}))
56+
57+
vi.mock(
58+
'@/features/query-explorer/components/editor-history-container',
59+
() => ({
60+
EditorHistoryContainer: ({
61+
runQuery,
62+
}: {
63+
runQuery?: (overrides?: {
64+
query: string
65+
variables: string
66+
}) => Promise<void>
67+
}) => (
68+
<button type='button' onClick={() => void runQuery?.()}>
69+
Run query
70+
</button>
71+
),
72+
}),
73+
)
74+
75+
vi.mock('@/features/sessions/components/sessions-variables-panel', () => ({
76+
SessionsVariablesPanel: () => <div data-testid='sessions-variables-panel' />,
77+
}))
78+
79+
vi.mock('@/features/sessions/components/sessions-repo-branch-filters', () => ({
80+
SessionsRepoBranchFilters: ({
81+
onResolvedRepoIdChange,
82+
}: {
83+
onResolvedRepoIdChange: (repoId: string | null) => void
84+
}) => {
85+
useEffect(() => {
86+
onResolvedRepoIdChange('repo-1')
87+
}, [onResolvedRepoIdChange])
88+
89+
return <div data-testid='sessions-repo-branch-filters' />
90+
},
91+
}))
92+
93+
vi.mock('@/features/dashboard/components/sessions-table', () => ({
94+
SessionsTable: () => <div data-testid='sessions-table' />,
95+
}))
96+
97+
vi.mock('@/features/sessions/components/sessions-checkpoints-table', () => ({
98+
SessionsCheckpointsTable: () => (
99+
<div data-testid='sessions-checkpoints-table' />
100+
),
101+
}))
102+
103+
vi.mock('@/features/dashboard/components/session-detail-sidebar', () => ({
104+
SessionDetailSidebar: ({
105+
sessionId,
106+
refreshToken,
107+
}: {
108+
sessionId: string | null
109+
refreshToken?: number
110+
}) => (
111+
<div
112+
data-testid='session-detail-sidebar'
113+
data-session-id={sessionId ?? ''}
114+
data-refresh-token={String(refreshToken ?? 0)}
115+
/>
116+
),
117+
}))
118+
119+
vi.mock('@/features/dashboard/components/checkpoint-sheet', () => ({
120+
CheckpointSheet: () => <div data-testid='checkpoint-sheet' />,
121+
}))
122+
123+
vi.mock('@/features/dashboard/graphql/fetch-dashboard-data', () => ({
124+
fetchDashboardCheckpointDetail: vi.fn(),
125+
subscribeDashboardInteractionUpdates: vi.fn(),
126+
}))
127+
128+
vi.mock('./run-dashboard-query', () => ({
129+
runDashboardQueryExplorerQuery: vi.fn(),
130+
}))
131+
132+
vi.mock('./use-sessions-result-sync', () => ({
133+
useSessionsResultSync: () => undefined,
134+
}))
135+
136+
const mockFetchDashboardCheckpointDetail = vi.mocked(
137+
fetchDashboardCheckpointDetail,
138+
)
139+
const mockSubscribeDashboardInteractionUpdates = vi.mocked(
140+
subscribeDashboardInteractionUpdates,
141+
)
142+
const mockRunDashboardQueryExplorerQuery = vi.mocked(
143+
runDashboardQueryExplorerQuery,
144+
)
145+
146+
function renderView() {
147+
return render(<SessionsView />)
148+
}
149+
150+
function latestSubscriptionHandlers() {
151+
const handlers = mockSubscribeDashboardInteractionUpdates.mock.lastCall?.[1]
152+
expect(handlers).toBeDefined()
153+
return handlers!
154+
}
155+
156+
function interactionUpdate(
157+
overrides: Partial<{
158+
turn_count: number
159+
latest_session_updated_at: string
160+
latest_turn_id: string
161+
latest_turn_updated_at: string
162+
}> = {},
163+
) {
164+
return {
165+
repo_id: 'repo-1',
166+
session_count: 1,
167+
turn_count: overrides.turn_count ?? 1,
168+
latest_session_id: 'session-1',
169+
latest_session_updated_at:
170+
overrides.latest_session_updated_at ?? '2026-05-19T10:00:00.000Z',
171+
latest_turn_id: overrides.latest_turn_id ?? 'turn-1',
172+
latest_turn_updated_at:
173+
overrides.latest_turn_updated_at ?? '2026-05-19T10:00:00.000Z',
174+
}
175+
}
176+
177+
function makeSessionRow(
178+
sessionId: string,
179+
overrides: Partial<DashboardInteractionSessionDto> = {},
180+
): DashboardInteractionSessionDto {
181+
return {
182+
session_id: sessionId,
183+
branch: 'main',
184+
actor: null,
185+
agent_type: 'claude-code',
186+
model: null,
187+
first_prompt: `Prompt ${sessionId}`,
188+
started_at: '2026-05-19T09:00:00.000Z',
189+
ended_at: null,
190+
last_event_at: '2026-05-19T09:05:00.000Z',
191+
turn_count: 1,
192+
checkpoint_count: 0,
193+
token_usage: null,
194+
file_paths: [],
195+
tool_uses: [],
196+
linked_checkpoints: [],
197+
latest_commit_author: null,
198+
...overrides,
199+
}
200+
}
201+
202+
describe('SessionsView', () => {
203+
afterEach(() => {
204+
vi.useRealTimers()
205+
})
206+
207+
beforeEach(() => {
208+
vi.clearAllMocks()
209+
const state = rootStoreInstance.getState()
210+
state.clearDashboardCache()
211+
state.resetDashboardFilters()
212+
state.clearRunHistory()
213+
state.setSessionsLandingDefaultsApplied(false)
214+
state.setQuery('query { stale }')
215+
state.setVariables('{"repoId":"stale"}')
216+
state.setVariablesHaveErrors(false)
217+
state.setResult({ status: 'idle' })
218+
mockFetchDashboardCheckpointDetail.mockResolvedValue({
219+
checkpoint_id: 'cp-1',
220+
strategy: 'default',
221+
branch: 'main',
222+
checkpoints_count: 1,
223+
files_touched: [],
224+
session_count: 0,
225+
token_usage: null,
226+
sessions: [],
227+
})
228+
mockSubscribeDashboardInteractionUpdates.mockImplementation(() => () => {})
229+
mockRunDashboardQueryExplorerQuery.mockResolvedValue()
230+
})
231+
232+
it('ignores the priming subscription update and refreshes on the next change', async () => {
233+
renderView()
234+
235+
await waitFor(() => {
236+
expect(mockRunDashboardQueryExplorerQuery).toHaveBeenCalledTimes(1)
237+
expect(mockSubscribeDashboardInteractionUpdates).toHaveBeenCalledTimes(1)
238+
})
239+
240+
const handlers = latestSubscriptionHandlers()
241+
242+
act(() => {
243+
handlers.onUpdate(interactionUpdate())
244+
})
245+
246+
expect(mockRunDashboardQueryExplorerQuery).toHaveBeenCalledTimes(1)
247+
248+
act(() => {
249+
handlers.onUpdate(
250+
interactionUpdate({
251+
turn_count: 2,
252+
latest_session_updated_at: '2026-05-19T10:05:00.000Z',
253+
latest_turn_id: 'turn-2',
254+
latest_turn_updated_at: '2026-05-19T10:05:00.000Z',
255+
}),
256+
)
257+
})
258+
259+
await waitFor(() => {
260+
expect(mockRunDashboardQueryExplorerQuery).toHaveBeenCalledTimes(2)
261+
})
262+
})
263+
264+
it('bumps the session detail refresh token only when the selected session row key changes', async () => {
265+
renderView()
266+
267+
const initialSelected = makeSessionRow('session-1', {
268+
last_event_at: '2026-05-19T10:00:00.000Z',
269+
turn_count: 1,
270+
})
271+
const unrelated = makeSessionRow('session-2', {
272+
last_event_at: '2026-05-19T10:01:00.000Z',
273+
turn_count: 1,
274+
})
275+
276+
act(() => {
277+
const state = rootStoreInstance.getState()
278+
state.setSessionRows([initialSelected, unrelated])
279+
state.setSelectedSessionId('session-1')
280+
state.setSelectedSessionSummary(initialSelected)
281+
})
282+
283+
await waitFor(() => {
284+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
285+
'data-session-id',
286+
'session-1',
287+
)
288+
})
289+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
290+
'data-refresh-token',
291+
'0',
292+
)
293+
294+
act(() => {
295+
rootStoreInstance.getState().setSessionRows([
296+
initialSelected,
297+
{
298+
...unrelated,
299+
last_event_at: '2026-05-19T10:02:00.000Z',
300+
turn_count: 2,
301+
},
302+
])
303+
})
304+
305+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
306+
'data-refresh-token',
307+
'0',
308+
)
309+
310+
act(() => {
311+
rootStoreInstance.getState().setSessionRows([
312+
{
313+
...initialSelected,
314+
last_event_at: '2026-05-19T10:03:00.000Z',
315+
turn_count: 2,
316+
},
317+
unrelated,
318+
])
319+
})
320+
321+
await waitFor(() => {
322+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
323+
'data-refresh-token',
324+
'1',
325+
)
326+
})
327+
328+
act(() => {
329+
rootStoreInstance.getState().setSessionRows([
330+
{
331+
...initialSelected,
332+
last_event_at: '2026-05-19T10:03:00.000Z',
333+
turn_count: 2,
334+
ended_at: '2026-05-19T10:04:00.000Z',
335+
},
336+
unrelated,
337+
])
338+
})
339+
340+
await waitFor(() => {
341+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
342+
'data-refresh-token',
343+
'2',
344+
)
345+
})
346+
347+
act(() => {
348+
const state = rootStoreInstance.getState()
349+
state.setSelectedSessionId('session-2')
350+
state.setSelectedSessionSummary(unrelated)
351+
})
352+
353+
await waitFor(() => {
354+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
355+
'data-session-id',
356+
'session-2',
357+
)
358+
})
359+
expect(screen.getByTestId('session-detail-sidebar')).toHaveAttribute(
360+
'data-refresh-token',
361+
'2',
362+
)
363+
})
364+
365+
it('falls back to polling when the interaction subscription errors', async () => {
366+
const consoleWarnSpy = vi
367+
.spyOn(console, 'warn')
368+
.mockImplementation(() => undefined)
369+
370+
renderView()
371+
372+
await waitFor(() => {
373+
expect(mockRunDashboardQueryExplorerQuery).toHaveBeenCalledTimes(1)
374+
expect(mockSubscribeDashboardInteractionUpdates).toHaveBeenCalledTimes(1)
375+
})
376+
377+
vi.useFakeTimers()
378+
379+
act(() => {
380+
latestSubscriptionHandlers().onError?.(new Error('ws unavailable'))
381+
})
382+
383+
expect(consoleWarnSpy).toHaveBeenCalledWith(
384+
'Sessions interaction subscription unavailable; falling back to polling',
385+
expect.any(Error),
386+
)
387+
388+
await act(async () => {
389+
vi.advanceTimersByTime(30_000)
390+
await Promise.resolve()
391+
})
392+
393+
expect(mockRunDashboardQueryExplorerQuery).toHaveBeenCalledTimes(2)
394+
})
395+
})

0 commit comments

Comments
 (0)