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
|
||||
- **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
|
||||
- **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:
|
||||
- **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)
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -1511,7 +1512,19 @@ function RunsTab({
|
||||
{detailRun.stderrExcerpt && (
|
||||
<div className="run-output-section">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
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 type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { CollapsibleErrorDisplay } from "./AgentsView";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
|
||||
interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -223,6 +223,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -339,6 +350,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
// Board view: compact grid layout
|
||||
displayAgents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
const healthTone = getHealthTone(health);
|
||||
return (
|
||||
<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}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -482,6 +494,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
// List view: detailed card layout
|
||||
displayAgents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
const healthTone = getHealthTone(health);
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
<span className="badge agent-list-health-badge" data-health={healthTone} title={health.reason ?? health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
<span className="badge agent-list-health-badge" data-health={healthTone} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
@@ -542,7 +555,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
<div className="agent-card-body">
|
||||
{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}
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./AgentsView.css";
|
||||
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 { 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 { resolveOrgChartLayoutMode, type OrgChartLayoutMode } from "./agentsOrgChartLayout";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
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 {
|
||||
if (node.children.length === 0) {
|
||||
@@ -150,6 +99,17 @@ function getOrgChartLeafCount(node: OrgTreeNode): number {
|
||||
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({
|
||||
node,
|
||||
onSelect,
|
||||
@@ -165,6 +125,7 @@ function OrgChartNode({
|
||||
}) {
|
||||
const { agent, children } = node;
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||
const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state);
|
||||
const subtreeLeafCount = getOrgChartLeafCount(node);
|
||||
@@ -199,9 +160,9 @@ function OrgChartNode({
|
||||
>
|
||||
{agent.state}
|
||||
</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.stateDerived && <span className="text-secondary">{health.label}</span>}
|
||||
{healthSummary.label && <span className="text-secondary">{healthSummary.label}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1109,6 +1070,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
) : (
|
||||
displayAgents.map((agent) => {
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
|
||||
return (
|
||||
@@ -1134,8 +1096,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
</div>
|
||||
<div className="agent-board-name">{agent.name}</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={health.reason ?? health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1151,6 +1113,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
// List view: detailed card layout
|
||||
displayAgents.map(agent => {
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||
const stateCardClass = getStateCardClass("agent-card", agent.state);
|
||||
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
||||
@@ -1221,8 +1184,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }} title={health.reason ?? health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
<span className="badge" style={{ color: health.color }} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
@@ -1247,7 +1210,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
<div className="agent-card-body">
|
||||
{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}
|
||||
{agent.taskId && (
|
||||
<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 () => {
|
||||
const activeRunId = "run-001";
|
||||
mockFetchAgentRunLogs.mockResolvedValueOnce([
|
||||
@@ -1333,8 +1385,9 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Active run log line")).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();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First entry")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second entry")).toBeInTheDocument();
|
||||
const viewer = screen.getByTestId("agent-log-viewer");
|
||||
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([
|
||||
{
|
||||
...mockAgents[0],
|
||||
@@ -338,17 +338,26 @@ describe("AgentListModal", () => {
|
||||
);
|
||||
|
||||
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.getByRole("button", { name: "Collapse error" })).toBeTruthy();
|
||||
expect(screen.queryByText("modal failure")).toBeNull();
|
||||
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" }));
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
...mockAgents[0],
|
||||
id: "agent-error",
|
||||
@@ -1024,20 +1024,30 @@ describe("AgentsView", () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
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" });
|
||||
fireEvent.click(expandButton);
|
||||
expect(screen.getByRole("button", { name: "Collapse error" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Collapse error" }));
|
||||
expect(screen.getByRole("button", { name: "Expand error" })).toBeTruthy();
|
||||
expect(screen.queryByText("something broke")).toBeNull();
|
||||
expect(screen.queryByLabelText("Agent error details")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open error details" }));
|
||||
|
||||
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" }));
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
@@ -1055,7 +1065,7 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user