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;
+}