Files
fusion/packages/dashboard/app/components/AgentReflectionsTab.tsx
gsxdsm c3d1716fdd refactor(dashboard): split monolithic styles.css into per-component files
Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).

- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
  React.lazy + Suspense; prefetch all chunks on idle so first navigation is
  instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
  635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.

- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
  so CSS regression tests load the full per-component bundle (mirroring Vite
  source order). Migrate 30+ tests off direct readFileSync('../styles.css').

- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
  imports actually inject styles in jsdom (fixes getComputedStyle assertions).

- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
  dashboard test files; points at loadAllAppCss() instead.

- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
  .form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.

- Mobile fixes along the way: scroll containment for view containers
  (min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
  mobile (with safe-area-inset for iOS home bar), AgentsView single-row
  header layout, ActivityLogModal close button on right, model-combobox
  z-index above the mobile quick-chat panel.

- Bug fix: SkillsView toggle was display:none which hid the input from the
  accessibility tree; replaced with the visually-hidden pattern so screen
  readers + getByRole still find the checkbox.

- Bug fix: standalone Delete button in TaskDetailModal for triage-column
  tasks (Actions dropdown is hidden in triage state, so previously no way
  to delete a freshly-created task without status change first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

581 lines
20 KiB
TypeScript

import "./AgentReflectionsTab.css";
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
ChevronDown,
ChevronRight,
Lightbulb,
Loader2,
RefreshCw,
Star,
Trash2,
TrendingDown,
TrendingUp,
Zap,
} from "lucide-react";
import type {
AgentPerformanceSummary,
AgentReflection,
} from "../api";
import type { AgentRating, AgentRatingSummary } from "@fusion/core";
import {
addAgentRating,
deleteAgentRating,
fetchAgentPerformance,
fetchAgentRatings,
fetchAgentRatingSummary,
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;
}
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error && err.message) {
return err.message;
}
return String(err);
}
function getTrendLabel(trend: string): string {
switch (trend) {
case "improving":
return "↑ Improving";
case "declining":
return "↓ Declining";
case "stable":
return "→ Stable";
default:
return "Insufficient data";
}
}
function getTrendClass(trend: string): string {
switch (trend) {
case "improving":
return "trend-improving";
case "declining":
return "trend-declining";
case "stable":
return "trend-stable";
default:
return "trend-insufficient";
}
}
function renderStars(score: number, maxScore: number = 5) {
return (
<span className="rating-stars">
{Array.from({ length: maxScore }, (_, i) => (
<Star
key={i}
size={14}
className={i < score ? "star-filled" : "star-empty"}
fill={i < score ? "currentColor" : "none"}
/>
))}
</span>
);
}
export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentReflectionsTabProps) {
const [reflections, setReflections] = useState<AgentReflection[]>([]);
const [performance, setPerformance] = useState<AgentPerformanceSummary | null>(null);
const [ratingSummary, setRatingSummary] = useState<AgentRatingSummary | null>(null);
const [ratings, setRatings] = useState<AgentRating[]>([]);
const [isLoadingReflections, setIsLoadingReflections] = useState(true);
const [isLoadingRatings, setIsLoadingRatings] = useState(true);
const [isReflecting, setIsReflecting] = useState(false);
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
const [expandedReflectionId, setExpandedReflectionId] = useState<string | null>(null);
const [newScore, setNewScore] = useState(0);
const [newCategory, setNewCategory] = useState("");
const [newComment, setNewComment] = useState("");
const loadReflectionData = useCallback(async () => {
try {
const [reflectionsData, performanceData] = await Promise.all([
fetchAgentReflections(agentId, 20, projectId),
fetchAgentPerformance(agentId, undefined, projectId),
]);
setReflections(reflectionsData);
setPerformance(performanceData);
} catch (err) {
addToast(`Failed to load reflections: ${getErrorMessage(err)}`, "error");
} finally {
setIsLoadingReflections(false);
}
}, [agentId, projectId, addToast]);
const loadRatingsData = useCallback(async () => {
try {
const [summaryData, ratingsData] = await Promise.all([
fetchAgentRatingSummary(agentId, projectId),
fetchAgentRatings(agentId, { limit: 50 }, projectId),
]);
setRatingSummary(summaryData);
setRatings(ratingsData);
} catch (err) {
addToast(`Failed to load ratings: ${getErrorMessage(err)}`, "error");
} finally {
setIsLoadingRatings(false);
}
}, [agentId, projectId, addToast]);
useEffect(() => {
void loadReflectionData();
void loadRatingsData();
}, [loadReflectionData, loadRatingsData]);
const handleReflectNow = async () => {
setIsReflecting(true);
try {
const reflection = await triggerAgentReflection(agentId, projectId);
if (!reflection) {
addToast("Not enough history to generate a reflection yet", "error");
return;
}
addToast("Reflection generated successfully", "success");
setIsLoadingReflections(true);
await loadReflectionData();
} catch (err: unknown) {
const message = getErrorMessage(err);
const normalizedMessage = message.toLowerCase();
if (normalizedMessage.includes("agent not found") || normalizedMessage.includes("not found")) {
addToast("This agent is no longer available. It may have been deleted.", "error");
} else if (normalizedMessage.includes("insufficient history")) {
addToast("Not enough history to generate a reflection yet", "error");
} else {
addToast(`Failed to generate reflection: ${message}`, "error");
}
} finally {
setIsReflecting(false);
}
};
const handleSubmitRating = async (e: React.FormEvent) => {
e.preventDefault();
if (newScore === 0) return;
setIsSubmittingRating(true);
try {
await addAgentRating(agentId, {
score: newScore,
category: newCategory || undefined,
comment: newComment || undefined,
raterType: "user",
}, projectId);
setNewScore(0);
setNewCategory("");
setNewComment("");
addToast("Rating added", "success");
await loadRatingsData();
} catch (err) {
addToast(`Failed to add rating: ${getErrorMessage(err)}`, "error");
} finally {
setIsSubmittingRating(false);
}
};
const handleDeleteRating = async (ratingId: string) => {
try {
await deleteAgentRating(agentId, ratingId, projectId);
addToast("Rating deleted", "success");
await loadRatingsData();
} catch (err) {
addToast(`Failed to delete rating: ${getErrorMessage(err)}`, "error");
}
};
const toggleExpanded = (id: string) => {
setExpandedReflectionId((prev) => (prev === id ? null : id));
};
if (isLoadingReflections && isLoadingRatings) {
return (
<div className="reflections-tab">
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading evaluation...</span>
</div>
</div>
);
}
const hasNoPerformanceData =
performance &&
performance.totalTasksCompleted === 0 &&
performance.totalTasksFailed === 0 &&
performance.recentReflectionCount === 0;
return (
<div className="reflections-tab">
<div className="reflections-header">
<h3>
<BarChart3 size={16} />
Performance, Reflections & Ratings
</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 && !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>
)}
<div className="reflections-ratings-section">
<h4>User Ratings</h4>
{isLoadingRatings ? (
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading ratings...</span>
</div>
) : (
<>
{ratingSummary && (
<div className="rating-summary-card">
<div className="rating-score-display">
<span className="rating-average">{ratingSummary.averageScore.toFixed(1)}</span>
{renderStars(Math.round(ratingSummary.averageScore))}
</div>
<div className="rating-stats">
<span className="rating-count">{ratingSummary.totalRatings} ratings</span>
<span className={`rating-trend-badge ${getTrendClass(ratingSummary.trend)}`}>
{getTrendLabel(ratingSummary.trend)}
</span>
</div>
</div>
)}
{ratingSummary && Object.keys(ratingSummary.categoryAverages).length > 0 && (
<div className="category-breakdown">
<h4>Category Averages</h4>
{Object.entries(ratingSummary.categoryAverages as Record<string, number>).map(([category, avg]) => (
<div key={category} className="category-item">
<span className="category-name">{category}</span>
<span className="category-score">{avg.toFixed(1)}</span>
</div>
))}
</div>
)}
<form className="add-rating-form" onSubmit={handleSubmitRating}>
<h4>Add Rating</h4>
<div className="star-selector">
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
type="button"
className="star-btn touch-target"
onClick={() => setNewScore(score)}
title={`${score} star${score > 1 ? "s" : ""}`}
>
<Star
size={24}
fill={score <= newScore ? "currentColor" : "none"}
className={score <= newScore ? "star-filled" : "star-empty"}
/>
</button>
))}
</div>
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="select add-rating-category-select"
>
<option value="">Select category...</option>
<option value="quality">Quality</option>
<option value="speed">Speed</option>
<option value="communication">Communication</option>
<option value="reliability">Reliability</option>
<option value="other">Other</option>
</select>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Optional comment..."
className="input add-rating-comment-input"
rows={3}
/>
<button
type="submit"
className="btn btn--primary"
disabled={newScore === 0 || isSubmittingRating}
>
{isSubmittingRating ? "Submitting..." : "Submit Rating"}
</button>
</form>
<div className="rating-history">
<h4>Rating History</h4>
{ratings.length === 0 ? (
<p className="no-ratings">No ratings yet</p>
) : (
ratings.map((rating) => (
<div key={rating.id} className="rating-history-item">
<div className="rating-item-header">
{renderStars(rating.score)}
{rating.category && (
<span className="rating-category-badge">{rating.category}</span>
)}
<span className="rating-time">{relativeTime(rating.createdAt)}</span>
<button
type="button"
className="rating-delete-btn touch-target"
onClick={() => void handleDeleteRating(rating.id)}
title="Delete rating"
>
<Trash2 size={14} />
</button>
</div>
{rating.comment && (
<p className="rating-comment">{rating.comment}</p>
)}
</div>
))
)}
</div>
</>
)}
</div>
<div className="reflections-list">
<h4>Reflection History</h4>
{isLoadingReflections ? (
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading reflections...</span>
</div>
) : 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>
);
}