diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 238661c65..c05bba31d 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -101,6 +101,6 @@ export default async function Home() { } return ( - + ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index a414fc741..9eea166ea 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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 = { diff --git a/packages/web/src/hooks/useSessionEvents.ts b/packages/web/src/hooks/useSessionEvents.ts new file mode 100644 index 000000000..4b919bf02 --- /dev/null +++ b/packages/web/src/hooks/useSessionEvents.ts @@ -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; +}