singularity-forge/web/components/sf/remaining-command-panels.tsx
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

1264 lines
55 KiB
TypeScript

"use client"
import { useState } from "react"
import {
AlertTriangle,
Archive,
CheckCircle2,
Clock,
Database,
Download,
GitBranch,
Layers,
ListChecks,
LoaderCircle,
Navigation,
RefreshCw,
RotateCcw,
Scissors,
Terminal,
Trash2,
Undo2,
XCircle,
Zap,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import type {
HistoryData,
HistoryPhaseAggregate,
HistorySliceAggregate,
HistoryModelAggregate,
InspectData,
HooksData,
HookStatusEntry,
ExportResult,
UndoInfo,
UndoResult,
CleanupData,
CleanupBranch,
CleanupSnapshot,
CleanupResult,
SteerData,
} from "@/lib/remaining-command-types"
import { cn } from "@/lib/utils"
import {
formatCost,
getLiveWorkspaceIndex,
useSFWorkspaceActions,
useSFWorkspaceState,
type WorkspaceMilestoneTarget,
type WorkspaceSliceTarget,
} from "@/lib/sf-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
// SHARED INFRASTRUCTURE
// ═══════════════════════════════════════════════════════════════════════
function PanelHeader({
title,
icon,
subtitle,
status,
onRefresh,
refreshing,
}: {
title: string
icon: React.ReactNode
subtitle?: string | null
status?: React.ReactNode
onRefresh?: () => void
refreshing?: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<span className="text-muted-foreground">{icon}</span>
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
{status}
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
</div>
{onRefresh && (
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
)}
</div>
)
}
function PanelError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
)
}
function PanelLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
)
}
function PanelEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
)
}
function InfoPill({ label, value, variant }: { label: string; value: string | number; variant?: "default" | "info" | "warning" | "success" | "error" }) {
return (
<div className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "info" && "border-info/20 bg-info/5 text-info",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "success" && "border-success/20 bg-success/5 text-success",
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
)}>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const seconds = Math.round(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainingSec = seconds % 60
if (minutes < 60) return remainingSec > 0 ? `${minutes}m ${remainingSec}s` : `${minutes}m`
const hours = Math.floor(minutes / 60)
const remainingMin = minutes % 60
return remainingMin > 0 ? `${hours}h ${remainingMin}m` : `${hours}h`
}
// ═══════════════════════════════════════════════════════════════════════
// 1. QUICK PANEL — Static usage instructions
// ═══════════════════════════════════════════════════════════════════════
export function QuickPanel() {
return (
<div className="space-y-4" data-testid="sf-surface-sf-quick">
<PanelHeader
title="Quick Task"
icon={<Zap className="h-3.5 w-3.5" />}
/>
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-4 space-y-3">
<p className="text-xs text-foreground">
Create a quick one-off task outside the current plan. Useful for small fixes, experiments, or ad-hoc work that
doesn&apos;t fit into the milestone structure.
</p>
<div className="space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Usage</h4>
<div className="rounded-md border border-border/50 bg-background/50 px-3 py-2 font-mono text-[11px] text-foreground/80">
/sf quick &lt;description&gt;
</div>
</div>
<div className="space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Examples</h4>
<div className="space-y-1.5">
{[
"Fix the typo in README.md header",
"Add .env.example with required keys",
"Update the LICENSE year to 2026",
"Run prettier on the whole project",
].map((example) => (
<div key={example} className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">$</span>
<code className="font-mono text-muted-foreground">/sf quick {example}</code>
</div>
))}
</div>
</div>
<div className="rounded-md border border-info/15 bg-info/5 px-3 py-2 text-[11px] text-info/90">
Quick tasks run as standalone units they don&apos;t affect milestone progress, slices, or the plan. Use them
for work that should happen now without ceremony.
</div>
</div>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 2. HISTORY PANEL — Project metrics and breakdowns
// ═══════════════════════════════════════════════════════════════════════
type HistoryTab = "phase" | "slice" | "model" | "units"
export function HistoryPanel() {
const workspace = useSFWorkspaceState()
const { loadHistoryData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.history
const data = state.data as HistoryData | null
const busy = state.phase === "loading"
const [activeTab, setActiveTab] = useState<HistoryTab>("phase")
return (
<div className="space-y-4" data-testid="sf-surface-sf-history">
<PanelHeader
title="History & Metrics"
icon={<Clock className="h-3.5 w-3.5" />}
onRefresh={() => void loadHistoryData()}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Loading history data…" />}
{data && (
<>
{/* Totals summary */}
<div className="flex flex-wrap gap-2">
<InfoPill label="Units" value={data.totals.units} />
<InfoPill label="Cost" value={formatCost(data.totals.cost)} variant="warning" />
<InfoPill label="Duration" value={formatDuration(data.totals.duration)} />
<InfoPill label="Tool Calls" value={data.totals.toolCalls} />
</div>
{/* Tab switcher */}
<div className="flex gap-1 rounded-lg border border-border/50 bg-card/50 p-0.5">
{(["phase", "slice", "model", "units"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"flex-1 rounded-md px-2.5 py-1 text-[11px] font-medium capitalize transition-colors",
activeTab === tab
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-muted-foreground",
)}
>
{tab === "units" ? "Recent" : `By ${tab}`}
</button>
))}
</div>
{/* By Phase */}
{activeTab === "phase" && data.byPhase.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Phase</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Units</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{data.byPhase.map((row: HistoryPhaseAggregate) => (
<tr key={row.phase} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80 capitalize">{row.phase}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{row.units}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(row.cost)}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatDuration(row.duration)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* By Slice */}
{activeTab === "slice" && data.bySlice.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Slice</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Units</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{data.bySlice.map((row: HistorySliceAggregate) => (
<tr key={row.sliceId} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{row.sliceId}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{row.units}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(row.cost)}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatDuration(row.duration)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* By Model */}
{activeTab === "model" && data.byModel.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Model</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Units</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{data.byModel.map((row: HistoryModelAggregate) => (
<tr key={row.model} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[180px]">{row.model}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{row.units}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(row.cost)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Recent Units */}
{activeTab === "units" && (
<>
{data.units.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Model</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{data.units.slice(0, 20).map((u, i) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{u.type}</td>
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[120px]">{u.id}</td>
<td className="px-2.5 py-1.5 text-muted-foreground truncate max-w-[120px]">{u.model}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(u.cost)}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatDuration(u.finishedAt - u.startedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<PanelEmpty message="No unit history recorded yet" />
)}
</>
)}
{activeTab === "phase" && data.byPhase.length === 0 && <PanelEmpty message="No phase breakdown available" />}
{activeTab === "slice" && data.bySlice.length === 0 && <PanelEmpty message="No slice breakdown available" />}
{activeTab === "model" && data.byModel.length === 0 && <PanelEmpty message="No model breakdown available" />}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 3. UNDO PANEL — Last completed unit info + undo action
// ═══════════════════════════════════════════════════════════════════════
export function UndoPanel() {
const workspace = useSFWorkspaceState()
const { loadUndoInfo, executeUndoAction } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.undo
const data = state.data as UndoInfo | null
const busy = state.phase === "loading"
const [confirming, setConfirming] = useState(false)
const [executing, setExecuting] = useState(false)
const [result, setResult] = useState<UndoResult | null>(null)
const handleUndo = async () => {
setExecuting(true)
setResult(null)
try {
const res = await executeUndoAction()
setResult(res)
setConfirming(false)
} finally {
setExecuting(false)
}
}
return (
<div className="space-y-4" data-testid="sf-surface-sf-undo">
<PanelHeader
title="Undo Last Unit"
icon={<Undo2 className="h-3.5 w-3.5" />}
onRefresh={() => { setResult(null); setConfirming(false); void loadUndoInfo() }}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Loading undo info…" />}
{/* Result banner */}
{result && (
<div className={cn(
"rounded-lg border px-3 py-2.5 text-xs",
result.success
? "border-success/20 bg-success/5 text-success"
: "border-destructive/20 bg-destructive/5 text-destructive",
)}>
<div className="flex items-center gap-2">
{result.success ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
<span className="font-medium">{result.success ? "Undo Successful" : "Undo Failed"}</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{result.message}</p>
</div>
)}
{data && (
<>
{data.lastUnitType ? (
<>
{/* Last unit info */}
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Last Completed Unit</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px]">
<span className="text-muted-foreground">Type</span>
<span className="font-mono text-foreground/80">{data.lastUnitType}</span>
<span className="text-muted-foreground">ID</span>
<span className="font-mono text-foreground/80 truncate">{data.lastUnitId ?? "—"}</span>
<span className="text-muted-foreground">Key</span>
<span className="font-mono text-foreground/80 truncate">{data.lastUnitKey ?? "—"}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<InfoPill label="Completed Units" value={data.completedCount} />
{data.commits.length > 0 && (
<InfoPill label="Commits" value={data.commits.length} variant="info" />
)}
</div>
{/* Commit SHAs */}
{data.commits.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground">Associated Commits</h4>
<div className="flex flex-wrap gap-1">
{data.commits.map((sha) => (
<Badge key={sha} variant="outline" className="text-[10px] px-1.5 py-0 font-mono">
{sha.slice(0, 8)}
</Badge>
))}
</div>
</div>
)}
{/* Confirmation */}
{!confirming ? (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setConfirming(true)}
disabled={executing || !!result?.success}
className="h-7 gap-1.5 text-xs"
>
<RotateCcw className="h-3 w-3" />
Undo Last Unit
</Button>
) : (
<div className="rounded-lg border border-warning/20 bg-warning/5 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-2 text-xs text-warning">
<AlertTriangle className="h-3.5 w-3.5" />
<span className="font-medium">This will revert the last unit and its git commits.</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void handleUndo()}
disabled={executing}
className="h-7 gap-1.5 text-xs"
>
{executing ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
Confirm Undo
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
disabled={executing}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</div>
)}
</>
) : (
<PanelEmpty message="No completed units to undo" />
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 4. STEER PANEL — Overrides display + steer message form
// ═══════════════════════════════════════════════════════════════════════
export function SteerPanel() {
const workspace = useSFWorkspaceState()
const { loadSteerData, sendSteer } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.steer
const data = state.data as SteerData | null
const busy = state.phase === "loading"
const [message, setMessage] = useState("")
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const handleSend = async () => {
if (!message.trim()) return
setSending(true)
setSent(false)
try {
await sendSteer(message.trim())
setSent(true)
setMessage("")
// Reload overrides after steering
void loadSteerData()
} finally {
setSending(false)
}
}
return (
<div className="space-y-4" data-testid="sf-surface-sf-steer">
<PanelHeader
title="Steer"
icon={<Navigation className="h-3.5 w-3.5" />}
onRefresh={() => { setSent(false); void loadSteerData() }}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Loading steer data…" />}
{/* Success banner */}
{sent && (
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 text-xs text-success flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
Steering message sent successfully.
</div>
)}
{/* Current overrides */}
<div className="space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Current Overrides</h4>
{data?.overridesContent ? (
<div className="rounded-lg border border-border/50 bg-background/50 px-3 py-2.5 text-[11px] font-mono text-foreground/80 whitespace-pre-wrap max-h-[200px] overflow-y-auto leading-relaxed">
{data.overridesContent}
</div>
) : (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 text-[11px] text-muted-foreground italic">
No active overrides
</div>
)}
</div>
{/* Steer message form */}
<div className="space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Send Steering Message</h4>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter steering instructions for the agent…"
className="min-h-[80px] text-xs resize-none"
/>
<Button
type="button"
variant="default"
size="sm"
onClick={() => void handleSend()}
disabled={sending || !message.trim()}
className="h-7 gap-1.5 text-xs"
>
{sending ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Navigation className="h-3 w-3" />}
Send
</Button>
</div>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 5. HOOKS PANEL — Hook entries table
// ═══════════════════════════════════════════════════════════════════════
export function HooksPanel() {
const workspace = useSFWorkspaceState()
const { loadHooksData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.hooks
const data = state.data as HooksData | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="sf-surface-sf-hooks">
<PanelHeader
title="Hooks"
icon={<Layers className="h-3.5 w-3.5" />}
status={data ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{data.entries.length} {data.entries.length === 1 ? "hook" : "hooks"}
</Badge>
) : null}
onRefresh={() => void loadHooksData()}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Loading hooks…" />}
{data && (
<>
{data.entries.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Name</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th className="px-2.5 py-1.5 text-center font-medium text-muted-foreground">Status</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Targets</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cycles</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry: HookStatusEntry) => {
const totalCycles = Object.values(entry.activeCycles).reduce((sum, n) => sum + n, 0)
return (
<tr key={entry.name} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{entry.name}</td>
<td className="px-2.5 py-1.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{entry.type}
</Badge>
</td>
<td className="px-2.5 py-1.5 text-center">
<Badge
variant={entry.enabled ? "secondary" : "outline"}
className={cn(
"text-[10px] px-1.5 py-0",
entry.enabled ? "border-success/30 text-success" : "text-muted-foreground",
)}
>
{entry.enabled ? "enabled" : "disabled"}
</Badge>
</td>
<td className="px-2.5 py-1.5 text-muted-foreground">
{entry.targets.length > 0 ? entry.targets.join(", ") : "all"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">
{totalCycles}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<PanelEmpty message="No hooks configured" />
)}
{/* Formatted status */}
{data.formattedStatus && (
<div className="rounded-lg border border-border/50 bg-background/50 px-3 py-2.5 text-[11px] font-mono text-muted-foreground whitespace-pre-wrap leading-relaxed">
{data.formattedStatus}
</div>
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 6. INSPECT PANEL — SF database overview
// ═══════════════════════════════════════════════════════════════════════
export function InspectPanel() {
const workspace = useSFWorkspaceState()
const { loadInspectData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.inspect
const data = state.data as InspectData | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="sf-surface-sf-inspect">
<PanelHeader
title="Inspect Database"
icon={<Database className="h-3.5 w-3.5" />}
subtitle={data?.schemaVersion != null ? `v${data.schemaVersion}` : null}
onRefresh={() => void loadInspectData()}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Loading database…" />}
{data && (
<>
{/* Counts */}
<div className="flex flex-wrap gap-2">
<InfoPill label="Decisions" value={data.counts.decisions} variant="info" />
<InfoPill label="Requirements" value={data.counts.requirements} variant="info" />
<InfoPill label="Artifacts" value={data.counts.artifacts} />
</div>
{/* Recent decisions */}
{data.recentDecisions.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Recent Decisions ({data.recentDecisions.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Decision</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Choice</th>
</tr>
</thead>
<tbody>
{data.recentDecisions.map((d) => (
<tr key={d.id} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{d.id}</td>
<td className="px-2.5 py-1.5 text-foreground/80 max-w-[200px] truncate">{d.decision}</td>
<td className="px-2.5 py-1.5 text-muted-foreground max-w-[150px] truncate">{d.choice}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Recent requirements */}
{data.recentRequirements.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Recent Requirements ({data.recentRequirements.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Status</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Description</th>
</tr>
</thead>
<tbody>
{data.recentRequirements.map((r) => (
<tr key={r.id} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{r.id}</td>
<td className="px-2.5 py-1.5">
<Badge
variant={r.status === "active" ? "secondary" : "outline"}
className={cn(
"text-[10px] px-1.5 py-0",
r.status === "active" && "border-success/30 text-success",
r.status === "validated" && "border-info/30 text-info",
r.status === "deferred" && "text-muted-foreground",
)}
>
{r.status}
</Badge>
</td>
<td className="px-2.5 py-1.5 text-foreground/80 max-w-[220px] truncate">{r.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{data.recentDecisions.length === 0 && data.recentRequirements.length === 0 && (
<PanelEmpty message="Database is empty — no decisions or requirements recorded" />
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 7. EXPORT PANEL — Format selection + download trigger
// ═══════════════════════════════════════════════════════════════════════
export function ExportPanel() {
const workspace = useSFWorkspaceState()
const { loadExportData } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.exportData
const data = state.data as ExportResult | null
const busy = state.phase === "loading"
const [format, setFormat] = useState<"markdown" | "json">("markdown")
const triggerDownload = (result: ExportResult) => {
const mimeType = result.format === "json" ? "application/json" : "text/markdown"
const blob = new Blob([result.content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = result.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleExport = async () => {
const result = await loadExportData(format)
if (result) triggerDownload(result)
}
return (
<div className="space-y-4" data-testid="sf-surface-sf-export">
<PanelHeader
title="Export"
icon={<Download className="h-3.5 w-3.5" />}
/>
{state.error && <PanelError message={state.error} />}
{/* Format selector */}
<div className="space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Format</h4>
<div className="flex gap-1 rounded-lg border border-border/50 bg-card/50 p-0.5">
{(["markdown", "json"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-[11px] font-medium capitalize transition-colors",
format === f
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-muted-foreground",
)}
>
{f === "markdown" ? "Markdown" : "JSON"}
</button>
))}
</div>
</div>
{/* Export button */}
<Button
type="button"
variant="default"
size="sm"
onClick={() => void handleExport()}
disabled={busy}
className="h-7 gap-1.5 text-xs"
>
{busy ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
Generate Export
</Button>
{/* Download result */}
{data && (
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-2 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="font-medium">Export Ready</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-mono text-muted-foreground">{data.filename}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => triggerDownload(data)}
className="h-6 gap-1 text-[10px]"
>
<Download className="h-2.5 w-2.5" />
Download Again
</Button>
</div>
</div>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 8. CLEANUP PANEL — Branches and snapshots management
// ═══════════════════════════════════════════════════════════════════════
export function CleanupPanel() {
const workspace = useSFWorkspaceState()
const { loadCleanupData, executeCleanupAction } = useSFWorkspaceActions()
const state = workspace.commandSurface.remainingCommands.cleanup
const data = state.data as CleanupData | null
const busy = state.phase === "loading"
const [executing, setExecuting] = useState(false)
const [result, setResult] = useState<CleanupResult | null>(null)
const mergedBranches = data?.branches.filter((b: CleanupBranch) => b.merged) ?? []
const oldSnapshots = data?.snapshots ?? []
const handleCleanup = async (type: "branches" | "snapshots") => {
setExecuting(true)
setResult(null)
try {
const branches = type === "branches" ? mergedBranches.map((b: CleanupBranch) => b.name) : []
const snapshots = type === "snapshots" ? oldSnapshots.map((s: CleanupSnapshot) => s.ref) : []
const res = await executeCleanupAction(branches, snapshots)
setResult(res)
// Reload after cleanup
void loadCleanupData()
} finally {
setExecuting(false)
}
}
return (
<div className="space-y-4" data-testid="sf-surface-sf-cleanup">
<PanelHeader
title="Cleanup"
icon={<Trash2 className="h-3.5 w-3.5" />}
onRefresh={() => { setResult(null); void loadCleanupData() }}
refreshing={busy}
/>
{state.error && <PanelError message={state.error} />}
{busy && !data && <PanelLoading label="Scanning for cleanup targets…" />}
{/* Result banner */}
{result && (
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 text-xs text-success">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="font-medium">Cleanup Complete</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{result.message}</p>
</div>
)}
{data && (
<>
{/* Branches table */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground">Branches ({data.branches.length})</h4>
{mergedBranches.length > 0 && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void handleCleanup("branches")}
disabled={executing}
className="h-6 gap-1 text-[10px]"
>
{executing ? <LoaderCircle className="h-2.5 w-2.5 animate-spin" /> : <Scissors className="h-2.5 w-2.5" />}
Delete Merged ({mergedBranches.length})
</Button>
)}
</div>
{data.branches.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Branch</th>
<th className="px-2.5 py-1.5 text-center font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{data.branches.map((b: CleanupBranch) => (
<tr key={b.name} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[250px]">
<span className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-muted-foreground shrink-0" />
{b.name}
</span>
</td>
<td className="px-2.5 py-1.5 text-center">
<Badge
variant={b.merged ? "secondary" : "outline"}
className={cn(
"text-[10px] px-1.5 py-0",
b.merged ? "border-success/30 text-success" : "text-muted-foreground",
)}
>
{b.merged ? "merged" : "active"}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<PanelEmpty message="No branches to clean up" />
)}
</div>
{/* Snapshots table */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground">Snapshots ({data.snapshots.length})</h4>
{oldSnapshots.length > 0 && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void handleCleanup("snapshots")}
disabled={executing}
className="h-6 gap-1 text-[10px]"
>
{executing ? <LoaderCircle className="h-2.5 w-2.5 animate-spin" /> : <Archive className="h-2.5 w-2.5" />}
Prune Snapshots ({oldSnapshots.length})
</Button>
)}
</div>
{data.snapshots.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Ref</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Date</th>
</tr>
</thead>
<tbody>
{data.snapshots.map((s: CleanupSnapshot) => (
<tr key={s.ref} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[200px]">{s.ref}</td>
<td className="px-2.5 py-1.5 text-right text-muted-foreground">{s.date}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<PanelEmpty message="No snapshots to prune" />
)}
</div>
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 9. QUEUE PANEL — Milestone registry from existing workspace data
// ═══════════════════════════════════════════════════════════════════════
function sliceProgress(slices: WorkspaceSliceTarget[]): { done: number; total: number } {
const done = slices.filter((s) => s.done).length
return { done, total: slices.length }
}
export function QueuePanel() {
const workspace = useSFWorkspaceState()
const workspaceIndex = getLiveWorkspaceIndex(workspace)
const milestones = workspaceIndex?.milestones ?? []
const active = workspaceIndex?.active
return (
<div className="space-y-4" data-testid="sf-surface-sf-queue">
<PanelHeader
title="Queue"
icon={<ListChecks className="h-3.5 w-3.5" />}
status={
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{milestones.length} {milestones.length === 1 ? "milestone" : "milestones"}
</Badge>
}
/>
{milestones.length > 0 ? (
<div className="space-y-2">
{milestones.map((m: WorkspaceMilestoneTarget) => {
const isActive = active?.milestoneId === m.id
const progress = sliceProgress(m.slices)
return (
<div
key={m.id}
className={cn(
"rounded-lg border px-3 py-2.5 space-y-1.5",
isActive
? "border-info/25 bg-info/5"
: "border-border/50 bg-card/50",
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-medium text-foreground/80">{m.id}</span>
<span className="text-xs text-foreground truncate">{m.title}</span>
{isActive && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 border-info/30 text-info">
active
</Badge>
)}
</div>
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{progress.done}/{progress.total} slices
</span>
</div>
{/* Progress bar */}
{progress.total > 0 && (
<div className="h-1 rounded-full bg-border/50 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
progress.done === progress.total ? "bg-success" : "bg-info",
)}
style={{ width: `${(progress.done / progress.total) * 100}%` }}
/>
</div>
)}
{/* Slice list for active milestone */}
{isActive && m.slices.length > 0 && (
<div className="space-y-0.5 pt-1">
{m.slices.map((s: WorkspaceSliceTarget) => (
<div key={s.id} className="flex items-center gap-2 text-[11px]">
{s.done ? (
<CheckCircle2 className="h-3 w-3 text-success shrink-0" />
) : (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full shrink-0",
active?.sliceId === s.id ? "bg-info" : "bg-border/50",
)} />
)}
<span className="font-mono text-muted-foreground">{s.id}</span>
<span className={cn(
"truncate",
s.done ? "text-muted-foreground line-through" : "text-foreground/80",
)}>
{s.title}
</span>
{active?.sliceId === s.id && !s.done && (
<Badge variant="outline" className="text-[9px] px-1 py-0 text-info">current</Badge>
)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
) : (
<PanelEmpty message="No milestones in the plan" />
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// 10. STATUS PANEL — Current active context from workspace data
// ═══════════════════════════════════════════════════════════════════════
export function StatusPanel() {
const workspace = useSFWorkspaceState()
const workspaceIndex = getLiveWorkspaceIndex(workspace)
const active = workspaceIndex?.active
const milestones = workspaceIndex?.milestones ?? []
const currentMilestone = milestones.find((m: WorkspaceMilestoneTarget) => m.id === active?.milestoneId)
const currentSlice = currentMilestone?.slices.find((s: WorkspaceSliceTarget) => s.id === active?.sliceId)
const totalSlices = milestones.reduce((sum: number, m: WorkspaceMilestoneTarget) => sum + m.slices.length, 0)
const doneSlices = milestones.reduce((sum: number, m: WorkspaceMilestoneTarget) => sum + m.slices.filter((s) => s.done).length, 0)
return (
<div className="space-y-4" data-testid="sf-surface-sf-status">
<PanelHeader
title="Status"
icon={<Terminal className="h-3.5 w-3.5" />}
/>
{/* Active context card */}
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-3 space-y-2">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Active Context</h4>
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[11px]">
<span className="text-muted-foreground">Phase</span>
<span className="font-mono text-foreground/80">
{active?.phase ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{active.phase}</Badge>
) : (
<span className="text-muted-foreground italic">idle</span>
)}
</span>
<span className="text-muted-foreground">Milestone</span>
<span className="font-mono text-foreground/80">
{currentMilestone ? (
<span>{currentMilestone.id} {currentMilestone.title}</span>
) : (
<span className="text-muted-foreground italic">none</span>
)}
</span>
<span className="text-muted-foreground">Slice</span>
<span className="font-mono text-foreground/80">
{currentSlice ? (
<span>{currentSlice.id} {currentSlice.title}</span>
) : (
<span className="text-muted-foreground italic">none</span>
)}
</span>
<span className="text-muted-foreground">Task</span>
<span className="font-mono text-foreground/80">
{active?.taskId ?? <span className="text-muted-foreground italic">none</span>}
</span>
</div>
</div>
{/* Overall progress */}
<div className="flex flex-wrap gap-2">
<InfoPill label="Milestones" value={milestones.length} />
<InfoPill label="Slices" value={`${doneSlices}/${totalSlices}`} variant={doneSlices === totalSlices && totalSlices > 0 ? "success" : "info"} />
</div>
{/* Progress bar */}
{totalSlices > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Overall Progress</span>
<span className="tabular-nums">{Math.round((doneSlices / totalSlices) * 100)}%</span>
</div>
<div className="h-1.5 rounded-full bg-border/50 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
doneSlices === totalSlices ? "bg-success" : "bg-info",
)}
style={{ width: `${(doneSlices / totalSlices) * 100}%` }}
/>
</div>
</div>
)}
{milestones.length === 0 && (
<PanelEmpty message="No plan loaded — run /sf to initialize" />
)}
</div>
)
}