perf: reduce dashboard SSE refresh and render churn (#451)

* perf: coalesce dashboard session refreshes

Batch membership-driven refreshes behind a short cadence and keep low-frequency steady-state refreshes so pause and PR data stay current without hammering /api/sessions during SSE churn.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* perf: memoize dashboard session views

Keep unchanged zones and cards cold during same-membership SSE updates by stabilizing dashboard callbacks, caching project groupings, and memoizing the session view tree with a render-cadence regression test.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: prevent stale refresh scheduling after project switch

Abort in-flight refreshes and ignore late completions from disposed effects so old project fetches cannot re-arm timers or reset the new project state.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Harsh Batheja 2026-03-13 09:26:07 +05:30 committed by GitHub
parent b064925621
commit 4e93bc1458
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 566 additions and 99 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import type { DashboardSession, AttentionLevel } from "@/lib/types";
import { SessionCard } from "./SessionCard";
@ -54,7 +54,7 @@ const zoneConfig: Record<
},
};
export function AttentionZone({
function AttentionZoneView({
level,
sessions,
variant = "grid",
@ -76,10 +76,7 @@ export function AttentionZone({
className="mb-2.5 flex items-center gap-2 py-0.5 text-left"
onClick={() => setCollapsed(!collapsed)}
>
<div
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: config.color }}
/>
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{config.label}
</span>
@ -128,10 +125,7 @@ export function AttentionZone({
onClick={() => setCollapsed(!collapsed)}
>
{/* Semantic dot — only zone-colored element */}
<div
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: config.color }}
/>
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
{/* Label — neutral, not zone-colored */}
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{config.label}
@ -172,3 +166,18 @@ export function AttentionZone({
</div>
);
}
function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean {
return (
prev.level === next.level &&
prev.variant === next.variant &&
prev.onSend === next.onSend &&
prev.onKill === next.onKill &&
prev.onMerge === next.onMerge &&
prev.onRestore === next.onRestore &&
prev.sessions.length === next.sessions.length &&
prev.sessions.every((session, index) => session === next.sessions[index])
);
}
export const AttentionZone = memo(AttentionZoneView, areAttentionZonePropsEqual);

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
type DashboardSession,
type DashboardStats,
@ -86,6 +86,19 @@ export function Dashboard({
return zones;
}, [sessions]);
const sessionsByProject = useMemo(() => {
const groupedSessions = new Map<string, DashboardSession[]>();
for (const session of sessions) {
const projectSessions = groupedSessions.get(session.projectId);
if (projectSessions) {
projectSessions.push(session);
continue;
}
groupedSessions.set(session.projectId, [session]);
}
return groupedSessions;
}, [sessions]);
const openPRs = useMemo(() => {
return sessions
.filter(
@ -100,7 +113,7 @@ export function Dashboard({
if (!allProjectsView) return [];
return projects.map((project) => {
const projectSessions = sessions.filter((session) => session.projectId === project.id);
const projectSessions = sessionsByProject.get(project.id) ?? [];
const counts: Record<AttentionLevel, number> = {
merge: 0,
respond: 0,
@ -123,9 +136,9 @@ export function Dashboard({
counts,
};
});
}, [activeOrchestrators, allProjectsView, projects, sessions]);
}, [activeOrchestrators, allProjectsView, projects, sessionsByProject]);
const handleSend = async (sessionId: string, message: string) => {
const handleSend = useCallback(async (sessionId: string, message: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -134,9 +147,9 @@ export function Dashboard({
if (!res.ok) {
console.error(`Failed to send message to ${sessionId}:`, await res.text());
}
};
}, []);
const handleKill = async (sessionId: string) => {
const handleKill = useCallback(async (sessionId: string) => {
if (!confirm(`Kill session ${sessionId}?`)) return;
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/kill`, {
method: "POST",
@ -144,16 +157,16 @@ export function Dashboard({
if (!res.ok) {
console.error(`Failed to kill ${sessionId}:`, await res.text());
}
};
}, []);
const handleMerge = async (prNumber: number) => {
const handleMerge = useCallback(async (prNumber: number) => {
const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
if (!res.ok) {
console.error(`Failed to merge PR #${prNumber}:`, await res.text());
}
};
}, []);
const handleRestore = async (sessionId: string) => {
const handleRestore = useCallback(async (sessionId: string) => {
if (!confirm(`Restore session ${sessionId}?`)) return;
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/restore`, {
method: "POST",
@ -161,7 +174,7 @@ export function Dashboard({
if (!res.ok) {
console.error(`Failed to restore ${sessionId}:`, await res.text());
}
};
}, []);
const handleSpawnOrchestrator = async (project: ProjectInfo) => {
setSpawningProjectIds((current) =>

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { memo, useState, useEffect, useRef } from "react";
import {
type DashboardSession,
type AttentionLevel,
@ -25,15 +25,15 @@ interface SessionCardProps {
}
const borderColorByLevel: Record<AttentionLevel, string> = {
merge: "border-l-[var(--color-status-ready)]",
merge: "border-l-[var(--color-status-ready)]",
respond: "border-l-[var(--color-status-error)]",
review: "border-l-[var(--color-accent-orange)]",
review: "border-l-[var(--color-accent-orange)]",
pending: "border-l-[var(--color-status-attention)]",
working: "border-l-[var(--color-status-working)]",
done: "border-l-[var(--color-border-default)]",
done: "border-l-[var(--color-border-default)]",
};
export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
const [expanded, setExpanded] = useState(false);
const [sendingAction, setSendingAction] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -41,7 +41,9 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
const pr = session.pr;
useEffect(() => {
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleAction = async (action: string, message: string) => {
@ -75,9 +77,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
)}
style={{
borderRadius: 7,
background: (expanded && !isReadyToMerge)
? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)"
: undefined,
background:
expanded && !isReadyToMerge
? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)"
: undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
@ -93,7 +96,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
<div className="flex-1" />
{isRestorable && (
<button
onClick={(e) => { e.stopPropagation(); onRestore?.(session.id); }}
onClick={(e) => {
e.stopPropagation();
onRestore?.(session.id);
}}
className="rounded border border-[rgba(88,166,255,0.35)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
>
restore
@ -112,12 +118,14 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
{/* Title — its own row, bigger, can wrap */}
<div className="px-4 pb-3">
<p className={cn(
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
level === "working"
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
: "text-[14px] font-semibold text-[var(--color-text-primary)]"
)}>
<p
className={cn(
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
level === "working"
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
: "text-[14px] font-semibold text-[var(--color-text-primary)]",
)}
>
{title}
</p>
</div>
@ -139,7 +147,13 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
{rateLimited && pr?.state === "open" && (
<div className="px-4 pb-3">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<svg className="h-3 w-3 text-[var(--color-text-tertiary)]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg
className="h-3 w-3 text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
@ -153,10 +167,19 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
<div className="px-4 pb-3.5 pt-0.5">
{isReadyToMerge && pr ? (
<button
onClick={(e) => { e.stopPropagation(); onMerge?.(pr.number); }}
onClick={(e) => {
e.stopPropagation();
onMerge?.(pr.number);
}}
className="inline-flex items-center gap-1.5 rounded-[5px] border-0 bg-[var(--color-status-ready)] px-3 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] transition-[filter,transform] duration-[100ms] hover:-translate-y-px hover:brightness-110"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Merge PR #{pr.number}
@ -180,7 +203,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
</a>
{alert.actionLabel && session.activity !== "active" && (
<button
onClick={(e) => { e.stopPropagation(); handleAction(alert.key, alert.actionMessage ?? ""); }}
onClick={(e) => {
e.stopPropagation();
handleAction(alert.key, alert.actionMessage ?? "");
}}
disabled={sendingAction === alert.key}
className="rounded border border-[rgba(88,166,255,0.25)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)] disabled:opacity-50"
>
@ -230,11 +256,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
<div className="space-y-1">
{pr.unresolvedComments.map((c) => (
<div key={c.url} className="flex items-center gap-2 text-[12px]">
<span className="w-3 shrink-0 text-center text-[var(--color-status-error)]"></span>
<span className="w-3 shrink-0 text-center text-[var(--color-status-error)]">
</span>
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
{c.path}
</span>
<a href={c.url} target="_blank" rel="noopener noreferrer" className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline">
<a
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline"
>
view
</a>
</div>
@ -246,7 +279,14 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
{pr && (
<DetailSection label="PR">
<p className="text-[12px] text-[var(--color-text-secondary)]">
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="hover:underline">{pr.title}</a>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{pr.title}
</a>
<br />
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
@ -257,13 +297,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
)}
{!pr && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">No PR associated with this session.</p>
<p className="text-[12px] text-[var(--color-text-tertiary)]">
No PR associated with this session.
</p>
)}
<div className="mt-3 flex gap-2 border-t border-[var(--color-border-subtle)] pt-3">
{isRestorable && (
<button
onClick={(e) => { e.stopPropagation(); onRestore?.(session.id); }}
onClick={(e) => {
e.stopPropagation();
onRestore?.(session.id);
}}
className="rounded border border-[rgba(88,166,255,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
>
restore session
@ -271,7 +316,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
)}
{!isTerminal && (
<button
onClick={(e) => { e.stopPropagation(); onKill?.(session.id); }}
onClick={(e) => {
e.stopPropagation();
onKill?.(session.id);
}}
className="rounded border border-[rgba(239,68,68,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:bg-[rgba(239,68,68,0.1)]"
>
terminate
@ -284,6 +332,18 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
);
}
function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps): boolean {
return (
prev.session === next.session &&
prev.onSend === next.onSend &&
prev.onKill === next.onKill &&
prev.onMerge === next.onMerge &&
prev.onRestore === next.onRestore
);
}
export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual);
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-2.5">
@ -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}`,

View File

@ -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<string, number>();
vi.mock("@/components/SessionCard", () => ({
SessionCard: memo(({ session }: { session: { id: string } }) => {
renderCounts.set(session.id, (renderCounts.get(session.id) ?? 0) + 1);
return <div data-testid={`session-card-${session.id}`}>{session.id}</div>;
}),
}));
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(<Dashboard initialSessions={initialSessions} />);
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();
});
});

View File

@ -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<Response>((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);

View File

@ -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<Pick<DashboardSession, "id">> | 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<ReturnType<typeof setTimeout> | null>(null);
const pendingMembershipKeyRef = useRef<string | null>(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]);