Browse Source

feat: add SSE real-time push to interview dashboard

Replace polling-only updates with Server-Sent Events (SSE):
- GET /api/interviews/{id}/events: SSE endpoint, registers client,
  sends initial state immediately, heartbeat every 15s
- broadcastSse() in pushState: pushes state to all connected browsers
  instantly when agent updates spec, chat response, or completes Q&A
- EventSource client in ui.ts: receives state events, renders immediately
- Polling kept as fallback: if SSE drops, client falls back to 2.5s
  polling until SSE reconnects
- Fixes stuck 'Agent Thinking' after chat/feedback completion: browser
  now receives final state push without manual refresh
bobbyunknown 1 month ago
parent
commit
4b76b1633b
2 changed files with 114 additions and 5 deletions
  1. 73 0
      src/interview/dashboard.ts
  2. 41 5
      src/interview/ui.ts

+ 73 - 0
src/interview/dashboard.ts

@@ -204,6 +204,23 @@ export function createDashboardServer(config: DashboardConfig): {
   // Interview state cache
   const stateCache = new Map<string, InterviewStateEntry>();
 
+  // SSE client registry: interviewId → Set<ServerResponse>
+  const sseClients = new Map<string, Set<import('node:http').ServerResponse>>();
+
+  function broadcastSse(interviewId: string, data: unknown) {
+    const clients = sseClients.get(interviewId);
+    if (!clients || clients.size === 0) return;
+    const payload = `event: state\ndata: ${JSON.stringify(data)}\n\n`;
+    for (const res of clients) {
+      try {
+        res.write(payload);
+      } catch {
+        clients.delete(res);
+      }
+    }
+    if (clients.size === 0) sseClients.delete(interviewId);
+  }
+
   // Periodic cleanup: remove terminal entries older than 24h
   const TERMINAL_MODES = new Set([
     'abandoned',
@@ -688,6 +705,61 @@ export function createDashboardServer(config: DashboardConfig): {
       return;
     }
 
+    // ── API: SSE stream (browser → dashboard, real-time push) ────────
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/events')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/events', '');
+      if (!interviewId || !isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      response.writeHead(200, {
+        'content-type': 'text/event-stream',
+        'cache-control': 'no-cache',
+        connection: 'keep-alive',
+        'access-control-allow-origin': '*',
+      });
+
+      // Register this client
+      let clients = sseClients.get(interviewId);
+      if (!clients) {
+        clients = new Set();
+        sseClients.set(interviewId, clients);
+      }
+      clients.add(response);
+
+      // Send initial state immediately
+      response.write(`event: state\ndata: ${JSON.stringify(entry)}\n\n`);
+
+      // Heartbeat every 15s to keep connection alive
+      const heartbeat = setInterval(() => {
+        try {
+          response.write(': hb\n\n');
+        } catch {
+          // will be cleaned up on close
+        }
+      }, 15000);
+
+      // Cleanup on disconnect
+      request.on('close', () => {
+        clearInterval(heartbeat);
+        clients?.delete(response);
+        if (clients && clients.size === 0) sseClients.delete(interviewId);
+      });
+      return;
+    }
+
     // ── API: push state (session → dashboard) ──────────────────────
     if (
       request.method === 'POST' &&
@@ -1249,6 +1321,7 @@ export function createDashboardServer(config: DashboardConfig): {
       }
       stateCache.set(entry.interviewId, entry);
       dedupRecovered(entry.interviewId, stateCache);
+      broadcastSse(entry.interviewId, entry);
     },
     getState: (id) => stateCache.get(id),
     storeAnswers: (id, answers) => {

+ 41 - 5
src/interview/ui.ts

@@ -1959,19 +1959,55 @@ export function renderInterviewPage(
       document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
       document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
 
+      // ── Real-time updates via SSE ─────────────────────────────────
+      let sseConnected = false;
+      let pollFallbackTimer = null;
+
+      function connectSse() {
+        const sseUrl = '/api/interviews/' + encodeURIComponent(interviewId) + '/events';
+        const es = new EventSource(sseUrl);
+
+        es.addEventListener('state', (e) => {
+          sseConnected = true;
+          if (pollFallbackTimer) {
+            clearTimeout(pollFallbackTimer);
+            pollFallbackTimer = null;
+          }
+          try {
+            const data = JSON.parse(e.data);
+            render(data);
+          } catch (_) {}
+        });
+
+        es.onerror = () => {
+          sseConnected = false;
+          es.close();
+          // Fallback to polling if SSE drops
+          if (!pollFallbackTimer) schedulePoll();
+        };
+      }
+
       function schedulePoll() {
-        setTimeout(async () => {
+        // Only poll if SSE is not connected
+        if (sseConnected) return;
+        pollFallbackTimer = setTimeout(async () => {
           try { await refresh(); } catch (_) {}
-          // Stop polling for terminal states
-          const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
-          if (!terminalModes.includes(state.data?.mode)) schedulePoll();
+          // Keep polling until SSE reconnects or indefinitely for terminal modes
+          pollFallbackTimer = null;
+          if (!sseConnected) {
+            const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
+            // For terminal modes, do a few more polls to catch final state, then stop
+            if (!terminalModes.includes(state.data?.mode)) {
+              schedulePoll();
+            }
+          }
         }, 2500);
       }
 
       refresh().catch((error) => {
         document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
       });
-      schedulePoll();
+      connectSse();
     </script>
   </body>
 </html>`;