Files
fusion/packages/dashboard/app/hooks/agentActivityStore.ts
gsxdsm 234430a978 FN-8865: show live agent activity in org charts
Expose live per-agent activity throughout the dashboard org chart and active-agent cards.

- Add an activity store with polling, visibility refresh, and reduced-motion support.

- Render activity labels and active indicators in org-chart nodes and agent panels.

- Cover activity normalization, polling behavior, and dashboard presentation with tests.

Files changed:

 .changeset/fn-8865-live-agent-activity.md          |   7 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/api.ts                      |  11 +-
 .../dashboard/app/components/ActiveAgentsPanel.css |  20 ++
 .../dashboard/app/components/ActiveAgentsPanel.tsx |  28 ++-
 packages/dashboard/app/components/AgentsView.css   |  39 ++++
 packages/dashboard/app/components/AgentsView.tsx   |  62 +++++-
 .../__tests__/ActiveAgentsPanel.test.tsx           | 122 ++++++++++-
 .../AgentsView.orgchart.activity.test.tsx          | 235 +++++++++++++++++++++
 .../__tests__/AgentsView.orgchart.test.tsx         |   7 +
 .../__tests__/agentsOrgChartActivity.test.ts       | 137 ++++++++++++
 .../app/components/agentsOrgChartActivity.ts       | 156 ++++++++++++++
 .../app/hooks/__tests__/agentActivityStore.test.ts | 179 ++++++++++++++++
 .../app/hooks/__tests__/useAgentActivity.test.tsx  |  60 ++++++
 packages/dashboard/app/hooks/agentActivityStore.ts | 221 +++++++++++++++++++
 packages/dashboard/app/hooks/useAgentActivity.ts   |  18 ++
 packages/dashboard/app/hooks/useReducedMotion.ts   |  23 ++
 17 files changed, 1307 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-8865

Fusion-Task-Lineage: 04413da2-4114-4a19-9b6b-152f12c95ac6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-09 17:10:49 -07:00

222 lines
8.3 KiB
TypeScript

import { getAgentActivity, type AgentActivityEvent, type AgentActivitySseFrame } from "../api";
import { subscribeSse } from "../sse-bus";
import {
ACTIVE_STATE_WINDOW_MS,
compareActivityEvents,
FLOW_EDGE_WINDOW_MS,
getActivityEventAgeMs,
parseActivityOccurredAt,
withActivityWindowTimestamp,
} from "../components/agentsOrgChartActivity";
export const ACTIVITY_EVENT_CAP = 200;
export const ACTIVITY_EXPIRY_TICK_MS = 1_000;
export interface AgentActivitySnapshot {
events: readonly AgentActivityEvent[];
activityByAgentId: ReadonlyMap<string, AgentActivityEvent>;
nowTick: number;
}
/*
FNXC:AgentActivityStore 2026-08-09-21:45:
AgentsView nests ActiveAgentsPanel, so independent hooks would double-fetch and create duplicate timers even though sse-bus already multiplexes sockets. This module singleton owns one seed, subscription, and expiry clock for every consumer.
The expiry interval is deliberately local clock work, not network polling: it publishes time decay so stale activity and flow affordances disappear without another request. Stop it when no retained event can still change.
*/
class AgentActivityStore {
private listeners = new Set<() => void>();
private retainers = new Map<string, string | undefined>();
private events: AgentActivityEvent[] = [];
private activityByAgentId = new Map<string, AgentActivityEvent>();
private seenIds = new Set<string>();
private unsubscribe: (() => void) | null = null;
private abortController: AbortController | null = null;
private expiryTimer: ReturnType<typeof setInterval> | null = null;
private projectId: string | undefined;
private contextVersion = 0;
private closeVersion = 0;
private snapshot: AgentActivitySnapshot = { events: [], activityByAgentId: new Map(), nowTick: Date.now() };
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): AgentActivitySnapshot => this.snapshot;
retain(hookId: string, projectId?: string): void {
const previousProject = this.projectId;
this.retainers.set(hookId, projectId);
this.closeVersion++;
// A project change may happen on the sole retained hook after its effect cleanup.
// Clear synchronously before opening so a new project never paints old activity.
if (previousProject !== projectId) {
this.switchProject(projectId);
return;
}
if (this.retainers.size === 1) {
if (this.unsubscribe) return;
this.open(projectId);
}
}
release(hookId: string): void {
this.retainers.delete(hookId);
if (this.retainers.size !== 0) return;
// StrictMode replays effect cleanup/setup in the same microtask. Defer the final
// close so a replay reuses the one stream instead of issuing a duplicate seed.
const closeVersion = ++this.closeVersion;
queueMicrotask(() => {
if (this.retainers.size === 0 && this.closeVersion === closeVersion) this.close();
});
}
private switchProject(projectId?: string): void {
this.closeTransport();
this.projectId = projectId;
this.contextVersion++;
this.events = [];
this.activityByAgentId.clear();
this.seenIds.clear();
this.publish();
this.openTransport();
}
private open(projectId?: string): void {
this.projectId = projectId;
this.contextVersion++;
this.openTransport();
}
private openTransport(): void {
const version = this.contextVersion;
const query = this.projectId ? `?projectId=${encodeURIComponent(this.projectId)}` : "";
const projectId = this.projectId;
this.unsubscribe = subscribeSse(`/api/events${query}`, {
events: { "agent:activity": (event) => this.handleFrame(event, version, projectId) },
onReconnect: () => this.seed(version, projectId),
});
void this.seed(version, projectId);
}
private closeTransport(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.abortController?.abort();
this.abortController = null;
this.stopExpiryClock();
}
private close(): void {
this.closeTransport();
}
private async seed(version: number, projectId: string | undefined): Promise<void> {
this.abortController?.abort();
const controller = new AbortController();
this.abortController = controller;
try {
const page = await getAgentActivity({ limit: ACTIVITY_EVENT_CAP, projectId }, { signal: controller.signal });
if (controller.signal.aborted || version !== this.contextVersion || projectId !== this.projectId) return;
let changed = false;
for (const event of page.events) changed = this.mergeEvent(event, projectId) || changed;
if (changed) this.publish();
this.updateExpiryClock();
} catch {
// A failed seed is intentionally no-data; the already-open stream remains live.
} finally {
if (this.abortController === controller) this.abortController = null;
}
}
private handleFrame(message: MessageEvent, version: number, projectId: string | undefined): void {
if (version !== this.contextVersion || projectId !== this.projectId) return;
try {
const frame = JSON.parse(String(message.data)) as AgentActivitySseFrame;
if ("truncated" in frame) {
void this.seed(version, projectId);
return;
}
if (this.mergeEvent(frame, projectId)) this.publish();
this.updateExpiryClock();
} catch {
// Ignore malformed SSE frames.
}
}
private mergeEvent(event: AgentActivityEvent, projectId: string | undefined): boolean {
// The event URL is project-scoped, but validate frames and seed rows as a defense-in-depth
// boundary: a stale or misrouted project frame must never populate a retained snapshot.
if (projectId !== undefined && event?.projectId !== projectId) return false;
if (!event || typeof event.eventId !== "string" || this.seenIds.has(event.eventId) || parseActivityOccurredAt(event.occurredAt) === null) return false;
this.seenIds.add(event.eventId);
this.events.push(withActivityWindowTimestamp(event, Date.now()));
// Comparator order is newest-first; remove the tail so backfills cannot evict fresh activity.
this.events.sort(compareActivityEvents);
if (this.events.length > ACTIVITY_EVENT_CAP) this.events.pop();
/*
FNXC:AgentActivityRetention 2026-08-09-22:00:
The latest-by-agent map must describe the bounded retained ring, not an unbounded
historical cache. Rebuild it after oldest-key eviction so an evicted event cannot
leave a node falsely active or idle; retain seen ids for this stream lifetime so
a replay of that evicted event remains an idempotent no-op.
*/
this.activityByAgentId.clear();
for (const retained of this.events) {
const current = this.activityByAgentId.get(retained.agentId);
if (!current || compareActivityEvents(retained, current) < 0) {
this.activityByAgentId.set(retained.agentId, retained);
}
}
return true;
}
private canExpire(now: number): boolean {
return this.events.some((event) => {
const age = getActivityEventAgeMs(event, now);
return age !== null && (age < ACTIVE_STATE_WINDOW_MS || age < FLOW_EDGE_WINDOW_MS);
});
}
private updateExpiryClock(): void {
if (this.retainers.size === 0 || !this.canExpire(Date.now()) || this.expiryTimer) return;
this.expiryTimer = setInterval(() => {
const now = Date.now();
this.publish(now);
if (!this.canExpire(now)) this.stopExpiryClock();
}, ACTIVITY_EXPIRY_TICK_MS);
}
private stopExpiryClock(): void {
if (!this.expiryTimer) return;
clearInterval(this.expiryTimer);
this.expiryTimer = null;
}
private publish(nowTick = Date.now()): void {
this.snapshot = { events: [...this.events], activityByAgentId: new Map(this.activityByAgentId), nowTick };
for (const listener of this.listeners) listener();
}
resetForTests(): void {
this.closeTransport();
this.listeners.clear();
this.retainers.clear();
this.events = [];
this.activityByAgentId.clear();
this.seenIds.clear();
this.projectId = undefined;
this.contextVersion = 0;
this.closeVersion++;
this.snapshot = { events: [], activityByAgentId: new Map(), nowTick: Date.now() };
}
}
export const agentActivityStore = new AgentActivityStore();
export function __resetAgentActivityStoreForTests(): void { agentActivityStore.resetForTests(); }