Files
fusion/packages/dashboard/app/components/ActiveAgentsPanel.tsx
Fusion 48208db9b0 feat(FN-2926): merge fusion/fn-2926
- Chat grouped tool calls refactored out of `ChatToolCalls.tsx` into inline rendering in `ChatView.tsx`, with updated styling and new tests; summary/expand logic for grouped calls updated
- New `QuickChatFAB` component added with styles, replacing the old `ChatToolCalls` inline callout with a persistent quick-access button
- Settings modal enhanced with agent runtime routes (register/unregister, run management) and memory settings routes (export, update limits)
- Remote access provider adapters updated to support real QR codes and live Tailscale URL sharing in TUI dashboard
- TUI dashboard app/controller/state updated to surface remote node status and shortcut hints
- `AgentsView` component updated with new layout and styling, plus comprehensive tests
- Route registration and agent runs tests updated to match new runtime API shape
- `surface-hover` fallback assertion aligned across tests
- Bundled dependency updates in `pnpm-lock.yaml`

Commits merged:
- test(FN-2926): align surface-hover fallback assertion
- feat(FN-2926): complete Steps 2-4 — inline grouped tool-call rendering and tests
- feat(FN-2926): complete Step 3 — refine grouped tool-call styles
- feat(FN-2926): complete Step 1 — update grouped tool call summary logic
- feat(remote): real QR codes, live tailscale URL, TUI status & shortcut
- feat(FN-2931): merge fusion/fn-2931
- feat(FN-2925): merge fusion/fn-2925
- feat(FN-2903): merge fusion/fn-2903

Files changed:
.../commands/dashboard-tui/__tests__/app.test.tsx  |   9 +-
 packages/cli/src/commands/dashboard-tui/app.tsx    |  97 +++++++++++--
 .../cli/src/commands/dashboard-tui/controller.ts   |  31 ++++
 packages/cli/src/commands/dashboard-tui/state.ts   |   9 +-
 packages/cli/src/commands/dashboard.ts             |   3 +-
 .../app/__tests__/status-colors-theme.test.ts      |   4 +-
 packages/dashboard/app/components/AgentsView.css   |  20 ++-
 packages/dashboard/app/components/AgentsView.tsx   |  11 +-
 .../dashboard/app/components/ChatToolCalls.tsx     | 160 ---------------------
 packages/dashboard/app/components/ChatView.css     |  53 +++----
 packages/dashboard/app/components/ChatView.tsx     | 131 ++++++++++++++++-
 packages/dashboard/app/components/QuickChatFAB.css |  87 ++++++-----
 packages/dashboard/app/components/QuickChatFAB.tsx | 124 +++++++++++++++-
 .../dashboard/app/components/SettingsModal.css     |  21 +++
 .../dashboard/app/components/SettingsModal.tsx     |  72 +++++++++-
 .../app/components/__tests__/AgentsView.test.tsx   |  37 +++++
 .../app/components/__tests__/ChatView.test.tsx     |  83 +++++++++--
 .../app/components/__tests__/QuickChatFAB.test.tsx |  68 ++++++---
 .../components/__tests__/SettingsModal.test.tsx    |  12 +-
 packages/dashboard/package.json                    |   2 +
 .../src/__tests__/routes-agent-runs.test.ts        |  13 +-
 .../src/routes/register-agent-runtime-routes.ts    |  49 ++++---
 .../src/routes/register-settings-memory-routes.ts  |  36 ++++-
 packages/engine/src/project-engine.ts              |   4 +-
 .../engine/src/remote-access/provider-adapters.ts  |  14 +-
 pnpm-lock.yaml                                     | 113 +++++++++++++++
 26 files changed, 933 insertions(+), 330 deletions(-)

Fusion-Task-Id: FN-2926
2026-04-29 13:26:25 -07:00

186 lines
6.0 KiB
TypeScript

import { useEffect, useState } from "react";
import { Activity, FileText } from "lucide-react";
import type { Agent } from "../api";
import type { TaskDetail } from "@fusion/core";
import { fetchTaskDetail } from "../api";
import "./ActiveAgentsPanel.css";
import { useLiveTranscript } from "../hooks/useLiveTranscript";
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 { 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;
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={`Select agent ${agent.name}`}
>
<div className="live-agent-card-header">
<div className="live-agent-card-name">
<span className="live-agent-pulse" />
<span>{agent.name}</span>
</div>
{agent.taskId && (
<span className="live-agent-task badge">{agent.taskId}</span>
)}
</div>
<div className="live-agent-card-transcript">
{entries.length === 0 ? (
<div className="live-agent-card-empty">
{currentStep ? (
<>
<div className="live-agent-card-status">
Step {stepNumber}
{totalSteps ? `/${totalSteps}` : ""}: {currentStep.name}
</div>
{executorModel && (
<div className="live-agent-card-status-sub">
{executorModel}
</div>
)}
<div className="live-agent-card-status-sub">
{isConnected ? "Waiting for output..." : "Connecting to log stream..."}
</div>
</>
) : (
<span>{isConnected ? "Waiting for output..." : "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">
<span className="text-secondary">{formatElapsed(elapsed)}</span>
<div className="live-agent-card-footer-actions">
{agent.taskId && onOpenTaskLogs && (
<button
type="button"
className="live-agent-card-logs-btn"
onClick={handleViewLogs}
title="View live run logs"
aria-label={`View live logs for ${agent.taskId}`}
>
<FileText size={12} />
<span>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;
}
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs }: ActiveAgentsPanelProps) {
// 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">
<div className="active-agents-panel-header">
<Activity size={16} />
<span>Active Agents ({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>
);
}