feat(FN-3530): add modal-based agent error details and UI/docs updates
- Add AgentErrorDetailsModal and wire agent list/detail views to open full error diagnostics while keeping inline errors compact - Add line-number gutter toggle in FileEditor and Files modal with dashboard persistence coverage - Align project memory scope guidance across core logic, CLI tool docs, and dashboard docs - Add regression coverage for runtime plugin alias behavior and update related engine/heartbeat tests - Include changesets for FN-3530 UI improvements and FN-3485 memory guidance updates Fusion-Task-Id: FN-3530
This commit is contained in:
5
.changeset/fn-3530-agent-error-modal.md
Normal file
5
.changeset/fn-3530-agent-error-modal.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve dashboard agent error UX by replacing inline stack-trace dumps with compact error indicators that open a shared details modal, including copy-to-clipboard and a prefilled GitHub issue shortcut.
|
||||||
@@ -264,6 +264,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
|
|||||||
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
|
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
|
||||||
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
|
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
|
||||||
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency
|
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency
|
||||||
|
- **Agent Error Details**: Agent collection views now show a compact inline error indicator (instead of raw stack traces) that opens a shared error-details modal with full text, copy action, and a prefilled "Report on GitHub" shortcut
|
||||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. In list view, each agent card also provides an explicit **View Details** action button in the card actions row for clearer discoverability, while the existing clickable identity/header area remains supported. The modal features a compact header with clear visual hierarchy:
|
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. In list view, each agent card also provides an explicit **View Details** action button in the card actions row for clearer discoverability, while the existing clickable identity/header area remains supported. The modal features a compact header with clear visual hierarchy:
|
||||||
- **Identity area** (left): Agent icon, name, and state/health badges
|
- **Identity area** (left): Agent icon, name, and state/health badges
|
||||||
- **Lifecycle controls** (center): Compact action buttons for state transitions (Start, Pause, Resume, Retry, Stop, Delete)
|
- **Lifecycle controls** (center): Compact action buttons for state transitions (Start, Pause, Resume, Retry, Stop, Delete)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useConfirm } from "../hooks/useConfirm";
|
|||||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
import { AgentImportModal } from "./AgentImportModal";
|
import { AgentImportModal } from "./AgentImportModal";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple className utility - joins class names conditionally
|
* Simple className utility - joins class names conditionally
|
||||||
@@ -1511,7 +1512,19 @@ function RunsTab({
|
|||||||
{detailRun.stderrExcerpt && (
|
{detailRun.stderrExcerpt && (
|
||||||
<div className="run-output-section">
|
<div className="run-output-section">
|
||||||
<div className="run-output-label run-output-label--error">Errors</div>
|
<div className="run-output-label run-output-label--error">Errors</div>
|
||||||
<pre className="run-output-panel run-output-panel--error">{detailRun.stderrExcerpt}</pre>
|
<AgentErrorIndicator
|
||||||
|
errorText={detailRun.stderrExcerpt}
|
||||||
|
summaryPrefix="Run error"
|
||||||
|
issueContext={{
|
||||||
|
surface: "AgentDetailView runs",
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
agentState,
|
||||||
|
runId: detailRun.id,
|
||||||
|
taskId: undefined,
|
||||||
|
timestamp: detailRun.startedAt,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
51
packages/dashboard/app/components/AgentErrorDetailsModal.css
Normal file
51
packages/dashboard/app/components/AgentErrorDetailsModal.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.agent-error-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
width: 100%;
|
||||||
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 30%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
color: var(--color-error);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-error-indicator__label {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-error-modal {
|
||||||
|
max-width: min(calc(var(--space-2xl) * 20), 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-error-modal__content {
|
||||||
|
padding: 0 var(--space-lg) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-error-modal__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: var(--btn-border-width) solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--space-md);
|
||||||
|
max-height: calc(var(--space-2xl) * 8);
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.agent-error-modal {
|
||||||
|
width: calc(100% - var(--space-lg));
|
||||||
|
}
|
||||||
|
}
|
||||||
124
packages/dashboard/app/components/AgentErrorDetailsModal.tsx
Normal file
124
packages/dashboard/app/components/AgentErrorDetailsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import "./AgentErrorDetailsModal.css";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
||||||
|
|
||||||
|
export interface AgentErrorIssueContext {
|
||||||
|
surface: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
agentState?: string;
|
||||||
|
runId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentErrorDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
errorText: string;
|
||||||
|
issueContext: AgentErrorIssueContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentErrorIssueUrl(errorText: string, context: AgentErrorIssueContext): string {
|
||||||
|
const title = `[Agent Error] ${context.surface}${context.agentName ? ` - ${context.agentName}` : ""}`;
|
||||||
|
const bodyLines = [
|
||||||
|
"## Agent Error Report",
|
||||||
|
"",
|
||||||
|
`- Surface: ${context.surface}`,
|
||||||
|
`- Agent ID: ${context.agentId ?? "unknown"}`,
|
||||||
|
`- Agent Name: ${context.agentName ?? "unknown"}`,
|
||||||
|
`- Agent State: ${context.agentState ?? "unknown"}`,
|
||||||
|
`- Run ID: ${context.runId ?? "n/a"}`,
|
||||||
|
`- Task ID: ${context.taskId ?? "n/a"}`,
|
||||||
|
`- Timestamp: ${context.timestamp ?? new Date().toISOString()}`,
|
||||||
|
"",
|
||||||
|
"## Error",
|
||||||
|
"```text",
|
||||||
|
errorText,
|
||||||
|
"```",
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
title,
|
||||||
|
body: bodyLines.join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${DEFAULT_ISSUE_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext }: AgentErrorDetailsModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label="Agent error details">
|
||||||
|
<div className="modal agent-error-modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
Agent Error Details
|
||||||
|
</h2>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="agent-error-modal__content">
|
||||||
|
<pre className="agent-error-modal__error">{errorText}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(errorText).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={copied ? "Copied error to clipboard" : "Copy error to clipboard"}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
href={issueUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
window.open(issueUrl, "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
Report on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentErrorIndicatorProps {
|
||||||
|
errorText: string;
|
||||||
|
issueContext: AgentErrorIssueContext;
|
||||||
|
summaryPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentErrorIndicator({ errorText, issueContext, summaryPrefix = "Error" }: AgentErrorIndicatorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="agent-error-indicator" onClick={() => setOpen(true)} aria-label="Open error details">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span className="agent-error-indicator__label">{summaryPrefix}</span>
|
||||||
|
</button>
|
||||||
|
<AgentErrorDetailsModal open={open} onClose={() => setOpen(false)} errorText={errorText} issueContext={issueContext} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import { getAgentHealthStatus } from "../utils/agentHealth";
|
|||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import type { AgentHealthStatus } from "../utils/agentHealth";
|
import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { CollapsibleErrorDisplay } from "./AgentsView";
|
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||||
|
|
||||||
interface AgentListModalProps {
|
interface AgentListModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -223,6 +223,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
return "muted";
|
return "muted";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHealthSummary = (agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } => {
|
||||||
|
if (agent.state === "error") {
|
||||||
|
return { title: undefined, label: "Error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: health.reason ?? health.label,
|
||||||
|
label: health.stateDerived ? null : health.label,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -339,6 +350,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
// Board view: compact grid layout
|
// Board view: compact grid layout
|
||||||
displayAgents.map(agent => {
|
displayAgents.map(agent => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
|
const healthSummary = getHealthSummary(agent, health);
|
||||||
const healthTone = getHealthTone(health);
|
const healthTone = getHealthTone(health);
|
||||||
return (
|
return (
|
||||||
<div key={agent.id} className="agent-board-card" data-state={agent.state}>
|
<div key={agent.id} className="agent-board-card" data-state={agent.state}>
|
||||||
@@ -350,7 +362,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
>
|
>
|
||||||
{agent.state}
|
{agent.state}
|
||||||
</span>
|
</span>
|
||||||
<span className="agent-board-health" data-health={healthTone} title={health.reason ?? health.label}>
|
<span className="agent-board-health" data-health={healthTone} title={healthSummary.title}>
|
||||||
{health.icon}
|
{health.icon}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,6 +494,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
// List view: detailed card layout
|
// List view: detailed card layout
|
||||||
displayAgents.map(agent => {
|
displayAgents.map(agent => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
|
const healthSummary = getHealthSummary(agent, health);
|
||||||
const healthTone = getHealthTone(health);
|
const healthTone = getHealthTone(health);
|
||||||
return (
|
return (
|
||||||
<div key={agent.id} className="agent-card" data-state={agent.state}>
|
<div key={agent.id} className="agent-card" data-state={agent.state}>
|
||||||
@@ -531,8 +544,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
>
|
>
|
||||||
{agent.state}
|
{agent.state}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge agent-list-health-badge" data-health={healthTone} title={health.reason ?? health.label}>
|
<span className="badge agent-list-health-badge" data-health={healthTone} title={healthSummary.title}>
|
||||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge text-secondary">
|
<span className="badge text-secondary">
|
||||||
{getRoleLabel(agent.role)}
|
{getRoleLabel(agent.role)}
|
||||||
@@ -542,7 +555,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
|||||||
|
|
||||||
<div className="agent-card-body">
|
<div className="agent-card-body">
|
||||||
{agent.state === "error" && agent.lastError ? (
|
{agent.state === "error" && agent.lastError ? (
|
||||||
<CollapsibleErrorDisplay errorText={agent.lastError} />
|
<AgentErrorIndicator
|
||||||
|
errorText={agent.lastError}
|
||||||
|
issueContext={{
|
||||||
|
surface: "AgentListModal list",
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
agentState: agent.state,
|
||||||
|
taskId: agent.taskId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{agent.taskId && (
|
{agent.taskId && (
|
||||||
<div className="agent-task">
|
<div className="agent-task">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./AgentsView.css";
|
import "./AgentsView.css";
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense, type CSSProperties } from "react";
|
||||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
|
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
|
||||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
|
|||||||
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||||
import { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "./agentsOrgChartLayout";
|
import { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "./agentsOrgChartLayout";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
|
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||||
|
|
||||||
export interface AgentsViewProps {
|
export interface AgentsViewProps {
|
||||||
addToast: (message: string, type?: "success" | "error") => void;
|
addToast: (message: string, type?: "success" | "error") => void;
|
||||||
@@ -89,58 +90,6 @@ function getStateCardClass(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollapsibleErrorDisplay({
|
|
||||||
errorText,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
errorText: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(errorText);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// Ignore clipboard errors
|
|
||||||
}
|
|
||||||
}, [errorText]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`agent-card-error${className ? ` ${className}` : ""}`}>
|
|
||||||
<div className="agent-card-error-header">
|
|
||||||
<span className="agent-card-error-preview" title={errorText}>
|
|
||||||
{errorText}
|
|
||||||
</span>
|
|
||||||
<div className="agent-card-error-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon touch-target agent-card-error-copy-btn"
|
|
||||||
onClick={() => void handleCopy()}
|
|
||||||
title={copied ? "Copied" : "Copy error"}
|
|
||||||
aria-label={copied ? "Copied error to clipboard" : "Copy error to clipboard"}
|
|
||||||
>
|
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon touch-target agent-card-error-toggle"
|
|
||||||
onClick={() => setExpanded((value) => !value)}
|
|
||||||
title={expanded ? "Collapse error" : "Expand error"}
|
|
||||||
aria-label={expanded ? "Collapse error" : "Expand error"}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expanded ? <pre className="agent-card-error-full">{errorText}</pre> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrgChartLeafCount(node: OrgTreeNode): number {
|
function getOrgChartLeafCount(node: OrgTreeNode): number {
|
||||||
if (node.children.length === 0) {
|
if (node.children.length === 0) {
|
||||||
@@ -150,6 +99,17 @@ function getOrgChartLeafCount(node: OrgTreeNode): number {
|
|||||||
return node.children.reduce((sum, child) => sum + getOrgChartLeafCount(child), 0);
|
return node.children.reduce((sum, child) => sum + getOrgChartLeafCount(child), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHealthSummary(agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } {
|
||||||
|
if (agent.state === "error") {
|
||||||
|
return { title: undefined, label: "Error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: health.reason ?? health.label,
|
||||||
|
label: health.stateDerived ? null : health.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function OrgChartNode({
|
function OrgChartNode({
|
||||||
node,
|
node,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -165,6 +125,7 @@ function OrgChartNode({
|
|||||||
}) {
|
}) {
|
||||||
const { agent, children } = node;
|
const { agent, children } = node;
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
|
const healthSummary = getHealthSummary(agent, health);
|
||||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||||
const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state);
|
const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state);
|
||||||
const subtreeLeafCount = getOrgChartLeafCount(node);
|
const subtreeLeafCount = getOrgChartLeafCount(node);
|
||||||
@@ -199,9 +160,9 @@ function OrgChartNode({
|
|||||||
>
|
>
|
||||||
{agent.state}
|
{agent.state}
|
||||||
</span>
|
</span>
|
||||||
<span className="org-chart-node__health" style={{ color: health.color }} title={health.reason ?? health.label}>
|
<span className="org-chart-node__health" style={{ color: health.color }} title={healthSummary.title}>
|
||||||
{health.icon}
|
{health.icon}
|
||||||
{!health.stateDerived && <span className="text-secondary">{health.label}</span>}
|
{healthSummary.label && <span className="text-secondary">{healthSummary.label}</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1109,6 +1070,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
) : (
|
) : (
|
||||||
displayAgents.map((agent) => {
|
displayAgents.map((agent) => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
|
const healthSummary = getHealthSummary(agent, health);
|
||||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||||
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
|
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
|
||||||
return (
|
return (
|
||||||
@@ -1134,8 +1096,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
</div>
|
</div>
|
||||||
<div className="agent-board-name">{agent.name}</div>
|
<div className="agent-board-name">{agent.name}</div>
|
||||||
<div className="agent-board-id">{agent.id}</div>
|
<div className="agent-board-id">{agent.id}</div>
|
||||||
<div className="agent-board-health" style={{ color: health.color }} title={health.reason ?? health.label}>
|
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1151,6 +1113,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
// List view: detailed card layout
|
// List view: detailed card layout
|
||||||
displayAgents.map(agent => {
|
displayAgents.map(agent => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
|
const healthSummary = getHealthSummary(agent, health);
|
||||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||||
const stateCardClass = getStateCardClass("agent-card", agent.state);
|
const stateCardClass = getStateCardClass("agent-card", agent.state);
|
||||||
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
||||||
@@ -1221,8 +1184,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
>
|
>
|
||||||
{agent.state}
|
{agent.state}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge" style={{ color: health.color }} title={health.reason ?? health.label}>
|
<span className="badge" style={{ color: health.color }} title={healthSummary.title}>
|
||||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge text-secondary">
|
<span className="badge text-secondary">
|
||||||
{getRoleLabel(agent.role)}
|
{getRoleLabel(agent.role)}
|
||||||
@@ -1247,7 +1210,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
|
|
||||||
<div className="agent-card-body">
|
<div className="agent-card-body">
|
||||||
{agent.state === "error" && agent.lastError ? (
|
{agent.state === "error" && agent.lastError ? (
|
||||||
<CollapsibleErrorDisplay errorText={agent.lastError} />
|
<AgentErrorIndicator
|
||||||
|
errorText={agent.lastError}
|
||||||
|
issueContext={{
|
||||||
|
surface: "AgentsView list",
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
agentState: agent.state,
|
||||||
|
taskId: agent.taskId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{agent.taskId && (
|
{agent.taskId && (
|
||||||
<div className="agent-task">
|
<div className="agent-task">
|
||||||
|
|||||||
@@ -1297,6 +1297,58 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows run error in modal and launches prefilled GitHub issue", async () => {
|
||||||
|
const runId = "run-error";
|
||||||
|
mockFetchAgentRuns.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: runId,
|
||||||
|
agentId: "agent-001",
|
||||||
|
startedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
endedAt: "2024-01-01T00:01:00.000Z",
|
||||||
|
status: "failed",
|
||||||
|
} as AgentHeartbeatRun,
|
||||||
|
]);
|
||||||
|
mockFetchAgentRunLogs.mockResolvedValueOnce([]);
|
||||||
|
mockFetchAgentRunDetail.mockResolvedValueOnce({
|
||||||
|
id: runId,
|
||||||
|
agentId: "agent-001",
|
||||||
|
startedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
endedAt: "2024-01-01T00:01:00.000Z",
|
||||||
|
status: "failed",
|
||||||
|
stderrExcerpt: "fatal: exploded",
|
||||||
|
} as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
initialTab="runs"
|
||||||
|
initialRunId={runId}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Open error details" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("fatal: exploded")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("Agent error details")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
|
||||||
|
expect(screen.getByLabelText("Agent error details")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fatal: exploded")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Report on GitHub" }));
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("https://github.com/Runfusion/Fusion/issues/new?"),
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
expect(openSpy.mock.calls[0]?.[0]).toContain("run-error");
|
||||||
|
openSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("auto-expands the active run when opened from running control context", async () => {
|
it("auto-expands the active run when opened from running control context", async () => {
|
||||||
const activeRunId = "run-001";
|
const activeRunId = "run-001";
|
||||||
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
||||||
@@ -1333,8 +1385,9 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Active run log line")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("System Prompt")).toBeInTheDocument();
|
expect(screen.getByText("System Prompt")).toBeInTheDocument();
|
||||||
|
const viewer = screen.getByTestId("agent-log-viewer");
|
||||||
|
expect(viewer.textContent).toContain("Active run log line");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1382,8 +1435,9 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
|
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("First entry")).toBeInTheDocument();
|
const viewer = screen.getByTestId("agent-log-viewer");
|
||||||
expect(screen.getByText("Second entry")).toBeInTheDocument();
|
expect(viewer.textContent).toContain("First entry");
|
||||||
|
expect(viewer.textContent).toContain("Second entry");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ describe("AgentListModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders collapsible error display for error agents in list view", async () => {
|
it("renders compact error indicator and opens modal with actions for error agents", async () => {
|
||||||
mockFetchAgents.mockResolvedValueOnce([
|
mockFetchAgents.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
...mockAgents[0],
|
...mockAgents[0],
|
||||||
@@ -338,17 +338,26 @@ describe("AgentListModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText("modal failure").length).toBeGreaterThan(0);
|
expect(screen.getByRole("button", { name: "Open error details" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Expand error" }));
|
expect(screen.queryByText("modal failure")).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Collapse error" })).toBeTruthy();
|
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
|
||||||
|
expect(screen.getByLabelText("Agent error details")).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
|
fireEvent.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClipboardWriteText).toHaveBeenCalledWith("modal failure");
|
expect(mockClipboardWriteText).toHaveBeenCalledWith("modal failure");
|
||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Copied error to clipboard" })).toBeTruthy();
|
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Report on GitHub" }));
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("https://github.com/Runfusion/Fusion/issues/new?"),
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
openSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders health badges via data attributes instead of inline color styles", async () => {
|
it("renders health badges via data attributes instead of inline color styles", async () => {
|
||||||
|
|||||||
@@ -1010,7 +1010,7 @@ describe("AgentsView", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders collapsible error display and supports expand/copy", async () => {
|
it("renders compact error indicator and opens modal with copy/github actions", async () => {
|
||||||
const errorAgent: Agent = {
|
const errorAgent: Agent = {
|
||||||
...mockAgents[0],
|
...mockAgents[0],
|
||||||
id: "agent-error",
|
id: "agent-error",
|
||||||
@@ -1024,20 +1024,30 @@ describe("AgentsView", () => {
|
|||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText("something broke").length).toBeGreaterThan(0);
|
expect(screen.getByRole("button", { name: "Open error details" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
const expandButton = screen.getByRole("button", { name: "Expand error" });
|
expect(screen.queryByText("something broke")).toBeNull();
|
||||||
fireEvent.click(expandButton);
|
expect(screen.queryByLabelText("Agent error details")).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Collapse error" })).toBeTruthy();
|
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Collapse error" }));
|
|
||||||
expect(screen.getByRole("button", { name: "Expand error" })).toBeTruthy();
|
expect(screen.getByLabelText("Agent error details")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("something broke").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
|
fireEvent.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClipboardWriteText).toHaveBeenCalledWith("something broke");
|
expect(mockClipboardWriteText).toHaveBeenCalledWith("something broke");
|
||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Copied error to clipboard" })).toBeTruthy();
|
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
fireEvent.click(screen.getByRole("link", { name: "Report on GitHub" }));
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("https://github.com/Runfusion/Fusion/issues/new?"),
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
expect(openSpy.mock.calls[0]?.[0]).toContain("Surface%3A+AgentsView+list");
|
||||||
|
openSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render error display without error state and lastError", async () => {
|
it("does not render error display without error state and lastError", async () => {
|
||||||
@@ -1055,7 +1065,7 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByText("should not show")).toBeNull();
|
expect(screen.queryByText("should not show")).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "Copy error to clipboard" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Open error details" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows refresh button", async () => {
|
it("shows refresh button", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user