Files
AgentHub/client/src/pages/ActivityFeed.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

590 lines
25 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## 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.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/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<DashboardEvent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
const [paused, setPaused] = useState(false);
const [loading, setLoading] = useState(true);
const [bufferCount, setBufferCount] = useState(0);
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => 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<Map<string, string>>(() => 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<Map<string, string>>(
() => 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<Map<string, AgentInfo>>(() => new Map());
const bufferRef = useRef<DashboardEvent[]>([]);
const pausedRef = useRef(paused);
// Refs let the websocket handler read the latest filter/page without
// re-subscribing on every change.
const apiParamsRef = useRef<Record<string, unknown>>({});
const pageRef = useRef(0);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string>([...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<string, string>();
const projectMap = new Map<string, string>();
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<string, AgentInfo>();
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<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
return (
<div className="animate-fade-in">
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Activity className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => (paused ? resume() : setPaused(true))} className="btn-ghost">
{paused ? (
<>
<Play className="w-4 h-4" /> {t("resume")}
</>
) : (
<>
<Pause className="w-4 h-4" /> {t("pause")}
</>
)}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="mb-3">
<EventFiltersInfo />
</div>
<div className="mb-4">
<EventFilters
value={filters}
onChange={setFilters}
sessionOptions={Array.from(sessionNameById.entries()).map(([id, label]) => ({
id,
label,
}))}
/>
</div>
{!loading && events.length === 0 ? (
<EmptyState
icon={Activity}
title={isEmptyFilters(filters) ? t("noActivity") : t("common:eventFilters.noResults")}
description={isEmptyFilters(filters) ? t("noActivityDesc") : ""}
/>
) : (
<>
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[calc(100vh-260px)] min-h-[560px] overflow-y-auto overflow-x-auto">
{loading && events.length === 0
? Array.from({ length: 12 }).map((_, i) => (
<div
key={`sk-${i}`}
className="flex items-center px-5 py-3.5 gap-4"
aria-busy="true"
>
<Skeleton className="w-3.5 h-3.5" rounded="sm" />
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-16" rounded="full" />
<Skeleton className="h-3 w-48 flex-shrink-0" />
<Skeleton className="h-3 flex-1" />
</div>
))
: null}
{events.map((event, i) => {
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={event.id ?? i} className="animate-slide-up">
<div
role="button"
tabIndex={0}
onClick={() => {
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"
>
<ChevronRight
className={`w-3.5 h-3.5 text-fg-muted transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
/>
<div className="w-16 flex-shrink-0 text-right font-mono leading-tight">
<div className="text-[11px] text-fg-muted">
{formatTime(event.created_at)}
</div>
<div className="text-[9px] text-fg-muted">
{formatDateShort(event.created_at)}
</div>
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
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 (
<div className="flex-1 min-w-0">
<p className="text-sm text-fg-secondary truncate">
{origin && (
<span
className="text-fg-muted mr-1"
title={`${event.session_id} · ${event.agent_id ?? ""}`}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</p>
</div>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono flex-shrink-0">
{event.tool_name}
</span>
)}
<span className="text-[11px] text-fg-muted flex-shrink-0 w-16 text-right">
{timeAgo(event.created_at)}
</span>
<Link
to={`/sessions/${event.session_id}`}
onClick={(e) => 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")}
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{isOpen && <EventDetail event={event} />}
</div>
);
})}
</div>
</div>
{total > 0 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-fg-muted">
{t("common:pagination.showing", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="First page"
>
«
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{t("common:pagination.previous")}
</button>
{(() => {
// 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 === "..." ? (
<span
key={`ellipsis-${idx}`}
className="px-2 py-1.5 text-xs text-fg-muted select-none"
>
...
</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
aria-current={p === page ? "page" : undefined}
className={`min-w-[32px] px-2.5 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-colors ${
p === page
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`}
>
{p + 1}
</button>
)
);
})()}
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{t("common:pagination.next")}
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="Last page"
>
»
</button>
</div>
</div>
)}
</>
)}
</div>
);
}