Files
fusion/packages/dashboard/app/components/command-center/agentActivityCursor.ts
gsxdsm aac090d0c3 FN-8866: add Command Center agent activity timeline
Add a Command Center live agent activity log with historical navigation.

- Stream and paginate organization-wide agent activity with timeline filters.
- Link activity rows to agent details and task views while reusing feed presentation.
- Add localized copy, documentation, a minor changeset, and coverage for live and scroll-back behavior.

Files changed:
 .changeset/fn-8866-agent-activity-surfaces.md      |   7 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/App.tsx                     |  12 ++
 packages/dashboard/app/components/ActivityFeed.tsx |  77 ++++----
 packages/dashboard/app/components/AgentsView.tsx   |  13 +-
 .../__tests__/AgentsView.focus-agent.test.tsx      |  59 ++++++
 .../command-center/AgentActivityPanel.css          |  98 +++++++++
 .../command-center/AgentActivityPanel.tsx          |  99 ++++++++++
 .../components/command-center/CommandCenter.tsx    |   9 +
 .../__tests__/AgentActivityPanel.mobile.test.ts    |  15 ++
 .../AgentActivityPanel.sse-integration.test.tsx    |  96 +++++++++
 .../__tests__/AgentActivityPanel.test.tsx          | 121 ++++++++++++
 .../__tests__/CommandCenter.test.tsx               |  27 ++-
 .../__tests__/agentActivityCursor.test.ts          |  47 +++++
 .../__tests__/useAgentActivity.test.tsx            |  14 ++
 .../command-center/agentActivityCursor.ts          |  75 +++++++
 .../command-center/agentActivityPresentation.tsx   |   8 +
 .../components/command-center/useAgentActivity.ts  | 218 +++++++++++++++++++++
 .../app/components/dashboard/MainContent.tsx       |  19 ++
 .../MainContent.agent-activity-navigation.test.tsx | 127 ++++++++++++
 .../dashboard/app/components/dashboard/types.ts    |   3 +
 packages/i18n/locales/en/app.json                  |  30 ++-
 packages/i18n/locales/es/app.json                  |  30 ++-
 packages/i18n/locales/fr/app.json                  |  30 ++-
 packages/i18n/locales/ko/app.json                  |  30 ++-
 packages/i18n/locales/pt-BR/app.json               |  30 ++-
 packages/i18n/locales/zh-CN/app.json               |  30 ++-
 packages/i18n/locales/zh-TW/app.json               |  30 ++-
 28 files changed, 1312 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-8866

Fusion-Task-Lineage: 817ae76e-52c6-4221-8642-df757e773627

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-09 20:19:52 -07:00

76 lines
3.7 KiB
TypeScript

import type { AgentActivityEvent, AgentActivityPage } from "../../api";
/*
FNXC:CommandCenterAgentActivity 2026-08-10-01:13:
The agent-activity route orders unique per-project `seq` values descending and applies `before` exclusively,
returning `{ events, nextCursor }`. Echoing that cursor is therefore exact: offset and timestamp tiebreakers
would only weaken this total order. Sequences are decimal strings and may exceed Number's safe range, so all
ordering uses BigInt. The no-progress counter is only a bounded backstop for a regressed server contract.
*/
/** Bounded page size sent as `limit`; must stay <= the server hard max of 1000. */
export const AGENT_ACTIVITY_PAGE_SIZE = 50;
/** Bounded retry backstop for a server whose cursor does not advance. */
export const MAX_CONSECUTIVE_NO_PROGRESS_PAGES = 3;
export type PageExhaustionReason = "end-of-history" | "no-progress" | "cursor-rewind" | "attempt-cap";
export interface ResolvePageInput {
requestedCursor: string | null;
existing: readonly AgentActivityEvent[];
page: AgentActivityPage;
consecutiveNoProgress: number;
}
export interface ResolvePageOutcome {
merged: AgentActivityEvent[];
nextCursor: string | null;
hasMore: boolean;
reason: PageExhaustionReason | null;
consecutiveNoProgress: number;
}
/** Numeric-safe descending compare on decimal `seq` strings. */
export function compareAgentActivityDesc(a: AgentActivityEvent, b: AgentActivityEvent): number {
const left = BigInt(a.seq);
const right = BigInt(b.seq);
return left === right ? 0 : left > right ? -1 : 1;
}
/** eventId-keyed dedupe where the newest incoming representation wins. */
export function mergeAgentActivity(existing: readonly AgentActivityEvent[], incoming: readonly AgentActivityEvent[]): AgentActivityEvent[] {
const rows = new Map<string, AgentActivityEvent>();
for (const event of existing) rows.set(event.eventId, event);
for (const event of incoming) rows.set(event.eventId, event);
return [...rows.values()].sort(compareAgentActivityDesc);
}
export function isStrictlyOlderCursor(candidate: string, requested: string | null): boolean {
return requested === null || BigInt(candidate) < BigInt(requested);
}
/** The sole owner of the HTTP continuation parameter name. */
export function cursorToQuery(cursor: string | null): Record<string, string> {
return cursor === null ? {} : { before: cursor };
}
export function isTruncationFrame(frame: unknown): frame is { truncated: true; fromSeq: string; toSeq: string } {
return typeof frame === "object" && frame !== null && (frame as { truncated?: unknown }).truncated === true;
}
export function resolvePageOutcome(input: ResolvePageInput): ResolvePageOutcome {
const { requestedCursor, existing, page, consecutiveNoProgress } = input;
const merged = mergeAgentActivity(existing, page.events);
const newRowCount = merged.length - existing.length;
if (page.nextCursor === null) return { merged, nextCursor: null, hasMore: false, reason: "end-of-history", consecutiveNoProgress: 0 };
const nextCount = consecutiveNoProgress + 1;
const cursorAdvances = isStrictlyOlderCursor(page.nextCursor, requestedCursor);
if ((newRowCount === 0 || !cursorAdvances) && nextCount >= MAX_CONSECUTIVE_NO_PROGRESS_PAGES) {
return { merged, nextCursor: requestedCursor, hasMore: false, reason: "attempt-cap", consecutiveNoProgress: nextCount };
}
if (newRowCount === 0) return { merged, nextCursor: page.nextCursor, hasMore: true, reason: "no-progress", consecutiveNoProgress: nextCount };
if (!cursorAdvances) return { merged, nextCursor: requestedCursor, hasMore: true, reason: "cursor-rewind", consecutiveNoProgress: nextCount };
return { merged, nextCursor: page.nextCursor, hasMore: true, reason: null, consecutiveNoProgress: 0 };
}