feat: compose draft authoring workbench

This commit is contained in:
lda
2026-08-09 06:16:15 +07:00 Verified
parent bc634d000a
commit c037e73bee
13 changed files with 1089 additions and 204 deletions
@@ -97,13 +97,14 @@ describe("DraftDetailRoute", () => {
expect(screen.getByText("Route summarize.ok to __end__.")).toBeInTheDocument();
});
it("keeps the raw draft closed by default and exposes no mutation controls", () => {
it("keeps the raw draft closed and marks deferred mutation controls unavailable", () => {
const { container } = renderRoute();
const details = container.querySelector("details");
expect(details).not.toBeNull();
expect(details).not.toHaveAttribute("open");
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByRole("button", { name: "Undo — Later" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Delete node — Later" })).toBeDisabled();
expect(screen.queryByRole("link", { name: /compile|artifact|save|edit|mutate/i })).toBeNull();
});
@@ -1,194 +1,17 @@
import { Link, useParams } from "react-router-dom";
import type {
DraftDiagnostic,
DraftWorkspace,
} from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
export { formatBoundedJson } from "../authoring/ContextInspector.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
const MAX_RAW_DRAFT_CHARS = 12_000;
const TRUNCATION_MARKER = "... truncated ...";
const titleFor = (workspace: DraftWorkspace): string =>
workspace.title?.trim() || workspace.workspaceId;
const formatStatus = (status: DraftWorkspace["status"]): string =>
status.charAt(0).toUpperCase() + status.slice(1);
const formatValue = (value: unknown): string => {
if (typeof value === "string") return value;
const encoded = JSON.stringify(value);
return encoded ?? String(value);
};
// Traverse until the display budget is exhausted so a large remote object is
// never fully materialized just to produce a clipped escape-hatch preview.
export const formatBoundedJson = (value: unknown, maxChars = MAX_RAW_DRAFT_CHARS): string => {
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
const contentLimit = Math.max(0, maxChars);
let output = "";
let truncated = false;
const activeObjects = new WeakSet<object>();
const append = (chunk: string): void => {
if (truncated) return;
if (output.length + chunk.length > contentLimit) {
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
truncated = true;
return;
}
output += chunk;
};
const appendJsonString = (value: string): void => {
append('"');
for (let index = 0; index < value.length; index++) {
if (truncated) return;
const code = value.charCodeAt(index);
if (code === 0x22) append('\\"');
else if (code === 0x5c) append("\\\\");
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
else if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = value.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
append(value.slice(index, index + 2));
index++;
} else {
append(`\\u${code.toString(16).padStart(4, "0")}`);
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
append(`\\u${code.toString(16).padStart(4, "0")}`);
} else {
append(value.charAt(index));
}
}
if (!truncated) append('"');
};
const visit = (current: unknown, depth: number): void => {
if (truncated) return;
if (current === null || typeof current !== "object") {
if (typeof current === "string") {
appendJsonString(current);
} else if (typeof current === "number") {
append(Number.isFinite(current) ? String(current) : "null");
} else if (typeof current === "boolean") {
append(current ? "true" : "false");
} else {
append("null");
}
return;
}
if (activeObjects.has(current)) {
append('"[Circular]"');
return;
}
activeObjects.add(current);
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (Array.isArray(current)) {
append("[");
let first = true;
for (const item of current) {
if (truncated) break;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
visit(item, depth + 1);
first = false;
}
if (!truncated) append(first ? "]" : `\n${indent}]`);
} else {
const record = current as Record<string, unknown>;
append("{");
let first = true;
for (const key in record) {
if (!Object.prototype.hasOwnProperty.call(current, key) || truncated) continue;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
appendJsonString(key);
append(": ");
visit(record[key], depth + 1);
first = false;
}
if (!truncated) append(first ? "}" : `\n${indent}}`);
}
activeObjects.delete(current);
};
visit(value, 0);
if (!truncated) return output;
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
return `${output.slice(0, markerStart)}${truncationMarker}`;
};
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
<li className="draft-detail__diagnostic">
<dl>
<Fact label="Code" value={diagnostic.code} />
<Fact label="Path" value={diagnostic.path} />
<Fact label="Message" value={diagnostic.message} />
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
</dl>
</li>
);
const DraftFacts = ({ draft }: { readonly draft: DraftWorkspace }) => (
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
<h2 id="draft-detail-summary-heading">Draft summary</h2>
<dl className="draft-detail__facts">
<Fact label="Status" value={formatStatus(draft.status)} />
<Fact label="Revision" value={`Revision ${draft.revision}`} />
<Fact label="Start step" value={formatValue(draft.summary.start)} />
<Fact label="Step count" value={String(draft.summary.stepCount)} />
<Fact label="Route count" value={String(draft.summary.routeCount)} />
</dl>
<h3>Step ids</h3>
<ul className="draft-detail__steps">
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
</ul>
</section>
);
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
{diagnostics.length > 0 ? (
<ol className="draft-detail__diagnostics">
{diagnostics.map((diagnostic, index) => (
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
))}
</ol>
) : (
<p>No diagnostics reported.</p>
)}
</section>
);
const RawDraft = ({ draft }: { readonly draft: Record<string, unknown> | null }) => (
<details className="draft-detail__raw">
<summary>Raw draft document</summary>
{draft ? (
<pre
aria-label="Raw draft JSON, horizontally scrollable"
role="region"
tabIndex={0}
>
{formatBoundedJson(draft)}
</pre>
) : (
<p>Full draft document was not returned</p>
)}
</details>
);
export const DraftDetailRoute = () => {
const { workspaceId = null } = useParams<{ workspaceId: string }>();
const drafts = useDraftWorkspace(workspaceId);
@@ -215,7 +38,7 @@ export const DraftDetailRoute = () => {
{draft && (
<>
<header className="draft-detail__header">
<p className="workspace-route-pending__eyebrow">Read-only draft</p>
<p className="workspace-route-pending__eyebrow">Draft authoring workbench</p>
<h1>{titleFor(draft)}</h1>
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
<p className="draft-detail__status-line">
@@ -226,11 +49,7 @@ export const DraftDetailRoute = () => {
</p>
</header>
<div className="draft-detail__panels">
<DraftFacts draft={draft} />
<Diagnostics diagnostics={draft.diagnostics} />
</div>
<RawDraft draft={draft.draft} />
<DraftWorkbench draft={draft} />
</>
)}
</div>