From 8625137702bb8b3660d05f252575cd695ffc3df5 Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:36:26 +0530 Subject: [PATCH] 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. --- .../web/src/app/sessions/[id]/page.test.tsx | 10 + packages/web/src/app/sessions/[id]/page.tsx | 20 +- packages/web/src/components/Dashboard.tsx | 21 +- .../web/src/components/DynamicFavicon.tsx | 37 +++- .../web/src/components/PullRequestsPage.tsx | 12 +- .../__tests__/DynamicFavicon.test.tsx | 99 +++++++++ .../__tests__/useSSESessionActivity.test.ts | 207 ++++++++++++++++++ .../hooks/__tests__/useSessionEvents.test.ts | 127 ++++++++++- .../web/src/hooks/useSSESessionActivity.ts | 54 +++++ packages/web/src/hooks/useSessionEvents.ts | 55 ++++- 10 files changed, 614 insertions(+), 28 deletions(-) create mode 100644 packages/web/src/components/__tests__/DynamicFavicon.test.tsx create mode 100644 packages/web/src/hooks/__tests__/useSSESessionActivity.test.ts create mode 100644 packages/web/src/hooks/useSSESessionActivity.ts diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index ec96b721c..45eb15790 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -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(() => { diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 453b6782c..fc8c33b00 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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(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; diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 0ec40bc1f..3dbb05adf 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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 = {}; + 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({ )}