Skip to content

Commit a4c38aa

Browse files
authored
feat: support parallel chat sessions streaming concurrently (#55)
Move stream lifecycle out of ChatThreadContainer into a session-keyed chatStreamStore (useSyncExternalStore, no new deps) so streams survive session/view switches and multiple sessions can stream at once (capped at 3). Background completions are folded in on return via refetch; background errors surface with the question restored to the composer. AgentRail rows show a pulse dot for streaming sessions.
1 parent ed8414d commit a4c38aa

8 files changed

Lines changed: 721 additions & 197 deletions

File tree

ui/DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ avatar, badge, button, card, checkbox, dialog, dropdown-menu, input, label, popo
4646
|---|---|
4747
| `ui/` | shadcn primitives (above) — generic, no app-specific logic |
4848
| `research/` | Research-session UI: sidebar, query composer/form, streaming progress, report viewer with feedback |
49-
| `chat/` | Chat thread machinery: `GenericChat` composes `ChatThreadContainer` + a transport, plus Streamdown-based markdown rendering and the tool menu |
49+
| `chat/` | Chat thread machinery: `GenericChat` composes `ChatThreadContainer` + a transport, plus Streamdown-based markdown rendering and the tool menu. `chatStreamStore.ts` is a session-keyed module store (`useSyncExternalStore`) that owns stream lifecycles so multiple sessions can stream in parallel and survive session switches (capped at `MAX_CONCURRENT_STREAMS`); `AgentRail` session rows show a pulse dot for sessions streaming in the background |
5050
| `shell/` | Top-level app shell: `AppShell` wires auth/health/pages together, `AgentRail` is the left nav rail (agents, theme toggle, billing) |
5151
| `layout/` | Cross-page chrome: `Navbar` (top bar, theme toggle, user menu), the theme system (`ThemeProvider`, `theme-context.ts`), and shared `HealthDot` status indicator |
5252
| `resources/` | RAG resource management: table + upload dialog |
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { RagChatMessage } from '@/types'
4+
import { ChatThreadContainer } from './ChatThreadContainer'
5+
import { resetChatStreamStore } from './chatStreamStore'
6+
import type { ChatTransport, StreamCallbacks } from './transports'
7+
8+
type CapturedStream = {
9+
message: string
10+
sessionId: string | null
11+
callbacks: StreamCallbacks
12+
}
13+
14+
function makeTransport(messagesBySession: Record<string, RagChatMessage[]> = {}) {
15+
const captured: CapturedStream[] = []
16+
const loadSessionMessages = vi.fn(async (sessionId: string) => ({
17+
session_id: sessionId,
18+
messages: messagesBySession[sessionId] ?? [],
19+
}))
20+
const transport = {
21+
key: 'workspace',
22+
listSessions: vi.fn(async () => []),
23+
loadSessionMessages,
24+
deleteLastExchange: vi.fn(async () => {}),
25+
streamMessage: vi.fn(
26+
(message: string, sessionId: string | null, _token: string, callbacks: StreamCallbacks) => {
27+
captured.push({ message, sessionId, callbacks })
28+
return new Promise<void>(() => {})
29+
},
30+
),
31+
} as unknown as ChatTransport
32+
return { transport, captured, loadSessionMessages }
33+
}
34+
35+
function renderContainer(transport: ChatTransport, activeSessionId: string | null) {
36+
const props = {
37+
transport,
38+
accessToken: 'token',
39+
onSessionActivated: vi.fn(),
40+
onSessionsChanged: vi.fn(),
41+
title: 'Chat',
42+
emptyState: 'Ask anything.',
43+
}
44+
const view = render(<ChatThreadContainer {...props} activeSessionId={activeSessionId} />)
45+
return {
46+
...props,
47+
rerenderWith: (nextSessionId: string | null) =>
48+
view.rerender(<ChatThreadContainer {...props} activeSessionId={nextSessionId} />),
49+
}
50+
}
51+
52+
describe('ChatThreadContainer parallel streams', () => {
53+
beforeEach(() => {
54+
resetChatStreamStore()
55+
})
56+
57+
it('keeps a stream alive when switching to another session and folds it in on return', async () => {
58+
const { transport, captured, loadSessionMessages } = makeTransport({ 'session-a': [], 'session-b': [] })
59+
const { rerenderWith } = renderContainer(transport, 'session-a')
60+
await waitFor(() => expect(loadSessionMessages).toHaveBeenCalledWith('session-a', 'token'))
61+
62+
fireEvent.change(screen.getByPlaceholderText('Ask a question...'), { target: { value: 'question for a' } })
63+
fireEvent.click(screen.getByLabelText('Send message'))
64+
expect(captured).toHaveLength(1)
65+
66+
// switch to session B: the stream for A must NOT be aborted
67+
rerenderWith('session-b')
68+
await waitFor(() => expect(loadSessionMessages).toHaveBeenCalledWith('session-b', 'token'))
69+
expect(captured[0].callbacks.signal?.aborted).toBe(false)
70+
71+
// A completes in the background
72+
captured[0].callbacks.onChunk('answer for a')
73+
captured[0].callbacks.onDone()
74+
75+
// returning to A refetches persisted messages instead of trusting stale state
76+
const callsBefore = loadSessionMessages.mock.calls.filter(([id]) => id === 'session-a').length
77+
rerenderWith('session-a')
78+
await waitFor(() => {
79+
const callsAfter = loadSessionMessages.mock.calls.filter(([id]) => id === 'session-a').length
80+
expect(callsAfter).toBe(callsBefore + 1)
81+
})
82+
})
83+
84+
it('streams in a second session while the first is still running', async () => {
85+
const { transport, captured } = makeTransport({ 'session-a': [], 'session-b': [] })
86+
const { rerenderWith } = renderContainer(transport, 'session-a')
87+
88+
fireEvent.change(await screen.findByPlaceholderText('Ask a question...'), { target: { value: 'first question' } })
89+
fireEvent.click(screen.getByLabelText('Send message'))
90+
91+
rerenderWith('session-b')
92+
const composer = await screen.findByPlaceholderText('Ask a question...')
93+
await waitFor(() => expect(composer).not.toBeDisabled())
94+
fireEvent.change(composer, { target: { value: 'second question' } })
95+
fireEvent.click(screen.getByLabelText('Send message'))
96+
97+
expect(captured).toHaveLength(2)
98+
expect(captured[0].callbacks.signal?.aborted).toBe(false)
99+
expect(captured[1].sessionId).toBe('session-b')
100+
101+
// both streams render independently in their own session view
102+
captured[1].callbacks.onChunk('b partial')
103+
expect(await screen.findByText('b partial')).toBeInTheDocument()
104+
})
105+
106+
it('surfaces a background stream error when returning to the session', async () => {
107+
const { transport, captured, loadSessionMessages } = makeTransport({ 'session-a': [], 'session-b': [] })
108+
const { rerenderWith } = renderContainer(transport, 'session-a')
109+
await waitFor(() => expect(loadSessionMessages).toHaveBeenCalledWith('session-a', 'token'))
110+
111+
fireEvent.change(screen.getByPlaceholderText('Ask a question...'), { target: { value: 'doomed question' } })
112+
fireEvent.click(screen.getByLabelText('Send message'))
113+
114+
rerenderWith('session-b')
115+
await waitFor(() => expect(loadSessionMessages).toHaveBeenCalledWith('session-b', 'token'))
116+
captured[0].callbacks.onError?.('backend exploded')
117+
118+
rerenderWith('session-a')
119+
expect(await screen.findByRole('alert')).toHaveTextContent('backend exploded')
120+
expect(screen.getByPlaceholderText('Ask a question...')).toHaveValue('doomed question')
121+
})
122+
123+
it('disables the composer only while the viewed session is streaming', async () => {
124+
const { transport, captured } = makeTransport({ 'session-a': [] })
125+
renderContainer(transport, 'session-a')
126+
127+
const composer = await screen.findByPlaceholderText('Ask a question...')
128+
fireEvent.change(composer, { target: { value: 'question' } })
129+
fireEvent.click(screen.getByLabelText('Send message'))
130+
expect(composer).toBeDisabled()
131+
132+
captured[0].callbacks.onChunk('done text')
133+
captured[0].callbacks.onDone()
134+
await waitFor(() => expect(composer).not.toBeDisabled())
135+
expect(screen.getByText('done text')).toBeInTheDocument()
136+
})
137+
})

0 commit comments

Comments
 (0)