/** * @file ActivityFeed.tsx * @description Real-time feed of agent events with server-driven filters and * 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 } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Activity, Pause, Play, RefreshCw, ChevronRight, ExternalLink } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { EventDetail } from "../components/EventDetail"; import { EventFilters, EMPTY_FILTERS, isEmptyFilters, expandStatusToEventTypes, } 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 } from "../lib/format"; import type { DashboardEvent } from "../lib/types"; const PAGE_SIZE = 50; // Max rows a single /api/events request can return (server cap). Refreshes // triggered by live events are bounded by this. const MAX_REFRESH = 500; // Debounce live-event refreshes so a burst of hook events (e.g. a stream of // PostToolUse results) triggers one refetch instead of dozens. const REFRESH_DEBOUNCE_MS = 500; export function ActivityFeed() { const { t } = useTranslation("activity"); const [events, setEvents] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [filters, setFilters] = useState(EMPTY_FILTERS); const [paused, setPaused] = useState(false); const [loading, setLoading] = useState(true); const [bufferCount, setBufferCount] = useState(0); const [expandedEvents, setExpandedEvents] = useState>(() => new Set()); // session_id → session name. Populated from /api/sessions on mount so rows // can render a friendly session pill instead of a bare UUID. const [sessionNameById, setSessionNameById] = useState>(() => new Map()); // session_id → project (basename of the session's cwd). Used as the leading // origin segment for events whose own payload carries no cwd (e.g. // TurnDuration "Turn completed" rows), so they match tool-use rows. const [sessionProjectById, setSessionProjectById] = useState>( () => new Map() ); // agent_id → subagent-facing info. Populated from /api/agents on mount so the // subagent pill can show subagent_type (e.g. "frontend-reviewer") instead of // a raw ID. Main agents intentionally yield no pill. const [agentInfoById, setAgentInfoById] = useState>(() => new Map()); const bufferRef = useRef([]); const pausedRef = useRef(paused); // Refs let the websocket handler read the latest filter/page without // re-subscribing on every change. const apiParamsRef = useRef>({}); const pageRef = useRef(0); const refreshTimerRef = useRef | null>(null); pausedRef.current = paused; pageRef.current = page; 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: filters.from ? new Date(filters.from).toISOString() : undefined, to: filters.to ? new Date(filters.to).toISOString() : undefined, }; }, [filters]); apiParamsRef.current = apiParams; // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); const load = useCallback(async () => { setLoading(true); try { const { events: data, total: totalCount } = await api.events.list({ ...apiParams, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }); setEvents(data); setTotal(totalCount); } catch (err) { // Non-fatal: keep the previous list; log so dev tools surface the // failure instead of raising an unhandled promise rejection. console.error("Failed to load events:", err); } finally { setLoading(false); } }, [apiParams, page, scope]); useEffect(() => { load(); }, [load]); // Reset to page 0 whenever filters change so the user lands on the first // page of the new filtered result set. useEffect(() => { setPage(0); }, [apiParams]); useEffect(() => { let cancelled = false; // Mount-time name lookups: ask for the server's safety cap so even // long-running deployments get full pill coverage. These are one-shot // fetches; cost computation is bounded by returned rows but agents // don't have it and sessions only compute it on the page-sized list, // so this is cheap. api.sessions .list({ limit: 10000 }) .then(({ sessions }) => { if (cancelled) return; const map = new Map(); const projectMap = new Map(); for (const s of sessions) { // Always populate so the EventDetail panel's "Session" row shows // an identifiable label even for unnamed sessions. Matches the // fallback used by SessionDetail's header. const label = s.name?.trim() || `Session ${s.id.slice(0, 8)}`; map.set(s.id, label); const project = projectFromCwd(s.cwd); if (project) projectMap.set(s.id, project); } setSessionNameById(map); setSessionProjectById(projectMap); }) .catch(() => { // Non-fatal: rows just render without the session name pill. }); api.agents .list({ limit: 10000 }) .then(({ agents }) => { if (cancelled) return; const map = new Map(); 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, }); } setAgentInfoById(map); }) .catch(() => { // Non-fatal: subagent pills fall back to the short-id label. }); return () => { cancelled = true; }; }, []); // Refetches the current page using the latest filter state. Capped at // MAX_REFRESH (server limit); at the default PAGE_SIZE this is a no-op cap. const refreshWithPagination = useCallback(async () => { const size = Math.min(PAGE_SIZE, MAX_REFRESH); try { const { events: data, total: totalCount } = await api.events.list({ ...apiParamsRef.current, limit: size, offset: pageRef.current * PAGE_SIZE, }); setEvents(data); setTotal(totalCount); } catch (err) { // Non-fatal: swallow the error so a flaky websocket burst doesn't // spam unhandled rejections; the next live event / manual refresh // will try again. console.error("Failed to refresh events:", err); } }, []); useEffect(() => { const unsubscribe = eventBus.subscribe((msg) => { // A remote source finished syncing → new remote events may exist; do a // filter-aware refresh (respects pause via refreshWithPagination's guard). if (isRemoteDataRefreshMessage(msg)) { if (!pausedRef.current) refreshWithPagination(); return; } if (msg.type !== "new_event") return; const event = msg.data as DashboardEvent; if (pausedRef.current) { bufferRef.current = [event, ...bufferRef.current]; setBufferCount(bufferRef.current.length); return; } // Debounce bursts into a single filter-aware refresh. if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = null; refreshWithPagination(); }, REFRESH_DEBOUNCE_MS); }); return () => { unsubscribe(); if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null; } }; }, [refreshWithPagination]); function resume() { pausedRef.current = false; bufferRef.current = []; setBufferCount(0); setPaused(false); // Catch-up via filtered refresh so buffered non-matching events don't leak in. refreshWithPagination(); } 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. const projectByEventId = useMemo(() => { const map = new Map(); for (const e of events) map.set(e.id, projectFromEvent(e)); return map; }, [events]); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); return (

{t("title")}

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

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

({ id, label, }))} />
{!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 (
{ if (event.id != null) toggleEvent(event.id); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (event.id != null) toggleEvent(event.id); } }} aria-expanded={isOpen} 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) ?? sessionProjectById.get(event.session_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 === "..." ? ( ... ) : ( ) ); })()}
)} )}
); }