diff --git a/server/src/index.ts b/server/src/index.ts index 0b90b357..e3c7df12 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { SSEManager } from './sse/sseManager' import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' +import { VisibilityTracker } from './visibility/visibilityTracker' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' @@ -42,6 +43,7 @@ let syncEngine: SyncEngine | null = null let happyBot: HappyBot | null = null let webServer: BunServer | null = null let sseManager: SSEManager | null = null +let visibilityTracker: VisibilityTracker | null = null let notificationHub: NotificationHub | null = null async function main() { @@ -87,7 +89,8 @@ async function main() { const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run' const pushService = new PushService(vapidKeys, vapidSubject, store) - sseManager = new SSEManager(30_000) + visibilityTracker = new VisibilityTracker() + sseManager = new SSEManager(30_000, visibilityTracker) const socketServer = createSocketServer({ store, @@ -102,7 +105,7 @@ async function main() { syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) const notificationChannels: NotificationChannel[] = [ - new PushNotificationChannel(pushService, config.miniAppUrl) + new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.miniAppUrl) ] // Initialize Telegram bot (optional) @@ -125,6 +128,7 @@ async function main() { webServer = await startWebServer({ getSyncEngine: () => syncEngine, getSseManager: () => sseManager, + getVisibilityTracker: () => visibilityTracker, jwtSecret, store, vapidPublicKey: vapidKeys.publicKey, diff --git a/server/src/push/pushNotificationChannel.ts b/server/src/push/pushNotificationChannel.ts index 29076fa4..76e73d24 100644 --- a/server/src/push/pushNotificationChannel.ts +++ b/server/src/push/pushNotificationChannel.ts @@ -1,11 +1,15 @@ import type { Session } from '../sync/syncEngine' import type { NotificationChannel } from '../notifications/notificationTypes' import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { PushPayload, PushService } from './pushService' export class PushNotificationChannel implements NotificationChannel { constructor( private readonly pushService: PushService, + private readonly sseManager: SSEManager, + private readonly visibilityTracker: VisibilityTracker, _appUrl: string ) {} @@ -31,6 +35,22 @@ export class PushNotificationChannel implements NotificationChannel { } } + const url = payload.data?.url ?? this.buildSessionPath(session.id) + if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { + const delivered = await this.sseManager.sendToast(session.namespace, { + type: 'toast', + data: { + title: payload.title, + body: payload.body, + sessionId: session.id, + url + } + }) + if (delivered > 0) { + return + } + } + await this.pushService.sendToNamespace(session.namespace, payload) } @@ -53,6 +73,22 @@ export class PushNotificationChannel implements NotificationChannel { } } + const url = payload.data?.url ?? this.buildSessionPath(session.id) + if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { + const delivered = await this.sseManager.sendToast(session.namespace, { + type: 'toast', + data: { + title: payload.title, + body: payload.body, + sessionId: session.id, + url + } + }) + if (delivered > 0) { + return + } + } + await this.pushService.sendToNamespace(session.namespace, payload) } diff --git a/server/src/sse/sseManager.test.ts b/server/src/sse/sseManager.test.ts index e624cee8..209c278f 100644 --- a/server/src/sse/sseManager.test.ts +++ b/server/src/sse/sseManager.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'bun:test' import { SSEManager } from './sseManager' import type { SyncEvent } from '../sync/syncEngine' +import { VisibilityTracker } from '../visibility/visibilityTracker' describe('SSEManager namespace filtering', () => { it('routes events to matching namespace', () => { - const manager = new SSEManager(0) + const manager = new SSEManager(0, new VisibilityTracker()) const receivedAlpha: SyncEvent[] = [] const receivedBeta: SyncEvent[] = [] @@ -35,7 +36,7 @@ describe('SSEManager namespace filtering', () => { }) it('broadcasts connection-changed to all namespaces', () => { - const manager = new SSEManager(0) + const manager = new SSEManager(0, new VisibilityTracker()) const received: Array<{ id: string; event: SyncEvent }> = [] manager.subscribe({ @@ -63,4 +64,58 @@ describe('SSEManager namespace filtering', () => { expect(received).toHaveLength(2) expect(received.map((entry) => entry.id).sort()).toEqual(['alpha', 'beta']) }) + + it('sends toast only to visible connections in a namespace', async () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const received: Array<{ id: string; event: SyncEvent }> = [] + + manager.subscribe({ + id: 'visible', + namespace: 'alpha', + all: true, + visibility: 'visible', + send: (event) => { + received.push({ id: 'visible', event }) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'hidden', + namespace: 'alpha', + all: true, + visibility: 'hidden', + send: (event) => { + received.push({ id: 'hidden', event }) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'other', + namespace: 'beta', + all: true, + visibility: 'visible', + send: (event) => { + received.push({ id: 'other', event }) + }, + sendHeartbeat: () => {} + }) + + const toastEvent: Extract = { + type: 'toast', + data: { + title: 'Test', + body: 'Toast body', + sessionId: 'session-1', + url: '/sessions/session-1' + } + } + + const delivered = await manager.sendToast('alpha', toastEvent) + + expect(delivered).toBe(1) + expect(received).toHaveLength(1) + expect(received[0]?.id).toBe('visible') + }) }) diff --git a/server/src/sse/sseManager.ts b/server/src/sse/sseManager.ts index 740f9ea9..2bd267b9 100644 --- a/server/src/sse/sseManager.ts +++ b/server/src/sse/sseManager.ts @@ -1,4 +1,6 @@ import type { SyncEvent } from '../sync/syncEngine' +import type { VisibilityState } from '../visibility/visibilityTracker' +import type { VisibilityTracker } from '../visibility/visibilityTracker' export type SSESubscription = { id: string @@ -17,9 +19,11 @@ export class SSEManager { private readonly connections: Map = new Map() private heartbeatTimer: NodeJS.Timeout | null = null private readonly heartbeatMs: number + private readonly visibilityTracker: VisibilityTracker - constructor(heartbeatMs = 30_000) { + constructor(heartbeatMs = 30_000, visibilityTracker: VisibilityTracker) { this.heartbeatMs = heartbeatMs + this.visibilityTracker = visibilityTracker } subscribe(options: { @@ -28,6 +32,7 @@ export class SSEManager { all?: boolean sessionId?: string | null machineId?: string | null + visibility?: VisibilityState send: (event: SyncEvent) => void | Promise sendHeartbeat: () => void | Promise }): SSESubscription { @@ -42,6 +47,11 @@ export class SSEManager { } this.connections.set(subscription.id, subscription) + this.visibilityTracker.registerConnection( + subscription.id, + subscription.namespace, + options.visibility ?? 'hidden' + ) this.ensureHeartbeat() return { id: subscription.id, @@ -54,11 +64,46 @@ export class SSEManager { unsubscribe(id: string): void { this.connections.delete(id) + this.visibilityTracker.removeConnection(id) if (this.connections.size === 0) { this.stopHeartbeat() } } + async sendToast(namespace: string, event: Extract): Promise { + const deliveries: Array> = [] + for (const connection of this.connections.values()) { + if (connection.namespace !== namespace) { + continue + } + if (!this.visibilityTracker.isVisibleConnection(connection.id)) { + continue + } + + deliveries.push( + Promise.resolve(connection.send(event)) + .then(() => ({ id: connection.id, ok: true })) + .catch(() => ({ id: connection.id, ok: false })) + ) + } + + if (deliveries.length === 0) { + return 0 + } + + const results = await Promise.all(deliveries) + let successCount = 0 + for (const result of results) { + if (result.ok) { + successCount += 1 + continue + } + this.unsubscribe(result.id) + } + + return successCount + } + broadcast(event: SyncEvent): void { for (const connection of this.connections.values()) { if (!this.shouldSend(connection, event)) { @@ -73,6 +118,9 @@ export class SSEManager { stop(): void { this.stopHeartbeat() + for (const id of this.connections.keys()) { + this.visibilityTracker.removeConnection(id) + } this.connections.clear() } diff --git a/server/src/visibility/visibilityTracker.ts b/server/src/visibility/visibilityTracker.ts new file mode 100644 index 00000000..9752946e --- /dev/null +++ b/server/src/visibility/visibilityTracker.ts @@ -0,0 +1,75 @@ +export type VisibilityState = 'visible' | 'hidden' + +export class VisibilityTracker { + private readonly visibleConnections = new Map>() + private readonly subscriptionToNamespace = new Map() + + registerConnection(subscriptionId: string, namespace: string, state: VisibilityState): void { + this.removeConnection(subscriptionId) + this.subscriptionToNamespace.set(subscriptionId, namespace) + if (state === 'visible') { + this.addVisibleConnection(namespace, subscriptionId) + } + } + + setVisibility(subscriptionId: string, namespace: string, state: VisibilityState): boolean { + const trackedNamespace = this.subscriptionToNamespace.get(subscriptionId) + if (!trackedNamespace || trackedNamespace !== namespace) { + return false + } + + if (state === 'visible') { + this.addVisibleConnection(trackedNamespace, subscriptionId) + return true + } + + this.removeVisibleConnection(trackedNamespace, subscriptionId) + return true + } + + removeConnection(subscriptionId: string): void { + const namespace = this.subscriptionToNamespace.get(subscriptionId) + if (!namespace) { + return + } + + this.subscriptionToNamespace.delete(subscriptionId) + this.removeVisibleConnection(namespace, subscriptionId) + } + + hasVisibleConnection(namespace: string): boolean { + const visible = this.visibleConnections.get(namespace) + return Boolean(visible && visible.size > 0) + } + + isVisibleConnection(subscriptionId: string): boolean { + const namespace = this.subscriptionToNamespace.get(subscriptionId) + if (!namespace) { + return false + } + const visible = this.visibleConnections.get(namespace) + return Boolean(visible && visible.has(subscriptionId)) + } + + private addVisibleConnection(namespace: string, subscriptionId: string): void { + const existing = this.visibleConnections.get(namespace) + if (existing) { + existing.add(subscriptionId) + return + } + + this.visibleConnections.set(namespace, new Set([subscriptionId])) + } + + private removeVisibleConnection(namespace: string, subscriptionId: string): void { + const existing = this.visibleConnections.get(namespace) + if (!existing) { + return + } + + existing.delete(subscriptionId) + if (existing.size === 0) { + this.visibleConnections.delete(namespace) + } + } +} diff --git a/server/src/web/routes/events.ts b/server/src/web/routes/events.ts index 337afd6b..d2dd2281 100644 --- a/server/src/web/routes/events.ts +++ b/server/src/web/routes/events.ts @@ -1,8 +1,11 @@ import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import { randomUUID } from 'node:crypto' +import { z } from 'zod' import type { SSEManager } from '../../sse/sseManager' import type { SyncEngine } from '../../sync/syncEngine' +import type { VisibilityState } from '../../visibility/visibilityTracker' +import type { VisibilityTracker } from '../../visibility/visibilityTracker' import type { WebAppEnv } from '../middleware/auth' function parseOptionalId(value: string | undefined): string | null { @@ -19,9 +22,19 @@ function parseBoolean(value: string | undefined): boolean { return value === 'true' || value === '1' } +function parseVisibility(value: string | undefined): VisibilityState { + return value === 'visible' ? 'visible' : 'hidden' +} + +const visibilitySchema = z.object({ + subscriptionId: z.string().min(1), + visibility: z.enum(['visible', 'hidden']) +}) + export function createEventsRoutes( getSseManager: () => SSEManager | null, - getSyncEngine: () => SyncEngine | null + getSyncEngine: () => SyncEngine | null, + getVisibilityTracker: () => VisibilityTracker | null ): Hono { const app = new Hono() @@ -36,6 +49,7 @@ export function createEventsRoutes( const sessionId = parseOptionalId(query.sessionId) const machineId = parseOptionalId(query.machineId) const subscriptionId = randomUUID() + const visibility = parseVisibility(query.visibility) const namespace = c.get('namespace') if (sessionId || machineId) { @@ -70,12 +84,23 @@ export function createEventsRoutes( all, sessionId, machineId, + visibility, send: (event) => stream.writeSSE({ data: JSON.stringify(event) }), sendHeartbeat: async () => { await stream.write(': heartbeat\n\n') } }) + await stream.writeSSE({ + data: JSON.stringify({ + type: 'connection-changed', + data: { + status: 'connected', + subscriptionId + } + }) + }) + await new Promise((resolve) => { const done = () => resolve() c.req.raw.signal.addEventListener('abort', done, { once: true }) @@ -86,5 +111,26 @@ export function createEventsRoutes( }) }) + app.post('/visibility', async (c) => { + const tracker = getVisibilityTracker() + if (!tracker) { + return c.json({ error: 'Not connected' }, 503) + } + + const json = await c.req.json().catch(() => null) + const parsed = visibilitySchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const namespace = c.get('namespace') + const updated = tracker.setVisibility(parsed.data.subscriptionId, namespace, parsed.data.visibility) + if (!updated) { + return c.json({ error: 'Subscription not found' }, 404) + } + + return c.json({ ok: true }) + }) + return app } diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 506207d2..4224a2cd 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -18,6 +18,7 @@ import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createPushRoutes } from './routes/push' import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer } from 'bun' import type { Server as SocketEngine } from '@socket.io/bun-engine' import type { WebSocketData } from '@socket.io/bun-engine' @@ -54,6 +55,7 @@ function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null + getVisibilityTracker: () => VisibilityTracker | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -82,7 +84,7 @@ function createWebApp(options: { app.route('/api', createBindRoutes(options.jwtSecret, options.store)) app.use('/api/*', createAuthMiddleware(options.jwtSecret)) - app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine)) + app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker)) app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) @@ -171,6 +173,7 @@ function createWebApp(options: { export async function startWebServer(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null + getVisibilityTracker: () => VisibilityTracker | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -181,6 +184,7 @@ export async function startWebServer(options: { const app = createWebApp({ getSyncEngine: options.getSyncEngine, getSseManager: options.getSseManager, + getVisibilityTracker: options.getVisibilityTracker, jwtSecret: options.jwtSecret, store: options.store, vapidPublicKey: options.vapidPublicKey, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a868bbb4..f9ccf570 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -154,10 +154,20 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ type: z.literal('machine-updated'), data: z.unknown().optional() }), + SessionEventBaseSchema.extend({ + type: z.literal('toast'), + data: z.object({ + title: z.string(), + body: z.string(), + sessionId: z.string(), + url: z.string() + }) + }), SessionEventBaseSchema.extend({ type: z.literal('connection-changed'), data: z.object({ - status: z.string() + status: z.string(), + subscriptionId: z.string().optional() }).optional() }) ]) diff --git a/web/src/App.tsx b/web/src/App.tsx index b672835b..dfdd9903 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' +import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { fetchLatestMessages } from '@/lib/message-window-store' @@ -18,14 +19,28 @@ import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' import { SyncingBanner } from '@/components/SyncingBanner' import { LoadingState } from '@/components/LoadingState' +import { ToastContainer } from '@/components/ToastContainer' +import { ToastProvider, useToast } from '@/lib/toast-context' +import type { SyncEvent } from '@/types/api' + +type ToastEvent = Extract export function App() { + return ( + + + + ) +} + +function AppInner() { const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() + const { addToast } = useToast() useEffect(() => { const tg = getTelegramWebApp() @@ -172,6 +187,14 @@ export function App() { }, [api, queryClient, selectedSessionId, startSync, endSync]) const handleSseEvent = useCallback(() => {}, []) + const handleToast = useCallback((event: ToastEvent) => { + addToast({ + title: event.data.title, + body: event.data.body, + sessionId: event.data.sessionId, + url: event.data.url + }) + }, [addToast]) const eventSubscription = useMemo(() => { if (selectedSessionId) { @@ -180,13 +203,20 @@ export function App() { return { all: true } }, [selectedSessionId]) - useSSE({ + const { subscriptionId } = useSSE({ enabled: Boolean(api && token), token: token ?? '', baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, onEvent: handleSseEvent, + onToast: handleToast + }) + + useVisibilityReporter({ + api, + subscriptionId, + enabled: Boolean(api && token) }) // Loading auth source @@ -271,6 +301,7 @@ export function App() {
+ ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c3dd7ff7..ab4c2ac3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { PushVapidPublicKeyResponse, SlashCommandsResponse, SpawnResponse, + VisibilityPayload, SessionResponse, SessionsResponse } from '@/types/api' @@ -172,6 +173,13 @@ export class ApiClient { }) } + async setVisibility(payload: VisibilityPayload): Promise { + await this.request('/api/visibility', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async getSession(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } diff --git a/web/src/components/ToastContainer.tsx b/web/src/components/ToastContainer.tsx new file mode 100644 index 00000000..719dc86e --- /dev/null +++ b/web/src/components/ToastContainer.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from '@tanstack/react-router' +import { Toast } from '@/components/ui/Toast' +import { useToast } from '@/lib/toast-context' + +export function ToastContainer() { + const navigate = useNavigate() + const { toasts, removeToast } = useToast() + + if (toasts.length === 0) { + return null + } + + return ( +
+ {toasts.map((toast) => ( + { + removeToast(toast.id) + if (toast.sessionId) { + void navigate({ + to: '/sessions/$sessionId', + params: { sessionId: toast.sessionId } + }) + return + } + if (toast.url) { + void navigate({ to: toast.url }) + } + }} + onClose={() => removeToast(toast.id)} + /> + ))} +
+ ) +} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx new file mode 100644 index 00000000..31263cc4 --- /dev/null +++ b/web/src/components/ui/Toast.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const toastVariants = cva( + 'pointer-events-auto w-full max-w-sm rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] shadow-lg', + { + variants: { + variant: { + default: 'border-[var(--app-border)] bg-[var(--app-bg)]' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +export type ToastProps = React.HTMLAttributes & + VariantProps & { + title: string + body: string + onClose?: () => void +} + +export function Toast({ title, body, onClose, className, variant, ...props }: ToastProps) { + const handleClose = (event: React.MouseEvent) => { + event.stopPropagation() + onClose?.() + } + + return ( +
+
+
+
{title}
+
{body}
+
+ {onClose ? ( + + ) : null} +
+
+ ) +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index bdd675e9..c131a1a5 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SyncEvent } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -14,9 +14,26 @@ type SSESubscription = { machineId?: string } -function buildEventsUrl(baseUrl: string, token: string, subscription: SSESubscription): string { +type VisibilityState = 'visible' | 'hidden' + +type ToastEvent = Extract + +function getVisibilityState(): VisibilityState { + if (typeof document === 'undefined') { + return 'hidden' + } + return document.visibilityState === 'visible' ? 'visible' : 'hidden' +} + +function buildEventsUrl( + baseUrl: string, + token: string, + subscription: SSESubscription, + visibility: VisibilityState +): string { const params = new URLSearchParams() params.set('token', token) + params.set('visibility', visibility) if (subscription.all) { params.set('all', 'true') } @@ -44,13 +61,16 @@ export function useSSE(options: { onConnect?: () => void onDisconnect?: (reason: string) => void onError?: (error: unknown) => void -}): void { + onToast?: (event: ToastEvent) => void +}): { subscriptionId: string | null } { const queryClient = useQueryClient() const onEventRef = useRef(options.onEvent) const onConnectRef = useRef(options.onConnect) const onDisconnectRef = useRef(options.onDisconnect) const onErrorRef = useRef(options.onError) + const onToastRef = useRef(options.onToast) const eventSourceRef = useRef(null) + const [subscriptionId, setSubscriptionId] = useState(null) useEffect(() => { onEventRef.current = options.onEvent @@ -68,6 +88,10 @@ export function useSSE(options: { onDisconnectRef.current = options.onDisconnect }, [options.onDisconnect]) + useEffect(() => { + onToastRef.current = options.onToast + }, [options.onToast]) + const subscription = options.subscription ?? {} const subscriptionKey = useMemo(() => { return `${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}` @@ -77,14 +101,31 @@ export function useSSE(options: { if (!options.enabled) { eventSourceRef.current?.close() eventSourceRef.current = null + setSubscriptionId(null) return } - const url = buildEventsUrl(options.baseUrl, options.token, subscription) + setSubscriptionId(null) + const url = buildEventsUrl(options.baseUrl, options.token, subscription, getVisibilityState()) const eventSource = new EventSource(url) eventSourceRef.current = eventSource const handleSyncEvent = (event: SyncEvent) => { + if (event.type === 'connection-changed') { + const data = event.data + if (data && typeof data === 'object' && 'subscriptionId' in data) { + const nextId = (data as { subscriptionId?: unknown }).subscriptionId + if (typeof nextId === 'string' && nextId.length > 0) { + setSubscriptionId(nextId) + } + } + } + + if (event.type === 'toast') { + onToastRef.current?.(event) + return + } + if (event.type === 'message-received') { ingestIncomingMessages(event.sessionId, [event.message]) } @@ -145,6 +186,9 @@ export function useSSE(options: { if (eventSourceRef.current === eventSource) { eventSourceRef.current = null } + setSubscriptionId(null) } }, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient]) + + return { subscriptionId } } diff --git a/web/src/hooks/useVisibilityReporter.ts b/web/src/hooks/useVisibilityReporter.ts new file mode 100644 index 00000000..03d4533b --- /dev/null +++ b/web/src/hooks/useVisibilityReporter.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef } from 'react' +import type { ApiClient } from '@/api/client' + +type VisibilityState = 'visible' | 'hidden' + +function getVisibilityState(): VisibilityState { + if (typeof document === 'undefined') { + return 'hidden' + } + return document.visibilityState === 'visible' ? 'visible' : 'hidden' +} + +export function useVisibilityReporter(options: { + api: ApiClient | null + subscriptionId: string | null + enabled?: boolean +}): void { + const lastStateRef = useRef(null) + const lastSubscriptionRef = useRef(null) + const pendingStateRef = useRef(null) + const inFlightRef = useRef(false) + const retryTimerRef = useRef | null>(null) + + const clearRetry = () => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current) + retryTimerRef.current = null + } + } + + useEffect(() => { + if (options.enabled === false) { + clearRetry() + return + } + if (!options.api || !options.subscriptionId) { + lastStateRef.current = null + lastSubscriptionRef.current = options.subscriptionId ?? null + pendingStateRef.current = null + clearRetry() + return + } + + const api = options.api + const subscriptionId = options.subscriptionId + if (lastSubscriptionRef.current !== subscriptionId) { + lastSubscriptionRef.current = subscriptionId + lastStateRef.current = null + pendingStateRef.current = null + clearRetry() + } + + const flush = () => { + if (lastSubscriptionRef.current !== subscriptionId) { + return + } + const desired = pendingStateRef.current + if (!desired) { + return + } + if (inFlightRef.current) { + return + } + if (retryTimerRef.current) { + return + } + if (lastStateRef.current === desired) { + pendingStateRef.current = null + return + } + + inFlightRef.current = true + let hadError = false + const activeSubscription = subscriptionId + void api.setVisibility({ + subscriptionId, + visibility: desired + }).then(() => { + if (lastSubscriptionRef.current !== activeSubscription) { + return + } + lastStateRef.current = desired + pendingStateRef.current = null + clearRetry() + }).catch((error) => { + if (lastSubscriptionRef.current !== activeSubscription) { + return + } + hadError = true + console.error('Failed to update visibility:', error) + if (!retryTimerRef.current) { + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null + flush() + }, 2000) + } + }).finally(() => { + inFlightRef.current = false + if (hadError || retryTimerRef.current) { + return + } + if (pendingStateRef.current && pendingStateRef.current !== lastStateRef.current) { + flush() + } + }) + } + + const report = () => { + const state = getVisibilityState() + pendingStateRef.current = state + flush() + } + + report() + document.addEventListener('visibilitychange', report) + return () => { + document.removeEventListener('visibilitychange', report) + clearRetry() + inFlightRef.current = false + } + }, [options.api, options.enabled, options.subscriptionId]) +} diff --git a/web/src/lib/toast-context.tsx b/web/src/lib/toast-context.tsx new file mode 100644 index 00000000..4386d9d2 --- /dev/null +++ b/web/src/lib/toast-context.tsx @@ -0,0 +1,77 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' + +export type Toast = { + id: string + title: string + body: string + sessionId: string + url: string +} + +export type ToastContextValue = { + toasts: Toast[] + addToast: (toast: Omit) => void + removeToast: (id: string) => void +} + +const ToastContext = createContext(null) +const TOAST_DURATION_MS = 6000 + +function createToastId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) + + useEffect(() => { + return () => { + for (const timer of timersRef.current.values()) { + clearTimeout(timer) + } + timersRef.current.clear() + } + }, []) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + const timer = timersRef.current.get(id) + if (timer) { + clearTimeout(timer) + timersRef.current.delete(id) + } + }, []) + + const addToast = useCallback((toast: Omit) => { + const id = createToastId() + setToasts((prev) => [...prev, { id, ...toast }]) + const timer = setTimeout(() => { + removeToast(id) + }, TOAST_DURATION_MS) + timersRef.current.set(id, timer) + }, [removeToast]) + + const value = useMemo(() => ({ + toasts, + addToast, + removeToast + }), [toasts, addToast, removeToast]) + + return ( + + {children} + + ) +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext) + if (!ctx) { + throw new Error('useToast must be used within ToastProvider') + } + return ctx +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 047c2b4c..1327c508 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -154,4 +154,9 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type VisibilityPayload = { + subscriptionId: string + visibility: 'visible' | 'hidden' +} + export type SyncEvent = ProtocolSyncEvent