"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import {
Activity,
AlertCircle,
AlertTriangle,
ArrowRight,
BarChart3,
Bot,
CheckCircle2,
ChevronRight,
Circle,
Clock,
Download,
FileJson,
FileText,
GitBranch,
Layers,
Loader2,
Play,
RotateCcw,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { authFetch } from "@/lib/auth";
import { buildProjectUrl, useSFWorkspaceState } from "@/lib/sf-workspace-store";
import { cn } from "@/lib/utils";
import type {
ProjectTotals,
VisualizerData,
VisualizerSlice,
VisualizerTask,
} from "@/lib/visualizer-types";
import {
formatCost,
formatDuration,
formatTokenCount,
} from "@/lib/visualizer-types";
// ─── Design Tokens ────────────────────────────────────────────────────────────
// Tab definitions — single source of truth
const TABS = [
{ value: "progress", label: "Progress", Icon: Layers },
{ value: "deps", label: "Dependencies", Icon: GitBranch },
{ value: "metrics", label: "Metrics", Icon: BarChart3 },
{ value: "timeline", label: "Timeline", Icon: Clock },
{ value: "agent", label: "Agent", Icon: Bot },
{ value: "changes", label: "Changes", Icon: Activity },
{ value: "export", label: "Export", Icon: Download },
] as const;
type TabValue = (typeof TABS)[number]["value"];
// ─── Shared Primitives ────────────────────────────────────────────────────────
function statusIcon(status: "complete" | "active" | "pending" | "done") {
switch (status) {
case "complete":
case "done":
return ;
case "active":
return ;
case "pending":
return ;
}
}
function taskStatusIcon(task: VisualizerTask) {
if (task.done) return statusIcon("done");
if (task.active) return statusIcon("active");
return statusIcon("pending");
}
function RiskBadge({ risk }: { risk: string }) {
const color =
risk === "high"
? "bg-destructive/15 text-destructive border-destructive/25 ring-destructive/10"
: risk === "medium"
? "bg-warning/15 text-warning border-warning/25 ring-warning/10"
: "bg-success/15 text-success border-success/25 ring-success/10";
return (
{risk}
);
}
function formatRelative(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime();
if (diff < 60_000) return "just now";
const min = Math.floor(diff / 60_000);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
function formatTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/** Prominent section label with left accent bar */
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
);
}
/** Large empty state with icon */
function EmptyState({
message,
icon: Icon = AlertCircle,
}: {
message: string;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
);
}
/** Metric card — key number with label */
function StatCard({
label,
value,
sub,
accent,
}: {
label: string;
value: string;
sub?: string;
accent?: "sky" | "emerald" | "amber" | "default";
}) {
const accentClasses = {
sky: "from-info/8 border-info/20",
emerald: "from-success/8 border-success/20",
amber: "from-warning/8 border-warning/20",
default: "from-transparent border-border",
}[accent ?? "default"];
return (
{label}
{value}
{sub &&
{sub}
}
);
}
/** Horizontal progress bar with label */
function ProgressBar({
value,
max,
color = "sky",
animated = false,
}: {
value: number;
max: number;
color?: "sky" | "emerald" | "amber";
animated?: boolean;
}) {
const pct = max > 0 ? Math.max(1, (value / max) * 100) : 0;
const barColor = {
sky: "bg-info",
emerald: "bg-success",
amber: "bg-warning",
}[color];
return (
);
}
// ─── Progress Tab ─────────────────────────────────────────────────────────────
function ProgressTab({ data }: { data: VisualizerData }) {
if (data.milestones.length === 0) {
return ;
}
const allSlices = data.milestones.flatMap((m) => m.slices);
const riskCounts = { low: 0, medium: 0, high: 0 };
for (const sl of allSlices) {
if (sl.risk === "high") riskCounts.high++;
else if (sl.risk === "medium") riskCounts.medium++;
else riskCounts.low++;
}
return (
{/* Risk Heatmap */}
{allSlices.length > 0 && (
Risk Heatmap
{data.milestones
.filter((m) => m.slices.length > 0)
.map((ms) => (
{ms.id}
{ms.slices.map((sl) => (
))}
))}
Low ({riskCounts.low})
Medium ({riskCounts.medium})
High ({riskCounts.high})
)}
{/* Milestone tree */}
{data.milestones.map((ms) => (
{/* Milestone header */}
{statusIcon(ms.status)}
{ms.id}
{ms.title}
{ms.status}
{ms.status === "pending" && ms.dependsOn.length > 0 && (
Depends on {ms.dependsOn.join(", ")}
)}
{/* Slices */}
{ms.slices.length > 0 && (
{ms.slices.map((sl) => {
const doneTasks = sl.tasks.filter((t) => t.done).length;
const slStatus = sl.done
? "done"
: sl.active
? "active"
: "pending";
return (
{statusIcon(slStatus)}
{sl.id}
{sl.title}
{sl.depends.length > 0 && (
deps: {sl.depends.join(", ")}
)}
{sl.tasks.length > 0 && (
{doneTasks}/{sl.tasks.length}
)}
{/* Tasks — only shown for active or partially-done slices */}
{(sl.active || sl.tasks.some((t) => t.active)) &&
sl.tasks.length > 0 && (
{sl.tasks.map((task) => (
{taskStatusIcon(task)}
{task.id}
{task.title}
{task.active && (
running
)}
))}
)}
);
})}
)}
))}
);
}
// ─── Deps Tab ─────────────────────────────────────────────────────────────────
function DepsTab({ data }: { data: VisualizerData }) {
const cp = data.criticalPath;
const activeMs = data.milestones.find((m) => m.status === "active");
const milestoneDeps = data.milestones.filter((m) => m.dependsOn.length > 0);
return (
{/* Milestone Dependencies */}
Milestone Dependencies
{milestoneDeps.length === 0 ? (
No milestone dependencies configured.
) : (
{milestoneDeps.flatMap((ms) =>
ms.dependsOn.map((dep) => (
)),
)}
)}
{/* Slice Dependencies */}
Slice Dependencies — Active Milestone
{!activeMs ? (
No active milestone.
) : (
(() => {
const slDeps = activeMs.slices.filter(
(s) => s.depends.length > 0,
);
if (slDeps.length === 0)
return (
No slice dependencies in {activeMs.id}.
);
return (
{slDeps.flatMap((sl) =>
sl.depends.map((dep) => (
)),
)}
);
})()
)}
{/* Critical Path */}
Critical Path
{cp.milestonePath.length === 0 ? (
No critical path data.
) : (
{/* Milestone chain */}
Milestone Chain
{cp.milestonePath.map((id, i) => (
{id}
{i < cp.milestonePath.length - 1 && (
)}
))}
{/* Milestone slack */}
{Object.keys(cp.milestoneSlack).length > 0 && (
Milestone Slack
{data.milestones
.filter((m) => !cp.milestonePath.includes(m.id))
.map((m) => (
{m.id}
{m.title}
slack: {cp.milestoneSlack[m.id] ?? 0}
))}
)}
{/* Slice critical path */}
{cp.slicePath.length > 0 && (
Slice Critical Path
{cp.slicePath.map((id, i) => (
{id}
{i < cp.slicePath.length - 1 && (
)}
))}
{/* Bottleneck warnings */}
{activeMs && (
{cp.slicePath
.map((sid) => activeMs.slices.find((s) => s.id === sid))
.filter(
(sl): sl is VisualizerSlice =>
sl != null && !sl.done && !sl.active,
)
.map((sl) => (
{sl.id}
is on the critical path but not yet started
))}
)}
)}
{/* Slice slack */}
{Object.keys(cp.sliceSlack).length > 0 && (
Slice Slack
{Object.entries(cp.sliceSlack).map(([id, slack]) => (
{id}: {slack}
))}
)}
)}
);
}
// ─── Metrics Tab ──────────────────────────────────────────────────────────────
function MetricsTab({ data }: { data: VisualizerData }) {
if (!data.totals) {
return ;
}
const totals = data.totals;
return (
{/* Summary stats */}
{/* By Phase */}
{data.byPhase.length > 0 && (
Cost by Phase
{data.byPhase.map((phase) => {
const pct =
totals.cost > 0 ? (phase.cost / totals.cost) * 100 : 0;
return (
{phase.phase}
{formatCost(phase.cost)}
{pct.toFixed(1)}%
{formatTokenCount(phase.tokens.total)} tok
{phase.units} units
);
})}
)}
{/* By Model */}
{data.byModel.length > 0 && (
Cost by Model
{data.byModel.map((model) => {
const pct =
totals.cost > 0 ? (model.cost / totals.cost) * 100 : 0;
return (
{model.model}
{formatCost(model.cost)}
{pct.toFixed(1)}%
{formatTokenCount(model.tokens.total)} tok
{model.units} units
);
})}
)}
{/* By Slice */}
{data.bySlice.length > 0 && (
Cost by Slice
Slice
Units
Cost
Duration
Tokens
{data.bySlice.map((sl) => (
{sl.sliceId}
{sl.units}
{formatCost(sl.cost)}
{formatDuration(sl.duration)}
{formatTokenCount(sl.tokens.total)}
))}
)}
{/* Projections */}
{data.bySlice.length >= 2 && (
)}
);
}
function ProjectionsSection({
data,
totals,
}: {
data: VisualizerData;
totals: ProjectTotals;
}) {
const sliceLevelEntries = data.bySlice.filter((s) => s.sliceId.includes("/"));
if (sliceLevelEntries.length < 2) return null;
const totalSliceCost = sliceLevelEntries.reduce((sum, s) => sum + s.cost, 0);
const avgCostPerSlice = totalSliceCost / sliceLevelEntries.length;
const projectedRemaining = avgCostPerSlice * data.remainingSliceCount;
const projectedTotal = totals.cost + projectedRemaining;
const burnRate =
totals.duration > 0 ? totals.cost / (totals.duration / 3_600_000) : 0;
return (
Projections
{burnRate > 0 && (
)}
{projectedTotal > 2 * totals.cost && data.remainingSliceCount > 0 && (
Projected total {formatCost(projectedTotal)} exceeds 2× current spend
)}
);
}
// ─── Timeline Tab ─────────────────────────────────────────────────────────────
function TimelineTab({ data }: { data: VisualizerData }) {
const sorted = [...data.units].sort((a, b) => a.startedAt - b.startedAt);
const recent = sorted.slice(-30);
const hasRunningUnit = recent.some(
(u) => !u.finishedAt || u.finishedAt === 0,
);
const [runningNow, setRunningNow] = useState(() => Date.now());
useEffect(() => {
if (!hasRunningUnit) return;
const interval = window.setInterval(() => {
setRunningNow(Date.now());
}, 1000);
return () => window.clearInterval(interval);
}, [hasRunningUnit]);
const referenceNow = hasRunningUnit ? runningNow : 0;
const durationForUnit = useCallback(
(unit: VisualizerData["units"][number]) =>
(unit.finishedAt || referenceNow) - unit.startedAt,
[referenceNow],
);
if (data.units.length === 0) {
return ;
}
const maxDuration = Math.max(...recent.map(durationForUnit), 1);
return (
{/* Header */}
Execution Timeline
Showing {recent.length} of {data.units.length} units — most recent
first
{/* Column headers */}
Time
Type
ID
Duration
Time
Cost
{[...recent].reverse().map((unit, i) => {
const duration = durationForUnit(unit);
const pct = (duration / maxDuration) * 100;
const isRunning = !unit.finishedAt || unit.finishedAt === 0;
return (
{formatTime(unit.startedAt)}
{isRunning ? (
) : (
)}
{unit.type}
{unit.id}
{formatDuration(duration)}
{formatCost(unit.cost)}
);
})}
);
}
// ─── Agent Tab ────────────────────────────────────────────────────────────────
function AgentTab({ data }: { data: VisualizerData }) {
const activity = data.agentActivity;
if (!activity) {
return (
);
}
const completed = activity.completedUnits;
const total = Math.max(completed, activity.totalSlices);
const pct =
total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;
return (
{/* Status card */}
{activity.active ? "Active" : "Idle"}
{activity.active ? "Agent is running" : "Waiting for next task"}
{activity.active && (
{formatDuration(activity.elapsed)}
elapsed
)}
{activity.currentUnit && (
Currently executing
{activity.currentUnit.type} — {activity.currentUnit.id}
)}
{/* Completion progress */}
{total > 0 && (
Completion Progress
{completed} / {total} slices
{pct}% complete
{total - completed} remaining
)}
{/* Stats grid */}
0
? `${activity.completionRate.toFixed(1)}/hr`
: "—"
}
accent="sky"
/>
{/* Recent units */}
{data.units.filter((u) => u.finishedAt > 0).length > 0 && (
Recent Completed Units
{data.units
.filter((u) => u.finishedAt > 0)
.slice(-5)
.reverse()
.map((u, i) => (
{formatTime(u.startedAt)}
{u.type}
{u.id}
{formatDuration(u.finishedAt - u.startedAt)}
{formatCost(u.cost)}
))}
)}
);
}
// ─── Changes Tab ──────────────────────────────────────────────────────────────
function ChangesTab({ data }: { data: VisualizerData }) {
const entries = data.changelog.entries;
if (entries.length === 0) {
return ;
}
const sorted = [...entries].reverse();
return (
{sorted.map((entry, i) => (
{/* Header */}
{entry.milestoneId}/{entry.sliceId}
{entry.title}
{entry.completedAt && (
{formatRelative(entry.completedAt)}
)}
{/* One-liner */}
{entry.oneLiner && (
“{entry.oneLiner}”
)}
{/* Files modified */}
{entry.filesModified.length > 0 && (
Files Modified
{entry.filesModified.map((f, fi) => (
{f.path}
{f.description && (
— {f.description}
)}
))}
)}
))}
);
}
// ─── Export Tab ───────────────────────────────────────────────────────────────
function ExportTab({ data }: { data: VisualizerData }) {
const downloadBlob = useCallback(
(content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
[],
);
const generateMarkdown = useCallback(() => {
const lines: string[] = [];
lines.push("# SF Workflow Report");
lines.push("");
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push(`Phase: ${data.phase}`);
lines.push("");
lines.push("## Milestones");
lines.push("");
for (const ms of data.milestones) {
const icon =
ms.status === "complete" ? "✓" : ms.status === "active" ? "▸" : "○";
lines.push(`### ${icon} ${ms.id}: ${ms.title} (${ms.status})`);
if (ms.dependsOn.length > 0)
lines.push(`Depends on: ${ms.dependsOn.join(", ")}`);
lines.push("");
for (const sl of ms.slices) {
const slIcon = sl.done ? "✓" : sl.active ? "▸" : "○";
lines.push(`- ${slIcon} **${sl.id}**: ${sl.title} [risk: ${sl.risk}]`);
for (const t of sl.tasks) {
const tIcon = t.done ? "✓" : t.active ? "▸" : "○";
lines.push(` - ${tIcon} ${t.id}: ${t.title}`);
}
}
lines.push("");
}
if (data.totals) {
lines.push("## Metrics Summary");
lines.push("");
lines.push(`| Metric | Value |`);
lines.push(`|--------|-------|`);
lines.push(`| Units | ${data.totals.units} |`);
lines.push(`| Total Cost | ${formatCost(data.totals.cost)} |`);
lines.push(`| Duration | ${formatDuration(data.totals.duration)} |`);
lines.push(`| Tokens | ${formatTokenCount(data.totals.tokens.total)} |`);
lines.push("");
}
if (data.criticalPath.milestonePath.length > 0) {
lines.push("## Critical Path");
lines.push("");
lines.push(`Milestone: ${data.criticalPath.milestonePath.join(" → ")}`);
if (data.criticalPath.slicePath.length > 0) {
lines.push(`Slice: ${data.criticalPath.slicePath.join(" → ")}`);
}
lines.push("");
}
if (data.changelog.entries.length > 0) {
lines.push("## Changelog");
lines.push("");
for (const entry of data.changelog.entries) {
lines.push(`### ${entry.milestoneId}/${entry.sliceId}: ${entry.title}`);
if (entry.oneLiner) lines.push(`> ${entry.oneLiner}`);
if (entry.filesModified.length > 0) {
lines.push("Files:");
for (const f of entry.filesModified)
lines.push(`- \`${f.path}\` — ${f.description}`);
}
if (entry.completedAt) lines.push(`Completed: ${entry.completedAt}`);
lines.push("");
}
}
return lines.join("\n");
}, [data]);
const handleMarkdown = () =>
downloadBlob(generateMarkdown(), "sf-report.md", "text/markdown");
const handleJSON = () =>
downloadBlob(
JSON.stringify(data, null, 2),
"sf-report.json",
"application/json",
);
return (
Export Project Data
Download the current visualizer data as a structured report. Markdown
includes milestones, metrics, critical path, and changelog in a
readable format. JSON contains the full raw data payload.
Download Markdown
Human-readable report with tables and structure
Download JSON
Full raw data payload for tooling
);
}
// ─── Custom Tab Bar ────────────────────────────────────────────────────────────
function VisualizerTabs({
defaultValue,
children,
}: {
defaultValue: TabValue;
children: React.ReactNode;
}) {
return (
{children}
);
}
function VisualizerTabList() {
return (
{TABS.map(({ value, label, Icon }) => (
{/* Active bottom border indicator */}
{/* Hover background */}
{/* Icon */}
{/* Label */}
{label}
))}
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function VisualizerView() {
const workspace = useSFWorkspaceState();
const projectCwd = workspace.boot?.project.cwd;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
try {
const resp = await authFetch(
buildProjectUrl("/api/visualizer", projectCwd),
);
if (!resp.ok) {
const body = await resp
.json()
.catch(() => ({ error: "Unknown error" }));
throw new Error(body.error || `HTTP ${resp.status}`);
}
const json: VisualizerData = await resp.json();
setData(json);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to fetch visualizer data",
);
} finally {
setLoading(false);
}
}, [projectCwd]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
fetchData();
const interval = setInterval(fetchData, 10_000);
return () => clearInterval(interval);
}, [fetchData]);
// Loading
if (loading && !data) {
return (
);
}
// Error (no cached data)
if (error && !data) {
return (
Failed to load visualizer
{error}
Retry
);
}
if (!data) return null;
return (
{/* Header */}
Workflow Visualizer
Phase:{" "}
{data.phase}
{data.remainingSliceCount > 0 && (
<>
·
{data.remainingSliceCount} slice
{data.remainingSliceCount !== 1 ? "s" : ""} remaining
>
)}
{error && (
<>
·
Stale — {error}
>
)}
{/* Tabs */}
);
}