@@ -319,14 +379,16 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "ci-unknown",
label: "CI unknown",
- className: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
+ className:
+ "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
url: pr.url + "/checks",
});
} else {
alerts.push({
key: "ci-fail",
label: `${failCount} CI check${failCount > 1 ? "s" : ""} failing`,
- className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
+ className:
+ "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
url: failedCheck?.url ?? pr.url + "/checks",
actionLabel: "ask to fix",
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
@@ -338,14 +400,16 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "changes",
label: "changes requested",
- className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
+ className:
+ "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
url: pr.url,
});
} else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
alerts.push({
key: "review",
label: "needs review",
- className: "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
+ className:
+ "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
url: pr.url,
actionLabel: "ask to post",
actionMessage: `Post ${pr.url} on slack asking for a review.`,
@@ -356,7 +420,8 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "conflict",
label: "merge conflict",
- className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
+ className:
+ "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
url: pr.url,
actionLabel: "ask to fix",
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
@@ -369,7 +434,8 @@ function getAlerts(session: DashboardSession): Alert[] {
key: "comments",
label: "unresolved comments",
count: pr.unresolvedThreads,
- className: "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
+ className:
+ "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
url: firstUrl,
actionLabel: "ask to resolve",
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
diff --git a/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx b/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx
new file mode 100644
index 000000000..57bdd8431
--- /dev/null
+++ b/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx
@@ -0,0 +1,79 @@
+import { act, render, waitFor } from "@testing-library/react";
+import { memo } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const renderCounts = new Map
();
+
+vi.mock("@/components/SessionCard", () => ({
+ SessionCard: memo(({ session }: { session: { id: string } }) => {
+ renderCounts.set(session.id, (renderCounts.get(session.id) ?? 0) + 1);
+ return {session.id}
;
+ }),
+}));
+
+import { Dashboard } from "../Dashboard";
+import { makeSession } from "../../__tests__/helpers";
+
+describe("Dashboard render cadence", () => {
+ let eventSourceMock: {
+ onmessage: ((event: MessageEvent) => void) | null;
+ onerror: (() => void) | null;
+ close: () => void;
+ };
+
+ beforeEach(() => {
+ renderCounts.clear();
+ eventSourceMock = {
+ onmessage: null,
+ onerror: null,
+ close: vi.fn(),
+ };
+ const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
+ global.EventSource = Object.assign(eventSourceConstructor, {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSED: 2,
+ }) as unknown as typeof EventSource;
+ global.fetch = vi.fn();
+ });
+
+ it("rerenders only the changed session card for same-membership snapshots", async () => {
+ const initialSessions = [
+ makeSession({ id: "session-1", summary: "First session" }),
+ makeSession({ id: "session-2", summary: "Second session" }),
+ ];
+
+ render();
+
+ expect(renderCounts.get("session-1")).toBe(1);
+ expect(renderCounts.get("session-2")).toBe(1);
+
+ await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull());
+
+ await act(async () => {
+ eventSourceMock.onmessage!({
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: [
+ {
+ id: "session-1",
+ status: "working",
+ activity: "idle",
+ lastActivityAt: new Date().toISOString(),
+ },
+ {
+ id: "session-2",
+ status: initialSessions[1].status,
+ activity: initialSessions[1].activity,
+ lastActivityAt: initialSessions[1].lastActivityAt,
+ },
+ ],
+ }),
+ } as MessageEvent);
+ });
+
+ expect(renderCounts.get("session-1")).toBe(2);
+ expect(renderCounts.get("session-2")).toBe(1);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/hooks/__tests__/useSessionEvents.test.ts b/packages/web/src/hooks/__tests__/useSessionEvents.test.ts
index 5802d894a..68f96b93e 100644
--- a/packages/web/src/hooks/__tests__/useSessionEvents.test.ts
+++ b/packages/web/src/hooks/__tests__/useSessionEvents.test.ts
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { renderHook, act } from "@testing-library/react";
+import { renderHook, act, waitFor } from "@testing-library/react";
import { useSessionEvents } from "../useSessionEvents";
-import type { DashboardSession, GlobalPauseState } from "@/lib/types";
-import { makeSession } from "@/__tests__/helpers";
+import type { DashboardSession, GlobalPauseState } from "../../lib/types";
+import { makeSession } from "../../__tests__/helpers";
describe("useSessionEvents", () => {
let eventSourceMock: {
@@ -14,7 +14,7 @@ describe("useSessionEvents", () => {
beforeEach(() => {
eventSourceInstances = [];
- global.EventSource = vi.fn(() => {
+ const eventSourceConstructor = vi.fn(() => {
const instance = {
onmessage: null as ((event: MessageEvent) => void) | null,
onerror: null as (() => void) | null,
@@ -24,10 +24,16 @@ describe("useSessionEvents", () => {
eventSourceMock = instance;
return instance as unknown as EventSource;
});
+ global.EventSource = Object.assign(eventSourceConstructor, {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSED: 2,
+ }) as unknown as typeof EventSource;
global.fetch = vi.fn();
});
afterEach(() => {
+ vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -73,7 +79,7 @@ describe("useSessionEvents", () => {
sessions: [...initialSessions, makeSession({ id: "session-new" })],
globalPause: makeGlobalPause({ reason: "Updated pause from different provider" }),
}),
- } as Response);
+ } as unknown as Response);
const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
@@ -107,7 +113,9 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause?.reason).toBe("Updated pause from different provider");
+ await waitFor(() => {
+ expect(result.current.globalPause?.reason).toBe("Updated pause from different provider");
+ });
});
it("clears globalPause when /api/sessions returns null pause", async () => {
@@ -120,7 +128,7 @@ describe("useSessionEvents", () => {
sessions: [...initialSessions, makeSession({ id: "session-new" })],
globalPause: null,
}),
- } as Response);
+ } as unknown as Response);
const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause));
@@ -154,7 +162,9 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause).toBeNull();
+ await waitFor(() => {
+ expect(result.current.globalPause).toBeNull();
+ });
});
it("sets globalPause when initially null and /api/sessions returns pause", async () => {
@@ -200,7 +210,44 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause?.reason).toBe("New rate limit detected");
+ await waitFor(() => {
+ expect(result.current.globalPause?.reason).toBe("New rate limit detected");
+ });
+ });
+
+ it("refreshes globalPause on stale same-membership snapshots without waiting for membership churn", async () => {
+ vi.useFakeTimers();
+ const initialSessions = makeSessions(2);
+
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ sessions: initialSessions,
+ globalPause: makeGlobalPause({ reason: "Pause updated during steady-state SSE" }),
+ }),
+ } as Response);
+
+ const { result } = renderHook(() => useSessionEvents(initialSessions, null));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ eventSourceMock!.onmessage!.call(eventSourceMock, {
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: initialSessions.map((session) => ({
+ id: session.id,
+ status: session.status,
+ activity: session.activity,
+ lastActivityAt: session.lastActivityAt,
+ })),
+ }),
+ } as MessageEvent);
+ await vi.advanceTimersByTimeAsync(120);
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
+ expect(result.current.globalPause?.reason).toBe("Pause updated during steady-state SSE");
});
});
@@ -238,8 +285,10 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours");
- expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1");
+ await waitFor(() => {
+ expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours");
+ expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1");
+ });
});
it("handles globalPause from OpenCode agent without provider-specific logic", async () => {
@@ -275,8 +324,10 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause?.reason).toBe("Model capacity exceeded");
- expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42");
+ await waitFor(() => {
+ expect(result.current.globalPause?.reason).toBe("Model capacity exceeded");
+ expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42");
+ });
});
it("handles globalPause from Codex agent without provider-specific logic", async () => {
@@ -312,8 +363,10 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
- expect(result.current.globalPause?.reason).toBe("API quota exhausted");
- expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99");
+ await waitFor(() => {
+ expect(result.current.globalPause?.reason).toBe("API quota exhausted");
+ expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99");
+ });
});
});
@@ -344,6 +397,184 @@ describe("useSessionEvents", () => {
expect(result.current.sessions[1].status).toBe("working");
});
+ it("preserves untouched session references across snapshot patches", async () => {
+ const sessions = makeSessions(2);
+
+ const { result } = renderHook(() => useSessionEvents(sessions, null));
+ const untouchedSession = result.current.sessions[1];
+
+ await act(async () => {
+ eventSourceMock!.onmessage!.call(eventSourceMock, {
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: [
+ {
+ id: "session-0",
+ status: "pr_open",
+ activity: "idle",
+ lastActivityAt: new Date().toISOString(),
+ },
+ ],
+ }),
+ } as MessageEvent);
+ });
+
+ expect(result.current.sessions[0]).not.toBe(sessions[0]);
+ expect(result.current.sessions[1]).toBe(untouchedSession);
+ });
+
+ it("coalesces bursty membership changes into a single refresh fetch", async () => {
+ vi.useFakeTimers();
+ const initialSessions = makeSessions(1);
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ sessions: [
+ ...initialSessions,
+ makeSession({ id: "session-1" }),
+ makeSession({ id: "session-2" }),
+ makeSession({ id: "session-3" }),
+ ],
+ globalPause: null,
+ }),
+ } as Response);
+
+ renderHook(() => useSessionEvents(initialSessions, null));
+
+ await act(async () => {
+ for (const sessionId of ["session-1", "session-2", "session-3"]) {
+ eventSourceMock!.onmessage!.call(eventSourceMock, {
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: [
+ {
+ id: "session-0",
+ status: "working",
+ activity: "active",
+ lastActivityAt: new Date().toISOString(),
+ },
+ {
+ id: sessionId,
+ status: "working",
+ activity: "active",
+ lastActivityAt: new Date().toISOString(),
+ },
+ ],
+ }),
+ } as MessageEvent);
+ }
+
+ await vi.advanceTimersByTimeAsync(120);
+ await Promise.resolve();
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) });
+ });
+
+ it("ignores stale refresh completions after project changes", async () => {
+ vi.useFakeTimers();
+ const alphaSessions = [makeSession({ id: "alpha-0", projectId: "alpha" })];
+ const betaSessions = [makeSession({ id: "beta-0", projectId: "beta" })];
+ let resolveAlphaFetch: ((value: Response) => void) | null = null;
+
+ vi.mocked(fetch).mockImplementation((input) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url.includes("project=alpha")) {
+ return new Promise((resolve) => {
+ resolveAlphaFetch = resolve;
+ });
+ }
+
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ sessions: [...betaSessions, makeSession({ id: "beta-1", projectId: "beta" })],
+ globalPause: null,
+ }),
+ } as unknown as Response);
+ });
+
+ const { result, rerender } = renderHook(
+ ({ sessions, project }) => useSessionEvents(sessions, null, project),
+ {
+ initialProps: { sessions: alphaSessions, project: "alpha" },
+ },
+ );
+
+ await act(async () => {
+ eventSourceInstances[0].onmessage?.({
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: [
+ {
+ id: "alpha-0",
+ status: "working",
+ activity: "active",
+ lastActivityAt: alphaSessions[0].lastActivityAt,
+ },
+ {
+ id: "alpha-1",
+ status: "working",
+ activity: "active",
+ lastActivityAt: new Date().toISOString(),
+ },
+ ],
+ }),
+ } as MessageEvent);
+ await vi.advanceTimersByTimeAsync(120);
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetch).toHaveBeenNthCalledWith(1, "/api/sessions?project=alpha", {
+ signal: expect.any(AbortSignal),
+ });
+
+ rerender({ sessions: betaSessions, project: "beta" });
+
+ await act(async () => {
+ resolveAlphaFetch?.({
+ ok: true,
+ json: async () => ({
+ sessions: [...alphaSessions, makeSession({ id: "alpha-1", projectId: "alpha" })],
+ globalPause: null,
+ }),
+ } as unknown as Response);
+ await Promise.resolve();
+ });
+
+ expect(result.current.sessions).toEqual(betaSessions);
+
+ await act(async () => {
+ eventSourceInstances[1].onmessage?.({
+ data: JSON.stringify({
+ type: "snapshot",
+ sessions: [
+ {
+ id: "beta-0",
+ status: "working",
+ activity: "active",
+ lastActivityAt: betaSessions[0].lastActivityAt,
+ },
+ {
+ id: "beta-1",
+ status: "working",
+ activity: "active",
+ lastActivityAt: new Date().toISOString(),
+ },
+ ],
+ }),
+ } as MessageEvent);
+ await vi.advanceTimersByTimeAsync(120);
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(fetch).toHaveBeenNthCalledWith(2, "/api/sessions?project=beta", {
+ signal: expect.any(AbortSignal),
+ });
+ });
+
it("swallows refresh fetch JSON failures without resetting sessions", async () => {
const sessions = makeSessions(1);
@@ -352,7 +583,7 @@ describe("useSessionEvents", () => {
json: async () => {
throw new Error("bad json");
},
- } as Response);
+ } as unknown as Response);
const { result } = renderHook(() => useSessionEvents(sessions, null));
@@ -376,8 +607,12 @@ describe("useSessionEvents", () => {
],
}),
} as MessageEvent);
+ });
- await Promise.resolve();
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("/api/sessions", {
+ signal: expect.any(AbortSignal),
+ });
});
expect(result.current.sessions).toHaveLength(1);
diff --git a/packages/web/src/hooks/useSessionEvents.ts b/packages/web/src/hooks/useSessionEvents.ts
index 926369d72..83d5cf731 100644
--- a/packages/web/src/hooks/useSessionEvents.ts
+++ b/packages/web/src/hooks/useSessionEvents.ts
@@ -3,6 +3,9 @@
import { useEffect, useReducer, useRef } from "react";
import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib/types";
+const MEMBERSHIP_REFRESH_DELAY_MS = 120;
+const STALE_REFRESH_INTERVAL_MS = 15000;
+
interface State {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
@@ -42,6 +45,15 @@ function reducer(state: State, action: Action): State {
}
}
+function createMembershipKey(
+ sessions: Array> | SSESnapshotEvent["sessions"],
+): string {
+ return sessions
+ .map((session) => session.id)
+ .sort()
+ .join("\u0000");
+}
+
export function useSessionEvents(
initialSessions: DashboardSession[],
initialGlobalPause?: GlobalPauseState | null,
@@ -53,6 +65,9 @@ export function useSessionEvents(
});
const sessionsRef = useRef(state.sessions);
const refreshingRef = useRef(false);
+ const refreshTimerRef = useRef | null>(null);
+ const pendingMembershipKeyRef = useRef(null);
+ const lastRefreshAtRef = useRef(Date.now());
useEffect(() => {
sessionsRef.current = state.sessions;
@@ -65,6 +80,69 @@ export function useSessionEvents(
useEffect(() => {
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
+ let disposed = false;
+ let activeRefreshController: AbortController | null = null;
+
+ const clearRefreshTimer = () => {
+ if (refreshTimerRef.current) {
+ clearTimeout(refreshTimerRef.current);
+ refreshTimerRef.current = null;
+ }
+ };
+
+ const scheduleRefresh = () => {
+ if (disposed) return;
+ if (refreshingRef.current || refreshTimerRef.current) return;
+ refreshTimerRef.current = setTimeout(() => {
+ if (disposed) return;
+ refreshTimerRef.current = null;
+ refreshingRef.current = true;
+ const requestedMembershipKey = pendingMembershipKeyRef.current;
+ const refreshController = new AbortController();
+ activeRefreshController = refreshController;
+
+ const sessionsUrl = project
+ ? `/api/sessions?project=${encodeURIComponent(project)}`
+ : "/api/sessions";
+
+ void fetch(sessionsUrl, { signal: refreshController.signal })
+ .then((res) => (res.ok ? res.json() : null))
+ .then(
+ (updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null) => {
+ if (disposed || refreshController.signal.aborted || !updated?.sessions) return;
+
+ lastRefreshAtRef.current = Date.now();
+ dispatch({
+ type: "reset",
+ sessions: updated.sessions,
+ globalPause: updated.globalPause ?? null,
+ });
+ },
+ )
+ .catch(() => undefined)
+ .finally(() => {
+ if (activeRefreshController === refreshController) {
+ activeRefreshController = null;
+ }
+ if (disposed || refreshController.signal.aborted) {
+ refreshingRef.current = false;
+ return;
+ }
+
+ refreshingRef.current = false;
+
+ if (
+ pendingMembershipKeyRef.current !== null &&
+ pendingMembershipKeyRef.current !== requestedMembershipKey
+ ) {
+ scheduleRefresh();
+ return;
+ }
+
+ pendingMembershipKeyRef.current = null;
+ });
+ }, MEMBERSHIP_REFRESH_DELAY_MS);
+ };
es.onmessage = (event: MessageEvent) => {
try {
@@ -73,36 +151,17 @@ export function useSessionEvents(
const snapshot = data as SSESnapshotEvent;
dispatch({ type: "snapshot", patches: snapshot.sessions });
- const currentIds = new Set(sessionsRef.current.map((s) => s.id));
- const snapshotIds = new Set(snapshot.sessions.map((s) => s.id));
- const sameMembership =
- currentIds.size === snapshotIds.size &&
- [...snapshotIds].every((id) => currentIds.has(id));
+ const currentMembershipKey = createMembershipKey(sessionsRef.current);
+ const snapshotMembershipKey = createMembershipKey(snapshot.sessions);
- if (!sameMembership && !refreshingRef.current) {
- refreshingRef.current = true;
- const sessionsUrl = project
- ? `/api/sessions?project=${encodeURIComponent(project)}`
- : "/api/sessions";
- void fetch(sessionsUrl)
- .then((res) => (res.ok ? res.json() : null))
- .then(
- (
- updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null,
- ) => {
- if (updated?.sessions) {
- dispatch({
- type: "reset",
- sessions: updated.sessions,
- globalPause: updated.globalPause ?? null,
- });
- }
- },
- )
- .catch(() => undefined)
- .finally(() => {
- refreshingRef.current = false;
- });
+ if (currentMembershipKey !== snapshotMembershipKey) {
+ pendingMembershipKeyRef.current = snapshotMembershipKey;
+ scheduleRefresh();
+ return;
+ }
+
+ if (Date.now() - lastRefreshAtRef.current >= STALE_REFRESH_INTERVAL_MS) {
+ scheduleRefresh();
}
}
} catch {
@@ -113,6 +172,12 @@ export function useSessionEvents(
es.onerror = () => undefined;
return () => {
+ disposed = true;
+ activeRefreshController?.abort();
+ activeRefreshController = null;
+ refreshingRef.current = false;
+ pendingMembershipKeyRef.current = null;
+ clearRefreshTimer();
es.close();
};
}, [project]);