- 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>
1610 lines
52 KiB
TypeScript
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'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 <description>
|
|
</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'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>
|
|
);
|
|
}
|