feat: add capability playground to discover

This commit is contained in:
lda
2026-08-11 20:30:30 +07:00 Verified
parent 13f56c4c95
commit 0fd4b561c7
12 changed files with 1136 additions and 48 deletions
+340
View File
@@ -1202,6 +1202,332 @@ tbody tr:hover {
line-height: 1.4;
}
/* The selected capability is an operator receipt: facts stay visible, while
contract payloads and execution evidence open only when the operator asks. */
.capability-playground {
position: sticky;
top: 1rem;
min-width: 0;
}
.capability-playground__header,
.capability-playground__receipt-heading,
.capability-playground__try-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.capability-playground__header h2,
.capability-playground__try-heading h3,
.capability-playground__receipt-heading h3 {
min-width: 0;
overflow-wrap: anywhere;
}
.capability-playground__header h2 {
margin-bottom: 0;
}
.capability-playground__source,
.capability-playground__receipt-label {
color: var(--color-slate);
font-family: var(--font-mono);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.capability-playground__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
margin-top: 0.9rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--color-border);
}
.capability-playground__tab {
min-width: 0;
padding: 0.5rem 0.75rem;
border-color: var(--color-border);
background: var(--color-paper);
color: var(--color-ink);
}
.capability-playground__tab:hover:not(:disabled),
.capability-playground__tab[aria-selected="true"] {
border-color: var(--color-ink);
background: var(--color-ink);
color: var(--color-paper);
}
.capability-playground__tab:focus-visible,
.capability-playground__panel:focus-visible,
.capability-playground__schema-block summary:focus-visible {
outline: 2px solid var(--color-signal-green);
outline-offset: 2px;
}
.capability-playground__panel {
min-width: 0;
margin: 0;
padding: 0.95rem 0 0;
border: 0;
background: transparent;
}
.capability-playground__panel > h3,
.capability-playground__try-heading h3,
.capability-playground__receipt-heading h3 {
margin-top: 0;
}
.capability-playground__facts,
.capability-playground__receipt-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
margin: 0 0 0.9rem;
}
.capability-playground__facts div,
.capability-playground__receipt-facts div {
min-width: 0;
padding: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-paper);
}
.capability-playground__facts dt,
.capability-playground__receipt-facts dt {
color: var(--color-slate);
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.capability-playground__facts dd,
.capability-playground__receipt-facts dd {
display: flex;
align-items: baseline;
gap: 0.3rem;
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.capability-playground__description,
.capability-playground__try-heading p {
color: var(--color-slate);
}
.capability-playground__schemas {
display: grid;
gap: 0.65rem;
margin-bottom: 0.9rem;
}
.capability-playground__schema-block h3 {
margin-bottom: 0.25rem;
}
.capability-playground__schema-block details {
border: 1px solid var(--color-border);
background: var(--color-paper);
}
.capability-playground__schema-block summary {
padding: 0.5rem 0.65rem;
cursor: pointer;
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.capability-playground__schema-block pre,
.capability-playground__receipt pre {
max-width: 100%;
margin: 0;
overflow: auto;
padding: 0.7rem;
background: var(--color-ink);
color: var(--color-paper);
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.capability-playground__try-heading {
align-items: center;
}
.capability-playground__try-heading p {
margin: 0;
max-width: 65ch;
}
.capability-playground__warning,
.capability-playground__disabled,
.capability-playground__local-error,
.capability-playground__runtime-note {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin: 0.85rem 0;
padding: 0.65rem 0.75rem;
border: 1px solid var(--color-border);
}
.capability-playground__warning {
border-color: var(--color-amber);
background: rgba(184, 134, 11, 0.08);
}
.capability-playground__warning svg {
flex: 0 0 auto;
color: var(--color-amber);
}
.capability-playground__warning p,
.capability-playground__disabled,
.capability-playground__local-error,
.capability-playground__runtime-note {
margin: 0;
}
.capability-playground__disabled,
.capability-playground__muted {
color: var(--color-slate);
}
.capability-playground__deployment {
display: grid;
gap: 0.3rem;
margin-bottom: 0.8rem;
}
.capability-playground__deployment input[type="text"] {
width: 100%;
min-width: 0;
}
.capability-playground__deployment small {
color: var(--color-slate);
}
.capability-playground__acknowledgement {
display: flex;
flex-basis: auto;
gap: 0.45rem;
align-items: flex-start;
padding: 0.65rem 0.75rem;
border: 1px solid var(--color-border);
background: var(--color-paper);
color: var(--color-ink);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
}
.capability-playground__acknowledgement input {
flex: 0 0 auto;
margin-top: 0.25rem;
accent-color: var(--color-signal-green);
}
.capability-playground__form-fieldset {
min-width: 0;
margin: 0.85rem 0 0;
padding: 0;
border: 0;
}
.capability-playground__local-error {
border-color: var(--color-red);
background: rgba(192, 57, 43, 0.08);
}
.capability-playground__receipt {
min-width: 0;
margin: 1rem 0 0;
padding: 0.85rem;
border: 1px solid var(--color-border);
background: var(--color-paper);
}
.capability-playground__receipt-label {
margin: 0 0 0.25rem;
text-transform: uppercase;
}
.capability-playground__receipt-heading h3 {
margin-bottom: 0;
}
.capability-playground__outcome {
display: flex;
gap: 0.35rem;
align-items: center;
margin: 0;
color: var(--color-signal-green);
font-family: var(--font-heading);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.capability-playground__outcome[data-outcome="runtime-error"] {
color: var(--color-amber);
}
.capability-playground__runtime-note {
border-color: var(--color-amber);
background: rgba(184, 134, 11, 0.08);
}
.capability-playground__receipt-section {
margin-top: 0.9rem;
}
.capability-playground__receipt-section h4 {
margin: 0 0 0.35rem;
font-family: var(--font-heading);
font-size: 0.82rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.capability-playground__diagnostics {
display: grid;
gap: 0.45rem;
margin: 0;
padding-left: 1.1rem;
}
.capability-playground__diagnostics li {
display: grid;
gap: 0.15rem;
overflow-wrap: anywhere;
}
.capability-playground__diagnostic-meta {
color: var(--color-slate);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.capability-playground__diagnostics small {
color: var(--color-slate);
}
.evidence-ledger .evidence-list {
margin: 0;
}
@@ -1704,6 +2030,10 @@ tbody tr:nth-child(10) { animation-delay: 270ms; }
position: static;
}
.capability-playground {
position: static;
}
.capability-discovery__filters {
grid-template-columns: 1fr;
}
@@ -1818,6 +2148,16 @@ tbody tr:nth-child(10) { animation-delay: 270ms; }
align-items: stretch;
}
.capability-playground__facts,
.capability-playground__receipt-facts {
grid-template-columns: 1fr;
}
.capability-playground__receipt-heading,
.capability-playground__try-heading {
flex-direction: column;
}
input[type="text"] {
flex-basis: auto;
}
@@ -3,7 +3,7 @@ import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-
import type { CapabilityDetail, CapabilitySummary } from "../domain/capability-models.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
import { withDiagnosticKeys } from "./diagnostic-key.js";
import { formatBoundedJson } from "./format-bounded-json.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
import { RouteForm } from "./RouteForm.js";
@@ -11,7 +11,7 @@ import {
type SchemaField,
} from "../schema-form/schema-field.js";
import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-values.js";
import { formatBoundedJson } from "./format-bounded-json.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
import {
capabilityLocalPathSuggestions,
@@ -5,7 +5,7 @@ import type {
OutputBinding,
StatePath,
} from "../domain/draft-workspace-models.js";
import { formatBoundedJson } from "./format-bounded-json.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
import {
capabilityLocalPathSuggestions,
inferredStateSchemaPreview,
@@ -0,0 +1,305 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initialState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import type { CapabilityCallResult, CapabilityDetail } from "../domain/capability-models.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
import type {
CapabilityPlaygroundController,
} from "./useCapabilityPlayground.js";
import { useCapabilityPlayground } from "./useCapabilityPlayground.js";
import { CapabilityPlayground } from "./CapabilityPlayground.js";
vi.mock("./useCapabilityPlayground.js", () => ({
useCapabilityPlayground: vi.fn(),
}));
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
const mockedUseCapabilityPlayground = vi.mocked(useCapabilityPlayground);
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const writeExecutor = {} as ConsoleWriteExecutor;
const nodeCapability: CapabilityDetail = {
kind: "node_spec",
name: "local.documents.read",
sourceId: "local.documents",
description: "Read project documents.",
isAsync: false,
outcomes: ["ok", "error"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", title: "Query" },
},
required: ["query"],
},
outputSchema: { type: "object", properties: { documents: { type: "array" } } },
wrapperHints: { input: "query", output: "documents" },
acceptsContext: false,
};
const wrapperCapability: CapabilityDetail = {
kind: "wrapper_artifact",
name: "local.documents.wrapper",
sourceId: "local.documents",
description: "Call the document wrapper.",
isAsync: false,
outcomes: ["ok", "runtime_error"],
inputSchema: nodeCapability.inputSchema,
outputSchema: nodeCapability.outputSchema,
wrapperHints: { input: "query", output: "documents" },
artifactId: "documents-wrapper",
title: "Documents wrapper",
version: 1,
requiredCapabilities: {},
};
const callResult = (overrides: Partial<CapabilityCallResult> = {}): CapabilityCallResult => ({
qualifiedName: nodeCapability.name,
sourceId: nodeCapability.sourceId,
kind: nodeCapability.kind,
deploymentId: null,
outcome: "ok",
output: { documents: ["README.md"] },
diagnostics: [
{
boundSource: "local.documents",
code: "dependency_checked",
logicalRef: "documents",
message: "Document source was checked.",
repairHint: null,
severity: "info",
},
],
...overrides,
});
const controller = (
overrides: Partial<CapabilityPlaygroundController> = {},
): CapabilityPlaygroundController => ({
phase: "idle",
result: null,
message: null,
acknowledged: false,
deploymentId: "",
setAcknowledged: vi.fn(),
setDeploymentId: vi.fn(),
call: vi.fn(),
reset: vi.fn(),
...overrides,
});
const renderPlayground = (capability: CapabilityDetail = nodeCapability) =>
render(
<CapabilityPlayground
capability={capability}
executor={writeExecutor}
target="http://workflow.example/rpc"
/>,
);
beforeEach(() => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: {
...initialState(),
evidence: [
{
id: "call-1",
target: "http://workflow.example/rpc",
operation: "workflow.capabilities.call",
label: "Call capability",
equivalentCli: "wf capability call",
request: {},
response: {},
durationMs: 18,
},
],
},
connectedTarget: "http://workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor,
});
mockedUseCapabilityPlayground.mockReturnValue(controller());
});
afterEach(() => cleanup());
describe("CapabilityPlayground", () => {
it("starts on an accessible Contract view with facts, schemas, and draft action", () => {
renderPlayground();
expect(screen.getByRole("tab", { name: "Contract" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Try capability" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: nodeCapability.name })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add to draft" })).toBeInTheDocument();
});
it("shows the literal-only form and immediate execution warning in Try", async () => {
const user = userEvent.setup();
renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("This calls the capability immediately against the connected workflow server.")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Query" })).toBeInTheDocument();
expect(screen.queryByRole("group", { name: "Value source" })).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /I understand/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Call capability" })).toBeDisabled();
});
it("gates the call behind acknowledgement and never calls for render, tab selection, or edits", async () => {
const user = userEvent.setup();
const call = vi.fn();
mockedUseCapabilityPlayground.mockReturnValue(
controller({ acknowledged: true, call }),
);
renderPlayground();
expect(call).not.toHaveBeenCalled();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
expect(call).not.toHaveBeenCalled();
await user.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
expect(call).not.toHaveBeenCalled();
});
it("passes only literal object values to the controller after acknowledgement", async () => {
const user = userEvent.setup();
const call = vi.fn();
const setAcknowledged = vi.fn();
mockedUseCapabilityPlayground.mockReturnValue(
controller({ acknowledged: true, call, setAcknowledged }),
);
renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
await user.click(screen.getByRole("button", { name: "Call capability" }));
expect(call).toHaveBeenCalledWith({ query: "README.md" });
});
it("shows deployment input only for wrapper capabilities", async () => {
const user = userEvent.setup();
const { unmount } = renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.queryByRole("textbox", { name: "Wrapper deployment ID" })).not.toBeInTheDocument();
unmount();
renderPlayground(wrapperCapability);
await user.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByRole("textbox", { name: "Wrapper deployment ID" })).toBeInTheDocument();
});
it.each([
["calling", controller({ phase: "calling", acknowledged: true }), "Calling capability..."],
["rejected", controller({ phase: "error", message: "Operation rejected by policy" }), "Operation rejected by policy"],
] as const)("renders the %s state inline", async (_label, state, textContent) => {
const user = userEvent.setup();
mockedUseCapabilityPlayground.mockReturnValue(state);
renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
if (state.phase === "calling") {
expect(screen.getByRole("button", { name: textContent })).toBeDisabled();
} else {
expect(screen.getByRole("alert")).toHaveTextContent(textContent);
}
});
it("renders outcome, evidence provenance, diagnostics, and bounded output in the receipt", async () => {
mockedUseCapabilityPlayground.mockReturnValue(
controller({ phase: "result", acknowledged: true, result: callResult() }),
);
renderPlayground();
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.getByText("18 ms")).toBeInTheDocument();
expect(screen.getByText("workflow.capabilities.call")).toBeInTheDocument();
expect(screen.getByText("Document source was checked.")).toBeInTheDocument();
expect(screen.getByLabelText("Capability output")).toHaveTextContent("README.md");
});
it("labels runtime_error as completed without claiming a workflow run or trace", async () => {
mockedUseCapabilityPlayground.mockReturnValue(
controller({
phase: "result",
acknowledged: true,
result: callResult({ outcome: "runtime_error", output: null, diagnostics: [] }),
}),
);
renderPlayground();
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("Completed with runtime error")).toBeInTheDocument();
expect(screen.getByText("No workflow run or trace was created.")).toBeInTheDocument();
});
it("rejects a non-object serialized root locally", async () => {
const user = userEvent.setup();
const call = vi.fn();
mockedUseCapabilityPlayground.mockReturnValue(
controller({ acknowledged: true, call }),
);
renderPlayground({
...nodeCapability,
name: "local.documents.invalid-root",
inputSchema: { type: "array", items: { type: "string" } },
});
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.click(screen.getByRole("button", { name: "Call capability" }));
expect(call).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"Capability inputs must serialize to an object.",
);
});
it("clears the receipt and acknowledgement when the capability changes", async () => {
const firstState = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
const secondState = controller();
mockedUseCapabilityPlayground.mockImplementation((qualifiedName: string | null) =>
qualifiedName === nodeCapability.name ? firstState : secondState,
);
const { rerender } = render(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target="http://workflow.example/rpc"
/>,
);
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("Completed")).toBeInTheDocument();
rerender(
<CapabilityPlayground
capability={{ ...nodeCapability, name: "local.documents.write" }}
executor={writeExecutor}
target="http://workflow.example/rpc"
/>,
);
expect(screen.queryByText("Completed")).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /I understand/i })).not.toBeChecked();
});
});
@@ -0,0 +1,380 @@
import { useState } from "react";
import { AlertCircle, CheckCircle2, Clock3, Play } from "lucide-react";
import type { EvidenceRecord } from "../../app/state.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
import type {
CapabilityCallResult,
CapabilityDetail,
} from "../domain/capability-models.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
import { useConsoleWorkspace } from "../context.js";
import { SchemaForm } from "../schema-form/SchemaForm.js";
import type { SchemaSerializationResult } from "../schema-form/schema-values.js";
import { useCapabilityPlayground } from "./useCapabilityPlayground.js";
export type CapabilityPlaygroundProps = {
readonly capability: CapabilityDetail;
readonly target: string | null;
readonly executor: ConsoleWriteExecutor | null;
readonly onAddToDraft?: () => void;
};
type PlaygroundTab = "contract" | "try";
const formatKind = (kind: CapabilityDetail["kind"]): string =>
kind === "node_spec" ? "Node spec" : "Wrapper artifact";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const latestCallEvidence = (
evidence: ReadonlyArray<EvidenceRecord>,
target: string | null,
): EvidenceRecord | null => {
for (let index = evidence.length - 1; index >= 0; index -= 1) {
const record = evidence[index];
if (record === undefined) continue;
if (
record.operation === "workflow.capabilities.call" &&
(target === null || record.target === target)
) {
return record;
}
}
return null;
};
const schemaText = (value: Record<string, unknown>): string =>
formatBoundedJson(value, 6_000);
const SchemaBlock = ({
heading,
value,
}: {
readonly heading: string;
readonly value: Record<string, unknown>;
}) => (
<div className="capability-playground__schema-block">
<h3>{heading}</h3>
<details>
<summary>View {heading.toLowerCase()}</summary>
<pre aria-label={`${heading} JSON`}>{schemaText(value)}</pre>
</details>
</div>
);
const ContractView = ({
capability,
onAddToDraft,
}: {
readonly capability: CapabilityDetail;
readonly onAddToDraft: (() => void) | undefined;
}) => (
<section
aria-labelledby="capability-playground-contract-heading"
className="capability-playground__panel"
id="capability-playground-contract-panel"
role="tabpanel"
tabIndex={0}
>
<h3 id="capability-playground-contract-heading">Current contract</h3>
<dl className="capability-playground__facts">
<div>
<dt>Kind</dt>
<dd>{formatKind(capability.kind)}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{capability.sourceId}</dd>
</div>
<div>
<dt>Async</dt>
<dd>{capability.isAsync ? "yes" : "no"}</dd>
</div>
<div>
<dt>Outcomes</dt>
<dd>{capability.outcomes.join(", ") || "none"}</dd>
</div>
{capability.kind === "wrapper_artifact" && (
<>
<div>
<dt>Artifact</dt>
<dd>{capability.artifactId}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{capability.version}</dd>
</div>
</>
)}
</dl>
{capability.description && <p className="capability-playground__description">{capability.description}</p>}
<div className="capability-playground__schemas">
<SchemaBlock heading="Input schema" value={capability.inputSchema} />
<SchemaBlock heading="Output schema" value={capability.outputSchema} />
<SchemaBlock heading="Wrapper hints" value={capability.wrapperHints} />
</div>
<button onClick={onAddToDraft} type="button">
Add to draft
</button>
</section>
);
const formatSerializationIssues = (
result: SchemaSerializationResult,
): string => result.issues.map((issue) => issue.message).join(" ");
const outcomeLabel = (outcome: string): string =>
outcome === "runtime_error" ? "Completed with runtime error" : "Completed";
const ResultReceipt = ({
result,
evidence,
}: {
readonly result: CapabilityCallResult;
readonly evidence: EvidenceRecord | null;
}) => (
<section
aria-labelledby="capability-playground-result-heading"
className="capability-playground__receipt"
>
<div className="capability-playground__receipt-heading">
<div>
<p className="capability-playground__receipt-label">Result receipt</p>
<h3 id="capability-playground-result-heading">{result.qualifiedName}</h3>
</div>
<p
className="capability-playground__outcome"
data-outcome={result.outcome === "runtime_error" ? "runtime-error" : "completed"}
>
<CheckCircle2 aria-hidden="true" size={17} strokeWidth={2} />
<span>{outcomeLabel(result.outcome)}</span>
</p>
</div>
<dl className="capability-playground__receipt-facts">
<div>
<dt>Outcome</dt>
<dd>{result.outcome}</dd>
</div>
<div>
<dt>Deployment</dt>
<dd>{result.deploymentId ?? "default"}</dd>
</div>
{evidence && (
<>
<div>
<dt>Evidence operation</dt>
<dd>{evidence.operation}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>
<Clock3 aria-hidden="true" size={14} strokeWidth={1.8} />
{evidence.durationMs} ms
</dd>
</div>
<div>
<dt>Target</dt>
<dd>{evidence.target}</dd>
</div>
</>
)}
</dl>
{result.outcome === "runtime_error" && (
<p className="capability-playground__runtime-note">
No workflow run or trace was created.
</p>
)}
<div className="capability-playground__receipt-section">
<h4>Diagnostics</h4>
{result.diagnostics.length > 0 ? (
<ul className="capability-playground__diagnostics">
{result.diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${index}`}>
<span className="capability-playground__diagnostic-meta">
{diagnostic.severity} / {diagnostic.code}
</span>
<span>{diagnostic.message}</span>
{diagnostic.repairHint && (
<small>Next: {diagnostic.repairHint}</small>
)}
</li>
))}
</ul>
) : (
<p className="capability-playground__muted">No diagnostics returned.</p>
)}
</div>
<div className="capability-playground__receipt-section">
<h4>Output</h4>
{result.output === null ? (
<p className="capability-playground__muted">No output returned.</p>
) : (
<pre aria-label="Capability output">
{formatBoundedJson(result.output)}
</pre>
)}
</div>
</section>
);
const TryView = ({
capability,
target,
executor,
}: {
readonly capability: CapabilityDetail;
readonly target: string | null;
readonly executor: ConsoleWriteExecutor | null;
}) => {
const controller = useCapabilityPlayground(capability.name);
const { connection } = useConsoleWorkspace();
const [localError, setLocalError] = useState<string | null>(null);
const evidence =
controller.phase === "result"
? latestCallEvidence(connection.evidence, target)
: null;
const operationAvailable = executor !== null && target !== null && controller.phase !== "disconnected";
const handleSubmit = (result: SchemaSerializationResult): void => {
setLocalError(null);
if (result.issues.length > 0) {
setLocalError(formatSerializationIssues(result));
return;
}
if (!isRecord(result.value)) {
setLocalError("Capability inputs must serialize to an object.");
return;
}
controller.call(result.value);
};
return (
<section
aria-labelledby="capability-playground-try-heading"
className="capability-playground__panel capability-playground__panel--try"
id="capability-playground-try-panel"
role="tabpanel"
tabIndex={0}
>
<div className="capability-playground__try-heading">
<div>
<h3 id="capability-playground-try-heading">Try capability</h3>
<p>
Use literal values from this form. Nothing runs until you acknowledge the immediate call.
</p>
</div>
<Play aria-hidden="true" size={20} strokeWidth={1.8} />
</div>
<div className="capability-playground__warning" role="note">
<AlertCircle aria-hidden="true" size={18} strokeWidth={1.8} />
<p>This calls the capability immediately against the connected workflow server.</p>
</div>
{!operationAvailable ? (
<p className="capability-playground__disabled" role="status">
Capability calls are unavailable until a workflow server is connected.
</p>
) : (
<>
{capability.kind === "wrapper_artifact" && (
<div className="capability-playground__deployment">
<label htmlFor="capability-wrapper-deployment">Wrapper deployment ID</label>
<input
id="capability-wrapper-deployment"
onChange={(event) => controller.setDeploymentId(event.target.value)}
type="text"
value={controller.deploymentId}
/>
<small>Optional. Leave blank to use the server default.</small>
</div>
)}
<label className="capability-playground__acknowledgement">
<input
checked={controller.acknowledged}
onChange={(event) => controller.setAcknowledged(event.target.checked)}
type="checkbox"
/>
<span>I understand this executes the capability now.</span>
</label>
<fieldset
className="capability-playground__form-fieldset"
disabled={!controller.acknowledged || controller.phase === "calling"}
>
<legend className="visually-hidden">Literal capability inputs</legend>
<SchemaForm
schema={capability.inputSchema}
onValueChange={() => setLocalError(null)}
onSubmit={handleSubmit}
showSourceControls={false}
submitLabel={controller.phase === "calling" ? "Calling capability..." : "Call capability"}
/>
</fieldset>
</>
)}
{localError && (
<p className="capability-playground__local-error" role="alert">
{localError}
</p>
)}
{controller.phase === "error" && controller.message && (
<p className="capability-playground__local-error" role="alert">
{controller.message}
</p>
)}
{controller.phase === "result" && controller.result && (
<ResultReceipt result={controller.result} evidence={evidence} />
)}
</section>
);
};
export const CapabilityPlayground = ({
capability,
target,
executor,
onAddToDraft,
}: CapabilityPlaygroundProps) => {
const [activeTab, setActiveTab] = useState<PlaygroundTab>("contract");
const tabId = (tab: PlaygroundTab): string => `capability-playground-tab-${tab}`;
return (
<section
aria-labelledby="capability-playground-heading"
className="capability-playground"
id="capability-detail"
>
<div className="capability-playground__header">
<h2 id="capability-playground-heading">{capability.name}</h2>
<span className="capability-playground__source">{capability.sourceId}</span>
</div>
<div
aria-label="Capability detail views"
className="capability-playground__tabs"
role="tablist"
>
{([
["contract", "Contract"],
["try", "Try capability"],
] as const).map(([tab, label]) => (
<button
aria-controls={`capability-playground-${tab}-panel`}
aria-selected={activeTab === tab}
className="capability-playground__tab"
id={tabId(tab)}
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
type="button"
>
{label}
</button>
))}
</div>
{activeTab === "contract" ? (
<ContractView capability={capability} onAddToDraft={onAddToDraft} />
) : (
<TryView capability={capability} executor={executor} target={target} />
)}
</section>
);
};
@@ -183,6 +183,11 @@ describe("DiscoverRoute", () => {
);
renderRoute();
expect(screen.getByRole("tab", { name: "Contract" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Try capability" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
@@ -190,6 +195,36 @@ describe("DiscoverRoute", () => {
expect(screen.getByRole("button", { name: "Add to draft" })).toBeInTheDocument();
});
it("keeps a disconnected operation state inline with the selected capability", async () => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({
selected: {
...summary,
isAsync: false,
inputSchema: { type: "object", properties: {} },
outputSchema: {},
wrapperHints: {},
acceptsContext: false,
},
}),
);
mockedUseConsoleWorkspace.mockReturnValue({
connection: initialState(),
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
renderRoute();
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(
screen.getByText("Capability calls are unavailable until a workflow server is connected."),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Capabilities" })).toBeInTheDocument();
});
it("exposes selected row state and associates the result with its detail", () => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({
@@ -1,10 +1,9 @@
import { useState } from "react";
import { Boxes, PackageOpen } from "lucide-react";
import type {
CapabilityDetail,
CapabilitySummary,
} from "../domain/capability-models.js";
import type { CapabilitySummary } from "../domain/capability-models.js";
import { CreateDraftDialog } from "../authoring/CreateDraftDialog.js";
import { useConsoleWorkspace } from "../context.js";
import { CapabilityPlayground } from "./CapabilityPlayground.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
const formatKind = (kind: CapabilitySummary["kind"]): string =>
@@ -15,19 +14,6 @@ const KindIcon = ({ kind }: { readonly kind: CapabilitySummary["kind"] }) => {
return <Icon aria-hidden="true" size={16} strokeWidth={1.8} />;
};
const SchemaBlock = ({
heading,
value,
}: {
readonly heading: string;
readonly value: Record<string, unknown>;
}) => (
<div className="capability-discovery__schema-block">
<h3>{heading}</h3>
<pre>{JSON.stringify(value, null, 2)}</pre>
</div>
);
const CapabilityRow = ({
item,
selected,
@@ -64,33 +50,9 @@ const CapabilityRow = ({
</li>
);
const DetailView = ({
detail,
onAddToDraft,
}: {
readonly detail: CapabilityDetail;
readonly onAddToDraft: () => void;
}) => (
<section aria-labelledby="capability-detail-heading" className="capability-discovery__detail" id="capability-detail">
<p className="workspace-route-pending__eyebrow">Selected contract</p>
<h2 id="capability-detail-heading">{detail.name}</h2>
<dl className="capability-discovery__facts">
<div><dt>Kind</dt><dd>{formatKind(detail.kind)}</dd></div>
<div><dt>Source</dt><dd>{detail.sourceId}</dd></div>
<div><dt>Async</dt><dd>{detail.isAsync ? "yes" : "no"}</dd></div>
<div><dt>Outcomes</dt><dd>{detail.outcomes.join(", ") || "none"}</dd></div>
</dl>
<div className="capability-discovery__schemas">
<SchemaBlock heading="Input schema" value={detail.inputSchema} />
<SchemaBlock heading="Output schema" value={detail.outputSchema} />
<SchemaBlock heading="Wrapper hints" value={detail.wrapperHints} />
</div>
<button onClick={onAddToDraft} type="button">Add to draft</button>
</section>
);
export const DiscoverRoute = () => {
const discovery = useCapabilityDiscovery();
const { connectedTarget, writeExecutor } = useConsoleWorkspace();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const isReady = discovery.phase === "ready";
@@ -174,9 +136,12 @@ export const DiscoverRoute = () => {
</section>
{discovery.selected ? (
<DetailView
detail={discovery.selected}
<CapabilityPlayground
capability={discovery.selected}
executor={writeExecutor}
key={discovery.selected.name}
onAddToDraft={() => setCreateDialogOpen(true)}
target={connectedTarget}
/>
) : (
<section aria-labelledby="capability-detail-empty-heading" className="capability-discovery__detail capability-discovery__detail--empty" id="capability-detail">
@@ -6,7 +6,7 @@ import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import { DraftDetailRoute } from "./DraftDetailRoute.js";
import { formatBoundedJson } from "../authoring/format-bounded-json.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
const globalStyles = readFileSync(
"src/styles/global.css",