/** * @file ActivityFeed.tsx * @description Real-time feed of agent events with server-driven filters and * a right-click session menu per row (`SessionRowMenu`), * batched pagination. Clicking a row toggles the inline EventDetail payload * view; the "View session" Link navigates to the session page. Live events * trigger a debounced, filter-aware refetch that preserves the user's * accumulated page size. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * - `../components/StatusBadge` * - `../components/EmptyState` * - `../components/EventDetail` * - `../components/EventFilters` * - `../components/EventFiltersInfo` * - `../components/Skeleton` * - `../lib/event-grouping` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `ActivityFeed` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **ActivityFeed** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Activity, Pause, Play, RefreshCw, ChevronRight, ExternalLink, Download, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { downloadText, exportName, toCsv } from "../lib/exportData"; import { toast } from "../lib/toast"; import { useDataScope } from "../lib/dataScope"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { EventDetail } from "../components/EventDetail"; import { useContextMenu } from "../components/ContextMenu"; import { SessionRowMenu } from "../components/SessionRowMenu"; import { EventFilters, isEmptyFilters, expandStatusToEventTypes, filtersFromParams, filtersToParams, } from "../components/EventFilters"; import type { EventFiltersValue } from "../components/EventFilters"; import { EventFiltersInfo } from "../components/EventFiltersInfo"; import { Skeleton } from "../components/Skeleton"; import { agentOriginLabel, buildEventTitle, buildOriginLabel, projectFromCwd, projectFromEvent, statusFromEventType, } from "../lib/event-grouping"; import type { AgentInfo } from "../lib/event-grouping"; import { formatTime, formatDateShort, timeAgo, isoOrUndefined } from "../lib/format"; import type { DashboardEvent } from "../lib/types"; const PAGE_SIZE = 50; // Throttle live-event refreshes so a burst of hook events (e.g. a stream of // PostToolUse results) triggers one refetch instead of dozens — and a steady // stream still refreshes, which a trailing debounce never did. const REFRESH_THROTTLE_MS = 3000; /** The agents on screen not asked about yet, and the sessions to ask for them. */ export function agentsToFetch( events: { session_id: string; agent_id?: string | null }[], asked: ReadonlySet ): { agentIds: string[]; sessionIds: string[] } { const fresh = events.filter((e) => e.agent_id && !asked.has(e.agent_id)); return { agentIds: [...new Set(fresh.map((e) => e.agent_id as string))], sessionIds: [...new Set(fresh.map((e) => e.session_id))], }; } export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) { const eventCtx = useContextMenu(); const { t } = useTranslation("activity"); const [events, setEvents] = useState([]); const [total, setTotal] = useState(0); // Filters and page live in the URL (replace), next to History's `view`. const [params, setParams] = useSearchParams(); const paramKey = params.toString(); const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]); const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0); // A filter change lands on page 0 in the same update (one load, not two). const setFilters = useCallback( (f: EventFiltersValue) => setParams( (prev) => { const next = filtersToParams(f, prev); next.delete("page"); return next; }, { replace: true } ), [setParams] ); const setPage = (fn: number | ((p: number) => number)) => { const n = typeof fn === "function" ? fn(page) : fn; setParams( (prev) => { const next = new URLSearchParams(prev); if (n > 0) next.set("page", String(n)); else next.delete("page"); return next; }, { replace: true } ); }; const [paused, setPaused] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Events that arrived while paused, or while reading a later page (live // refresh only runs on page 1, so rows never shift under the reader). const [newCount, setNewCount] = useState(0); const [expandedEvents, setExpandedEvents] = useState>(() => new Set()); // agent_id → subagent-facing info for the agents of the sessions on screen, // fetched in one call per new set of sessions (never every agent). const [agentInfoById, setAgentInfoById] = useState>(() => new Map()); const askedAgents = useRef(new Set()); const pausedRef = useRef(paused); pausedRef.current = paused; const live = page === 0 && !paused; function toggleEvent(id: number) { setExpandedEvents((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } // Convert UI filter state → API params. Status presets expand into // event_type values and merge with any explicit event_type selection. const apiParams = useMemo(() => { const statusExpanded = expandStatusToEventTypes(filters.status); const eventTypeMerged = Array.from(new Set([...filters.event_type, ...statusExpanded])); return { event_type: eventTypeMerged.length > 0 ? eventTypeMerged : undefined, tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined, agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined, session_id: filters.session_id.length > 0 ? filters.session_id : undefined, q: filters.q || undefined, from: isoOrUndefined(filters.from), to: isoOrUndefined(filters.to), }; }, [filters]); // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); // Newest request wins: a filter typed during a slow load never gets // overwritten by the older response. const loadSeq = useRef(0); const load = useCallback( async (quiet = false) => { const seq = ++loadSeq.current; if (!quiet) setLoading(true); try { const { events: data, total: totalCount } = await api.events.list({ ...apiParams, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }); if (seq !== loadSeq.current) return; setEvents(data); setTotal(totalCount); setError(null); if (page === 0) setNewCount(0); } catch (err) { if (seq === loadSeq.current) setError(err instanceof Error ? err.message : String(err)); } finally { if (seq === loadSeq.current) setLoading(false); } }, [apiParams, page, scope] ); useEffect(() => { load(); }, [load]); // Agent labels for agents on screen not asked about yet — keyed by agent, so // a subagent spawned later in a live session gets its label too. useEffect(() => { const { agentIds, sessionIds: missing } = agentsToFetch(events, askedAgents.current); if (missing.length === 0) return; for (const id of agentIds) askedAgents.current.add(id); api.agents .list({ session_ids: missing, limit: 5000 }) .then(({ agents }) => setAgentInfoById((prev) => { const map = new Map(prev); for (const a of agents) map.set(a.id, { type: a.type, subagent_type: a.subagent_type, name: a.name, parent_agent_id: a.parent_agent_id, }); return map; }) ) .catch(() => { // Non-fatal: subagent pills fall back to the short-id label. for (const id of agentIds) askedAgents.current.delete(id); }); }, [events]); // Session labels come joined on each row (`session_name`, `session_cwd`). const sessionNameById = useMemo(() => { const map = new Map(); for (const e of events) map.set(e.session_id, e.session_name?.trim() || `Session ${e.session_id.slice(0, 8)}`); return map; }, [events]); // Live: page 1 refreshes (throttled); elsewhere, and while paused, new // events only bump a counter. useLiveRefresh(() => void load(true), { enabled: live, throttleMs: REFRESH_THROTTLE_MS, match: (msg) => msg.type === "new_event" || isRemoteDataRefreshMessage(msg), }); useEffect(() => { if (live) return; return eventBus.subscribe((msg) => { if (msg.type === "new_event") setNewCount((n) => n + 1); }); }, [live]); function resume() { setPaused(false); setNewCount(0); if (page !== 0) setPage(0); // Catch-up via filtered refresh so buffered non-matching events don't leak in. else void load(true); } const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); // Precompute the project name per event so row rendering doesn't re-parse // event.data JSON on every render pass; events without their own cwd fall // back to their session's. const projectByEventId = useMemo(() => { const map = new Map(); for (const e of events) map.set(e.id, projectFromEvent(e) ?? projectFromCwd(e.session_cwd)); return map; }, [events]); async function handleExport(kind: "csv" | "json") { try { const all: DashboardEvent[] = []; for (let offset = 0; offset < 10000; offset += 500) { const res = await api.events.list({ ...apiParams, limit: 500, offset }); all.push(...res.events); if (res.events.length < 500 || all.length >= res.total) break; } const name = exportName("activity", kind); if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json"); else downloadText( name, toCsv(all, [ "id", "created_at", "session_id", "session_name", "event_type", "tool_name", "agent_id", "agent_name", "summary", ]), "text/csv" ); } catch (err) { toast.error(err); } } const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); return (
{eventCtx.menu && ( )}
{!embedded && (
)}
{/* Under History's tab bar the tab already names the page. */}

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{t("subtitle")} {paused && ( {t("paused", { count: newCount })} )}

({ id, label, }))} />
{error && (
{t("failedLoad")}: {error}
)} {!paused && page > 0 && newCount > 0 && ( )} {!loading && events.length === 0 ? ( ) : ( <>
{loading && events.length === 0 ? Array.from({ length: 12 }).map((_, i) => (
)) : null} {events.map((event, i) => { const isOpen = event.id != null && expandedEvents.has(event.id); return (
{/* The row is a plain container; its expand control is a real button and "Session" a sibling link (no nested interactives). */}
{ if ((e.target as HTMLElement).closest("a,button")) return; if (event.id != null) toggleEvent(event.id); }} onContextMenu={(e) => eventCtx.open(e, event)} className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none" >
{formatTime(event.created_at)}
{formatDateShort(event.created_at)}
{(() => { const sname = sessionNameById.get(event.session_id); const project = projectByEventId.get(event.id) ?? null; const origin = buildOriginLabel( project, sname ?? null, agentOriginLabel(event.agent_id, agentInfoById) ); return (

{origin && ( {origin} · )} {buildEventTitle(event)}

); })()} {event.tool_name && ( {event.tool_name} )} {timeAgo(event.created_at)} e.stopPropagation()} title={t("viewSession")} className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-md bg-surface-2 text-fg-secondary hover:text-accent hover:bg-accent/10 border border-border hover:border-accent/30 transition-colors flex-shrink-0 font-medium" > {t("viewSession")}
{isOpen && }
); })}
{total > 0 && (
{t("common:pagination.showing", { from: page * PAGE_SIZE + 1, to: Math.min((page + 1) * PAGE_SIZE, total), total, })}
{(() => { // Compact numbered page buttons: show up to 5 pages around // the current page, with ellipses when appropriate. const pages: (number | "...")[] = []; const windowSize = 5; let start = Math.max(0, page - Math.floor(windowSize / 2)); let end = Math.min(totalPages - 1, start + windowSize - 1); start = Math.max(0, Math.min(start, end - windowSize + 1)); if (start > 0) { pages.push(0); if (start > 1) pages.push("..."); } for (let i = start; i <= end; i++) pages.push(i); if (end < totalPages - 1) { if (end < totalPages - 2) pages.push("..."); pages.push(totalPages - 1); } return pages.map((p, idx) => p === "..." ? ( ... ) : ( ) ); })()}
)} )}
); }