feat: edit selected-step dataflow

This commit is contained in:
lda
2026-08-10 01:00:23 +07:00 Verified
parent 664cc897f4
commit 7453d6b21b
15 changed files with 672 additions and 169 deletions
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDetail } from "../domain/capability-models.js";
@@ -102,9 +102,9 @@ describe("ContextInspector", () => {
expect(screen.getByRole("textbox", { name: "Step id" })).toHaveAttribute("readonly");
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Existing title");
expect(screen.getByRole("textbox", { name: "Source path for Count" })).toHaveValue("input.count");
expect(screen.getAllByText("Bind")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("input.count");
});
it("keeps deferred actions focusable while keyboard activation does not dispatch", async () => {
@@ -162,7 +162,7 @@ describe("ContextInspector", () => {
},
{
code: "invalid_node_input_field",
path: "nodes[0].input[0].target",
path: "bindings[0].target",
message: "Destination field is not declared.",
stepId: "read",
repairHint: null,
@@ -183,10 +183,11 @@ describe("ContextInspector", () => {
/>,
);
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
expect(screen.getByRole("textbox", { name: "Title" })).toHaveAttribute(
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveAttribute(
"aria-invalid",
"true",
);
@@ -231,6 +232,7 @@ describe("ContextInspector", () => {
expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue(
"Locally edited description",
);
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue(
"Locally edited title",
);
@@ -1,18 +1,13 @@
import type { ReactNode } from "react";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { CapabilityDetail, CapabilitySummary } from "../domain/capability-models.js";
import type { SchemaValueIssue } from "../schema-form/schema-values.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
import { withDiagnosticKeys } from "./diagnostic-key.js";
import { formatBoundedJson } from "./format-bounded-json.js";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
import { RouteForm } from "./RouteForm.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
import {
canonicalCapabilityFormData,
capabilityFormDataFromValue,
} from "./canonical-capability-form.js";
import { parseTOMLPath } from "../schema-form/schema-paths.js";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
@@ -40,107 +35,6 @@ const diagnosticParts = (path: string): string[] => {
return normalized.split(".").filter((part) => part.length > 0);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const schemaPathParts = (value: unknown): ReadonlyArray<string | number> | null => {
if (typeof value === "string") {
const parts = parseTOMLPath(value);
return parts?.map((part) => /^\d+$/.test(part) ? Number(part) : part) ?? null;
}
if (!isRecord(value) || value.root !== "local" || !Array.isArray(value.parts)) return null;
return value.parts.every((part): part is string => typeof part === "string")
? value.parts.map((part) => /^\d+$/.test(part) ? Number(part) : part)
: null;
};
const nodeIdForDiagnostic = (
draft: DraftWorkspace,
parts: ReadonlyArray<string>,
): string | null => {
const nodeIndexPart = parts[parts.indexOf("nodes") + 1];
const nodeIndex = nodeIndexPart === undefined ? NaN : Number(nodeIndexPart);
if (!Number.isInteger(nodeIndex) || nodeIndex < 0 || !isRecord(draft.draft)) return null;
const nodes = draft.draft.nodes;
if (Array.isArray(nodes)) {
const node = nodes[nodeIndex];
return isRecord(node) && typeof node.id === "string" ? node.id : null;
}
const steps = draft.draft.steps;
if (!isRecord(steps)) return null;
const stepIds = Object.keys(steps).toSorted();
return stepIds[nodeIndex] ?? null;
};
const inputBindingsForStep = (
draft: DraftWorkspace,
stepId: string,
): ReadonlyArray<unknown> | null => {
if (!isRecord(draft.draft)) return null;
const steps = draft.draft.steps;
if (isRecord(steps) && isRecord(steps[stepId]) && Array.isArray(steps[stepId].input)) {
return steps[stepId].input;
}
const nodes = draft.draft.nodes;
if (!Array.isArray(nodes)) return null;
const node = nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
return isRecord(node) && Array.isArray(node.input) ? node.input : null;
};
const fieldDiagnostic = (
diagnostic: DraftDiagnostic,
stepId: string,
draft: DraftWorkspace,
): SchemaValueIssue | null => {
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return null;
const parts = diagnosticParts(diagnostic.path);
const stepIndex = parts.findIndex((part) => part === stepId);
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return null;
const nodeIndex = parts.indexOf("nodes");
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return null;
const inputIndex = parts.indexOf("input");
if (inputIndex < 0) return null;
const inputPath = parts.slice(inputIndex + 1);
const bindingIndex = inputPath[0] === undefined ? NaN : Number(inputPath[0]);
if (Number.isInteger(bindingIndex) && bindingIndex >= 0) {
const bindings = inputBindingsForStep(draft, stepId);
const binding = bindings?.[bindingIndex];
if (!isRecord(binding)) return null;
const target = schemaPathParts(binding.target);
return target === null ? null : { path: target, message: diagnostic.message };
}
return { path: inputPath, message: diagnostic.message };
};
const metadataDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
stepId: string,
draft: DraftWorkspace,
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
const parts = diagnosticParts(diagnostic.path);
const stepIndex = parts.findIndex((part) => part === stepId);
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
const nodeIndex = parts.indexOf("nodes");
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return [];
if (parts.includes("input")) return [];
const field = parts.at(-1);
return field === "desc" || field === "retry" || field === "timeout_seconds"
? [{ path: [field], message: diagnostic.message }]
: [];
});
const inputDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
stepId: string,
draft: DraftWorkspace,
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
const issue = fieldDiagnostic(diagnostic, stepId, draft);
return issue === null ? [] : [issue];
});
const routeDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
selection: Extract<WorkbenchSelection, { readonly kind: "edge" }>,
@@ -323,49 +217,18 @@ export const ContextInspector = ({
);
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
const preservedForm =
controller.preservedCapabilityForm?.kind === "update" &&
controller.preservedCapabilityForm.input.stepId === selection.nodeId
? capabilityFormDataFromValue(controller.preservedCapabilityForm.input)
: null;
const formData = canonicalCapabilityFormData(draft, selection.nodeId) ?? preservedForm;
const unsupported = node?.data.kind === "unsupported";
content = (
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
<p className="workspace-route-pending__eyebrow">Selected step</p>
<h2 id="node-selection-heading">{selection.nodeId}</h2>
<dl className="authoring-inspector__facts">
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
<Fact label="Reference" value={node?.data.nodeRef ?? formData?.capabilityName ?? "none"} />
</dl>
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
{!unsupported && capabilityDetailPhase === "loading" && (
<p role="status">Loading capability schema...</p>
)}
{!unsupported && capabilityDetailPhase === "error" && (
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
)}
{!unsupported && capabilityDetailPhase === "ready" && capabilityDetail !== null && (() => {
if (formData === null) return <p role="status">Canonical node data is unavailable.</p>;
return (
<CapabilityNodeForm
key={`node:${selection.nodeId}:${controller.resetGeneration}`}
capabilityName={formData.capabilityName}
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId, draft)}
initialInputSources={formData.initialInputSources}
initialInputValue={formData.initialInputValue}
initialValue={formData.initialValue}
inputSchema={capabilityDetail.inputSchema}
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId, draft)}
onDirtyChange={controller.markDirty}
onSubmit={controller.updateCapability}
onValueChange={(value) => controller.rememberCapabilityForm("update", value)}
stepIdReadOnly
submitLabel="Apply changes"
/>
);
})()}
</section>
<SelectedCapabilityInspector
key={selection.nodeId}
capabilityDetail={capabilityDetail}
capabilityDetailMessage={capabilityDetailMessage}
capabilityDetailPhase={capabilityDetailPhase}
controller={controller}
draft={draft}
nodeRef={node?.data.nodeRef ?? null}
stepId={selection.nodeId}
{...(node?.data.kind ? { nodeKind: node.data.kind } : {})}
/>
);
}
@@ -56,6 +56,18 @@ const capabilityDetail: CapabilityDetail = {
acceptsContext: false,
};
const dataflowCapabilityDetail: CapabilityDetail = {
...capabilityDetail,
inputSchema: {
type: "object",
properties: { title: { type: "string" } },
},
outputSchema: {
type: "object",
properties: { text: { type: "string" } },
},
};
const setViewport = (width: number): void => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
@@ -202,6 +214,50 @@ describe("DraftWorkbench", () => {
expect(container.querySelector(".draft-workbench")).toHaveAttribute("data-dirty", "true");
});
it("keeps the selected dataflow tab and unsaved rows across mobile close and reopen", async () => {
setViewport(390);
mockedUseAuthoringCapabilityDetail.mockReturnValue({
phase: "ready",
detail: dataflowCapabilityDetail,
message: null,
});
const user = userEvent.setup();
const { container } = render(
<DraftWorkbench
draft={workspace}
initialSelection={{ kind: "node", nodeId: "collect" }}
/>,
);
const inspector = container.querySelector("#draft-workbench-inspector") as HTMLElement;
await user.click(
within(inspector).getByRole("tab", { name: "Inputs", hidden: true }),
);
await user.click(
within(inspector).getByRole("button", { name: "Add input row", hidden: true }),
);
await user.type(
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
"title",
);
await user.click(
within(inspector).getByRole("tab", { name: "Outputs", hidden: true }),
);
const inspectorTrigger = screen.getByRole("button", { name: "Open context inspector" });
await user.click(inspectorTrigger);
await user.click(screen.getByRole("button", { name: "Close context inspector" }));
expect(inspectorTrigger).toHaveFocus();
await user.click(inspectorTrigger);
expect(
within(inspector).getByRole("tab", { name: "Outputs" }),
).toHaveAttribute("aria-selected", "true");
expect(
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
).toHaveValue("title");
});
it("reopens an open desktop sheet as a modal after resizing to mobile", async () => {
setViewport(1024);
const user = userEvent.setup();
@@ -0,0 +1,219 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDetail } from "../domain/capability-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
afterEach(() => cleanup());
const detail: CapabilityDetail = {
kind: "node_spec",
name: "demo.read",
sourceId: "demo",
description: "Read a report",
isAsync: false,
outcomes: ["ok"],
inputSchema: { type: "object", properties: { title: { type: "string" } } },
outputSchema: { type: "object", properties: { text: { type: "string" } } },
wrapperHints: {},
acceptsContext: false,
};
const draft = (stepId: string, input: unknown, output: unknown): DraftWorkspace => ({
workspaceId: "draft-report",
revision: 3,
title: "Report",
status: "invalid",
diagnostics: [],
summary: { name: "report", start: stepId, stepCount: 1, routeCount: 0, steps: [stepId] },
draft: {
state_schema: { type: "object", properties: { existing: { type: "string" } } },
steps: {
[stepId]: {
use: "demo.read",
input,
output,
desc: "Read the report",
retry: 2,
timeout_seconds: 45,
},
},
routes: {},
},
});
const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => ({
draft: workspace,
selection: { kind: "node", nodeId: "read" },
insertionContext: null,
dirty: false,
phase: "idle",
message: null,
resetGeneration: 0,
addCapability: vi.fn(),
updateCapability: vi.fn(),
setStepInputs: vi.fn(),
setStepOutputs: vi.fn(),
updateSetup: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
reload: vi.fn(),
reapply: vi.fn(),
rememberCapabilityForm: vi.fn(),
rememberRouteForm: vi.fn(),
select: vi.fn(),
markDirty: vi.fn(),
preservedCapabilityForm: null,
});
describe("SelectedCapabilityInspector", () => {
it("composes setup, inputs, and outputs while preserving malformed rows and dispatching focused saves", async () => {
const user = userEvent.setup();
const workspace = draft(
"read",
[
{ target: "title", value: "Existing title" },
{ target: "broken", value: () => "not JSON" },
],
[{ source: "text", target: "state.existing" }],
);
const controller = controllerFor(workspace);
render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(3);
expect(screen.getByRole("tab", { name: "Setup" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("region", { name: "Raw unsupported input row 2" })).toHaveTextContent("broken");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(controller.setStepInputs).not.toHaveBeenCalled();
expect(screen.getAllByRole("alert").some((alert) =>
alert.textContent?.includes("Remove or repair every unsupported input row before saving.") ?? false,
)).toBe(true);
await user.click(screen.getByRole("button", { name: "Remove unsupported input row 2" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(controller.setStepInputs).toHaveBeenCalledWith([{ target: "title", value: "Existing title" }]);
await user.click(screen.getByRole("tab", { name: "Outputs" }));
await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(controller.setStepOutputs).toHaveBeenCalledWith([
{ source: "text", target: "state.existing" },
]);
await user.click(screen.getByRole("tab", { name: "Setup" }));
await user.click(screen.getByRole("button", { name: "Save setup" }));
expect(controller.updateSetup).toHaveBeenCalledWith({});
});
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
const user = userEvent.setup();
const workspace = draft(
"read",
[{ target: "title", value: "Existing title" }],
[{ source: "text", target: "state.existing" }],
);
const controller = controllerFor(workspace);
render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
fireEvent.change(screen.getByRole("spinbutton", { name: "Retry", hidden: true }), {
target: { value: "-1" },
});
await user.click(screen.getByRole("button", { name: "Save setup" }));
await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
await user.click(screen.getByRole("tab", { name: "Outputs" }));
await user.clear(screen.getByRole("combobox", { name: "Target for output row 1" }));
await user.click(screen.getByRole("button", { name: "Save outputs" }));
const diagnosticIds = [...document.querySelectorAll('[id$="-error"], [id$="-errors"]')]
.map((element) => element.id);
expect(new Set(diagnosticIds).size).toBe(diagnosticIds.length);
expect(diagnosticIds.length).toBeGreaterThanOrEqual(3);
});
it("rehydrates canonical rows when the selected step changes", async () => {
const first = draft("first", [{ target: "title", value: "First" }], []);
const second = draft("second", [{ target: "title", value: "Second" }], []);
const controller = controllerFor(first);
const { rerender } = render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={first}
nodeKind="use"
nodeRef="demo.read"
stepId="first"
/>,
);
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
rerender(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={{ ...controller, draft: second }}
draft={second}
key="second"
nodeKind="use"
nodeRef="demo.read"
stepId="second"
/>,
);
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
});
it.each([
["loading", "Loading capability schema..."],
["error", "Capability schema failed to load."],
] as const)("shows capability detail %s feedback", (phase, message) => {
const workspace = draft("read", [], []);
render(
<SelectedCapabilityInspector
capabilityDetail={phase === "loading" ? null : detail}
capabilityDetailMessage={phase === "error" ? null : message}
capabilityDetailPhase={phase}
controller={controllerFor(workspace)}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
expect(screen.getByRole(phase === "error" ? "alert" : "status")).toHaveTextContent(message);
});
});
@@ -0,0 +1,203 @@
import { useState, type ReactNode } from "react";
import type { CapabilityDetail } from "../domain/capability-models.js";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import { CapabilitySetupForm } from "./CapabilitySetupForm.js";
import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js";
import {
bindingDiagnosticsForStep,
inputBindingRows,
outputBindingRows,
projectSelectedStepDataflow,
} from "./selected-step-dataflow.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
type InspectorTab = "setup" | "inputs" | "outputs";
export type SelectedCapabilityInspectorProps = {
readonly draft: DraftWorkspace;
readonly stepId: string;
readonly nodeKind?: string;
readonly nodeRef: string | null;
readonly controller: DraftAuthoringController;
readonly capabilityDetail: CapabilityDetail | null;
readonly capabilityDetailPhase: "disconnected" | "loading" | "ready" | "error";
readonly capabilityDetailMessage: string | null;
};
type JsonRecord = Readonly<Record<string, unknown>>;
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const selectedStep = (draft: DraftWorkspace, stepId: string): JsonRecord | null => {
if (!isRecord(draft.draft)) return null;
if (Array.isArray(draft.draft.nodes)) {
const node = draft.draft.nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
return isRecord(node) ? node : null;
}
const steps = draft.draft.steps;
return isRecord(steps) && isRecord(steps[stepId]) ? steps[stepId] : null;
};
const diagnosticParts = (path: string): ReadonlyArray<string> => {
const normalized = path.startsWith("/")
? path.slice(1).replaceAll("~1", "/").replaceAll("~0", "~")
: path.replace(/\[([^\]]+)\]/g, ".$1");
return normalized.split(".").filter((part) => part.length > 0);
};
const setupDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
stepId: string,
): ReadonlyArray<{ readonly path: ReadonlyArray<string>; readonly message: string }> =>
diagnostics.flatMap((diagnostic) => {
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
const parts = diagnosticParts(diagnostic.path);
const stepIndex = parts.indexOf(stepId);
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
const field = parts.at(-1);
return field === "desc" || field === "retry" || field === "timeout_seconds"
? [{ path: [field], message: diagnostic.message }]
: [];
});
const emptyStateSchema = { type: "object", properties: {} };
const tabLabels: Record<InspectorTab, string> = { setup: "Setup", inputs: "Inputs", outputs: "Outputs" };
const tabPanelId = (tab: InspectorTab): string => `selected-step-panel-${tab}`;
const tabId = (tab: InspectorTab): string => `selected-step-tab-${tab}`;
const TabPanel = ({
activeTab,
children,
tab,
}: {
readonly activeTab: InspectorTab;
readonly children: ReactNode;
readonly tab: InspectorTab;
}) => (
<section
aria-labelledby={tabId(tab)}
className="selected-capability-inspector__panel"
hidden={activeTab !== tab}
id={tabPanelId(tab)}
role="tabpanel"
>
{children}
</section>
);
export const SelectedCapabilityInspector = ({
draft,
stepId,
nodeKind,
nodeRef,
controller,
capabilityDetail,
capabilityDetailPhase,
capabilityDetailMessage,
}: SelectedCapabilityInspectorProps) => {
const [activeTab, setActiveTab] = useState<InspectorTab>("setup");
const rawStep = selectedStep(draft, stepId);
const projected = projectSelectedStepDataflow(draft, stepId);
const preservedForm = controller.preservedCapabilityForm?.kind === "update" &&
controller.preservedCapabilityForm.input.stepId === stepId
? controller.preservedCapabilityForm.input
: null;
// Forms receive raw-row projections so malformed persisted entries stay in order.
const inputRows = inputBindingRows(rawStep?.input ?? preservedForm?.inputBindings);
const outputRows = outputBindingRows(rawStep?.output);
const inputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "input", projected?.compiledNodeIndex ?? null);
const outputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "output", projected?.compiledNodeIndex ?? null);
const setupInitialValue = projected === null
? preservedForm ?? {}
: {
...(projected.description !== undefined ? { description: projected.description } : {}),
...(projected.retry !== undefined ? { retry: projected.retry } : {}),
...(projected.timeoutSeconds !== undefined ? { timeoutSeconds: projected.timeoutSeconds } : {}),
};
const detailReady = capabilityDetailPhase === "ready" && capabilityDetail !== null;
const isUnsupported = nodeKind !== undefined && nodeKind !== "use";
return (
<section className="selected-capability-inspector" aria-label="Selected step editor">
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
<p className="workspace-route-pending__eyebrow">Selected step</p>
<h2 id="node-selection-heading">{stepId}</h2>
<dl className="authoring-inspector__facts">
<div><dt>Kind</dt><dd>{nodeKind ?? "use"}</dd></div>
<div><dt>Reference</dt><dd>{nodeRef ?? projected?.capabilityName ?? "none"}</dd></div>
</dl>
<label className="selected-capability-inspector__step-id">
Step id
<input aria-label="Step id" readOnly value={stepId} />
</label>
{isUnsupported && <p role="status">Read-only: unsupported step kind.</p>}
</section>
{!isUnsupported && (
<>
<div aria-label="Selected step views" className="selected-capability-inspector__tabs" role="tablist">
{(Object.keys(tabLabels) as InspectorTab[]).map((tab) => (
<button
aria-controls={tabPanelId(tab)}
aria-selected={activeTab === tab}
className="selected-capability-inspector__tab"
id={tabId(tab)}
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
tabIndex={activeTab === tab ? 0 : -1}
type="button"
>
{tabLabels[tab]}
</button>
))}
</div>
{!detailReady && capabilityDetailPhase === "loading" && <p role="status">Loading capability schema...</p>}
{!detailReady && capabilityDetailPhase === "error" && (
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
)}
{!detailReady && capabilityDetailPhase === "disconnected" && (
<p role="status">Connect to inspect the capability schema.</p>
)}
{detailReady && (
<>
<TabPanel activeTab={activeTab} tab="setup">
<CapabilitySetupForm
key={`setup:${stepId}:${controller.resetGeneration}`}
diagnostics={setupDiagnostics(draft.diagnostics, stepId)}
initialValue={setupInitialValue}
onDirtyChange={controller.markDirty}
onSubmit={controller.updateSetup}
/>
</TabPanel>
<TabPanel activeTab={activeTab} tab="inputs">
<StepInputBindingsForm
key={`inputs:${stepId}:${controller.resetGeneration}`}
initialRows={inputRows}
inputSchema={capabilityDetail.inputSchema}
onDirtyChange={controller.markDirty}
onSubmit={controller.setStepInputs}
rowDiagnostics={inputDiagnostics.rowIssues}
/>
</TabPanel>
<TabPanel activeTab={activeTab} tab="outputs">
<StepOutputBindingsForm
key={`outputs:${stepId}:${controller.resetGeneration}`}
initialRows={outputRows}
onDirtyChange={controller.markDirty}
onSubmit={controller.setStepOutputs}
outputSchema={capabilityDetail.outputSchema}
rowDiagnostics={outputDiagnostics.rowIssues}
stateSchema={(isRecord(draft.draft) ? draft.draft.state_schema : null) ?? emptyStateSchema}
/>
</TabPanel>
</>
)}
</>
)}
</section>
);
};
@@ -51,6 +51,47 @@ describe("projectAuthoringGraph", () => {
expect(projectAuthoringGraph(reordered)).toEqual(projectAuthoringGraph(draft));
});
it("summarizes canonical selected-step bindings with truthful grammar", () => {
const model = projectAuthoringGraph({
...draft,
steps: {
collect: {
use: "demo.collect",
input: [
{ target: "title", value: "Report" },
{ target: "count", path: "input.count" },
],
output: [{ source: "text", target: "state.report" }],
},
review: draft.steps.review,
},
});
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe(
"2 inputs · 1 state write",
);
});
it("uses singular labels and omits empty binding summaries", () => {
const model = projectAuthoringGraph({
...draft,
steps: {
collect: {
use: "demo.collect",
input: [{ target: "title", value: "Report" }],
output: [{ source: ".", target: "state.report" }, { source: ".", target: "state.raw" }],
},
review: draft.steps.review,
},
});
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe(
"1 input · 2 state writes",
);
const empty = projectAuthoringGraph({ ...draft, steps: { collect: { use: "demo.collect" } } });
expect(empty.nodes.find((node) => node.id === "collect")?.data.summary).toBeUndefined();
});
});
describe("WorkbenchSelection", () => {
@@ -1,4 +1,5 @@
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
import { inputBindingRows, outputBindingRows } from "./selected-step-dataflow.js";
type JsonRecord = Readonly<Record<string, unknown>>;
@@ -67,6 +68,18 @@ const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
detail: stringValue(step.desc),
};
if (kind === "use") {
const inputCount = inputBindingRows(step.input).filter((row) => row.kind === "canonical").length;
const outputCount = outputBindingRows(step.output).filter((row) => row.kind === "canonical").length;
if (inputCount > 0 || outputCount > 0) {
const inputLabel = `${inputCount} input${inputCount === 1 ? "" : "s"}`;
const outputLabel = `${outputCount} state write${outputCount === 1 ? "" : "s"}`;
node.summary = [inputCount > 0 ? inputLabel : null, outputCount > 0 ? outputLabel : null]
.filter((value): value is string => value !== null)
.join(" · ");
}
}
if (kind === "use") node.node = stringValue(step.use) ?? id;
if (kind === "interrupt") {
node.kind = stringValue(payload?.kind) ?? "Interrupt";