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 tiann/hapi#693

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-05-26 14:45:43 +08:00
committed by GitHub
co-authored by Cursor
parent 3258c52947
commit 9af6696a68
6 changed files with 110 additions and 15 deletions
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isGlobalScopedMessageStreamEvent } from './useSSE'
describe('useSSE scope handling', () => {
it('treats message stream events as global-scoped skips', () => {
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'message-cancelled')).toBe(true)
})
it('does not skip session lifecycle events on the global connection', () => {
expect(isGlobalScopedMessageStreamEvent('global', 'session-updated')).toBe(false)
expect(isGlobalScopedMessageStreamEvent('global', 'session-added')).toBe(false)
expect(isGlobalScopedMessageStreamEvent('global', 'session-removed')).toBe(false)
})
it('processes message stream events on full-scoped connections', () => {
expect(isGlobalScopedMessageStreamEvent('full', 'message-received')).toBe(false)
})
})
+25 -3
View File
@@ -21,6 +21,18 @@ type SSESubscription = {
machineId?: string
}
export type SSEScope = 'global' | 'full'
const MESSAGE_STREAM_EVENT_TYPES = new Set<SyncEvent['type']>([
'message-received',
'messages-consumed',
'message-cancelled'
])
export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
return scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(eventType)
}
type VisibilityState = 'visible' | 'hidden'
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
@@ -105,6 +117,7 @@ export function useSSE(options: {
token: string
baseUrl: string
subscription?: SSESubscription
scope?: SSEScope
onEvent: (event: SyncEvent) => void
onConnect?: () => void
onDisconnect?: (reason: string) => void
@@ -151,10 +164,11 @@ export function useSSE(options: {
}, [options.onToast])
const subscription = options.subscription ?? {}
const scope = options.scope ?? 'full'
const subscriptionKey = useMemo(() => {
return `${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}`
}, [subscription.all, subscription.sessionId, subscription.machineId])
return `${scope}|${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}`
}, [scope, subscription.all, subscription.sessionId, subscription.machineId])
useEffect(() => {
if (!options.enabled) {
@@ -425,6 +439,14 @@ export function useSSE(options: {
return
}
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
if (event.type === 'message-received') {
queueSessionListInvalidation()
}
onEventRef.current(event)
return
}
if (event.type === 'messages-consumed') {
markMessagesConsumed(event.sessionId, event.localIds, event.invokedAt)
}
@@ -575,7 +597,7 @@ export function useSSE(options: {
}
setSubscriptionId(null)
}
}, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient, reconnectNonce])
}, [options.baseUrl, options.enabled, options.scope, options.token, scope, subscriptionKey, queryClient, reconnectNonce])
return { subscriptionId }
}