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
This commit is contained in:
13
.changeset/active-agents-live-status-and-logs.md
Normal file
13
.changeset/active-agents-live-status-and-logs.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
"runfusion.ai": patch
|
||||
"@fusion/core": patch
|
||||
"@fusion/dashboard": patch
|
||||
"@fusion/desktop": patch
|
||||
"@fusion/engine": patch
|
||||
"@fusion/mobile": patch
|
||||
"@fusion/pi-claude-cli": patch
|
||||
"@fusion/plugin-sdk": patch
|
||||
---
|
||||
|
||||
Surface live run status on Active Agent cards instead of a generic "Connecting…" placeholder. The card now polls the agent's task and shows the current step (e.g. *"Step 5/8: Write Tests"*) and executor model while the SSE log stream warms up. A new "Live logs" button on the card opens the task detail modal directly on the Logs tab.
|
||||
@@ -48,7 +48,7 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchUnreadCount, reportDashboardPerf } from "./api";
|
||||
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
import { subscribeSse } from "./sse-bus";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||
@@ -478,6 +478,15 @@ function AppInner() {
|
||||
modalManager.openDetailTask(task, initialTab);
|
||||
}, [modalManager]);
|
||||
|
||||
const handleOpenTaskLogs = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const task = await fetchTaskDetail(taskId, currentProject?.id);
|
||||
modalManager.openDetailTask(task, "logs");
|
||||
} catch (err) {
|
||||
addToast(`Failed to open task logs: ${(err as Error).message}`, "error");
|
||||
}
|
||||
}, [modalManager, currentProject?.id, addToast]);
|
||||
|
||||
const handleOpenNodes = useCallback(() => {
|
||||
if (!nodesEnabled) return;
|
||||
setNodesOpen((prev) => !prev);
|
||||
@@ -667,7 +676,7 @@ function AppInner() {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<AgentsView addToast={addToast} projectId={currentProject?.id} />
|
||||
<AgentsView addToast={addToast} projectId={currentProject?.id} onOpenTaskLogs={handleOpenTaskLogs} />
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,27 @@
|
||||
}
|
||||
|
||||
.live-agent-card-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.live-agent-card-status {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live-agent-card-status-sub {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live-agent-card-line {
|
||||
@@ -91,6 +110,32 @@
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.live-agent-card-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.live-agent-card-logs-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.live-agent-card-logs-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong, var(--border));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.active-agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Activity } from "lucide-react";
|
||||
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";
|
||||
|
||||
@@ -7,20 +10,66 @@ interface LiveAgentCardProps {
|
||||
agent: Agent;
|
||||
projectId?: string;
|
||||
onSelect?: (agentId: string) => void;
|
||||
onOpenTaskLogs?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function LiveAgentCard({ agent, projectId, onSelect }: LiveAgentCardProps) {
|
||||
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();
|
||||
@@ -49,7 +98,24 @@ function LiveAgentCard({ agent, projectId, onSelect }: LiveAgentCardProps) {
|
||||
<div className="live-agent-card-transcript">
|
||||
{entries.length === 0 ? (
|
||||
<div className="live-agent-card-empty">
|
||||
{isConnected ? "Waiting for output..." : "Connecting..."}
|
||||
{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) => (
|
||||
@@ -61,7 +127,21 @@ function LiveAgentCard({ agent, projectId, onSelect }: LiveAgentCardProps) {
|
||||
</div>
|
||||
<div className="live-agent-card-footer">
|
||||
<span className="text-secondary">{formatElapsed(elapsed)}</span>
|
||||
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
|
||||
<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>
|
||||
);
|
||||
@@ -77,9 +157,10 @@ interface ActiveAgentsPanelProps {
|
||||
agents: Agent[];
|
||||
projectId?: string;
|
||||
onAgentSelect?: (agentId: string) => void;
|
||||
onOpenTaskLogs?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect }: ActiveAgentsPanelProps) {
|
||||
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
|
||||
@@ -96,7 +177,7 @@ export function ActiveAgentsPanel({ agents, projectId, onAgentSelect }: ActiveAg
|
||||
</div>
|
||||
<div className="active-agents-grid">
|
||||
{uniqueAgents.map(agent => (
|
||||
<LiveAgentCard key={agent.id} agent={agent} projectId={projectId} onSelect={onAgentSelect} />
|
||||
<LiveAgentCard key={agent.id} agent={agent} projectId={projectId} onSelect={onAgentSelect} onOpenTaskLogs={onOpenTaskLogs} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
projectId?: string;
|
||||
onOpenTaskLogs?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
@@ -257,7 +258,7 @@ function OrgChartNode({
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewProps) {
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
|
||||
@@ -1314,7 +1315,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
)}
|
||||
|
||||
{/* Secondary sections after the main collection */}
|
||||
<ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||
<ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} onOpenTaskLogs={onOpenTaskLogs} />
|
||||
</div>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
|
||||
Reference in New Issue
Block a user