Files
fusion/packages/dashboard/app/components/ActiveAgentsPanel.tsx
gsxdsm 9e7c57da08 FN-7139: show task columns on agent badges
Enrich dashboard agent task indicators with linked task column context.

- Add API-side transient taskColumn enrichment for active agent task links, including an unresolved sentinel for missing tasks.
- Render shared agent task badges across agent panels, detail views, lists, and mobile surfaces.
- Extend dashboard/API coverage and document the task-column badge behavior with a published changeset.

Files changed:
 .changeset/fn-7139-agent-task-column-context.md    |  7 ++
 docs/dashboard-guide.md                            |  2 +
 packages/core/src/types.ts                         |  5 ++
 .../dashboard/app/components/ActiveAgentsPanel.tsx |  3 +-
 .../dashboard/app/components/AgentDetailView.tsx   |  5 +-
 .../dashboard/app/components/AgentListModal.tsx    |  3 +-
 .../dashboard/app/components/AgentTaskBadge.tsx    | 28 +++++++
 packages/dashboard/app/components/AgentsView.tsx   |  3 +-
 .../__tests__/ActiveAgentsPanel.test.tsx           | 55 ++++++++++---
 .../__tests__/AgentDetailView.core.test.tsx        | 65 +++++++++++++++
 .../AgentDetailView.mobile-scroll.test.tsx         | 16 +++-
 .../components/__tests__/AgentListModal.test.tsx   | 16 +++-
 .../app/components/__tests__/AgentsView.test.tsx   | 23 +++++-
 .../dashboard/src/__tests__/routes-agents.test.ts  | 92 +++++++++++++++++++++-
 packages/dashboard/src/routes.ts                   | 13 ++-
 15 files changed, 306 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-7139
Fusion-Task-Lineage: 3f6d9470-b98f-4bd3-a91e-7e69ab624d48
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 17:11:13 -07:00

224 lines
8.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { Activity, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Agent } from "../api";
import type { TaskDetail } from "@fusion/core";
import { fetchTaskDetail } from "../api";
import "./ActiveAgentsPanel.css";
import { useLiveTranscript } from "../hooks/useLiveTranscript";
import { resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals";
import { AgentTaskBadge } from "./AgentTaskBadge";
interface LiveAgentCardProps {
agent: Agent;
projectId?: string;
onSelect?: (agentId: string) => void;
onOpenTaskLogs?: (taskId: string) => void;
}
const TASK_STATUS_POLL_MS = 5000;
function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgentCardProps) {
const { t } = useTranslation("app");
const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId);
const [task, setTask] = useState<TaskDetail | null>(null);
// Poll the agent's task so the empty state can show real run progress
// (current step, executor model) instead of just "Connecting..." while the
// SSE log stream is still warming up.
useEffect(() => {
if (!agent.taskId) {
setTask(null);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const load = async () => {
try {
const data = await fetchTaskDetail(agent.taskId!, projectId);
if (!cancelled) setTask(data);
} catch {
// best-effort; leave previous value in place
} finally {
if (!cancelled) {
timer = setTimeout(load, TASK_STATUS_POLL_MS);
}
}
};
void load();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [agent.taskId, projectId]);
const elapsed = agent.lastHeartbeatAt
? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000)
: 0;
// Compute next heartbeat ETA from last + interval. Negative deltas mean the
// beat is overdue — surface that explicitly rather than rendering a stale
// future time.
const nextHeartbeatLabel = (() => {
if (!agent.lastHeartbeatAt) return null;
const intervalMs = resolveHeartbeatIntervalMs(
(agent.runtimeConfig as { heartbeatIntervalMs?: number } | undefined)?.heartbeatIntervalMs,
);
const nextMs = new Date(agent.lastHeartbeatAt).getTime() + intervalMs;
const deltaSec = Math.round((nextMs - Date.now()) / 1000);
if (!Number.isFinite(deltaSec)) return null;
if (deltaSec <= 0) return t("agents.heartbeatOverdue", "Heartbeat overdue {{elapsed}}", { elapsed: formatElapsed(-deltaSec) });
return t("agents.nextHeartbeat", "Next heartbeat in {{elapsed}}", { elapsed: formatElapsed(deltaSec) });
})();
const currentStep = task?.steps?.[task.currentStep ?? 0];
const totalSteps = task?.steps?.length ?? 0;
const stepNumber = (task?.currentStep ?? 0) + 1;
const executorModel = task?.modelId;
const handleSelect = () => {
if (onSelect) {
onSelect(agent.id);
}
};
const handleViewLogs = (e: React.MouseEvent) => {
e.stopPropagation();
if (agent.taskId && onOpenTaskLogs) {
onOpenTaskLogs(agent.taskId);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
return (
<div
className="live-agent-card"
onClick={handleSelect}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={t("agents.selectAgent", "Select agent {{name}}", { name: agent.name })}
>
<div className="live-agent-card-header">
<div className="live-agent-card-name">
<span
className={`status-dot ${agent.state === "running" ? "status-dot--pending" : "status-dot--online"}`}
aria-hidden="true"
/>
<span>{agent.name}</span>
</div>
{agent.taskId && (
<span className="live-agent-task badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
)}
</div>
<div className="live-agent-card-transcript">
{entries.length === 0 ? (
<div className="live-agent-card-empty">
{!agent.taskId ? (
// "active" agents that aren't currently working a task have no
// SSE stream to attach to; useLiveTranscript bails out with
// isConnected=false. Showing "Connecting..." here is misleading
// — the agent is just idle.
<span>{agent.state === "running" ? t("agents.starting", "Starting...") : t("agents.idleNoTask", "Idle — no task assigned")}</span>
) : currentStep ? (
<>
<div className="live-agent-card-status">
{t("agents.step", "Step {{number}}{{total}}: {{name}}", { number: stepNumber, total: totalSteps ? `/${totalSteps}` : "", name: currentStep.name })}
</div>
{executorModel && (
<div className="live-agent-card-status-sub">
{executorModel}
</div>
)}
<div className="live-agent-card-status-sub">
{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connectingStream", "Connecting to log stream...")}
</div>
</>
) : (
<span>{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connecting", "Connecting...")}</span>
)}
</div>
) : (
entries.slice(0, 20).map((entry, i) => (
<div key={i} className="live-agent-card-line">
{entry.text}
</div>
))
)}
</div>
<div className="live-agent-card-footer">
<div className="live-agent-card-footer-meta">
<span className="text-secondary" title={t("agents.timeSinceLastHeartbeat", "Time since last heartbeat")}>
{formatElapsed(elapsed)}
</span>
{nextHeartbeatLabel && (
<span className="live-agent-card-next-heartbeat" title={nextHeartbeatLabel}>
{nextHeartbeatLabel}
</span>
)}
</div>
<div className="live-agent-card-footer-actions">
{agent.taskId && onOpenTaskLogs && (
<button
type="button"
className="live-agent-card-logs-btn"
onClick={handleViewLogs}
title={t("agents.viewLiveLogs", "View live run logs")}
aria-label={t("agents.viewLogsFor", "View live logs for {{taskId}}", { taskId: agent.taskId })}
>
<FileText size={12} />
<span>{t("agents.liveLogs", "Live logs")}</span>
</button>
)}
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
</div>
</div>
</div>
);
}
function formatElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
interface ActiveAgentsPanelProps {
agents: Agent[];
projectId?: string;
onAgentSelect?: (agentId: string) => void;
onOpenTaskLogs?: (taskId: string) => void;
className?: string;
}
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs, className = "" }: ActiveAgentsPanelProps) {
const { t } = useTranslation("app");
// Dedupe by id defensively. The store should return unique agents but a race
// between the initial fetch and an SSE refresh can briefly surface the same
// agent twice — without this guard React floods the console with duplicate
// key warnings (which previously snowballed into OOM).
const uniqueAgents = Array.from(new Map(agents.map((a) => [a.id, a])).values());
if (uniqueAgents.length === 0) return null;
return (
<div className={`active-agents-panel ${className}`.trim()}>
<div className="active-agents-panel-header">
<Activity size={16} />
<span>{t("agents.activeAgents", "Active Agents ({{count}})", { count: uniqueAgents.length })}</span>
</div>
<div className="active-agents-grid">
{uniqueAgents.map(agent => (
<LiveAgentCard key={agent.id} agent={agent} projectId={projectId} onSelect={onAgentSelect} onOpenTaskLogs={onOpenTaskLogs} />
))}
</div>
</div>
);
}