feat: event-driven live tab titles and favicons via SSE (#848)

* feat: event-driven live tab titles and favicons via SSE

Switch tab titles and favicons from polling to real-time SSE updates:

- Extend useSessionEvents to expose sseAttentionLevels map from SSE
  snapshots (server-computed, includes full PR state)
- Refactor DynamicFavicon to use SSE attention levels instead of
  recomputing from the full sessions array (which has stale PR data
  between refreshes)
- Add useSSESessionActivity hook for session detail page to update
  document.title emoji immediately on activity change
- Add live dashboard title showing count of sessions needing attention
- Update PullRequestsPage to use new DynamicFavicon API

Closes #115

* fix: seed initial attention levels to avoid stale favicon/title on first render

Accept initialAttentionLevels parameter in useSessionEvents so callers
can seed the attention map from initialSessions via getAttentionLevel().
This prevents the favicon and dashboard title from briefly showing
"all clear" before the first SSE snapshot arrives.

* fix: add EventSource mock to session page test for SSE hook compatibility

* fix: reset stale activity state when sessionId changes in useSSESessionActivity

Add sessionId to the effect dependency array and reset state to null at
the start of each effect run so callers that reuse the hook with a
different sessionId don't see the previous session's activity.

* fix: reset sseAttentionLevels on initialSessions change to prevent stale data on project switch

The reset action now accepts an optional sseAttentionLevels field. When
initialSessions changes (e.g., project switch via sidebar), the dispatch
passes the current initialAttentionLevels via ref so the favicon and
dashboard title reflect the new project immediately rather than showing
stale attention data until the first SSE snapshot.
This commit is contained in:
Harsh Batheja 2026-04-03 02:36:26 +05:30 committed by Gaurav Bhola
parent f07aedfa58
commit 8625137702
10 changed files with 614 additions and 28 deletions

View File

@ -44,6 +44,16 @@ describe("SessionPage project polling", () => {
beforeEach(() => {
vi.useFakeTimers();
sessionDetailSpy.mockClear();
const eventSourceMock = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
readyState: 1,
};
global.EventSource = vi.fn(
() => eventSourceMock as unknown as EventSource,
) as unknown as typeof EventSource;
});
afterEach(() => {

View File

@ -4,17 +4,22 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import { isOrchestratorSession } from "@composio/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { type DashboardSession, getAttentionLevel, type AttentionLevel } from "@/lib/types";
import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
import { useSSESessionActivity } from "@/hooks/useSSESessionActivity";
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "..." : s;
}
/** Build a descriptive tab title from session data. */
function buildSessionTitle(session: DashboardSession): string {
function buildSessionTitle(
session: DashboardSession,
activityOverride?: ActivityState | null,
): string {
const id = session.id;
const emoji = session.activity ? (activityIcon[session.activity] ?? "") : "";
const activity = activityOverride !== undefined ? activityOverride : session.activity;
const emoji = activity ? (activityIcon[activity] ?? "") : "";
const isOrchestrator = isOrchestratorSession(session);
let detail: string;
@ -62,14 +67,17 @@ export default function SessionPage() {
const sessionIsOrchestratorRef = useRef(false);
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
// Update document title based on session data
// Subscribe to SSE for real-time activity updates (title emoji)
const sseActivity = useSSESessionActivity(id);
// Update document title based on session data + SSE activity override
useEffect(() => {
if (session) {
document.title = buildSessionTitle(session);
document.title = buildSessionTitle(session, sseActivity?.activity);
} else {
document.title = `${id} | Session Detail`;
}
}, [session, id]);
}, [session, id, sseActivity]);
useEffect(() => {
sessionProjectIdRef.current = sessionProjectId;

View File

@ -14,7 +14,7 @@ import {
isPRMergeReady,
} from "@/lib/types";
import { AttentionZone } from "./AttentionZone";
import { DynamicFavicon } from "./DynamicFavicon";
import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
import { ThemeToggle } from "./ThemeToggle";
@ -72,10 +72,18 @@ function DashboardInner({
orchestrators,
}: DashboardProps) {
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
const { sessions, globalPause, connectionStatus } = useSessionEvents(
const initialAttentionLevels = useMemo(() => {
const levels: Record<string, AttentionLevel> = {};
for (const s of initialSessions) {
levels[s.id] = getAttentionLevel(s);
}
return levels;
}, [initialSessions]);
const { sessions, globalPause, connectionStatus, sseAttentionLevels } = useSessionEvents(
initialSessions,
initialGlobalPause,
projectId,
initialAttentionLevels,
);
const searchParams = useSearchParams();
const activeSessionId = searchParams.get("session") ?? undefined;
@ -139,6 +147,13 @@ function DashboardInner({
setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks));
}, [orchestratorLinks]);
// Update document title with live attention counts from SSE
useEffect(() => {
const needsAttention = countNeedingAttention(sseAttentionLevels);
const label = projectName ?? "ao";
document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label;
}, [sseAttentionLevels, projectName]);
useEffect(() => {
setMobileMenuOpen(false);
}, [searchParams]);
@ -494,7 +509,7 @@ function DashboardInner({
)}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<div id="mobile-dashboard-anchor" aria-hidden="true" />
<DynamicFavicon sessions={sessions} projectName={projectName} />
<DynamicFavicon sseAttentionLevels={sseAttentionLevels} projectName={projectName} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">

View File

@ -1,21 +1,21 @@
"use client";
import { useEffect } from "react";
import { type DashboardSession, type AttentionLevel, getAttentionLevel } from "@/lib/types";
import type { SSEAttentionMap } from "@/hooks/useSessionEvents";
/**
* Determine overall health from sessions.
* Determine overall health from SSE attention levels.
* - "green" all sessions working/done/pending, nothing needs attention
* - "yellow" some sessions need review or response
* - "red" critical: sessions stuck, errored, or needing immediate action
*/
function computeHealth(sessions: DashboardSession[]): "green" | "yellow" | "red" {
if (sessions.length === 0) return "green";
function computeHealthFromLevels(levels: SSEAttentionMap): "green" | "yellow" | "red" {
const entries = Object.values(levels);
if (entries.length === 0) return "green";
let hasYellow = false;
for (const session of sessions) {
const level: AttentionLevel = getAttentionLevel(session);
for (const level of entries) {
if (level === "respond") return "red";
if (level === "review" || level === "merge") hasYellow = true;
}
@ -38,20 +38,35 @@ function generateFaviconSvg(initial: string, color: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/** Count sessions that need human attention (respond, review, merge). */
export function countNeedingAttention(levels: SSEAttentionMap): number {
let count = 0;
for (const level of Object.values(levels)) {
if (level === "respond" || level === "review" || level === "merge") {
count++;
}
}
return count;
}
interface DynamicFaviconProps {
sessions: DashboardSession[];
/** Server-computed attention levels from SSE snapshots. */
sseAttentionLevels: SSEAttentionMap;
projectName?: string;
}
/**
* Client component that dynamically updates the browser favicon
* based on system health (session attention levels).
* based on system health (session attention levels from SSE).
*
* Uses server-computed attention levels from SSE snapshots for real-time
* updates, rather than recomputing from the full sessions array.
*/
export function DynamicFavicon({ sessions, projectName = "A" }: DynamicFaviconProps) {
export function DynamicFavicon({ sseAttentionLevels, projectName = "A" }: DynamicFaviconProps) {
const initial = projectName.charAt(0).toUpperCase();
useEffect(() => {
const health = computeHealth(sessions);
const health = computeHealthFromLevels(sseAttentionLevels);
const color = HEALTH_COLORS[health];
const href = generateFaviconSvg(initial, color);
@ -64,7 +79,7 @@ export function DynamicFavicon({ sessions, projectName = "A" }: DynamicFaviconPr
}
link.type = "image/svg+xml";
link.href = href;
}, [sessions, initial]);
}, [sseAttentionLevels, initial]);
return null;
}

View File

@ -7,6 +7,7 @@ import {
type DashboardSession,
type DashboardPR,
type DashboardOrchestratorLink,
getAttentionLevel,
} from "@/lib/types";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
@ -35,7 +36,14 @@ export function PullRequestsPage({
orchestrators,
}: PullRequestsPageProps) {
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
const { sessions } = useSessionEvents(initialSessions, null, projectId);
const initialAttentionLevels = useMemo(() => {
const levels: Record<string, ReturnType<typeof getAttentionLevel>> = {};
for (const s of initialSessions) {
levels[s.id] = getAttentionLevel(s);
}
return levels;
}, [initialSessions]);
const { sessions, sseAttentionLevels } = useSessionEvents(initialSessions, null, projectId, initialAttentionLevels);
const searchParams = useSearchParams();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -82,7 +90,7 @@ export function PullRequestsPage({
/>
) : null}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<DynamicFavicon sessions={sessions} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
<DynamicFavicon sseAttentionLevels={sseAttentionLevels} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">

View File

@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { countNeedingAttention, DynamicFavicon } from "../DynamicFavicon";
import type { SSEAttentionMap } from "@/hooks/useSessionEvents";
describe("countNeedingAttention", () => {
it("returns 0 for empty map", () => {
expect(countNeedingAttention({})).toBe(0);
});
it("returns 0 when all sessions are working/pending/done", () => {
const levels: SSEAttentionMap = {
"s-1": "working",
"s-2": "pending",
"s-3": "done",
};
expect(countNeedingAttention(levels)).toBe(0);
});
it("counts respond, review, and merge sessions", () => {
const levels: SSEAttentionMap = {
"s-1": "respond",
"s-2": "review",
"s-3": "merge",
"s-4": "working",
"s-5": "done",
};
expect(countNeedingAttention(levels)).toBe(3);
});
it("counts a single attention-needing session", () => {
const levels: SSEAttentionMap = {
"s-1": "respond",
};
expect(countNeedingAttention(levels)).toBe(1);
});
});
describe("DynamicFavicon", () => {
beforeEach(() => {
const existing = document.querySelector('link[rel="icon"]');
if (existing) existing.remove();
});
it("creates a green favicon when no sessions need attention", () => {
const levels: SSEAttentionMap = { "s-1": "working", "s-2": "done" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link).not.toBeNull();
expect(link!.href).toContain("%2322c55e"); // green
});
it("creates a yellow favicon when sessions need review", () => {
const levels: SSEAttentionMap = { "s-1": "review", "s-2": "working" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23eab308"); // yellow
});
it("creates a red favicon when sessions need response", () => {
const levels: SSEAttentionMap = { "s-1": "respond" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23ef4444"); // red
});
it("uses first letter of projectName as initial", () => {
const levels: SSEAttentionMap = {};
render(<DynamicFavicon sseAttentionLevels={levels} projectName="MyApp" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("M"); // initial letter
});
it("defaults to A when no projectName given", () => {
const levels: SSEAttentionMap = {};
render(<DynamicFavicon sseAttentionLevels={levels} />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("A");
});
it("updates favicon when attention levels change", () => {
const { rerender } = render(
<DynamicFavicon sseAttentionLevels={{ "s-1": "working" }} projectName="Test" />,
);
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%2322c55e"); // green
rerender(<DynamicFavicon sseAttentionLevels={{ "s-1": "respond" }} projectName="Test" />);
link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23ef4444"); // red
});
});

View File

@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSSESessionActivity } from "../useSSESessionActivity";
describe("useSSESessionActivity", () => {
let eventSourceMock: {
onmessage: ((event: MessageEvent) => void) | null;
onopen: (() => void) | null;
onerror: (() => void) | null;
readyState: number;
close: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
const eventSourceConstructor = vi.fn(() => {
const instance = {
onmessage: null as ((event: MessageEvent) => void) | null,
onopen: null as (() => void) | null,
onerror: null as (() => void) | null,
readyState: 0,
close: vi.fn(),
};
eventSourceMock = instance;
return instance as unknown as EventSource;
});
global.EventSource = Object.assign(eventSourceConstructor, {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}) as unknown as typeof EventSource;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns null initially", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
expect(result.current).toBeNull();
});
it("updates activity when SSE snapshot contains the target session", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
{ id: "session-2", status: "working", activity: "idle", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toEqual({ activity: "active" });
});
it("updates when activity changes", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("active");
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "idle", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("idle");
});
it("does not update when activity stays the same (referential stability)", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
const first = result.current;
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toBe(first);
});
it("ignores snapshots that do not contain the session", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-other", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("passes project parameter to EventSource URL", () => {
renderHook(() => useSSESessionActivity("session-1", "my-project"));
expect(global.EventSource).toHaveBeenCalledWith("/api/events?project=my-project");
});
it("uses default URL when no project is specified", () => {
renderHook(() => useSSESessionActivity("session-1"));
expect(global.EventSource).toHaveBeenCalledWith("/api/events");
});
it("closes EventSource on unmount", () => {
const { unmount } = renderHook(() => useSSESessionActivity("session-1"));
unmount();
expect(eventSourceMock.close).toHaveBeenCalled();
});
it("ignores non-snapshot events", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({ type: "heartbeat" }),
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("handles malformed JSON gracefully", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: "not-json",
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("resets activity and reconnects when sessionId changes", () => {
let currentSessionId = "session-1";
const { result, rerender } = renderHook(() =>
useSSESessionActivity(currentSessionId),
);
const firstInstance = eventSourceMock;
act(() => {
firstInstance.onmessage!.call(firstInstance, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("active");
currentSessionId = "session-2";
rerender();
// State should reset to null immediately on sessionId change
expect(result.current).toBeNull();
// First EventSource should have been closed
expect(firstInstance.close).toHaveBeenCalled();
});
});

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useSessionEvents } from "../useSessionEvents";
import type { DashboardSession, GlobalPauseState } from "../../lib/types";
import type { AttentionLevel, DashboardSession, GlobalPauseState } from "../../lib/types";
import { makeSession } from "../../__tests__/helpers";
describe("useSessionEvents", () => {
@ -663,4 +663,129 @@ describe("useSessionEvents", () => {
expect(result.current.sessions[0].id).toBe("session-0");
});
});
describe("sseAttentionLevels", () => {
it("starts with empty attention levels when no initial levels provided", () => {
const sessions = makeSessions(2);
const { result } = renderHook(() => useSessionEvents(sessions));
expect(result.current.sseAttentionLevels).toEqual({});
});
it("seeds attention levels from initialAttentionLevels parameter", () => {
const sessions = makeSessions(2);
const initialLevels = { "session-0": "working" as const, "session-1": "respond" as const };
const { result } = renderHook(() => useSessionEvents(sessions, null, undefined, initialLevels));
expect(result.current.sseAttentionLevels).toEqual({
"session-0": "working",
"session-1": "respond",
});
});
it("populates attention levels from SSE snapshot", () => {
const sessions = makeSessions(2);
const { result } = renderHook(() => useSessionEvents(sessions));
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
{ id: "session-1", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels).toEqual({
"session-0": "working",
"session-1": "respond",
});
});
it("updates attention levels when they change", () => {
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions));
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels["session-0"]).toBe("working");
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels["session-0"]).toBe("respond");
});
it("preserves referential stability when attention levels do not change", () => {
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions));
const ts = new Date().toISOString();
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
],
}),
} as MessageEvent);
});
const firstLevels = result.current.sseAttentionLevels;
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels).toBe(firstLevels);
});
it("resets sseAttentionLevels to new initialAttentionLevels when initialSessions changes", () => {
const sessions1 = makeSessions(1);
let currentSessions = sessions1;
let currentLevels: Record<string, AttentionLevel> | undefined = { "session-0": "respond" as const };
const { result, rerender } = renderHook(() =>
useSessionEvents(currentSessions, null, undefined, currentLevels),
);
expect(result.current.sseAttentionLevels).toEqual({ "session-0": "respond" });
const sessions2 = makeSessions(1).map((s) => ({ ...s, id: "session-new" }));
const levels2 = { "session-new": "working" as const };
currentSessions = sessions2;
currentLevels = levels2;
rerender();
expect(result.current.sseAttentionLevels).toEqual({ "session-new": "working" });
expect(result.current.sseAttentionLevels["session-0"]).toBeUndefined();
});
});
});

View File

@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import type { ActivityState, SSESnapshotEvent } from "@/lib/types";
interface SessionActivity {
activity: ActivityState | null;
}
/**
* Lightweight SSE subscriber that tracks a single session's activity state.
*
* Used by the session detail page to update document.title emoji in real-time
* without waiting for the full session HTTP poll cycle.
*/
export function useSSESessionActivity(
sessionId: string,
project?: string,
): SessionActivity | null {
const [activity, setActivity] = useState<SessionActivity | null>(null);
useEffect(() => {
setActivity(null);
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
let disposed = false;
es.onmessage = (event: MessageEvent) => {
if (disposed) return;
try {
const data = JSON.parse(event.data as string) as { type: string };
if (data.type !== "snapshot") return;
const snapshot = data as SSESnapshotEvent;
const match = snapshot.sessions.find((s) => s.id === sessionId);
if (!match) return;
setActivity((prev) => {
if (prev && prev.activity === match.activity) return prev;
return { activity: match.activity };
});
} catch {
return;
}
};
return () => {
disposed = true;
es.close();
};
}, [sessionId, project]);
return activity;
}

View File

@ -1,7 +1,12 @@
"use client";
import { useEffect, useReducer, useRef } from "react";
import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib/types";
import type {
AttentionLevel,
DashboardSession,
GlobalPauseState,
SSESnapshotEvent,
} from "@/lib/types";
/** Debounce before fetching full session list after membership change. */
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
@ -12,21 +17,34 @@ const DISCONNECTED_GRACE_PERIOD_MS = 4000;
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
/** Server-computed attention levels from the latest SSE snapshot. */
export type SSEAttentionMap = Readonly<Record<string, AttentionLevel>>;
interface State {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
connectionStatus: ConnectionStatus;
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
sseAttentionLevels: SSEAttentionMap;
}
type Action =
| { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null }
| { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null; sseAttentionLevels?: SSEAttentionMap }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
| { type: "setConnection"; status: ConnectionStatus };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "reset":
return { ...state, sessions: action.sessions, globalPause: action.globalPause };
return {
...state,
sessions: action.sessions,
globalPause: action.globalPause,
...(action.sseAttentionLevels !== undefined
? { sseAttentionLevels: action.sseAttentionLevels }
: {}),
};
case "setConnection":
return { ...state, connectionStatus: action.status };
case "snapshot": {
@ -50,7 +68,25 @@ function reducer(state: State, action: Action): State {
lastActivityAt: patch.lastActivityAt,
};
});
return changed ? { ...state, sessions: next } : state;
// Build attention level map from server-computed values
const levels: Record<string, AttentionLevel> = {};
for (const p of action.patches) {
levels[p.id] = p.attentionLevel;
}
const sessionsChanged = changed;
const levelsChanged =
Object.keys(levels).length !== Object.keys(state.sseAttentionLevels).length ||
action.patches.some((p) => state.sseAttentionLevels[p.id] !== p.attentionLevel);
if (!sessionsChanged && !levelsChanged) return state;
return {
...state,
sessions: sessionsChanged ? next : state.sessions,
sseAttentionLevels: levelsChanged ? levels : state.sseAttentionLevels,
};
}
}
}
@ -68,13 +104,17 @@ export function useSessionEvents(
initialSessions: DashboardSession[],
initialGlobalPause?: GlobalPauseState | null,
project?: string,
initialAttentionLevels?: SSEAttentionMap,
): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
connectionStatus: "connected" as ConnectionStatus,
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
});
const sessionsRef = useRef(state.sessions);
const initialAttentionLevelsRef = useRef(initialAttentionLevels);
initialAttentionLevelsRef.current = initialAttentionLevels;
const refreshingRef = useRef(false);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingMembershipKeyRef = useRef<string | null>(null);
@ -86,7 +126,12 @@ export function useSessionEvents(
}, [state.sessions]);
useEffect(() => {
dispatch({ type: "reset", sessions: initialSessions, globalPause: initialGlobalPause ?? null });
dispatch({
type: "reset",
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
sseAttentionLevels: initialAttentionLevelsRef.current ?? ({} as SSEAttentionMap),
});
}, [initialSessions, initialGlobalPause]);
useEffect(() => {