summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx216
1 files changed, 208 insertions, 8 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index ada8a3d..87102a2 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -16,11 +16,13 @@ import {
failDirectiveStep,
skipDirectiveStep,
stopTask,
+ listDirectiveRevisions,
} from "../lib/api";
import type {
DirectiveStatus,
DirectiveSummary,
DirectiveWithSteps,
+ DirectiveRevision,
} from "../lib/api";
// Status dot color, matching the existing tabular UI's badge palette so the
@@ -365,6 +367,27 @@ function DirectiveFolder({
const orchestratorRunning = !!directive.orchestratorTaskId;
// Tasks subfolder open state — independent of the directive folder.
const [tasksOpen, setTasksOpen] = useState<boolean>(true);
+ // Revisions subfolder — collapsed by default since most contracts have
+ // no merged history yet.
+ const [revisionsOpen, setRevisionsOpen] = useState<boolean>(false);
+ const [revisions, setRevisions] = useState<DirectiveRevision[]>([]);
+ // Fetch revisions only when the parent folder is open. Re-fetch whenever
+ // the directive's pr_url changes so a freshly-raised PR appears.
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ listDirectiveRevisions(directive.id)
+ .then((res) => {
+ if (!cancelled) setRevisions(res.revisions);
+ })
+ .catch((err) => {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] failed to load revisions", err);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, directive.id, directive.prUrl]);
return (
<div className="select-none">
@@ -477,6 +500,61 @@ function DirectiveFolder({
</ul>
)}
</li>
+
+ {/* revisions/ subfolder — per-PR frozen snapshots of this contract.
+ Only rendered when there's at least one revision; otherwise the
+ folder body would be a confusing empty placeholder. */}
+ {revisions.length > 0 && (
+ <li>
+ <button
+ type="button"
+ onClick={() => setRevisionsOpen((p) => !p)}
+ className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ <Caret open={revisionsOpen} />
+ <FolderIcon open={revisionsOpen} />
+ <span className="truncate flex-1 text-left">revisions/</span>
+ <span className="text-[10px] text-[#556677]">
+ {revisions.length}
+ </span>
+ </button>
+
+ {revisionsOpen && (
+ <ul className="py-0.5">
+ {revisions.map((r) => {
+ const isSelected =
+ selection?.directiveId === directive.id &&
+ selection?.taskId === `revision:${r.id}`;
+ return (
+ <li key={r.id}>
+ <button
+ type="button"
+ onClick={() =>
+ onSelect({
+ directiveId: directive.id,
+ taskId: `revision:${r.id}`,
+ })
+ }
+ title={`v${r.version} · ${r.prState} · ${r.prUrl}`}
+ className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
+ isSelected
+ ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+ }`}
+ >
+ <FileIcon />
+ <span className="truncate flex-1">
+ v{r.version}.md
+ </span>
+ <RevisionStateBadge prState={r.prState} />
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </li>
+ )}
</ul>
)}
</div>
@@ -484,6 +562,115 @@ function DirectiveFolder({
}
/**
+ * Read-only viewer for a frozen contract revision. We render the markdown as
+ * plain pre-formatted text — these are immutable historical records, not
+ * places to edit. A header strip shows the PR state and a deep link.
+ */
+function RevisionViewer({
+ directiveId,
+ revisionId,
+}: {
+ directiveId: string;
+ revisionId: string;
+}) {
+ const [revision, setRevision] = useState<DirectiveRevision | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ listDirectiveRevisions(directiveId)
+ .then((res) => {
+ if (cancelled) return;
+ const found = res.revisions.find((r) => r.id === revisionId) ?? null;
+ if (!found) setError("Revision not found");
+ setRevision(found);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [directiveId, revisionId]);
+
+ if (loading) {
+ return (
+ <div className="flex-1 flex items-center justify-center">
+ <p className="text-[#556677] font-mono text-[12px]">Loading revision…</p>
+ </div>
+ );
+ }
+ if (error || !revision) {
+ return (
+ <div className="flex-1 flex items-center justify-center">
+ <p className="text-red-400 font-mono text-[12px]">
+ {error ?? "Revision not found"}
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]">
+ <div className="flex-1 overflow-y-auto">
+ <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]">
+ <div className="flex items-center gap-3 mb-1">
+ <h1 className="text-[24px] font-medium text-white tracking-tight">
+ v{revision.version}
+ </h1>
+ <RevisionStateBadge prState={revision.prState} />
+ </div>
+ <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-1">
+ Frozen {new Date(revision.frozenAt).toLocaleString()}
+ </p>
+ <p className="text-[11px] font-mono text-[#7788aa] mb-8">
+ <a
+ href={revision.prUrl}
+ target="_blank"
+ rel="noreferrer"
+ className="text-[#75aafc] hover:text-[#9bc3ff] underline"
+ >
+ {revision.prUrl}
+ </a>
+ </p>
+
+ {/* Render the frozen markdown as plain pre-formatted text. We
+ deliberately do not parse it into rich nodes — the goal is to
+ show the historical record exactly as it was at PR time. */}
+ <pre className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-[#e0eaf8]">
+ {revision.content}
+ </pre>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+/** Tiny pill showing the PR state of a revision (open / merged / closed). */
+function RevisionStateBadge({ prState }: { prState: string }) {
+ const tone =
+ prState === "merged"
+ ? "text-emerald-300 border-emerald-700/60"
+ : prState === "closed"
+ ? "text-[#7788aa] border-[#2a3a5a]"
+ : "text-amber-300 border-amber-600/40";
+ return (
+ <span
+ className={`text-[9px] font-mono uppercase border rounded px-1 py-0 ${tone}`}
+ >
+ {prState}
+ </span>
+ );
+}
+
+/**
* Right-side status indicator. Composes the colored status dot with optional
* "live" pulse (orchestrator running) and "glow" attention ring (pending user
* question waiting on a response).
@@ -760,14 +947,25 @@ function EditorShell({
);
}
+ // The "task" param can encode either a real task id, or a revision via the
+ // `revision:<uuid>` prefix. Split that out so the right pane can switch
+ // between the live task stream and the read-only revision viewer.
+ const revisionId =
+ selectedTaskId && selectedTaskId.startsWith("revision:")
+ ? selectedTaskId.slice("revision:".length)
+ : null;
+ const realTaskId = revisionId ? null : selectedTaskId;
+
// Resolve the label for the breadcrumb when a task is selected.
- const taskLabel = selectedTaskId
- ? selectedTaskId === directive.orchestratorTaskId
+ const taskLabel = realTaskId
+ ? realTaskId === directive.orchestratorTaskId
? "orchestrator"
- : selectedTaskId === directive.completionTaskId
+ : realTaskId === directive.completionTaskId
? "completion"
- : directive.steps.find((s) => s.taskId === selectedTaskId)?.name ??
- selectedTaskId.slice(0, 8)
+ : directive.steps.find((s) => s.taskId === realTaskId)?.name ??
+ realTaskId.slice(0, 8)
+ : revisionId
+ ? "revision"
: null;
return (
@@ -800,10 +998,12 @@ function EditorShell({
</div>
</div>
- {selectedTaskId ? (
+ {revisionId ? (
+ <RevisionViewer directiveId={directive.id} revisionId={revisionId} />
+ ) : realTaskId ? (
<DocumentTaskStream
- taskId={selectedTaskId}
- label={taskLabel ?? selectedTaskId.slice(0, 8)}
+ taskId={realTaskId}
+ label={taskLabel ?? realTaskId.slice(0, 8)}
/>
) : (
<DocumentEditor