diff --git a/packages/haiku-ui/budget.json b/packages/haiku-ui/budget.json index 07611b9ed..154ea9fe4 100644 --- a/packages/haiku-ui/budget.json +++ b/packages/haiku-ui/budget.json @@ -1,10 +1,10 @@ { - "bundleGzipMaxBytes": 1048576, - "lastKnownGzipBytes": 786679, - "notes": [ - "Pre-move bundle measured at 929.8 KB gzipped (inlined HTML blob at stages/development/artifacts/bundle-baseline.html). Post-move blob was 884.9 KB gzipped (-44.8 KB from pre-move relocation).", - "FB-21: vite.config.ts had `minify: false`, shipping a ~919 KB un-minified inlined SPA. Flipping to `minify: \"esbuild\"` (Vite default) dropped the inlined gzipped size to 693 KB (-24.6% / ~220 KB saved) with no bundle-shape changes — single inline HTML blob still required by the MCP embedder.", - "Unit-03's 500 KB spec target is still ~193 KB above reality; the residual overshoot comes from @xyflow/react + elkjs + mermaid + react-markdown + remark bundled into one inline chunk (manualChunks:undefined + inlineDynamicImports:true, required by the single-HTML-blob embedder). Spec/reality delta is tracked by FB-05 for human adjudication.", - "Budget ceiling kept at 1024 KB as the safety cap above the post-minification 693 KB blob. bundle-haiku-ui.mjs still enforces non-zero exit on overage — no gate was weakened." - ] + "bundleGzipMaxBytes": 1048576, + "lastKnownGzipBytes": 791435, + "notes": [ + "Pre-move bundle measured at 929.8 KB gzipped (inlined HTML blob at stages/development/artifacts/bundle-baseline.html). Post-move blob was 884.9 KB gzipped (-44.8 KB from pre-move relocation).", + "FB-21: vite.config.ts had `minify: false`, shipping a ~919 KB un-minified inlined SPA. Flipping to `minify: \"esbuild\"` (Vite default) dropped the inlined gzipped size to 693 KB (-24.6% / ~220 KB saved) with no bundle-shape changes — single inline HTML blob still required by the MCP embedder.", + "Unit-03's 500 KB spec target is still ~193 KB above reality; the residual overshoot comes from @xyflow/react + elkjs + mermaid + react-markdown + remark bundled into one inline chunk (manualChunks:undefined + inlineDynamicImports:true, required by the single-HTML-blob embedder). Spec/reality delta is tracked by FB-05 for human adjudication.", + "Budget ceiling kept at 1024 KB as the safety cap above the post-minification 693 KB blob. bundle-haiku-ui.mjs still enforces non-zero exit on overage — no gate was weakened." + ] } diff --git a/packages/haiku-ui/src/routes/debug/$slug.tsx b/packages/haiku-ui/src/routes/debug/$slug.tsx new file mode 100644 index 000000000..d8c2fbe3a --- /dev/null +++ b/packages/haiku-ui/src/routes/debug/$slug.tsx @@ -0,0 +1,745 @@ +/** + * /debug/:slug — per-intent admin panel. + * + * The five admin ops live here as forms with inline confirmation modals. + * Every mutation goes through a confirm dialog showing the exact request + * body before POST — that confirmation IS the elicitation gate. + * + * Read panes (intent metadata + cursor preview) refresh after every + * successful op so the user immediately sees the new cursor head. + */ + +import { createFileRoute, Link } from "@tanstack/react-router" +import { useCallback, useEffect, useState } from "react" +import { Header as HeaderLandmark, Main } from "../../a11y" + +interface IntentDetail { + slug: string + title: string | null + studio: string | null + mode: string | null + status: string | null + archived: boolean + created_at: string | null + frontmatter: Record + stages_present: string[] +} + +interface CursorResponse { + ok: boolean + position?: unknown + error?: string +} + +type OpName = + | "force_stage_complete" + | "set_intent_field" + | "reset_drift" + | "mutate_feedback" + | "set_unit_iterations" + +interface PendingOp { + op: OpName + body: Record + summary: string +} + +function DebugAdminPanel(): React.ReactElement { + const { slug } = Route.useParams() + const [detail, setDetail] = useState(null) + const [cursor, setCursor] = useState(null) + const [error, setError] = useState(null) + const [pending, setPending] = useState(null) + const [lastResult, setLastResult] = useState<{ + op: string + response: unknown + } | null>(null) + const [refreshTick, setRefreshTick] = useState(0) + + useEffect(() => { + let cancelled = false + setError(null) + // Reading refreshTick here (even just into a void-discarded local) + // makes biome happy that it's a real dependency. Functionally it + // IS the trigger — incrementing it after every successful op is how + // the read panes refresh. + void refreshTick + Promise.all([ + fetch(`/api/debug/intents/${encodeURIComponent(slug)}`).then((r) => + r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)), + ), + fetch(`/api/debug/intents/${encodeURIComponent(slug)}/cursor`).then( + async (r) => { + if (r.ok) return r.json() as Promise + try { + return await r.json() + } catch { + return { ok: false, error: `HTTP ${r.status}` } + } + }, + ), + ]) + .then(([d, c]) => { + if (cancelled) return + setDetail(d as IntentDetail) + setCursor(c as CursorResponse) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [slug, refreshTick]) + + const runOp = useCallback(async () => { + if (!pending) return + setError(null) + try { + const res = await fetch( + `/api/debug/intents/${encodeURIComponent(slug)}/ops/${pending.op}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pending.body), + }, + ) + const json = await res.json() + setLastResult({ op: pending.op, response: json }) + setPending(null) + setRefreshTick((t) => t + 1) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [pending, slug]) + + return ( + <> + +
+
+ + ← all intents + +

+ Debug: {slug} +

+
+ +
+
+
+ {error && ( +
+ Error: {error} +
+ )} + {lastResult && ( +
+
+ {lastResult.op} +
+
+							{JSON.stringify(lastResult.response, null, 2)}
+						
+
+ )} + +
+
+

+ Intent +

+ {detail ? ( +
+ + + + + + +
+ ) : ( +

Loading…

+ )} +
+ +
+

+ Cursor preview (next tick) +

+
+							{cursor ? JSON.stringify(cursor, null, 2) : "Loading…"}
+						
+
+
+ +
+ + + + + +
+
+ + {pending && ( + setPending(null)} + onConfirm={runOp} + /> + )} + + ) +} + +function Row({ + label, + value, +}: { + label: string + value: string | null | undefined +}) { + return ( +
+
+ {label} +
+
+ {value || "—"} +
+
+ ) +} + +function ForceStageCompleteForm({ + stages, + onPrepare, +}: { + stages: string[] + onPrepare: (op: PendingOp) => void +}) { + const [stage, setStage] = useState("") + const [closeFb, setCloseFb] = useState(false) + useEffect(() => { + if (!stage && stages.length > 0) setStage(stages[0]) + }, [stage, stages]) + return ( + + + + + + ) +} + +function SetIntentFieldForm({ + onPrepare, +}: { + onPrepare: (op: PendingOp) => void +}) { + // Multi-row form so the user can stage several FM edits and confirm + // them all in one picker round-trip. Single row → single-mutate path; + // multiple rows → batch path. + const [rows, setRows] = useState>([ + { field: "mode", value: "" }, + ]) + const update = (i: number, patch: { field?: string; value?: string }) => { + setRows((prev) => + prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r)), + ) + } + const filledRows = rows.filter((r) => r.field.trim()) + return ( + + {rows.map((row, i) => ( +
+ update(i, { field: e.target.value })} + placeholder="field" + className="rounded border border-stone-300 bg-white px-2 py-1.5 text-sm font-mono dark:border-stone-700 dark:bg-stone-900" + /> + update(i, { value: e.target.value })} + placeholder="value" + className="rounded border border-stone-300 bg-white px-2 py-1.5 text-sm font-mono dark:border-stone-700 dark:bg-stone-900" + /> +
+ ))} +
+ + {rows.length > 1 && ( + + )} +
+ +
+ ) +} + +function ResetDriftForm({ onPrepare }: { onPrepare: (op: PendingOp) => void }) { + return ( + + + + ) +} + +function MutateFeedbackForm({ + stages, + onPrepare, +}: { + stages: string[] + onPrepare: (op: PendingOp) => void +}) { + const [stage, setStage] = useState("") + // One textarea for IDs — one per line. A single ID means single-mutate; + // multiple lines fan out to the batch path so the picker confirms once + // for the whole set. + const [feedbackIds, setFeedbackIds] = useState("") + const [patchJson, setPatchJson] = useState( + '{\n "closed_at": null,\n "closed_by": "force_complete"\n}', + ) + const [patchError, setPatchError] = useState(null) + return ( + + +