From 9af6696a68243af2c73f23ce71df71141a4edd2f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 26 May 2026 07:45:43 +0100 Subject: [PATCH] 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 --- hub/src/sync/syncEngine.ts | 1 + web/src/App.tsx | 41 +++++++++++++++++-------- web/src/hooks/useSSE.test.ts | 20 ++++++++++++ web/src/hooks/useSSE.ts | 28 +++++++++++++++-- web/src/lib/appSseSubscriptions.test.ts | 15 +++++++++ web/src/lib/appSseSubscriptions.ts | 20 ++++++++++++ 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 web/src/hooks/useSSE.test.ts create mode 100644 web/src/lib/appSseSubscriptions.test.ts create mode 100644 web/src/lib/appSseSubscriptions.ts diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index cc824bd6..6ca66737 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -364,6 +364,7 @@ export class SyncEngine { ): Promise { await this.messageService.sendMessage(sessionId, payload) this.sessionCache.markMessageQueued(sessionId) + this.sessionCache.recordSessionActivity(sessionId, Date.now()) } async cancelQueuedMessage( diff --git a/web/src/App.tsx b/web/src/App.tsx index 10c97da1..fc23635a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts new file mode 100644 index 00000000..3942c34f --- /dev/null +++ b/web/src/hooks/useSSE.test.ts @@ -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) + }) +}) diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index ddd3bc91..e7259158 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -21,6 +21,18 @@ type SSESubscription = { machineId?: string } +export type SSEScope = 'global' | 'full' + +const MESSAGE_STREAM_EVENT_TYPES = new Set([ + '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 @@ -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 } } diff --git a/web/src/lib/appSseSubscriptions.test.ts b/web/src/lib/appSseSubscriptions.test.ts new file mode 100644 index 00000000..42ae0419 --- /dev/null +++ b/web/src/lib/appSseSubscriptions.test.ts @@ -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' }) + }) +}) diff --git a/web/src/lib/appSseSubscriptions.ts b/web/src/lib/appSseSubscriptions.ts new file mode 100644 index 00000000..0ada6601 --- /dev/null +++ b/web/src/lib/appSseSubscriptions.ts @@ -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 } +}