mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user