feat: add schema approval surface

This commit is contained in:
lda
2026-07-09 05:41:42 +07:00 Verified
parent f5a81106dd
commit a65da60a5c
3 changed files with 263 additions and 0 deletions
@@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SchemaApprovalSurface } from "./SchemaApprovalSurface.js";
afterEach(() => cleanup());
describe("SchemaApprovalSurface", () => {
it("renders explicit schema fields and outcome actions", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
render(
<SchemaApprovalSurface
title="Issue review resume"
schema={{
type: "object",
required: ["selected_issue_ids"],
properties: {
selected_issue_ids: { type: "array", description: "Issue ids to create" },
comment: { type: "string" },
},
}}
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
outcomes={["submitted", "cancelled"]}
runId="run_recorded_lda_report"
onSubmit={onSubmit}
onCancel={onCancel}
/>,
);
const surface = screen.getByRole("group", { name: /issue review resume/i });
expect(within(surface).getByText("selected issue ids")).toBeInTheDocument();
expect(within(surface).getByText("required")).toBeInTheDocument();
expect(within(surface).getByText("[\"risk-1\"]")).toBeInTheDocument();
expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument();
fireEvent.click(within(surface).getByRole("button", { name: /submit/i }));
fireEvent.click(within(surface).getByRole("button", { name: /cancel/i }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("renders payload preview for loose object schemas", () => {
render(
<SchemaApprovalSurface
title="Issue review resume"
schema={{ type: "object" }}
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
outcomes={["submitted", "cancelled"]}
runId="run_recorded_lda_report"
/>,
);
expect(screen.getByText("No additional resume fields are declared by this schema.")).toBeInTheDocument();
expect(screen.getByText("selected_issue_ids")).toBeInTheDocument();
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
});
it("shows submitted and cancelled states without active actions", () => {
const { rerender } = render(
<SchemaApprovalSurface
title="Issue review resume"
schema={{ type: "object" }}
payload={{}}
outcomes={["submitted", "cancelled"]}
runId={null}
state="submitted"
/>,
);
expect(screen.getByText("Outcome: submitted")).toBeInTheDocument();
rerender(
<SchemaApprovalSurface
title="Issue review resume"
schema={{ type: "object" }}
payload={{}}
outcomes={["submitted", "cancelled"]}
runId={null}
state="cancelled"
/>,
);
expect(screen.getByText("Outcome: cancelled")).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
import { buildSchemaApprovalModel } from "./schema-approval-model.js";
export type SchemaApprovalSurfaceProps = {
readonly title: string;
readonly schema: unknown;
readonly payload: unknown;
readonly outcomes: ReadonlyArray<string>;
readonly runId: string | null;
readonly state?: "ready" | "submitted" | "cancelled";
readonly onSubmit?: (() => void) | undefined;
readonly onCancel?: (() => void) | undefined;
};
export const SchemaApprovalSurface = ({
title,
schema,
payload,
outcomes,
runId,
state = "ready",
onSubmit,
onCancel,
}: SchemaApprovalSurfaceProps) => {
const model = buildSchemaApprovalModel({ schema, payload, outcomes });
const isResolved = state !== "ready";
return (
<section className="schema-approval-surface" role="group" aria-label={title} data-state={state}>
<header className="schema-approval-surface__header">
<span>Schema-backed decision</span>
<strong>{title}</strong>
<code>{runId ?? "run unavailable"}</code>
</header>
<div className="schema-approval-surface__body">
{model.hasExplicitFields ? (
<dl className="schema-approval-surface__fields">
{model.fields.map((field) => (
<div key={field.name} className="schema-approval-surface__field" data-kind={field.kind}>
<dt>
<span>{field.label}</span>
{field.required ? <small>required</small> : <small>optional</small>}
</dt>
<dd>
<code>{field.valuePreview ?? "not provided"}</code>
{field.description ? <p>{field.description}</p> : null}
</dd>
</div>
))}
</dl>
) : (
<div className="schema-approval-surface__loose">
<p>No additional resume fields are declared by this schema.</p>
<dl>
{model.payloadPreview.map((entry) => (
<div key={entry.key}>
<dt>{entry.key}</dt>
<dd><code>{entry.value}</code></dd>
</div>
))}
</dl>
</div>
)}
</div>
<footer className="schema-approval-surface__actions">
{isResolved ? (
<strong>Outcome: {state}</strong>
) : (
<>
<button type="button" onClick={onSubmit} disabled={!onSubmit}>
Submit
</button>
<button type="button" onClick={onCancel} disabled={!onCancel}>
Cancel
</button>
</>
)}
<span>{model.outcomes.join(" / ")}</span>
</footer>
</section>
);
};
@@ -925,3 +925,97 @@
.demo-outcome-panel small {
color: var(--text-secondary);
}
/* --- schema approval surface --- */
.schema-approval-surface {
display: grid;
gap: 0.75rem;
min-width: 0;
padding: 0.85rem;
border: 1px solid color-mix(in oklch, var(--accent-amber) 48%, var(--stage-line));
border-radius: 0.85rem;
background:
linear-gradient(135deg, color-mix(in oklch, var(--accent-amber) 16%, transparent), transparent 48%),
var(--stage-surface);
color: var(--text-primary);
}
.schema-approval-surface__header,
.schema-approval-surface__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.schema-approval-surface__header span,
.schema-approval-surface__actions span,
.schema-approval-surface__field small {
color: var(--text-muted);
font: 700 0.68rem/1 var(--font-mono);
}
.schema-approval-surface__header strong {
font: 700 1rem/1.1 var(--font-display);
}
.schema-approval-surface__header code,
.schema-approval-surface__field code,
.schema-approval-surface__loose code {
font: 600 0.72rem/1.35 var(--font-mono);
color: var(--accent-cyan);
}
.schema-approval-surface__fields,
.schema-approval-surface__loose dl {
display: grid;
gap: 0.5rem;
margin: 0;
}
.schema-approval-surface__field,
.schema-approval-surface__loose dl > div {
display: grid;
grid-template-columns: minmax(8rem, 0.55fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
padding: 0.55rem;
border: 1px solid color-mix(in oklch, var(--stage-line) 70%, transparent);
border-radius: 0.6rem;
background: color-mix(in oklch, var(--stage-inset) 82%, transparent);
}
.schema-approval-surface__field dt {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin: 0;
}
.schema-approval-surface__field dd,
.schema-approval-surface__loose dd {
display: grid;
gap: 0.3rem;
margin: 0;
}
.schema-approval-surface__field p,
.schema-approval-surface__loose p {
margin: 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.schema-approval-surface__actions button {
border: 1px solid var(--stage-line);
border-radius: 0.55rem;
padding: 0.48rem 0.7rem;
background: color-mix(in oklch, var(--stage-surface) 78%, var(--accent-cyan));
color: var(--text-primary);
font: 700 0.78rem/1 var(--font-mono);
}
.schema-approval-surface__actions button:disabled {
opacity: 0.55;
}