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
+1
View File
@@ -364,6 +364,7 @@ export class SyncEngine {
): Promise<void> {
await this.messageService.sendMessage(sessionId, payload)
this.sessionCache.markMessageQueued(sessionId)
this.sessionCache.recordSessionActivity(sessionId, Date.now())
}
async cancelQueuedMessage(
+29 -12
View File
@@ -19,6 +19,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useTranslation } from '@/lib/use-translation'
import { VoiceProvider } from '@/lib/voice-context'
import { requireHubUrlForLogin } from '@/lib/runtime-config'
import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib/appSseSubscriptions'
import { LoginPrompt } from '@/components/LoginPrompt'
import { InstallPrompt } from '@/components/InstallPrompt'
import { OfflineBanner } from '@/components/OfflineBanner'
@@ -295,28 +296,44 @@ function AppInner() {
})
}, [addToast, translateIncomingToast])
const eventSubscription = useMemo(() => {
if (selectedSessionId) {
return { sessionId: selectedSessionId }
}
return { all: true }
}, [selectedSessionId])
const globalEventSubscription = useMemo(() => getAppGlobalSseSubscription(), [])
const sessionEventSubscription = useMemo(
() => getAppSessionSseSubscription(selectedSessionId),
[selectedSessionId]
)
const sseEnabled = Boolean(api && token)
const { subscriptionId } = useSSE({
enabled: Boolean(api && token),
const { subscriptionId: globalSubscriptionId } = useSSE({
enabled: sseEnabled,
token: token ?? '',
baseUrl,
subscription: eventSubscription,
subscription: globalEventSubscription,
scope: 'global',
onConnect: handleSseConnect,
onDisconnect: handleSseDisconnect,
onEvent: handleSseEvent,
onEvent: () => {},
onToast: handleToast
})
const { subscriptionId: sessionSubscriptionId } = useSSE({
enabled: sseEnabled && Boolean(sessionEventSubscription),
token: token ?? '',
baseUrl,
subscription: sessionEventSubscription ?? undefined,
scope: 'full',
onEvent: handleSseEvent
})
useVisibilityReporter({
api,
subscriptionId,
enabled: Boolean(api && token)
subscriptionId: globalSubscriptionId,
enabled: sseEnabled
})
useVisibilityReporter({
api,
subscriptionId: sessionSubscriptionId,
enabled: sseEnabled && Boolean(sessionEventSubscription)
})
// Loading auth source
+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 }
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from './appSseSubscriptions'
describe('app SSE subscriptions', () => {
it('always uses a global all:true subscription for the session list', () => {
expect(getAppGlobalSseSubscription()).toEqual({ all: true })
})
it('uses a session-scoped subscription only when a session is selected', () => {
expect(getAppSessionSseSubscription(null)).toBeNull()
expect(getAppSessionSseSubscription(undefined)).toBeNull()
expect(getAppSessionSseSubscription('')).toBeNull()
expect(getAppSessionSseSubscription('session-a')).toEqual({ sessionId: 'session-a' })
})
})
+20
View File
@@ -0,0 +1,20 @@
export type AppGlobalSseSubscription = {
all: true
}
export type AppSessionSseSubscription = {
sessionId: string
}
export function getAppGlobalSseSubscription(): AppGlobalSseSubscription {
return { all: true }
}
export function getAppSessionSseSubscription(
selectedSessionId: string | null | undefined
): AppSessionSseSubscription | null {
if (!selectedSessionId) {
return null
}
return { sessionId: selectedSessionId }
}