// ST-803 + ST-805 (refactor in ST-806-acceptance): client-side hook die de // Solo Paneel realtime stream beheert. // // - Mount in de (app)-layout via SoloRealtimeBridge zodat hij Server Action- // refreshes overleeft (anders kapt Next.js' soft-navigation de SSE). // - Opent EventSource('/api/realtime/solo?product_id=...') wanneer // productId niet null is; sluit de stream als productId null wordt. // - Reconnect met exponential backoff (1s → 30s, reset bij ready). // - PBI-74: stream blijft open op tab hidden (geen close meer). Bij // hidden→visible en bij window 'online' triggeren we een directe // workspace-store resync. Postgres NOTIFY heeft geen replay, dus zonder deze // resync zouden hidden-tab events permanent verloren zijn. // - Cleanup op unmount. // - Connection-status (status, showConnectingIndicator) wordt naar de // solo-store geschreven; UI-componenten lezen daar uit. // - Dispatcht events naar de solo-store via handleRealtimeEvent. Task- // updates worden in document.startViewTransition + flushSync gewikkeld // zodat het kanban-kaartje soepel naar zijn nieuwe kolom animeert // (animatie A — vereist view-transition-name op de cards). 'use client' import { useEffect, useRef } from 'react' import { flushSync } from 'react-dom' import { useSoloStore } from '@/stores/solo-store' import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 const CONNECTING_INDICATOR_DELAY_MS = 4_000 export function useSoloRealtime(productId: string | null) { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const indicatorTimerRef = useRef | null>(null) const readyCountRef = useRef(0) useEffect(() => { const setStatus = useSoloStore.getState().setRealtimeStatus const handleEvent = useSoloStore.getState().handleRealtimeEvent const handleJobEvent = useSoloStore.getState().handleJobEvent const initJobs = useSoloStore.getState().initJobs const setWorkers = useSoloStore.getState().setWorkers const incrementWorkers = useSoloStore.getState().incrementWorkers const decrementWorkers = useSoloStore.getState().decrementWorkers const setWorkerQuota = useSoloStore.getState().setWorkerQuota if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit setStatus('disconnected', false) return } const close = () => { if (sourceRef.current) { sourceRef.current.close() sourceRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null } } const scheduleIndicator = (next: RealtimeStatus) => { if (indicatorTimerRef.current) { clearTimeout(indicatorTimerRef.current) indicatorTimerRef.current = null } if (next === 'open') { setStatus('open', false) } else { // Status meteen bijwerken, indicator pas na 4s — voorkomt flikker // bij microscopische disconnects. setStatus(next, false) indicatorTimerRef.current = setTimeout(() => { setStatus(useSoloStore.getState().realtimeStatus, true) }, CONNECTING_INDICATOR_DELAY_MS) } } const connect = () => { close() scheduleIndicator('connecting') const source = new EventSource( `/api/realtime/solo?product_id=${encodeURIComponent(productId)}`, ) sourceRef.current = source source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS scheduleIndicator('open') readyCountRef.current += 1 // PBI-74: latere ready = post-reconnect → directe workspace-resync. if (readyCountRef.current > 1) { void useSoloStore.getState().resyncActiveScopes('reconnect') } }) source.addEventListener('claude_jobs_initial', (e) => { if (!e.data) return try { initJobs(JSON.parse(e.data) as JobState[]) } catch { // ignore malformed payload } }) source.addEventListener('workers_initial', (e) => { if (!e.data) return try { const { count } = JSON.parse(e.data) as { count: number } setWorkers(count) } catch { // ignore malformed payload } }) source.onmessage = (e) => { if (!e.data) return try { const raw = JSON.parse(e.data) as RealtimeEvent | ClaudeJobEvent | { type: string } if ('type' in raw) { if (raw.type === 'claude_job_enqueued' || raw.type === 'claude_job_status') { handleJobEvent(raw as ClaudeJobEvent) return } if (raw.type === 'worker_connected') { incrementWorkers(); return } if (raw.type === 'worker_disconnected') { decrementWorkers(); return } if (raw.type === 'worker_heartbeat') { const hb = raw as { type: 'worker_heartbeat' last_quota_pct: number last_quota_check_at: string } setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at) return } return } const payload = raw as RealtimeEvent // Animatie A: kanban-move animeren via View Transitions API. Voor // task UPDATE-events wrap'en we de store-update in een view // transition. flushSync forceert React om synchroon te renderen // tijdens de transition-callback zodat de nieuwe DOM-state wordt // gesnapshot voor de animatie. const animate = payload.entity === 'task' && payload.op === 'U' && typeof document !== 'undefined' && typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === 'function' if (animate) { ;( document as Document & { startViewTransition: (cb: () => void) => unknown } ).startViewTransition(() => { flushSync(() => handleEvent(payload)) }) } else { handleEvent(payload) } } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error('[realtime] failed to parse event', err, e.data) } } } source.onerror = () => { if (sourceRef.current !== source) return close() scheduleIndicator('disconnected') if (document.visibilityState === 'hidden') return const delay = backoffRef.current backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) reconnectTimerRef.current = setTimeout(connect, delay) } } // PBI-74: stream blijft open op hidden. Reconnect alleen als de stream // door netwerkfout/server-close weg is en de tab visible is. Bij iedere // visible-overgang triggeren we een store-resync — gemiste events tijdens // throttling/freeze worden via de solo-workspace route alsnog opgepakt. const onVisibility = () => { if (document.visibilityState !== 'visible') return if (sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } void useSoloStore.getState().resyncActiveScopes('visible') } const onOnline = () => { void useSoloStore.getState().resyncActiveScopes('reconnect') } connect() document.addEventListener('visibilitychange', onVisibility) window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('online', onOnline) if (indicatorTimerRef.current) clearTimeout(indicatorTimerRef.current) close() readyCountRef.current = 0 } }, [productId]) }