feat(FN-1183): add agent reflections tab to dashboard

- Add API routes for agent reflections (GET, POST, DELETE)
- Create AgentReflectionsTab component with reflection list and form
- Integrate reflections tab into AgentDetailView
- Add CSS styles for the reflections tab UI
- Write tests for API routes and component
This commit is contained in:
gsxdsm
2026-04-09 11:15:57 -07:00
parent f0619ed678
commit ddc574bdd7
7 changed files with 1316 additions and 3 deletions

View File

@@ -3,13 +3,14 @@ import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight
ChevronDown, ChevronRight, BarChart3
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
/**
* Simple className utility - joins class names conditionally
@@ -50,7 +51,7 @@ interface AgentDetailViewProps {
onChildClick?: (childId: string) => void;
}
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory";
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory" | "reflections";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
@@ -60,6 +61,7 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "employees", label: "Employees", icon: GitBranch },
{ id: "soul", label: "Soul", icon: Heart },
{ id: "memory", label: "Memory", icon: FileText },
{ id: "reflections", label: "Reflections", icon: BarChart3 },
{ id: "config", label: "Settings", icon: Settings },
];
@@ -441,6 +443,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/>
)}
{activeTab === "reflections" && (
<AgentReflectionsTab
agentId={agent.id}
projectId={projectId}
addToast={addToast}
/>
)}
{activeTab === "config" && (
<ConfigTab
agent={agent}

View File

@@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
import {
fetchAgentReflections,
fetchAgentPerformance,
triggerAgentReflection,
} from "../api";
vi.mock("../api", () => ({
fetchAgentReflections: vi.fn(),
fetchAgentPerformance: vi.fn(),
triggerAgentReflection: vi.fn(),
}));
const mockedFetchAgentReflections = vi.mocked(fetchAgentReflections);
const mockedFetchAgentPerformance = vi.mocked(fetchAgentPerformance);
const mockedTriggerAgentReflection = vi.mocked(triggerAgentReflection);
describe("AgentReflectionsTab", () => {
const mockReflections = [
{
id: "ref-001",
agentId: "agent-001",
timestamp: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
trigger: "periodic",
metrics: {
tasksCompleted: 5,
tasksFailed: 1,
avgDurationMs: 120000,
},
insights: ["Insight 1", "Insight 2"],
suggestedImprovements: ["Improve X", "Fix Y"],
summary: "Test summary for the reflection",
},
{
id: "ref-002",
agentId: "agent-001",
timestamp: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
trigger: "manual",
metrics: {
tasksCompleted: 3,
tasksFailed: 0,
avgDurationMs: 90000,
},
insights: ["Another insight"],
suggestedImprovements: ["Another suggestion"],
summary: "Another summary",
},
];
const mockPerformance = {
agentId: "agent-001",
totalTasksCompleted: 10,
totalTasksFailed: 2,
avgDurationMs: 110000,
successRate: 0.833,
commonErrors: ["Error 1"],
strengths: ["Strong point"],
weaknesses: ["Weak point"],
recentReflectionCount: 3,
computedAt: new Date().toISOString(),
};
const addToast = vi.fn();
beforeEach(() => {
mockedFetchAgentReflections.mockReset();
mockedFetchAgentPerformance.mockReset();
mockedTriggerAgentReflection.mockReset();
mockedFetchAgentReflections.mockResolvedValue(mockReflections);
mockedFetchAgentPerformance.mockResolvedValue(mockPerformance);
addToast.mockReset();
});
it("renders loading state initially", () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
expect(screen.getByText("Loading reflections...")).toBeInTheDocument();
});
it("renders performance summary cards with data", async () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Tasks Completed")).toBeInTheDocument();
});
expect(screen.getByText("10")).toBeInTheDocument(); // totalTasksCompleted
expect(screen.getByText("2")).toBeInTheDocument(); // totalTasksFailed
expect(screen.getByText("83%")).toBeInTheDocument(); // successRate
expect(screen.getByText("3")).toBeInTheDocument(); // recentReflectionCount
});
it("renders reflections list with correct timestamps and trigger badges", async () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Reflection History")).toBeInTheDocument();
});
// Check trigger badges
expect(screen.getByText("Periodic")).toBeInTheDocument();
expect(screen.getByText("Manual")).toBeInTheDocument();
// Check summaries are shown
expect(screen.getByText("Test summary for the reflection")).toBeInTheDocument();
expect(screen.getByText("Another summary")).toBeInTheDocument();
});
it("shows empty state when no reflections exist", async () => {
mockedFetchAgentReflections.mockResolvedValue([]);
mockedFetchAgentPerformance.mockResolvedValue({
...mockPerformance,
totalTasksCompleted: 0,
totalTasksFailed: 0,
recentReflectionCount: 0,
});
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("No reflections yet")).toBeInTheDocument();
});
expect(screen.getByText("Trigger a reflection to get started")).toBeInTheDocument();
});
it("shows 'no performance data' when summary has zeros", async () => {
mockedFetchAgentPerformance.mockResolvedValue({
...mockPerformance,
totalTasksCompleted: 0,
totalTasksFailed: 0,
recentReflectionCount: 0,
});
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("No performance data yet")).toBeInTheDocument();
});
});
it("clicking a reflection card expands it to show insights and suggestions", async () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Test summary for the reflection")).toBeInTheDocument();
});
// Click on the first reflection card
const firstCard = screen.getByText("Test summary for the reflection").closest(".reflection-card");
expect(firstCard).toBeInTheDocument();
fireEvent.click(firstCard!);
// Check expanded content
await waitFor(() => {
expect(screen.getByText("Insights")).toBeInTheDocument();
expect(screen.getByText("Suggested Improvements")).toBeInTheDocument();
});
expect(screen.getByText("Insight 1")).toBeInTheDocument();
expect(screen.getByText("Improve X")).toBeInTheDocument();
});
it("clicking Reflect Now calls triggerAgentReflection and refreshes data", async () => {
mockedTriggerAgentReflection.mockResolvedValue({
...mockReflections[0],
id: "ref-003",
});
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Reflect Now")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Reflect Now"));
// Should call triggerAgentReflection
await waitFor(() => {
expect(mockedTriggerAgentReflection).toHaveBeenCalledWith("agent-001", "test-project");
});
// Should show success toast
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Reflection generated successfully", "success");
});
// Should refresh data
expect(mockedFetchAgentReflections).toHaveBeenCalledTimes(2); // Initial + refresh
expect(mockedFetchAgentPerformance).toHaveBeenCalledTimes(2); // Initial + refresh
});
it("shows error toast when Reflect Now fails", async () => {
mockedTriggerAgentReflection.mockRejectedValue(new Error("Service unavailable"));
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Reflect Now")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Reflect Now"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Service unavailable"), "error");
});
});
it("disables Reflect Now button while reflecting is in progress", async () => {
mockedTriggerAgentReflection.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockReflections[0]), 1000))
);
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Reflect Now")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Reflect Now"));
// Should show "Reflecting..." text
await waitFor(() => {
expect(screen.getByText("Reflecting...")).toBeInTheDocument();
});
// Button should be disabled
const button = screen.getByText("Reflecting...").closest("button");
expect(button).toBeDisabled();
});
it("shows error toast when loading data fails", async () => {
mockedFetchAgentReflections.mockRejectedValue(new Error("Network error"));
mockedFetchAgentPerformance.mockResolvedValue(mockPerformance);
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to load reflections"), "error");
});
});
it("displays metrics when expanded", async () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Test summary for the reflection")).toBeInTheDocument();
});
const firstCard = screen.getByText("Test summary for the reflection").closest(".reflection-card");
fireEvent.click(firstCard!);
await waitFor(() => {
expect(screen.getByText("Metrics")).toBeInTheDocument();
});
expect(screen.getByText(/Tasks:/)).toBeInTheDocument();
expect(screen.getByText(/Failed:/)).toBeInTheDocument();
});
it("collapses reflection when clicking again", async () => {
render(
<AgentReflectionsTab agentId="agent-001" projectId="test-project" addToast={addToast} />
);
await waitFor(() => {
expect(screen.getByText("Test summary for the reflection")).toBeInTheDocument();
});
const firstCard = screen.getByText("Test summary for the reflection").closest(".reflection-card");
fireEvent.click(firstCard!);
await waitFor(() => {
expect(screen.getByText("Insights")).toBeInTheDocument();
});
// Click again to collapse
fireEvent.click(firstCard!);
await waitFor(() => {
expect(screen.queryByText("Insights")).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,342 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
ChevronDown,
ChevronRight,
Lightbulb,
Loader2,
RefreshCw,
TrendingDown,
TrendingUp,
Zap,
} from "lucide-react";
import type { AgentPerformanceSummary, AgentReflection } from "../api";
import {
fetchAgentPerformance,
fetchAgentReflections,
triggerAgentReflection,
} from "../api";
interface AgentReflectionsTabProps {
agentId: string;
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
}
/** Format a number in milliseconds to a human-readable duration string */
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
return `${(ms / 3_600_000).toFixed(1)}h`;
}
/** Format a percentage value (0-1) to a percentage string */
function formatPercent(rate: number): string {
return `${Math.round(rate * 100)}%`;
}
/** Format an ISO timestamp to a relative time string */
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
if (diffMs < 0) {
const absDiff = Math.abs(diffMs);
if (absDiff < 60_000) return "in a moment";
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
return `in ${Math.floor(absDiff / 86_400_000)}d`;
}
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
return `${Math.floor(diffMs / 86_400_000)}d ago`;
}
/** Get display label for a trigger type */
function getTriggerLabel(trigger: string): string {
switch (trigger) {
case "periodic":
return "Periodic";
case "post-task":
return "Post-Task";
case "manual":
return "Manual";
case "user-requested":
return "User Requested";
default:
return trigger;
}
}
export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentReflectionsTabProps) {
const [reflections, setReflections] = useState<AgentReflection[]>([]);
const [performance, setPerformance] = useState<AgentPerformanceSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isReflecting, setIsReflecting] = useState(false);
const [expandedReflectionId, setExpandedReflectionId] = useState<string | null>(null);
// Load data on mount
const loadData = useCallback(async () => {
try {
const [reflectionsData, performanceData] = await Promise.all([
fetchAgentReflections(agentId, 20, projectId),
fetchAgentPerformance(agentId, undefined, projectId),
]);
setReflections(reflectionsData);
setPerformance(performanceData);
} catch (err: any) {
addToast(`Failed to load reflections: ${err.message}`, "error");
} finally {
setIsLoading(false);
}
}, [agentId, projectId, addToast]);
useEffect(() => {
void loadData();
}, [loadData]);
// Handle reflect now button
const handleReflectNow = async () => {
setIsReflecting(true);
try {
await triggerAgentReflection(agentId, projectId);
addToast("Reflection generated successfully", "success");
setIsLoading(true);
await loadData();
} catch (err: any) {
addToast(`Failed to generate reflection: ${err.message}`, "error");
} finally {
setIsReflecting(false);
}
};
// Toggle expanded state
const toggleExpanded = (id: string) => {
setExpandedReflectionId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="reflections-tab">
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "24px",
justifyContent: "center",
}}
>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading reflections...</span>
</div>
</div>
);
}
// Check if performance summary has no data
const hasNoPerformanceData =
performance &&
performance.totalTasksCompleted === 0 &&
performance.totalTasksFailed === 0 &&
performance.recentReflectionCount === 0;
return (
<div className="reflections-tab">
{/* Header */}
<div className="reflections-header">
<h3>
<BarChart3 size={16} />
Performance & Reflections
</h3>
<button
className="btn btn-secondary"
onClick={handleReflectNow}
disabled={isReflecting}
title="Generate a manual reflection"
>
{isReflecting ? (
<>
<Loader2 size={14} className="animate-spin" />
Reflecting...
</>
) : (
<>
<RefreshCw size={14} />
Reflect Now
</>
)}
</button>
</div>
{/* Performance Summary Grid */}
{performance && !hasNoPerformanceData && (
<div className="reflections-stats-grid">
<div className="reflections-stat-card">
<div className="stat-value">
<TrendingUp size={16} style={{ color: "var(--color-success)" }} />
{performance.totalTasksCompleted}
</div>
<div className="stat-label">Tasks Completed</div>
</div>
<div className="reflections-stat-card">
<div className="stat-value">
<TrendingDown size={16} style={{ color: "var(--color-error)" }} />
{performance.totalTasksFailed}
</div>
<div className="stat-label">Tasks Failed</div>
</div>
<div className="reflections-stat-card">
<div className="stat-value">
<Zap size={16} style={{ color: "var(--in-progress)" }} />
{formatDuration(performance.avgDurationMs)}
</div>
<div className="stat-label">Avg Duration</div>
</div>
<div className="reflections-stat-card">
<div className="stat-value">
<BarChart3
size={16}
style={{
color:
performance.successRate >= 0.8
? "var(--color-success)"
: performance.successRate >= 0.5
? "var(--color-warning)"
: "var(--color-error)",
}}
/>
{formatPercent(performance.successRate)}
</div>
<div className="stat-label">Success Rate</div>
</div>
<div className="reflections-stat-card">
<div className="stat-value">
<Lightbulb size={16} style={{ color: "var(--color-info)" }} />
{performance.recentReflectionCount}
</div>
<div className="stat-label">Reflections</div>
</div>
</div>
)}
{hasNoPerformanceData && (
<div className="reflections-no-data">
<BarChart3 size={24} opacity={0.3} />
<p>No performance data yet</p>
</div>
)}
{/* Reflections List */}
<div className="reflections-list">
<h4>Reflection History</h4>
{reflections.length === 0 ? (
<div className="reflection-empty">
<Lightbulb size={32} opacity={0.3} />
<p>No reflections yet</p>
<p className="text-secondary">Trigger a reflection to get started</p>
</div>
) : (
<div className="reflection-cards">
{reflections.map((reflection) => {
const isExpanded = expandedReflectionId === reflection.id;
return (
<div
key={reflection.id}
className={`reflection-card ${isExpanded ? "reflection-card--expanded" : ""}`}
onClick={() => toggleExpanded(reflection.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && toggleExpanded(reflection.id)}
>
<div className="reflection-card-header">
<span className={`reflection-trigger-badge reflection-trigger-${reflection.trigger}`}>
{getTriggerLabel(reflection.trigger)}
</span>
<span className="reflection-timestamp">{relativeTime(reflection.timestamp)}</span>
<span className="reflection-chevron">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
</div>
<div className="reflection-summary">{reflection.summary}</div>
{isExpanded && (
<div className="reflection-details">
{reflection.insights.length > 0 && (
<div className="reflection-insights">
<h5>
<Lightbulb size={14} /> Insights
</h5>
<ul>
{reflection.insights.map((insight, i) => (
<li key={i}>{insight}</li>
))}
</ul>
</div>
)}
{reflection.suggestedImprovements.length > 0 && (
<div className="reflection-suggestions">
<h5>
<TrendingUp size={14} /> Suggested Improvements
</h5>
<ul>
{reflection.suggestedImprovements.map((suggestion, i) => (
<li key={i}>{suggestion}</li>
))}
</ul>
</div>
)}
{reflection.metrics && (
<div className="reflection-metrics">
<h5>Metrics</h5>
<div className="metrics-grid">
{reflection.metrics.tasksCompleted !== undefined && (
<div className="metric">
<span className="metric-label">Tasks:</span>
<span className="metric-value">{reflection.metrics.tasksCompleted}</span>
</div>
)}
{reflection.metrics.tasksFailed !== undefined && (
<div className="metric">
<span className="metric-label">Failed:</span>
<span className="metric-value">{reflection.metrics.tasksFailed}</span>
</div>
)}
{reflection.metrics.avgDurationMs !== undefined && (
<div className="metric">
<span className="metric-label">Avg Duration:</span>
<span className="metric-value">{formatDuration(reflection.metrics.avgDurationMs)}</span>
</div>
)}
{reflection.metrics.errorCount !== undefined && (
<div className="metric">
<span className="metric-label">Errors:</span>
<span className="metric-value">{reflection.metrics.errorCount}</span>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}