Skip to content

Commit 9af6696

Browse files
fix(web): keep global SSE alive for session list status updates (#694)
When a session is open, the web app now keeps an always-on all:true SSE connection for sidebar session-updated events while using a second session-scoped stream for message delivery. Also bump session activity on hub sendMessage so web-originated sends refresh list timestamps. Fixes #693 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3258c52 commit 9af6696

6 files changed

Lines changed: 110 additions & 15 deletions

File tree

hub/src/sync/syncEngine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ export class SyncEngine {
364364
): Promise<void> {
365365
await this.messageService.sendMessage(sessionId, payload)
366366
this.sessionCache.markMessageQueued(sessionId)
367+
this.sessionCache.recordSessionActivity(sessionId, Date.now())
367368
}
368369

369370
async cancelQueuedMessage(

web/src/App.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack'
1919
import { useTranslation } from '@/lib/use-translation'
2020
import { VoiceProvider } from '@/lib/voice-context'
2121
import { requireHubUrlForLogin } from '@/lib/runtime-config'
22+
import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib/appSseSubscriptions'
2223
import { LoginPrompt } from '@/components/LoginPrompt'
2324
import { InstallPrompt } from '@/components/InstallPrompt'
2425
import { OfflineBanner } from '@/components/OfflineBanner'
@@ -295,28 +296,44 @@ function AppInner() {
295296
})
296297
}, [addToast, translateIncomingToast])
297298

298-
const eventSubscription = useMemo(() => {
299-
if (selectedSessionId) {
300-
return { sessionId: selectedSessionId }
301-
}
302-
return { all: true }
303-
}, [selectedSessionId])
299+
const globalEventSubscription = useMemo(() => getAppGlobalSseSubscription(), [])
300+
const sessionEventSubscription = useMemo(
301+
() => getAppSessionSseSubscription(selectedSessionId),
302+
[selectedSessionId]
303+
)
304+
const sseEnabled = Boolean(api && token)
304305

305-
const { subscriptionId } = useSSE({
306-
enabled: Boolean(api && token),
306+
const { subscriptionId: globalSubscriptionId } = useSSE({
307+
enabled: sseEnabled,
307308
token: token ?? '',
308309
baseUrl,
309-
subscription: eventSubscription,
310+
subscription: globalEventSubscription,
311+
scope: 'global',
310312
onConnect: handleSseConnect,
311313
onDisconnect: handleSseDisconnect,
312-
onEvent: handleSseEvent,
314+
onEvent: () => {},
313315
onToast: handleToast
314316
})
315317

318+
const { subscriptionId: sessionSubscriptionId } = useSSE({
319+
enabled: sseEnabled && Boolean(sessionEventSubscription),
320+
token: token ?? '',
321+
baseUrl,
322+
subscription: sessionEventSubscription ?? undefined,
323+
scope: 'full',
324+
onEvent: handleSseEvent
325+
})
326+
327+
useVisibilityReporter({
328+
api,
329+
subscriptionId: globalSubscriptionId,
330+
enabled: sseEnabled
331+
})
332+
316333
useVisibilityReporter({
317334
api,
318-
subscriptionId,
319-
enabled: Boolean(api && token)
335+
subscriptionId: sessionSubscriptionId,
336+
enabled: sseEnabled && Boolean(sessionEventSubscription)
320337
})
321338

322339
// Loading auth source

web/src/hooks/useSSE.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isGlobalScopedMessageStreamEvent } from './useSSE'
3+
4+
describe('useSSE scope handling', () => {
5+
it('treats message stream events as global-scoped skips', () => {
6+
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
7+
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
8+
expect(isGlobalScopedMessageStreamEvent('global', 'message-cancelled')).toBe(true)
9+
})
10+
11+
it('does not skip session lifecycle events on the global connection', () => {
12+
expect(isGlobalScopedMessageStreamEvent('global', 'session-updated')).toBe(false)
13+
expect(isGlobalScopedMessageStreamEvent('global', 'session-added')).toBe(false)
14+
expect(isGlobalScopedMessageStreamEvent('global', 'session-removed')).toBe(false)
15+
})
16+
17+
it('processes message stream events on full-scoped connections', () => {
18+
expect(isGlobalScopedMessageStreamEvent('full', 'message-received')).toBe(false)
19+
})
20+
})

web/src/hooks/useSSE.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ type SSESubscription = {
2121
machineId?: string
2222
}
2323

24+
export type SSEScope = 'global' | 'full'
25+
26+
const MESSAGE_STREAM_EVENT_TYPES = new Set<SyncEvent['type']>([
27+
'message-received',
28+
'messages-consumed',
29+
'message-cancelled'
30+
])
31+
32+
export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
33+
return scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(eventType)
34+
}
35+
2436
type VisibilityState = 'visible' | 'hidden'
2537

2638
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
@@ -105,6 +117,7 @@ export function useSSE(options: {
105117
token: string
106118
baseUrl: string
107119
subscription?: SSESubscription
120+
scope?: SSEScope
108121
onEvent: (event: SyncEvent) => void
109122
onConnect?: () => void
110123
onDisconnect?: (reason: string) => void
@@ -151,10 +164,11 @@ export function useSSE(options: {
151164
}, [options.onToast])
152165

153166
const subscription = options.subscription ?? {}
167+
const scope = options.scope ?? 'full'
154168

155169
const subscriptionKey = useMemo(() => {
156-
return `${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}`
157-
}, [subscription.all, subscription.sessionId, subscription.machineId])
170+
return `${scope}|${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}`
171+
}, [scope, subscription.all, subscription.sessionId, subscription.machineId])
158172

159173
useEffect(() => {
160174
if (!options.enabled) {
@@ -425,6 +439,14 @@ export function useSSE(options: {
425439
return
426440
}
427441

442+
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
443+
if (event.type === 'message-received') {
444+
queueSessionListInvalidation()
445+
}
446+
onEventRef.current(event)
447+
return
448+
}
449+
428450
if (event.type === 'messages-consumed') {
429451
markMessagesConsumed(event.sessionId, event.localIds, event.invokedAt)
430452
}
@@ -575,7 +597,7 @@ export function useSSE(options: {
575597
}
576598
setSubscriptionId(null)
577599
}
578-
}, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient, reconnectNonce])
600+
}, [options.baseUrl, options.enabled, options.scope, options.token, scope, subscriptionKey, queryClient, reconnectNonce])
579601

580602
return { subscriptionId }
581603
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from './appSseSubscriptions'
3+
4+
describe('app SSE subscriptions', () => {
5+
it('always uses a global all:true subscription for the session list', () => {
6+
expect(getAppGlobalSseSubscription()).toEqual({ all: true })
7+
})
8+
9+
it('uses a session-scoped subscription only when a session is selected', () => {
10+
expect(getAppSessionSseSubscription(null)).toBeNull()
11+
expect(getAppSessionSseSubscription(undefined)).toBeNull()
12+
expect(getAppSessionSseSubscription('')).toBeNull()
13+
expect(getAppSessionSseSubscription('session-a')).toEqual({ sessionId: 'session-a' })
14+
})
15+
})

web/src/lib/appSseSubscriptions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type AppGlobalSseSubscription = {
2+
all: true
3+
}
4+
5+
export type AppSessionSseSubscription = {
6+
sessionId: string
7+
}
8+
9+
export function getAppGlobalSseSubscription(): AppGlobalSseSubscription {
10+
return { all: true }
11+
}
12+
13+
export function getAppSessionSseSubscription(
14+
selectedSessionId: string | null | undefined
15+
): AppSessionSseSubscription | null {
16+
if (!selectedSessionId) {
17+
return null
18+
}
19+
return { sessionId: selectedSessionId }
20+
}

0 commit comments

Comments
 (0)