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:
parent
f07aedfa58
commit
8625137702
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue