feat(web): subscribe Dashboard to SSE for real-time session updates

Wire the Dashboard component to the existing /api/events SSE endpoint
so session status, activity, and lastActivityAt update in real-time
without requiring a full page refresh. SSE snapshots are lightweight
patches merged into the full server-rendered session objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-03-01 19:56:12 +05:30
parent 4cda43795b
commit 0929a51ee7
3 changed files with 73 additions and 3 deletions

View File

@ -101,6 +101,6 @@ export default async function Home() {
}
return (
<Dashboard sessions={sessions} stats={computeStats(sessions)} orchestratorId={orchestratorId} projectName={projectName} />
<Dashboard initialSessions={sessions} stats={computeStats(sessions)} orchestratorId={orchestratorId} projectName={projectName} />
);
}

View File

@ -13,9 +13,10 @@ import { CI_STATUS } from "@composio/ao-core/types";
import { AttentionZone } from "./AttentionZone";
import { PRTableRow } from "./PRStatus";
import { DynamicFavicon } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
interface DashboardProps {
sessions: DashboardSession[];
initialSessions: DashboardSession[];
stats: DashboardStats;
orchestratorId?: string | null;
projectName?: string;
@ -23,7 +24,8 @@ interface DashboardProps {
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
export function Dashboard({ sessions, stats, orchestratorId, projectName }: DashboardProps) {
export function Dashboard({ initialSessions, stats, orchestratorId, projectName }: DashboardProps) {
const sessions = useSessionEvents(initialSessions);
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {

View File

@ -0,0 +1,68 @@
"use client";
import { useEffect, useReducer } from "react";
import type { DashboardSession, SSESnapshotEvent } from "@/lib/types";
type Action =
| { type: "reset"; sessions: DashboardSession[] }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] };
function reducer(state: DashboardSession[], action: Action): DashboardSession[] {
switch (action.type) {
case "reset":
return action.sessions;
case "snapshot": {
const patchMap = new Map(action.patches.map((p) => [p.id, p]));
let changed = false;
const next = state.map((s) => {
const patch = patchMap.get(s.id);
if (!patch) return s;
if (
s.status === patch.status &&
s.activity === patch.activity &&
s.lastActivityAt === patch.lastActivityAt
) {
return s;
}
changed = true;
return { ...s, status: patch.status, activity: patch.activity, lastActivityAt: patch.lastActivityAt };
});
return changed ? next : state;
}
}
}
export function useSessionEvents(initialSessions: DashboardSession[]): DashboardSession[] {
const [sessions, dispatch] = useReducer(reducer, initialSessions);
// Reset state when server-rendered props change (e.g. full page refresh)
useEffect(() => {
dispatch({ type: "reset", sessions: initialSessions });
}, [initialSessions]);
useEffect(() => {
const es = new EventSource("/api/events");
es.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as { type: string };
if (data.type === "snapshot") {
const snapshot = data as SSESnapshotEvent;
dispatch({ type: "snapshot", patches: snapshot.sessions });
}
} catch {
// Ignore malformed messages
}
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do here
};
return () => {
es.close();
};
}, []);
return sessions;
}