singularity-forge/web/components/sf/remaining-command-panels.tsx
Mikael Hugo 22cbd83675 fix: update test snapshots for queryInstruction and complete /sf prefix Phase 2 deprecation
- Fix memory-embeddings-llm-gateway tests: add queryInstruction field to
  expected config objects after loadGatewayConfigFromEnv was updated to
  return it
- Add STYLEGUIDE.md: SF code standards adapted from ace-coder patterns
  (purpose doctrine, principles, anti-patterns STY001-012, thresholds,
  naming, patterns, documentation sections)
- Phase 2 /sf prefix removal: update all web components, browser dispatch,
  and tests to use direct commands (/autonomous, /stop, /next, /discuss,
  /init, /new-milestone) instead of /sf-prefixed forms
  - workflow-actions.ts: all command strings updated
  - chat-mode.tsx: SF_ACTIONS array updated
  - project-welcome.tsx: primaryCommand values updated
  - command-surface.tsx: fallback display updated
  - remaining-command-panels.tsx: usage examples updated
  - browser-slash-command-dispatch.ts: add stop/new-milestone/init to
    SF_PASSTHROUGH_COMMANDS so they route correctly to the extension
  - recovery-diagnostics-service.ts: suggestion commands updated
  - welcome-screen.ts: hint text updated
  - All affected tests updated to match new command strings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-09 00:17:47 +02:00

1610 lines
52 KiB
TypeScript

"use client";
import {
AlertTriangle,
Archive,
CheckCircle2,
Clock,
Database,
Download,
GitBranch,
Layers,
ListChecks,
LoaderCircle,
Navigation,
RefreshCw,
RotateCcw,
Scissors,
Terminal,
Trash2,
Undo2,
XCircle,
Zap,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type {
CleanupBranch,
CleanupData,
CleanupResult,
CleanupSnapshot,
ExportResult,
HistoryData,
HistoryModelAggregate,
HistoryPhaseAggregate,
HistorySliceAggregate,
HookStatusEntry,
HooksData,
InspectData,
SteerData,
UndoInfo,
UndoResult,
} from "@/lib/remaining-command-types";
import {
formatCost,
getLiveWorkspaceIndex,
useSFWorkspaceActions,
useSFWorkspaceState,
type WorkspaceMilestoneTarget,
type WorkspaceSliceTarget,
} from "@/lib/sf-workspace-store";
import { cn } from "@/lib/utils";
// ═══════════════════════════════════════════════════════════════════════
// 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">
/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">
/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 /init to initialize" />
)}
</div>
);
}