Files
VolcanicCore/docs/superpowers/plans/2026-08-30-plan-5-node-editor-dialog.md

96 KiB
Raw Permalink Blame History

VolcanicCore — Plan 5: Node Editor Dialog (N8N-style)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship an N8N-style node editor. Double-click a node on the canvas → three-column modal (Input / Parameters / Output). All parameter fields are expression editors: {input.foo} tokens are colored inline and previewed live; clicking a leaf in the Input tree inserts a reference at the caret. Number/boolean/integer schema fields survive expressions via a small coercion pass on both save (client) and run (executor).

Architecture: Almost entirely frontend. One backend change: after Executor interpolates config strings, coerce string leaves back to the schema-declared type for integer / number / boolean fields so nodes that check JsonValueKind.Number still work when their value came from a template. Frontend introduces a client-side port of TemplateEngine.Render (behavior parity locked by mirroring all 17 backend tests case-for-case) and three new components (JsonTree, ExpressionField, NodeEditorDialog). SchemaForm is rewritten to use ExpressionField for every leaf. The old right-side NodeConfigPanel is deleted; its WebhookUrlHint block moves into the dialog.

Tech Stack: Same as Plans 3–4. No new runtime deps. Vitest + React Testing Library + userEvent for frontend tests; xUnit for backend tests.

Spec: docs/superpowers/specs/2026-08-30-node-editor-dialog-design.md Preceding plans: Plans 1–4 (all shipped). Head at plan start: 744e19e. Follow-ups this plan clears: none (this plan itself files one new follow-up in §10 of the spec — the webhook stand-by mode).

Divergences from the spec: none.


File structure at end of plan

frontend/src/
  expr/                                         # NEW
    templateEngine.ts
    templateEngine.test.ts
    JsonTree.tsx
    JsonTree.test.tsx
    ExpressionField.tsx
    ExpressionField.test.tsx
  editor/
    nodeIoLookup.ts                             # NEW
    nodeIoLookup.test.ts                        # NEW
    NodeEditorDialog.tsx                        # NEW
    NodeEditorDialog.test.tsx                   # NEW
    Canvas.tsx                                  # CHANGED — onNodeDoubleClick
    NodeConfigPanel.tsx                         # DELETED
    graphAdapter.ts                             # CHANGED — coerceConfigForSave
    graphAdapter.test.ts                        # CHANGED — coercion tests
  forms/
    SchemaForm.tsx                              # CHANGED — uses ExpressionField
    SchemaForm.test.tsx                         # CHANGED — expression-aware tests
  pages/
    EditorPage.tsx                              # CHANGED — dialog wiring, panel removed

src/VolcanicCore.Api/Engine/
  Executor.cs                                   # CHANGED — CoerceToSchema after InterpolateStrings

tests/VolcanicCore.Tests/
  ExecutorTests.cs                              # CHANGED — 2 new coercion tests

frontend/e2e/
  smoke.spec.ts                                 # CHANGED — extended with dialog edit

How to run

Backend:

dotnet build
dotnet test

Frontend unit / integration:

pnpm --dir frontend test

Frontend E2E (needs a one-time pnpm exec playwright install chromium):

pnpm --dir frontend test:e2e

Live smoke (optional during development):

DATA_DIRECTORY=$(pwd)/_smoke ADMIN_PASSWORD=hunter2 \
  dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099
# in another terminal:
pnpm --dir frontend dev
# → open http://127.0.0.1:5173

Commit convention: Conventional Commits. Use Claude's identity:

git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "..."

Task 1: Client-side TemplateEngine port

Ports VolcanicCore.Core.TemplateEngine.Render to TypeScript with byte-exact behavioral parity. All 17 backend test cases mirrored; if a backend case is added later, this file gains it too.

Files:

  • Create: frontend/src/expr/templateEngine.ts

  • Create: frontend/src/expr/templateEngine.test.ts

  • Step 1: Write the failing tests (all 17 cases at once)

// frontend/src/expr/templateEngine.test.ts
import { describe, it, expect } from "vitest";
import { render } from "./templateEngine";

const J = (json: string) => JSON.parse(json);

describe("templateEngine.render — parity with VolcanicCore.Core.TemplateEngine", () => {
  it("no placeholders returns the template unchanged", () => {
    expect(render("hello world", J(`{"x":1}`))).toBe("hello world");
  });

  it("whole input renders as compact json", () => {
    expect(render("got: {input}", J(`{"a":1,"b":"x"}`))).toBe(`got: {"a":1,"b":"x"}`);
  });

  it("top-level string field renders without json quotes", () => {
    expect(render("value: {input.test}", J(`{"test":"hello"}`))).toBe("value: hello");
  });

  it("nested path lookup", () => {
    expect(render("{input.a.b.c}", J(`{"a":{"b":{"c":42}}}`))).toBe("42");
  });

  it("array index lookup", () => {
    expect(render("{input.tags[1]}", J(`{"tags":["x","y","z"]}`))).toBe("y");
  });

  it("missing property renders as empty string", () => {
    expect(render("value: {input.missing}", J(`{"other":1}`))).toBe("value: ");
  });

  it("missing nested path renders as empty string", () => {
    expect(render("[{input.a.b.c}]", J(`{"a":{"b":{}}}`))).toBe("[]");
  });

  it("array index out of range renders as empty string", () => {
    expect(render("<{input.tags[9]}>", J(`{"tags":["only"]}`))).toBe("<>");
  });

  it("numeric and boolean and null leaves render as their string form", () => {
    expect(render("{input.n}|{input.f}|{input.b}|{input.z}", J(`{"n":42,"f":1.5,"b":true,"z":null}`)))
      .toBe("42|1.5|true|");
  });

  it("object and array leaves render as compact json", () => {
    expect(render("{input.o}/{input.a}", J(`{"o":{"k":1},"a":[1,2]}`))).toBe(`{"k":1}/[1,2]`);
  });

  it("doubled braces render as literal single braces", () => {
    expect(render("{{not a template}}", J(`{}`))).toBe("{not a template}");
  });

  it("doubled braces around a real placeholder", () => {
    expect(render("{{{input.x}}}", J(`{"x":"hello"}`))).toBe("{hello}");
  });

  it("null input renders placeholders as empty string", () => {
    expect(render("hi {input.name}", null)).toBe("hi ");
  });

  it("whole input placeholder with null input renders empty", () => {
    expect(render("{input}", null)).toBe("");
  });

  it("unrecognised placeholder is left as literal text", () => {
    expect(render("{node.foo}", J(`{"foo":1}`))).toBe("{node.foo}");
  });

  it("unclosed brace is left as literal text", () => {
    expect(render("start {input.x and more", J(`{"x":1}`))).toBe("start {input.x and more");
  });

  it("bracketed string key lookup", () => {
    expect(render(`{input["weird.key"]}`, J(`{"weird.key":"v"}`))).toBe("v");
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run templateEngine

Expected: FAIL — Cannot find module './templateEngine'.

  • Step 3: Implement the engine
// frontend/src/expr/templateEngine.ts
//
// Client-side port of VolcanicCore.Core.TemplateEngine.Render. MUST stay
// byte-exact with the backend — the dialog preview shows the user what the
// executor will produce. Divergence is a bug. Backend tests live in
// tests/VolcanicCore.Tests/TemplateEngineTests.cs; add a matching case here
// when adding one there.

export function render(template: string, input: unknown): string {
  if (!template || template.indexOf("{") < 0) return template;

  let out = "";
  let i = 0;
  while (i < template.length) {
    const c = template[i];

    if (c === "{") {
      if (template[i + 1] === "{") {
        out += "{";
        i += 2;
        continue;
      }
      const end = template.indexOf("}", i + 1);
      if (end < 0) {
        out += template.slice(i);
        break;
      }
      const expr = template.slice(i + 1, end);
      const evald = tryEvaluate(expr, input);
      if (evald === undefined) {
        out += "{" + expr + "}";
      } else {
        out += evald;
      }
      i = end + 1;
      continue;
    }

    if (c === "}" && template[i + 1] === "}") {
      out += "}";
      i += 2;
      continue;
    }

    out += c;
    i++;
  }
  return out;
}

// Returns undefined if the expression is not one we own (so the caller
// passes it through verbatim), or a string (possibly empty) if it is.
function tryEvaluate(expr: string, input: unknown): string | undefined {
  expr = expr.trim();
  if (!expr.startsWith("input")) return undefined;

  let rest = expr.slice(5);

  // input is null/undefined → any well-formed `input`-rooted expression renders empty
  if (input === null || input === undefined) {
    if (rest.length === 0) return "";
    return isWellFormedTail(rest) ? "" : undefined;
  }

  let walker: unknown = input;
  let have = true;
  while (rest.length > 0) {
    if (rest[0] === ".") {
      let j = 1;
      while (j < rest.length && isIdentChar(rest[j])) j++;
      if (j === 1) return undefined;
      const name = rest.slice(1, j);
      if (have && isPlainObject(walker) && name in walker) walker = walker[name];
      else have = false;
      rest = rest.slice(j);
      continue;
    }
    if (rest[0] === "[") {
      const close = rest.indexOf("]");
      if (close < 0) return undefined;
      const inside = rest.slice(1, close).trim();
      if (inside.length >= 2 && inside[0] === `"` && inside[inside.length - 1] === `"`) {
        const key = inside.slice(1, -1);
        if (have && isPlainObject(walker) && key in walker) walker = walker[key];
        else have = false;
      } else if (/^-?\d+$/.test(inside)) {
        const idx = Number(inside);
        if (have && Array.isArray(walker) && idx >= 0 && idx < walker.length) walker = walker[idx];
        else have = false;
      } else {
        return undefined;
      }
      rest = rest.slice(close + 1);
      continue;
    }
    return undefined;
  }
  return have ? renderValue(walker) : "";
}

function isWellFormedTail(tail: string): boolean {
  let i = 0;
  while (i < tail.length) {
    if (tail[i] === ".") {
      let j = i + 1;
      while (j < tail.length && isIdentChar(tail[j])) j++;
      if (j === i + 1) return false;
      i = j;
    } else if (tail[i] === "[") {
      const close = tail.slice(i).indexOf("]");
      if (close < 0) return false;
      i += close + 1;
    } else {
      return false;
    }
  }
  return true;
}

function renderValue(v: unknown): string {
  if (v === null || v === undefined) return "";
  if (typeof v === "string") return v;
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  return JSON.stringify(v);
}

function isIdentChar(c: string): boolean {
  return /[A-Za-z0-9_]/.test(c);
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run templateEngine

Expected: PASS — 17/17 tests green.

  • Step 5: Commit
git add frontend/src/expr/templateEngine.ts frontend/src/expr/templateEngine.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): client-side TemplateEngine port with parity tests"

Task 2: Backend schema coercion in Executor

After InterpolateStrings runs, walk the top-level properties of the node's schema; for each integer/number/boolean property whose value is now a JSON string, parse it to its typed form. Failure throws InvalidOperationException which the existing per-node try/catch surfaces as a normal node failure.

Files:

  • Modify: src/VolcanicCore.Api/Engine/Executor.cs

  • Test: tests/VolcanicCore.Tests/ExecutorTests.cs

  • Step 1: Write the failing tests

Append at the bottom of tests/VolcanicCore.Tests/ExecutorTests.cs, right before the closing } of the ExecutorTests class:

// --- coercion of templated primitives ---------------------------------

// A test node that requires status to be a JSON number, mirroring
// WebResponseNode's real check pattern. We assert on ctx.Config here.
private sealed class NumberFieldNode : IActionNode
{
    public List<INodeContext> Calls { get; } = new();
    public string Id { get; init; } = "test.numberfield";
    public string DisplayName => Id;
    public NodeCategory Category => NodeCategory.Terminal;
    public int InputCount => 1;
    public int OutputCount => 0;
    public string? ConnectionProviderId => null;
    public JsonSchema ConfigSchema => new(JsonDocument.Parse("""
        {
          "type": "object",
          "properties": { "status": { "type": "integer" } }
        }
    """).RootElement);
    public Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
        INodeContext ctx, IReadOnlyList<IReadOnlyList<Item>> _, CancellationToken __)
    {
        // Strict JsonValueKind.Number check — the coercion pass must produce
        // a number here, not a string.
        var s = ctx.Config.GetProperty("status");
        if (s.ValueKind != JsonValueKind.Number)
            throw new InvalidOperationException($"expected number, got {s.ValueKind}");
        lock (Calls) Calls.Add(ctx);
        return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(Array.Empty<IReadOnlyList<Item>>());
    }
}

[Fact]
public async Task Template_into_integer_config_field_is_coerced_to_number()
{
    var rec = new NumberFieldNode();
    var (er, nr, exec, _) = Build(new TestTrigger(), rec);

    var graph = new WorkflowGraph
    {
        Nodes = new[]
        {
            new WorkflowNode("t", "test.trigger",     null, "{}"),
            new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
        },
        Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
    };

    var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = 201 }));
    var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
        new[] { trig }, null, null, CancellationToken.None);

    Assert.Equal("completed", er.Get(eid)!.Status);
    var got = rec.Calls.Single().Config.GetProperty("status");
    Assert.Equal(JsonValueKind.Number, got.ValueKind);
    Assert.Equal(201, got.GetInt32());
}

[Fact]
public async Task Non_parseable_integer_config_fails_node_with_clear_message()
{
    var rec = new NumberFieldNode();
    var (er, nr, exec, _) = Build(new TestTrigger(), rec);

    var graph = new WorkflowGraph
    {
        Nodes = new[]
        {
            new WorkflowNode("t", "test.trigger",     null, "{}"),
            new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
        },
        Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
    };

    var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = "abc" }));
    var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
        new[] { trig }, null, null, CancellationToken.None);

    Assert.Equal("failed", er.Get(eid)!.Status);
    var row = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
    Assert.Equal("failed", row.Status);
    Assert.NotNull(row.Error);
    Assert.Contains("expected integer", row.Error!);
    Assert.Contains("status", row.Error!);
    Assert.Empty(rec.Calls);
}
  • Step 2: Run tests to verify they fail
dotnet test --filter "FullyQualifiedName~ExecutorTests.Template_into_integer_config_field_is_coerced_to_number|FullyQualifiedName~ExecutorTests.Non_parseable_integer_config_fails_node_with_clear_message"

Expected: FAIL — first test throws inside NumberFieldNode.ExecuteAsync because status is still a JsonValueKind.String after InterpolateStrings. The failure bubbles up as run.Status == "failed", not "completed". Second test also fails: row.Error won't contain "expected integer"; it'll say "expected number, got String" from the node's own guard.

  • Step 3: Implement CoerceToSchema in Executor

In src/VolcanicCore.Api/Engine/Executor.cs, change RunNodeAsync to call the new coercion helper right after InterpolateStrings:

private async Task<NodeResult> RunNodeAsync(
    string executionId, string workflowId, WorkflowNode node, IActionNode action,
    IReadOnlyList<IReadOnlyList<Item>> byPort, Guid? answerChannelId, CancellationToken ct)
{
    var inputsJson = SerializeInputs(byPort);
    using var cfgDoc = JsonDocument.Parse(node.ConfigJson);
    var firstInput = byPort.Count > 0 && byPort[0].Count > 0 ? byPort[0][0].Json : (JsonElement?)null;
    var templatedConfig = InterpolateStrings(cfgDoc.RootElement, firstInput);
    // After templating, coerce string leaves that the schema declares as
    // integer/number/boolean back to their typed JSON form. Nodes check
    // ValueKind strictly, so a templated `"{input.code}" -> "201"` string
    // would silently miss the ValueKind.Number check without this pass.
    var coercedConfig = CoerceToSchema(templatedConfig, _nodes.Get(node.TypeId).ConfigSchema);
    var ctx = new ExecutionContext(
        executionId, workflowId, node.Id, coercedConfig,
        answerChannelId, _lf.CreateLogger($"node.{node.Id}"),
        node.ConnectionId, _instances);

    var startedAt = DateTimeOffset.UtcNow;
    try
    {
        var outputs = await action.ExecuteAsync(ctx, byPort, ct);
        return new NodeResult(true, outputs, inputsJson, startedAt, DateTimeOffset.UtcNow, null);
    }
    catch (Exception ex)
    {
        return new NodeResult(false, null, inputsJson, startedAt, DateTimeOffset.UtcNow, ex.Message);
    }
}

// Walks the top-level properties of the schema. For each property whose
// declared type is integer/number/boolean, if the config leaf is a JSON
// string (post-interpolation), parse it back to its typed form. Missing
// properties, non-primitive schema types, and string-typed schema fields
// are left untouched. Throws InvalidOperationException with a readable
// message on parse failure — the caller surfaces this as a normal node
// failure with the message stored on the node execution row.
private static JsonElement CoerceToSchema(JsonElement config, JsonSchema schema)
{
    if (config.ValueKind != JsonValueKind.Object) return config;
    if (schema.Root.ValueKind != JsonValueKind.Object) return config;
    if (!schema.Root.TryGetProperty("properties", out var props)
        || props.ValueKind != JsonValueKind.Object) return config;

    // Fast path: nothing to coerce → skip the rewrite.
    var anyNeedsCoerce = false;
    foreach (var prop in props.EnumerateObject())
    {
        var t = TypeOf(prop.Value);
        if (t is null || t == "string") continue;
        if (!config.TryGetProperty(prop.Name, out var leaf)) continue;
        if (leaf.ValueKind == JsonValueKind.String) { anyNeedsCoerce = true; break; }
    }
    if (!anyNeedsCoerce) return config;

    var buffer = new System.IO.MemoryStream();
    using (var w = new Utf8JsonWriter(buffer))
    {
        w.WriteStartObject();
        foreach (var p in config.EnumerateObject())
        {
            w.WritePropertyName(p.Name);
            if (props.TryGetProperty(p.Name, out var propSchema)
                && p.Value.ValueKind == JsonValueKind.String)
            {
                var t = TypeOf(propSchema);
                var s = p.Value.GetString() ?? "";
                switch (t)
                {
                    case "integer":
                        if (long.TryParse(s, System.Globalization.NumberStyles.Integer,
                                System.Globalization.CultureInfo.InvariantCulture, out var i))
                            w.WriteNumberValue(i);
                        else
                            throw new InvalidOperationException(
                                $"config '{p.Name}': expected integer, got '{s}'");
                        break;
                    case "number":
                        if (double.TryParse(s, System.Globalization.NumberStyles.Float,
                                System.Globalization.CultureInfo.InvariantCulture, out var d))
                            w.WriteNumberValue(d);
                        else
                            throw new InvalidOperationException(
                                $"config '{p.Name}': expected number, got '{s}'");
                        break;
                    case "boolean":
                        if (s == "true") w.WriteBooleanValue(true);
                        else if (s == "false") w.WriteBooleanValue(false);
                        else
                            throw new InvalidOperationException(
                                $"config '{p.Name}': expected boolean, got '{s}'");
                        break;
                    default:
                        p.Value.WriteTo(w);
                        break;
                }
            }
            else
            {
                p.Value.WriteTo(w);
            }
        }
        w.WriteEndObject();
    }
    return JsonDocument.Parse(buffer.ToArray()).RootElement.Clone();
}

private static string? TypeOf(JsonElement schema)
{
    if (schema.ValueKind != JsonValueKind.Object) return null;
    if (!schema.TryGetProperty("type", out var t) || t.ValueKind != JsonValueKind.String) return null;
    return t.GetString();
}
  • Step 4: Run tests to verify they pass
dotnet test --filter "FullyQualifiedName~ExecutorTests.Template_into_integer_config_field_is_coerced_to_number|FullyQualifiedName~ExecutorTests.Non_parseable_integer_config_fails_node_with_clear_message"

Expected: PASS — both cases green.

  • Step 5: Run full backend suite to confirm no regressions
dotnet test

Expected: PASS — 79/79 (77 existing + 2 new).

  • Step 6: Commit
git add src/VolcanicCore.Api/Engine/Executor.cs tests/VolcanicCore.Tests/ExecutorTests.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(executor): coerce templated string leaves to schema-declared primitive types"

Task 3: Client-side coerceConfigForSave in graphAdapter

Mirror the backend coercion at save time: if a value is a plain literal (no {), and the schema says integer/number/boolean, save as native JSON type. If the value contains {, keep it as a string so the executor's runtime pass handles it.

Files:

  • Modify: frontend/src/editor/graphAdapter.ts

  • Test: frontend/src/editor/graphAdapter.test.ts

  • Step 1: Write the failing tests

Append at the bottom of frontend/src/editor/graphAdapter.test.ts:

import type { NodeTypeDto } from "@/api/types";

describe("toGraph — schema-typed coercion on save", () => {
  const numberNodeType: NodeTypeDto = {
    id: "test.numberfield",
    displayName: "Number Field",
    category: "Action",
    inputCount: 1,
    outputCount: 0,
    connectionProviderId: null,
    configSchema: {
      type: "object",
      properties: {
        status: { type: "integer" },
        ratio:  { type: "number"  },
        active: { type: "boolean" },
        note:   { type: "string"  },
      },
    },
  };

  it("literal number strings are coerced to typed numbers", () => {
    const g = toGraph(
      [{
        id: "n1", type: "vc",
        position: { x: 0, y: 0 },
        data: { typeId: "test.numberfield", config: {
          status: "200", ratio: "0.5", active: "true", note: "hi",
        } },
      }],
      [],
      [numberNodeType],
    );
    expect(g.nodes[0].config).toEqual({
      status: 200, ratio: 0.5, active: true, note: "hi",
    });
  });

  it("expression strings are kept as strings so the executor's runtime coercion handles them", () => {
    const g = toGraph(
      [{
        id: "n1", type: "vc",
        position: { x: 0, y: 0 },
        data: { typeId: "test.numberfield", config: {
          status: "{input.code}", ratio: "0.5",
        } },
      }],
      [],
      [numberNodeType],
    );
    expect(g.nodes[0].config).toEqual({
      status: "{input.code}",  // still string — has `{`
      ratio: 0.5,              // literal → coerced
    });
  });

  it("empty string on a numeric field is preserved as empty string", () => {
    // Undefined-ish literal — user hasn't typed anything. Passing "" keeps
    // the persisted graph honest; the node will hit its own default handling.
    const g = toGraph(
      [{
        id: "n1", type: "vc",
        position: { x: 0, y: 0 },
        data: { typeId: "test.numberfield", config: { status: "" } },
      }],
      [],
      [numberNodeType],
    );
    expect(g.nodes[0].config.status).toBe("");
  });

  it("nodes with no matching schema entry pass config through unchanged", () => {
    const g = toGraph(
      [{
        id: "n1", type: "vc",
        position: { x: 0, y: 0 },
        data: { typeId: "unknown.type", config: { anything: "raw" } },
      }],
      [],
      [numberNodeType],
    );
    expect(g.nodes[0].config).toEqual({ anything: "raw" });
  });

  it("non-string values pass through untouched", () => {
    // A numeric leaf that's already a number (e.g. round-tripped) shouldn't
    // be double-processed.
    const g = toGraph(
      [{
        id: "n1", type: "vc",
        position: { x: 0, y: 0 },
        data: { typeId: "test.numberfield", config: { status: 200 } },
      }],
      [],
      [numberNodeType],
    );
    expect(g.nodes[0].config.status).toBe(200);
  });
});

Note: The existing toGraph(nodes, edges) two-arg signature needs a third nodeTypes argument. Update the existing test above these new cases to pass [] as the third arg, and update the callers in EditorPage.tsx in Task 9. For this task, also update the top of the test file:

// existing signature test: change `toGraph(nodes, edges)` -> `toGraph(nodes, edges, [])`

Find every existing toGraph(...) call in graphAdapter.test.ts and add , [] as the third argument.

  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run graphAdapter

Expected: FAIL — toGraph doesn't accept a third argument; new assertions fail.

  • Step 3: Implement coerceConfigForSave in graphAdapter

Replace the current toGraph in frontend/src/editor/graphAdapter.ts:

import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode, JsonSchemaObject, NodeTypeDto, WorkflowGraph } from "@/api/types";

export interface VcNodeData extends Record<string, unknown> {
  typeId: string;
  name?: string;
  config: Record<string, unknown>;
  connection_id?: string | null;
}

export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; edges: Edge[] } {
  const nodes: Node<VcNodeData>[] = g.nodes.map((n) => ({
    id: n.id,
    type: "vc",
    position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 },
    data: {
      typeId: n.type_id,
      name: n.name,
      config: n.config ?? {},
      connection_id: n.connection_id ?? null,
    },
  }));
  const edges: Edge[] = g.edges.map((e) => ({
    id: `${e.from_node}:${e.from_port}->${e.to_node}:${e.to_port}`,
    source: e.from_node,
    sourceHandle: `o-${e.from_port}`,
    target: e.to_node,
    targetHandle: `i-${e.to_port}`,
  }));
  return { nodes, edges };
}

export function toGraph(
  nodes: Node<VcNodeData>[],
  edges: Edge[],
  nodeTypes: NodeTypeDto[],
): WorkflowGraph {
  const typeById = new Map(nodeTypes.map((t) => [t.id, t] as const));
  const gNodes: GraphNode[] = nodes.map((n) => {
    const nt = typeById.get(n.data.typeId);
    const config = nt ? coerceConfigForSave(n.data.config ?? {}, nt.configSchema) : (n.data.config ?? {});
    const node: GraphNode = {
      id: n.id,
      type_id: n.data.typeId,
      position: { x: n.position.x, y: n.position.y },
      config,
    };
    if (n.data.name !== undefined) node.name = n.data.name;
    if (n.data.connection_id != null) node.connection_id = n.data.connection_id;
    return node;
  });
  const gEdges: GraphEdge[] = edges.map((e) => ({
    from_node: e.source,
    from_port: parseHandlePort(e.sourceHandle, "o"),
    to_node: e.target,
    to_port: parseHandlePort(e.targetHandle, "i"),
  }));
  return { nodes: gNodes, edges: gEdges };
}

// Per-property coercion. Fields whose schema type is integer/number/boolean:
//   - literal string with no `{`     → parse to native JSON type
//   - string containing `{`          → keep as string (executor coerces at run time)
//   - already-typed value            → passed through untouched
//   - empty string                   → passed through untouched (user hasn't filled it in)
// Fields whose schema type is string or missing pass through untouched.
export function coerceConfigForSave(
  config: Record<string, unknown>,
  schema: JsonSchemaObject,
): Record<string, unknown> {
  const props = schema.properties;
  if (!props) return config;
  const out: Record<string, unknown> = {};
  for (const [name, value] of Object.entries(config)) {
    const prop = props[name];
    if (!prop || typeof value !== "string" || value === "" || value.indexOf("{") >= 0) {
      out[name] = value;
      continue;
    }
    switch (prop.type) {
      case "integer": {
        const n = Number(value);
        out[name] = Number.isInteger(n) ? n : value;
        break;
      }
      case "number": {
        const n = Number(value);
        out[name] = Number.isFinite(n) ? n : value;
        break;
      }
      case "boolean":
        out[name] = value === "true" ? true : value === "false" ? false : value;
        break;
      default:
        out[name] = value;
    }
  }
  return out;
}

function parseHandlePort(handle: string | null | undefined, prefix: "i" | "o"): number {
  if (!handle) return 0;
  const m = new RegExp(`^${prefix}-(\\d+)$`).exec(handle);
  return m ? Number(m[1]) : 0;
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run graphAdapter

Expected: PASS — all cases green (existing + new coercion tests).

  • Step 5: Commit
git add frontend/src/editor/graphAdapter.ts frontend/src/editor/graphAdapter.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): coerce literal number/boolean strings to typed JSON on save"

Task 4: JsonTree component

Expand/collapse tree that renders arbitrary JSON with clickable leaves. Each leaf click emits a JSON path (body.email, arr[0], ["weird.key"]). Consumer decides what to do with the path (in the dialog: prepend input., wrap in {...}, insert at caret).

Files:

  • Create: frontend/src/expr/JsonTree.tsx

  • Create: frontend/src/expr/JsonTree.test.tsx

  • Step 1: Write the failing tests

// frontend/src/expr/JsonTree.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { JsonTree } from "./JsonTree";

describe("JsonTree", () => {
  it("renders top-level object keys collapsed by default", () => {
    render(<JsonTree value={{ a: 1, b: { c: 2 } }} />);
    expect(screen.getByText("a")).toBeInTheDocument();
    expect(screen.getByText("b")).toBeInTheDocument();
    expect(screen.queryByText("c")).not.toBeInTheDocument();
  });

  it("expands a nested object when the row is clicked", async () => {
    render(<JsonTree value={{ a: { b: 2 } }} />);
    await userEvent.click(screen.getByText("a"));
    expect(screen.getByText("b")).toBeInTheDocument();
  });

  it("emits a dot-form path for a plain leaf on click", async () => {
    const onPick = vi.fn();
    render(<JsonTree value={{ body: { email: "a@b" } }} onPickPath={onPick} />);
    await userEvent.click(screen.getByText("body"));
    await userEvent.click(screen.getByText("email"));
    expect(onPick).toHaveBeenCalledWith("body.email");
  });

  it("emits bracket-form for a key containing dots", async () => {
    const onPick = vi.fn();
    render(<JsonTree value={{ "weird.key": "v" }} onPickPath={onPick} />);
    await userEvent.click(screen.getByText("weird.key"));
    expect(onPick).toHaveBeenCalledWith(`["weird.key"]`);
  });

  it("emits bracket-form for a key containing dashes", async () => {
    const onPick = vi.fn();
    render(<JsonTree value={{ "x-y": 1 }} onPickPath={onPick} />);
    await userEvent.click(screen.getByText("x-y"));
    expect(onPick).toHaveBeenCalledWith(`["x-y"]`);
  });

  it("emits numeric index form for array elements", async () => {
    const onPick = vi.fn();
    render(<JsonTree value={{ tags: ["a", "b", "c"] }} onPickPath={onPick} />);
    await userEvent.click(screen.getByText("tags"));
    await userEvent.click(screen.getByText("b"));
    expect(onPick).toHaveBeenCalledWith("tags[1]");
  });

  it("does not fire onPickPath when pickable is false", async () => {
    const onPick = vi.fn();
    render(<JsonTree value={{ a: 1 }} onPickPath={onPick} pickable={false} />);
    await userEvent.click(screen.getByText("a"));
    expect(onPick).not.toHaveBeenCalled();
  });

  it("renders empty state for empty object", () => {
    render(<JsonTree value={{}} />);
    expect(screen.getByText(/no data/i)).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run JsonTree

Expected: FAIL — Cannot find module './JsonTree'.

  • Step 3: Implement JsonTree
// frontend/src/expr/JsonTree.tsx
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";

interface Props {
  value: unknown;
  onPickPath?: (path: string) => void;
  pickable?: boolean; // default true; false renders leaves non-clickable & dimmed
}

export function JsonTree({ value, onPickPath, pickable = true }: Props) {
  if (isEmptyRoot(value)) {
    return <div className="text-xs text-slate-500 italic">No data.</div>;
  }
  return <Node label="" value={value} path="" depth={0} pickable={pickable} onPick={onPickPath} />;
}

function isEmptyRoot(v: unknown): boolean {
  if (v === null || v === undefined) return true;
  if (Array.isArray(v)) return v.length === 0;
  if (typeof v === "object") return Object.keys(v as object).length === 0;
  return false;
}

interface NodeProps {
  label: string;
  value: unknown;
  path: string;
  depth: number;
  pickable: boolean;
  onPick?: (path: string) => void;
}

function Node({ label, value, path, depth, pickable, onPick }: NodeProps) {
  // Root — render children as top-level rows without a wrapping expando.
  if (depth === 0) {
    return (
      <ul className="text-xs font-mono">
        {renderChildren(value, path, depth, pickable, onPick)}
      </ul>
    );
  }
  const branch = isBranch(value);
  const [open, setOpen] = useState(false);
  const clickable = pickable && (!branch || true);
  const pickHandler = () => {
    if (!pickable) return;
    if (branch) setOpen((o) => !o);
    else onPick?.(path);
  };
  return (
    <li>
      <button
        type="button"
        onClick={pickHandler}
        className={
          "flex items-center gap-1 w-full text-left py-0.5 px-1 rounded " +
          (pickable ? "hover:bg-slate-800/80 " : "opacity-50 cursor-default ") +
          (clickable ? "" : "cursor-default")
        }
      >
        {branch ? (open ? <ChevronDown size={12} /> : <ChevronRight size={12} />) : <span className="w-3" />}
        <span className="text-sky-300">{label}</span>
        {!branch && (
          <>
            <span className="text-slate-500">:</span>
            <span className="text-slate-300 truncate">{leafPreview(value)}</span>
          </>
        )}
        {branch && (
          <span className="text-slate-500 ml-1">{summarize(value)}</span>
        )}
      </button>
      {branch && open && (
        <ul className="ml-4 border-l border-slate-800 pl-2">
          {renderChildren(value, path, depth, pickable, onPick)}
        </ul>
      )}
    </li>
  );
}

function renderChildren(
  value: unknown, parentPath: string, depth: number,
  pickable: boolean, onPick?: (path: string) => void,
) {
  if (Array.isArray(value)) {
    return value.map((v, i) => {
      const childPath = `${parentPath}[${i}]`;
      return (
        <Node
          key={i} label={String(i)} value={v} path={childPath}
          depth={depth + 1} pickable={pickable} onPick={onPick}
        />
      );
    });
  }
  if (value !== null && typeof value === "object") {
    return Object.entries(value as Record<string, unknown>).map(([k, v]) => {
      const seg = needsBracketForm(k) ? `["${k.replace(/"/g, `\\"`)}"]` : `.${k}`;
      const childPath = parentPath === "" ? (needsBracketForm(k) ? seg : k) : `${parentPath}${seg}`;
      return (
        <Node
          key={k} label={k} value={v} path={childPath}
          depth={depth + 1} pickable={pickable} onPick={onPick}
        />
      );
    });
  }
  return null;
}

function isBranch(v: unknown): boolean {
  return v !== null && typeof v === "object";
}

function leafPreview(v: unknown): string {
  if (v === null) return "null";
  if (typeof v === "string") return `"${truncate(v, 40)}"`;
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  return JSON.stringify(v);
}

function truncate(s: string, n: number): string {
  return s.length <= n ? s : s.slice(0, n) + "…";
}

function summarize(v: unknown): string {
  if (Array.isArray(v)) return `[${v.length}]`;
  if (v !== null && typeof v === "object") {
    const n = Object.keys(v as object).length;
    return `{${n}}`;
  }
  return "";
}

function needsBracketForm(key: string): boolean {
  return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run JsonTree

Expected: PASS — all cases green.

  • Step 5: Commit
git add frontend/src/expr/JsonTree.tsx frontend/src/expr/JsonTree.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): JsonTree with click-to-emit-path leaves"

Task 5: ExpressionField component

Text field with:

  • Monospace textarea + a mirrored overlay <div> that colors {...} runs in a single accent color.
  • Live preview line beneath that renders the template against a previewInput (the first item on port 0).
  • Schema-type validity hint (⚠ expected integer) for literals on non-string schema fields.
  • Imperative insertAtCaret(text) exposed via forwardRef.
  • Single-line variant (auto-grow up to 5 rows, Enter blurs) vs code variant (10 rows).

Files:

  • Create: frontend/src/expr/ExpressionField.tsx

  • Create: frontend/src/expr/ExpressionField.test.tsx

  • Step 1: Write the failing tests

// frontend/src/expr/ExpressionField.test.tsx
import { describe, it, expect, vi } from "vitest";
import { useRef, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ExpressionField, type ExpressionFieldHandle } from "./ExpressionField";

describe("ExpressionField", () => {
  it("emits the raw text on change", async () => {
    const onChange = vi.fn();
    render(<ExpressionField value="" onChange={onChange} />);
    await userEvent.type(screen.getByRole("textbox"), "hi");
    expect(onChange).toHaveBeenLastCalledWith("hi");
  });

  it("shows the live preview against previewInput", () => {
    render(
      <ExpressionField
        value="hi {input.name}!"
        onChange={() => {}}
        previewInput={{ name: "alice" }}
      />,
    );
    expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!");
  });

  it("omits the preview line when previewInput is undefined", () => {
    render(<ExpressionField value="hi {input.name}!" onChange={() => {}} />);
    expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument();
  });

  it("shows a schema-validity warning for a bad integer literal", () => {
    render(
      <ExpressionField
        value="abc"
        onChange={() => {}}
        schemaType="integer"
        previewInput={{}}
      />,
    );
    expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i);
  });

  it("does not warn on integer literals that are valid", () => {
    render(
      <ExpressionField
        value="200"
        onChange={() => {}}
        schemaType="integer"
        previewInput={{}}
      />,
    );
    expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
  });

  it("does not warn on integer fields when the value contains a template", () => {
    render(
      <ExpressionField
        value="{input.code}"
        onChange={() => {}}
        schemaType="integer"
        previewInput={{ code: 200 }}
      />,
    );
    expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
  });

  it("insertAtCaret splices text at the current caret position", async () => {
    function Harness() {
      const ref = useRef<ExpressionFieldHandle>(null);
      const [v, setV] = useState("hello ");
      return (
        <>
          <ExpressionField ref={ref} value={v} onChange={setV} />
          <button onClick={() => ref.current?.insertAtCaret("{input.name}")}>insert</button>
        </>
      );
    }
    render(<Harness />);
    const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
    ta.focus();
    ta.setSelectionRange(ta.value.length, ta.value.length);
    await userEvent.click(screen.getByRole("button", { name: /insert/i }));
    expect(ta.value).toBe("hello {input.name}");
  });

  it("single-line variant blurs on Enter instead of inserting a newline", async () => {
    const onChange = vi.fn();
    render(<ExpressionField value="" onChange={onChange} singleLine />);
    const ta = screen.getByRole("textbox");
    await userEvent.type(ta, "hi{Enter}");
    // The Enter keydown was captured — no newline reached the value.
    expect(onChange).toHaveBeenLastCalledWith("hi");
    expect(document.activeElement).not.toBe(ta);
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run ExpressionField

Expected: FAIL — Cannot find module './ExpressionField'.

  • Step 3: Implement ExpressionField
// frontend/src/expr/ExpressionField.tsx
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { render as renderTemplate } from "./templateEngine";

export interface ExpressionFieldHandle {
  insertAtCaret(text: string): void;
  focus(): void;
}

export type ExpressionSchemaType = "string" | "integer" | "number" | "boolean";

interface Props {
  value: string;
  onChange: (v: string) => void;
  onFocus?: () => void;
  previewInput?: unknown;
  schemaType?: ExpressionSchemaType;
  singleLine?: boolean;
  codeBlock?: boolean;      // format:"code" fields — 10-row textarea, no Enter-blur
  placeholder?: string;
  id?: string;
}

export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function ExpressionField(
  { value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id },
  ref,
) {
  const taRef = useRef<HTMLTextAreaElement | null>(null);
  const overlayRef = useRef<HTMLDivElement | null>(null);
  const [overlayScroll, setOverlayScroll] = useState({ top: 0, left: 0 });

  useImperativeHandle(ref, () => ({
    focus() {
      taRef.current?.focus();
    },
    insertAtCaret(text: string) {
      const ta = taRef.current;
      if (!ta) return;
      const start = ta.selectionStart ?? ta.value.length;
      const end = ta.selectionEnd ?? ta.value.length;
      const next = ta.value.slice(0, start) + text + ta.value.slice(end);
      onChange(next);
      // Restore caret position after React re-renders the value.
      requestAnimationFrame(() => {
        ta.focus();
        const caret = start + text.length;
        ta.setSelectionRange(caret, caret);
      });
    },
  }), [onChange]);

  const rows = codeBlock ? 10 : singleLine ? 1 : 3;

  const tokens = useMemo(() => tokenize(value), [value]);
  const preview = useMemo(() => {
    if (previewInput === undefined) return null;
    if (schemaType !== "string" && value !== "" && value.indexOf("{") < 0) {
      if (!isValidLiteral(value, schemaType)) {
        return { kind: "warn" as const, text: `⚠ expected ${schemaType}` };
      }
    }
    const rendered = renderTemplate(value, previewInput);
    const truncated = rendered.length > 200 ? rendered.slice(0, 200) + "…" : rendered;
    return { kind: "ok" as const, text: truncated };
  }, [value, previewInput, schemaType]);

  function syncScroll() {
    const ta = taRef.current;
    if (!ta) return;
    setOverlayScroll({ top: ta.scrollTop, left: ta.scrollLeft });
  }

  useEffect(() => {
    if (overlayRef.current) {
      overlayRef.current.scrollTop = overlayScroll.top;
      overlayRef.current.scrollLeft = overlayScroll.left;
    }
  }, [overlayScroll]);

  return (
    <div className="w-full">
      <div className="relative">
        <div
          ref={overlayRef}
          aria-hidden
          className={
            "absolute inset-0 pointer-events-none whitespace-pre-wrap break-words font-mono text-sm " +
            "px-3 py-2 leading-5 text-slate-100 overflow-hidden"
          }
        >
          {tokens.map((t, i) =>
            t.kind === "expr"
              ? <span key={i} className="text-amber-300">{t.text}</span>
              : <span key={i}>{t.text}</span>,
          )}
          {/* Trailing newline safeguard: keeps overlay height equal to the textarea when
              the value ends in a newline. */}
          {value.endsWith("\n") && <span> </span>}
        </div>
        <textarea
          ref={taRef}
          id={id}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onFocus={onFocus}
          onScroll={syncScroll}
          onKeyDown={(e) => {
            if (singleLine && e.key === "Enter") {
              e.preventDefault();
              (e.target as HTMLTextAreaElement).blur();
            }
          }}
          rows={rows}
          placeholder={placeholder}
          spellCheck={false}
          className={
            "relative w-full rounded bg-slate-950 border border-slate-800 " +
            "px-3 py-2 outline-none focus:border-brand-500 " +
            "font-mono text-sm leading-5 resize-none " +
            "text-transparent caret-slate-100 selection:text-slate-100 selection:bg-brand-600/40"
          }
          style={{ WebkitTextFillColor: "transparent" }}
        />
      </div>
      {preview && (
        <div
          data-testid="expr-preview"
          className={
            "mt-1 text-xs font-mono truncate " +
            (preview.kind === "warn" ? "text-amber-400" : "text-slate-500")
          }
        >
          {preview.kind === "warn" ? preview.text : `→ ${preview.text}`}
        </div>
      )}
    </div>
  );
});

type Token = { kind: "text" | "expr"; text: string };

// Tokenizer for the overlay. Matches the executor's parsing shape: `{{` and
// `}}` are literal braces (still rendered as `{` / `}` in the "text" class),
// unclosed `{` is rendered as text, and `{...}` runs are the "expr" class.
function tokenize(s: string): Token[] {
  const out: Token[] = [];
  let i = 0;
  let buf = "";
  const flush = () => { if (buf) { out.push({ kind: "text", text: buf }); buf = ""; } };
  while (i < s.length) {
    const c = s[i];
    if (c === "{") {
      if (s[i + 1] === "{") { buf += "{"; i += 2; continue; }
      const end = s.indexOf("}", i + 1);
      if (end < 0) { buf += s.slice(i); break; }
      flush();
      out.push({ kind: "expr", text: s.slice(i, end + 1) });
      i = end + 1;
      continue;
    }
    if (c === "}" && s[i + 1] === "}") { buf += "}"; i += 2; continue; }
    buf += c;
    i++;
  }
  flush();
  return out;
}

function isValidLiteral(v: string, t: ExpressionSchemaType): boolean {
  if (t === "integer") {
    if (!/^-?\d+$/.test(v)) return false;
    return Number.isInteger(Number(v));
  }
  if (t === "number") {
    const n = Number(v);
    return Number.isFinite(n);
  }
  if (t === "boolean") return v === "true" || v === "false";
  return true;
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run ExpressionField

Expected: PASS — all cases green.

  • Step 5: Commit
git add frontend/src/expr/ExpressionField.tsx frontend/src/expr/ExpressionField.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(expr): ExpressionField with token highlight overlay and live preview"

Task 6: Rewrite SchemaForm to use ExpressionField

Every schema leaf becomes an ExpressionField. Non-string schema types get schemaType hinted through. The form still stores the value as a string in Record<string, unknown>; the client coerceConfigForSave (Task 3) turns literal number/boolean strings back into typed JSON at save time.

Files:

  • Modify: frontend/src/forms/SchemaForm.tsx

  • Modify: frontend/src/forms/SchemaForm.test.tsx

  • Step 1: Write the failing tests

Replace frontend/src/forms/SchemaForm.test.tsx with:

import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { SchemaForm } from "./SchemaForm";
import type { JsonSchemaObject } from "@/api/types";

describe("SchemaForm — ExpressionField-backed", () => {
  it("renders string fields from schema.properties with titles as labels", () => {
    const schema: JsonSchemaObject = {
      type: "object",
      required: ["url"],
      properties: {
        url: { type: "string", title: "Server URL" },
        user: { type: "string", title: "User" },
      },
    };
    render(<SchemaForm schema={schema} value={{ url: "nats://x" }} onChange={() => {}} />);
    expect(screen.getByLabelText(/server url/i)).toHaveValue("nats://x");
    expect(screen.getByLabelText(/user/i)).toHaveValue("");
  });

  it("emits onChange with the merged object on input", async () => {
    const schema: JsonSchemaObject = {
      type: "object",
      properties: { url: { type: "string" } },
    };
    const onChange = vi.fn();
    function Wrapper() {
      const [v, setV] = useState<Record<string, unknown>>({});
      return (
        <SchemaForm
          schema={schema}
          value={v}
          onChange={(next) => { onChange(next); setV(next); }}
        />
      );
    }
    render(<Wrapper />);
    await userEvent.type(screen.getByLabelText(/url/i), "abc");
    const last = onChange.mock.calls.at(-1)![0];
    expect(last).toEqual({ url: "abc" });
  });

  it("renders integer fields as expression fields storing string values", async () => {
    const schema: JsonSchemaObject = {
      type: "object",
      properties: { status: { type: "integer", title: "Status" } },
    };
    const onChange = vi.fn();
    function Wrapper() {
      const [v, setV] = useState<Record<string, unknown>>({});
      return (
        <SchemaForm schema={schema} value={v} onChange={(next) => { onChange(next); setV(next); }} />
      );
    }
    render(<Wrapper />);
    await userEvent.type(screen.getByLabelText(/status/i), "200");
    const last = onChange.mock.calls.at(-1)![0];
    // SchemaForm stores strings; the graphAdapter coerces to number at save.
    expect(last).toEqual({ status: "200" });
  });

  it("shows a live preview when previewInput is supplied", () => {
    const schema: JsonSchemaObject = {
      type: "object",
      properties: { subject: { type: "string", title: "Subject" } },
    };
    render(
      <SchemaForm
        schema={schema}
        value={{ subject: "hi {input.name}" }}
        onChange={() => {}}
        previewInput={{ name: "alice" }}
      />,
    );
    expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice");
  });

  it("marks required fields with an asterisk", () => {
    const schema: JsonSchemaObject = {
      type: "object",
      required: ["url"],
      properties: { url: { type: "string", title: "URL" } },
    };
    render(<SchemaForm schema={schema} value={{}} onChange={() => {}} />);
    expect(screen.getByText("URL")).toBeInTheDocument();
    expect(screen.getByText("*")).toBeInTheDocument();
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run SchemaForm

Expected: FAIL — old renderer still emits typed inputs; new assertions fail.

  • Step 3: Rewrite SchemaForm

Replace frontend/src/forms/SchemaForm.tsx with:

import { forwardRef, useImperativeHandle, useRef } from "react";
import type { JsonSchemaObject } from "@/api/types";
import {
  ExpressionField,
  type ExpressionFieldHandle,
  type ExpressionSchemaType,
} from "@/expr/ExpressionField";

interface Props {
  schema: JsonSchemaObject;
  value: Record<string, unknown>;
  onChange: (v: Record<string, unknown>) => void;
  previewInput?: unknown;
  onFieldFocus?: (handle: ExpressionFieldHandle) => void;
}

export function SchemaForm({ schema, value, onChange, previewInput, onFieldFocus }: Props) {
  const required = new Set(schema.required ?? []);
  const props = schema.properties ?? {};

  function set(name: string, v: string) {
    onChange({ ...value, [name]: v });
  }

  return (
    <div className="space-y-3">
      {Object.entries(props).map(([name, prop]) => {
        const label = prop.title ?? name;
        const id = `sf-${name}`;
        const isRequired = required.has(name);
        const raw = toEditorString(value[name]);
        const st = mapSchemaType(prop.type);
        return (
          <label key={name} htmlFor={id} className="block text-sm space-y-1">
            <span className="text-slate-300">
              {label}
              {isRequired && <span className="text-red-400 ml-0.5">*</span>}
            </span>
            <SchemaExpressionField
              id={id}
              value={raw}
              onChange={(v) => set(name, v)}
              onFieldFocus={onFieldFocus}
              previewInput={previewInput}
              schemaType={st}
              codeBlock={prop.format === "code"}
              singleLine={prop.format !== "code"}
            />
          </label>
        );
      })}
    </div>
  );
}

const SchemaExpressionField = forwardRef<ExpressionFieldHandle, {
  id: string; value: string; onChange: (v: string) => void;
  previewInput?: unknown; schemaType?: ExpressionSchemaType;
  singleLine?: boolean; codeBlock?: boolean;
  onFieldFocus?: (handle: ExpressionFieldHandle) => void;
}>(function SchemaExpressionField(props, _outer) {
  const ref = useRef<ExpressionFieldHandle | null>(null);
  useImperativeHandle(_outer, () => ref.current ?? { insertAtCaret: () => {}, focus: () => {} });
  return (
    <ExpressionField
      ref={ref}
      id={props.id}
      value={props.value}
      onChange={props.onChange}
      previewInput={props.previewInput}
      schemaType={props.schemaType}
      singleLine={props.singleLine}
      codeBlock={props.codeBlock}
      onFocus={() => { if (ref.current) props.onFieldFocus?.(ref.current); }}
    />
  );
});

function mapSchemaType(t?: string): ExpressionSchemaType {
  if (t === "integer") return "integer";
  if (t === "number") return "number";
  if (t === "boolean") return "boolean";
  return "string";
}

// Value stored in state may be a number/bool if it round-tripped from the
// backend already coerced. Render it as its literal string form so the user
// can edit it as an expression.
function toEditorString(v: unknown): string {
  if (v === undefined || v === null) return "";
  if (typeof v === "string") return v;
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  return JSON.stringify(v);
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run SchemaForm

Expected: PASS — all cases green.

  • Step 5: Run the full frontend suite so we catch any consumer that broke
pnpm --dir frontend test

Expected: PASS — no regressions in unrelated tests. If EditorPage or ConnectionsPage tests now break because they assumed a type="number" input, we address them in Task 9 (EditorPage) or here for ConnectionsPage. Fix those tests inline to expect the new expression-field shape.

  • Step 6: Commit
git add frontend/src/forms/SchemaForm.tsx frontend/src/forms/SchemaForm.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(forms): SchemaForm renders every leaf as ExpressionField"

Task 7: nodeIoLookup helper

Pure function: given an ExecutionDetail and a node id, return the raw inputs / outputs for that node, or null if the node didn't appear in the execution.

Files:

  • Create: frontend/src/editor/nodeIoLookup.ts

  • Create: frontend/src/editor/nodeIoLookup.test.ts

  • Step 1: Write the failing tests

// frontend/src/editor/nodeIoLookup.test.ts
import { describe, it, expect } from "vitest";
import { findLatestIo } from "./nodeIoLookup";
import type { ExecutionDetail } from "@/api/types";

const detail = (nodes: Array<{ nodeId: string; inputs: unknown; outputs: unknown }>): ExecutionDetail => ({
  id: "e1", workflowId: "wf", trigger: "manual", status: "completed",
  startedAt: "", endedAt: null, error: null,
  nodes: nodes.map(n => ({
    nodeId: n.nodeId, status: "completed",
    startedAt: "", endedAt: "", error: null,
    inputs: n.inputs, outputs: n.outputs,
  })),
});

describe("findLatestIo", () => {
  it("returns inputs and outputs for a matching node id", () => {
    const d = detail([
      { nodeId: "n1", inputs: [[{ json: { a: 1 } }]], outputs: [[{ json: { b: 2 } }]] },
      { nodeId: "n2", inputs: [[{ json: { c: 3 } }]], outputs: [[{ json: { d: 4 } }]] },
    ]);
    const io = findLatestIo(d, "n2");
    expect(io).toEqual({
      inputs:  [[{ json: { c: 3 } }]],
      outputs: [[{ json: { d: 4 } }]],
    });
  });

  it("returns null when the node did not appear in the execution", () => {
    const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]);
    expect(findLatestIo(d, "missing")).toBeNull();
  });

  it("returns null when the execution is undefined", () => {
    expect(findLatestIo(undefined, "n1")).toBeNull();
  });

  it("returns inputs and outputs verbatim (does not clone or reshape)", () => {
    const inputs = [[{ json: { a: 1 } }]];
    const outputs = [[{ json: { b: 2 } }]];
    const d = detail([{ nodeId: "n1", inputs, outputs }]);
    const io = findLatestIo(d, "n1")!;
    expect(io.inputs).toBe(inputs);
    expect(io.outputs).toBe(outputs);
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run nodeIoLookup

Expected: FAIL — Cannot find module './nodeIoLookup'.

  • Step 3: Implement
// frontend/src/editor/nodeIoLookup.ts
import type { ExecutionDetail } from "@/api/types";

export interface NodeIo {
  inputs: unknown;
  outputs: unknown;
}

export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null {
  if (!execution) return null;
  const row = execution.nodes.find((n) => n.nodeId === nodeId);
  if (!row) return null;
  return { inputs: row.inputs, outputs: row.outputs };
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run nodeIoLookup

Expected: PASS — 4/4 tests green.

  • Step 5: Commit
git add frontend/src/editor/nodeIoLookup.ts frontend/src/editor/nodeIoLookup.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): findLatestIo helper for pulling a node's row out of an ExecutionDetail"

Task 8: NodeEditorDialog component

Three-column modal shell:

  • Input (left ~25%) — JsonTree of the port-0 inputs; leaves in inputs[0][0].json are clickable and insert {input.<path>} at the last-focused parameter caret.
  • Parameters (middle) — node label, optional connection dropdown, SchemaForm, optional WebhookUrlHint, Run button, Delete button, Done button.
  • Output (right ~25%) — JsonTree of the port-0 outputs; non-clickable.

ESC and backdrop-click close. Focus trap on open. Changes flow into parent state via patchNode; parent still owns the toolbar Save.

Files:

  • Create: frontend/src/editor/NodeEditorDialog.tsx

  • Create: frontend/src/editor/NodeEditorDialog.test.tsx

  • Step 1: Write the failing tests

// frontend/src/editor/NodeEditorDialog.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { type ComponentProps, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import type { Node } from "@xyflow/react";
import type { VcNodeData } from "./graphAdapter";
import { NodeEditorDialog } from "./NodeEditorDialog";
import type { ConnectionDto, ExecutionDetail, ExecutionSummary, NodeTypeDto } from "@/api/types";

const NODE_TYPES: NodeTypeDto[] = [
  {
    id: "http.webhook", displayName: "Webhook", category: "Trigger",
    inputCount: 0, outputCount: 1, connectionProviderId: null,
    configSchema: { type: "object", properties: { path: { type: "string", title: "URL path" } } },
  },
  {
    id: "http.response", displayName: "Web Response", category: "Terminal",
    inputCount: 1, outputCount: 0, connectionProviderId: null,
    configSchema: {
      type: "object",
      properties: {
        status: { type: "integer", title: "HTTP status", default: 200 },
        body:   { type: "string",  title: "Body" },
      },
    },
  },
];

function defaultNode(): Node<VcNodeData> {
  return {
    id: "n1", type: "vc",
    position: { x: 0, y: 0 },
    data: { typeId: "http.response", name: "Reply", config: {}, connection_id: null },
  };
}

// Non-stateful render — for tests that don't mutate node state.
// The mocks it exposes (onPatch, onDelete, onClose) are the props actually passed.
function renderDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
  const props: ComponentProps<typeof NodeEditorDialog> = {
    node: defaultNode(),
    nodeTypes: NODE_TYPES,
    connections: [],
    workflowId: "wf1",
    workflowActive: false,
    triggerNodeIds: ["t1"],
    onPatch: vi.fn(),
    onDelete: vi.fn(),
    onClose: vi.fn(),
    ...overrides,
  };
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const utils = render(
    <QueryClientProvider client={qc}>
      <NodeEditorDialog {...props} />
    </QueryClientProvider>,
  );
  return { ...utils, props };
}

// Stateful render — for tests that type into controlled fields. The wrapper
// applies onPatch to local state so React doesn't fight the DOM value.
function renderControlledDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>> = {}) {
  function Harness() {
    const [n, setN] = useState<Node<VcNodeData>>(overrides.node ?? defaultNode());
    return (
      <NodeEditorDialog
        node={n}
        nodeTypes={overrides.nodeTypes ?? NODE_TYPES}
        connections={overrides.connections ?? []}
        workflowId={overrides.workflowId ?? "wf1"}
        workflowActive={overrides.workflowActive ?? false}
        triggerNodeIds={overrides.triggerNodeIds ?? ["t1"]}
        onPatch={(patch) => setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))}
        onDelete={overrides.onDelete ?? (() => {})}
        onClose={overrides.onClose ?? (() => {})}
      />
    );
  }
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={qc}>
      <Harness />
    </QueryClientProvider>,
  );
}

beforeEach(() => {
  server.use(
    http.get("/api/workflows/:id/executions", () =>
      HttpResponse.json([] as ExecutionSummary[])),
  );
});

describe("NodeEditorDialog", () => {
  it("renders the three column headers", () => {
    renderDialog();
    expect(screen.getByRole("heading", { name: /input/i })).toBeInTheDocument();
    expect(screen.getByRole("heading", { name: /parameters/i })).toBeInTheDocument();
    expect(screen.getByRole("heading", { name: /output/i })).toBeInTheDocument();
  });

  it("ESC calls onClose", async () => {
    const { props } = renderDialog();
    await userEvent.keyboard("{Escape}");
    expect(props.onClose).toHaveBeenCalled();
  });

  it("clicking the backdrop calls onClose", async () => {
    const { props } = renderDialog();
    await userEvent.click(screen.getByTestId("dialog-backdrop"));
    expect(props.onClose).toHaveBeenCalled();
  });

  it("editing the node label flows into node.data.name", async () => {
    renderControlledDialog();
    const label = screen.getByLabelText(/node label/i) as HTMLTextAreaElement;
    await userEvent.clear(label);
    await userEvent.type(label, "Custom");
    expect(label.value).toBe("Custom");
  });

  it("editing a parameter flows into node.data.config", async () => {
    renderControlledDialog();
    const body = screen.getByLabelText(/^body/i) as HTMLTextAreaElement;
    await userEvent.type(body, "hi");
    expect(body.value).toBe("hi");
  });

  it("Run button is disabled when there are no trigger nodes", () => {
    renderDialog({ triggerNodeIds: [] });
    expect(screen.getByRole("button", { name: /^run/i })).toBeDisabled();
  });

  it("Run button POSTs an execution and shows the fresh row on success", async () => {
    server.use(
      http.post("/api/workflows/:id/executions", () =>
        HttpResponse.json({ executionId: "e-new" }, { status: 202 })),
      http.get("/api/executions/e-new", () =>
        HttpResponse.json({
          id: "e-new", workflowId: "wf1", trigger: "manual", status: "completed",
          startedAt: "", endedAt: "", error: null,
          nodes: [
            { nodeId: "t1", status: "completed", startedAt: "", endedAt: "", error: null,
              inputs: [], outputs: [[{ json: { code: 200 } }]] },
            { nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
              inputs: [[{ json: { code: 200 } }]], outputs: [] },
          ],
        } satisfies ExecutionDetail)),
    );
    renderDialog();
    await userEvent.click(screen.getByRole("button", { name: /^run/i }));
    await waitFor(() => expect(screen.getByText("code")).toBeInTheDocument());
  });

  it("Webhook URL block shows for http.webhook", () => {
    const node: Node<VcNodeData> = {
      id: "w1", type: "vc",
      position: { x: 0, y: 0 },
      data: { typeId: "http.webhook", name: "Webhook", config: { path: "hook" }, connection_id: null },
    };
    renderDialog({ node });
    expect(screen.getByText(/webhook url/i)).toBeInTheDocument();
  });

  it("Webhook URL block is hidden for non-webhook types", () => {
    renderDialog();
    expect(screen.queryByText(/webhook url/i)).not.toBeInTheDocument();
  });

  it("connection dropdown shows only when node type has connectionProviderId", () => {
    const withConn: NodeTypeDto = {
      ...NODE_TYPES[1],
      id: "nats.publish", connectionProviderId: "nats", displayName: "NATS Publish",
    };
    const node: Node<VcNodeData> = {
      id: "n1", type: "vc",
      position: { x: 0, y: 0 },
      data: { typeId: "nats.publish", name: "Pub", config: {}, connection_id: null },
    };
    const connections: ConnectionDto[] = [{ id: "c1", providerId: "nats", name: "prod", config: {} }];
    renderDialog({ node, nodeTypes: [...NODE_TYPES, withConn], connections });
    expect(screen.getByLabelText(/connection/i)).toBeInTheDocument();
  });

  it("Delete button calls onDelete then onClose", async () => {
    const { props } = renderDialog();
    await userEvent.click(screen.getByRole("button", { name: /delete node/i }));
    expect(props.onDelete).toHaveBeenCalled();
    expect(props.onClose).toHaveBeenCalled();
  });

  it("clicking a leaf in the Input tree inserts {input.<path>} at the focused parameter", async () => {
    // Seed an execution so the input tree has a leaf.
    server.use(
      http.get("/api/workflows/:id/executions", () =>
        HttpResponse.json([
          { id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
            startedAt: "", endedAt: "", error: null } satisfies ExecutionSummary,
        ])),
      http.get("/api/executions/e1", () =>
        HttpResponse.json({
          id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
          startedAt: "", endedAt: "", error: null,
          nodes: [
            { nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
              inputs: [[{ json: { email: "a@b" } }]], outputs: [] },
          ],
        } satisfies ExecutionDetail)),
    );
    // Controlled — the body textarea is a controlled input and would fight
    // the DOM value on each render without a real state owner.
    renderControlledDialog();
    const body = await screen.findByLabelText(/^body/i) as HTMLTextAreaElement;
    body.focus();
    await userEvent.click(await screen.findByText("email"));
    expect(body.value).toBe("{input.email}");
  });
});
  • Step 2: Run tests to verify they fail
pnpm --dir frontend test --run NodeEditorDialog

Expected: FAIL — Cannot find module './NodeEditorDialog'.

  • Step 3: Implement NodeEditorDialog
// frontend/src/editor/NodeEditorDialog.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { X, Play, Trash2, Copy, Check } from "lucide-react";
import type { Node } from "@xyflow/react";
import type { ConnectionDto, ExecutionDetail, NodeTypeDto } from "@/api/types";
import { qk, useApiMutation, useExecution, useExecutions, useInvalidate } from "@/api/hooks";
import { apiFetch } from "@/api/client";
import { SchemaForm } from "@/forms/SchemaForm";
import { JsonTree } from "@/expr/JsonTree";
import { findLatestIo } from "./nodeIoLookup";
import type { VcNodeData } from "./graphAdapter";
import type { ExpressionFieldHandle } from "@/expr/ExpressionField";

interface Props {
  node: Node<VcNodeData>;
  nodeTypes: NodeTypeDto[];
  connections: ConnectionDto[];
  workflowId: string;
  workflowActive: boolean;
  triggerNodeIds: string[];
  onPatch: (patch: Partial<VcNodeData>) => void;
  onDelete: () => void;
  onClose: () => void;
}

export function NodeEditorDialog({
  node, nodeTypes, connections, workflowId, workflowActive,
  triggerNodeIds, onPatch, onDelete, onClose,
}: Props) {
  const nt = nodeTypes.find((t) => t.id === node.data.typeId);
  const focusedFieldRef = useRef<ExpressionFieldHandle | null>(null);
  const dialogRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") { e.preventDefault(); onClose(); }
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  const executions = useExecutions(workflowId);
  const [pinnedExecutionId, setPinnedExecutionId] = useState<string | null>(null);
  const eid = pinnedExecutionId ?? executions.data?.[0]?.id;
  const execution = useExecution(eid);
  const io = useMemo(
    () => findLatestIo(execution.data, node.id),
    [execution.data, node.id],
  );

  const inputsArray = normalizePorts(io?.inputs);
  const outputsArray = normalizePorts(io?.outputs);
  const previewInput = inputsArray[0]?.[0]?.json;

  const invalidate = useInvalidate();
  const qc = useQueryClient();
  const runMutation = useApiMutation(
    () => {
      const startFrom = triggerNodeIds[0];
      if (!startFrom) throw new Error("no trigger");
      return apiFetch<{ executionId: string }>(
        `/api/workflows/${workflowId}/executions`,
        { method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
      );
    },
    {
      onSuccess: async (res) => {
        await invalidate(qk.executions(workflowId));
        await qc.invalidateQueries({ queryKey: qk.execution(res.executionId) });
        setPinnedExecutionId(res.executionId);
      },
    },
  );

  const canRun = triggerNodeIds.length > 0 && !runMutation.isPending;

  const registerFocus = useCallback((h: ExpressionFieldHandle) => {
    focusedFieldRef.current = h;
  }, []);
  const insertPath = useCallback((path: string) => {
    focusedFieldRef.current?.insertAtCaret(`{input.${path}}`);
  }, []);

  const filteredConnections = connections.filter((c) => c.providerId === nt?.connectionProviderId);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        data-testid="dialog-backdrop"
        className="absolute inset-0 bg-black/60"
        onClick={onClose}
      />
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-label={`Edit ${nt?.displayName ?? node.data.typeId}`}
        className="relative bg-slate-900 border border-slate-800 rounded-lg shadow-2xl w-[95vw] max-w-[1280px] h-[90vh] flex flex-col"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="flex items-center gap-3 border-b border-slate-800 px-4 py-3">
          <div>
            <div className="text-xs uppercase text-slate-500">{nt?.category ?? ""}</div>
            <div className="text-lg font-medium">{nt?.displayName ?? node.data.typeId}</div>
          </div>
          <button
            onClick={onClose}
            aria-label="Close"
            className="ml-auto text-slate-400 hover:text-slate-200"
          >
            <X size={18} />
          </button>
        </div>

        <div className="flex-1 min-h-0 grid grid-cols-[minmax(240px,25%)_1fr_minmax(240px,25%)] divide-x divide-slate-800">
          <PortsColumn title="Input" ports={inputsArray} clickableFirst onPickPath={insertPath} />
          <div className="p-4 overflow-y-auto space-y-4">
            <label className="block text-sm space-y-1">
              <span className="text-slate-300">Node label</span>
              <textarea
                aria-label="Node label"
                value={node.data.name ?? ""}
                onChange={(e) => onPatch({ name: e.target.value })}
                rows={1}
                className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm font-mono leading-5 resize-none"
              />
            </label>
            {nt?.connectionProviderId && (
              <label className="block text-sm space-y-1">
                <span className="text-slate-300">Connection</span>
                <select
                  value={node.data.connection_id ?? ""}
                  onChange={(e) => onPatch({ connection_id: e.target.value || null })}
                  className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
                >
                  <option value="">— none —</option>
                  {filteredConnections.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
                </select>
              </label>
            )}
            {nt && (
              <SchemaForm
                schema={nt.configSchema}
                value={node.data.config}
                onChange={(v) => onPatch({ config: v })}
                previewInput={previewInput}
                onFieldFocus={registerFocus}
              />
            )}
            {node.data.typeId === "http.webhook" && (
              <WebhookUrlHint
                workflowId={workflowId}
                workflowActive={workflowActive}
                path={typeof node.data.config.path === "string" ? node.data.config.path : ""}
              />
            )}
            <div className="flex items-center gap-2 pt-2 border-t border-slate-800">
              <button
                onClick={() => runMutation.mutate()}
                disabled={!canRun}
                title={triggerNodeIds.length === 0 ? "Add a trigger node to run" : undefined}
                className="px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-sm flex items-center gap-1.5 disabled:opacity-50"
              >
                <Play size={14} /> Run
              </button>
              {runMutation.isPending && <span className="text-xs text-slate-400">running…</span>}
              {runMutation.isError && (
                <span className="text-xs text-red-400">
                  {(runMutation.error as Error)?.message ?? "run failed"}
                </span>
              )}
              <button
                onClick={() => { onDelete(); onClose(); }}
                className="ml-auto px-3 py-1.5 rounded text-sm text-red-400 hover:text-red-300 flex items-center gap-1"
              >
                <Trash2 size={14} /> Delete node
              </button>
              <button
                onClick={onClose}
                className="px-3 py-1.5 rounded bg-brand-600 hover:bg-brand-700 text-sm"
              >
                Done
              </button>
            </div>
          </div>
          <PortsColumn title="Output" ports={outputsArray} clickableFirst={false} />
        </div>
      </div>
    </div>
  );
}

interface Port {
  items: { json: unknown }[];
}
function normalizePorts(v: unknown): Port[] {
  if (!Array.isArray(v)) return [];
  return v.map((port) => ({
    items: Array.isArray(port) ? port.map((it) => ({ json: (it as { json: unknown } | null)?.json })) : [],
  }));
}

function PortsColumn({
  title, ports, clickableFirst, onPickPath,
}: {
  title: "Input" | "Output";
  ports: Port[];
  clickableFirst: boolean;
  onPickPath?: (path: string) => void;
}) {
  return (
    <div className="p-3 overflow-y-auto">
      <h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
      {ports.length === 0 && (
        <div className="text-xs text-slate-500 italic">
          No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
        </div>
      )}
      {ports.map((port, p) => (
        <div key={p} className="mb-3">
          {ports.length > 1 && <div className="text-xs text-slate-500 mb-1">Port {p}</div>}
          {port.items.length === 0 && <div className="text-xs text-slate-500 italic">(no items)</div>}
          {port.items.map((it, i) => (
            <div key={i} className="mb-2">
              {port.items.length > 1 && <div className="text-xs text-slate-500 mb-1">Item {i}</div>}
              <JsonTree
                value={it.json}
                pickable={clickableFirst && p === 0 && i === 0}
                onPickPath={onPickPath}
              />
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

// Moved verbatim from the deleted NodeConfigPanel — same behavior.
function WebhookUrlHint({
  workflowId, workflowActive, path,
}: { workflowId: string; workflowActive: boolean; path: string }) {
  const [copied, setCopied] = useState(false);
  const cleanPath = path.replace(/^\/+/, "");
  const url = `${window.location.origin}/wh/${workflowId}/${cleanPath}`;

  return (
    <div className="bg-slate-950/60 border border-slate-800 rounded p-3 space-y-2">
      <div className="flex items-center justify-between">
        <span className="text-xs uppercase text-slate-500">Webhook URL</span>
        <button
          type="button"
          onClick={async () => {
            try {
              await navigator.clipboard.writeText(url);
              setCopied(true);
              setTimeout(() => setCopied(false), 1500);
            } catch {
              // Clipboard blocked — leave state alone.
            }
          }}
          className="text-slate-400 hover:text-slate-200 flex items-center gap-1 text-xs"
          aria-label="Copy webhook URL"
        >
          {copied ? <><Check size={12} /> Copied</> : <><Copy size={12} /> Copy</>}
        </button>
      </div>
      <code className="block break-all text-xs text-slate-200 font-mono">{url}</code>
      <p className="text-xs text-slate-400">
        Accepts <code className="text-slate-200">POST</code> (JSON body) or{" "}
        <code className="text-slate-200">GET</code> (query params →{" "}
        <code className="text-slate-200">{"{a:'1',…}"}</code>). Click the URL to trigger from the browser.
      </p>
      {!path && (
        <p className="text-xs text-amber-400">Set a URL path above to complete the endpoint.</p>
      )}
      {!workflowActive && (
        <p className="text-xs text-slate-500">
          The webhook only responds when the workflow is <strong>active</strong> — activate it
          from the toolbar to receive requests.
        </p>
      )}
    </div>
  );
}
  • Step 4: Run tests to verify they pass
pnpm --dir frontend test --run NodeEditorDialog

Expected: PASS — all cases green.

  • Step 5: Commit
git add frontend/src/editor/NodeEditorDialog.tsx frontend/src/editor/NodeEditorDialog.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): NodeEditorDialog — three-column N8N-style modal"

Task 9: Wire double-click on Canvas + rewire EditorPage + delete NodeConfigPanel

Canvas listens for React Flow's onNodeDoubleClick and calls a new onEditNode prop. EditorPage manages editingId state, renders NodeEditorDialog when set, and drops the right-side NodeConfigPanel entirely. toGraph now needs nodeTypes passed in.

Files:

  • Modify: frontend/src/editor/Canvas.tsx

  • Modify: frontend/src/pages/EditorPage.tsx

  • Delete: frontend/src/editor/NodeConfigPanel.tsx

  • Step 1: Wire onNodeDoubleClick in Canvas

Edit frontend/src/editor/Canvas.tsx. Add onEditNode to the Props interface and pass it through:

interface Props {
  nodes: Node<VcNodeData>[];
  edges: Edge[];
  onNodesChange: (n: Node<VcNodeData>[]) => void;
  onEdgesChange: (e: Edge[]) => void;
  onSelectNode: (id: string | null) => void;
  onEditNode: (id: string) => void;
  onDropNodeType: (typeId: string, screenX: number, screenY: number) => void;
}

Inside the Inner component, add:

function Inner({
  nodes, edges, onNodesChange, onEdgesChange, onSelectNode, onEditNode, onDropNodeType,
}: Props) {
  // ...existing code unchanged...

  return (
    <div className="h-full" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={handleNodes}
        onEdgesChange={handleEdges}
        onConnect={onConnect}
        onSelectionChange={({ nodes: sel }) => onSelectNode(sel[0]?.id ?? null)}
        onNodeDoubleClick={(_e, n) => onEditNode(n.id)}
        onNodesDelete={(deleted) => {
          const gone = new Set(deleted.map((n) => n.id));
          onEdgesChange(edges.filter((e) => !gone.has(e.source) && !gone.has(e.target)));
        }}
        deleteKeyCode={DELETE_KEYS}
        nodeTypes={vcNodeTypes}
        fitView
      >
        <Background />
        <Controls />
      </ReactFlow>
    </div>
  );
}
  • Step 2: Rewrite EditorPage: drop NodeConfigPanel, add dialog

Replace frontend/src/pages/EditorPage.tsx with:

import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Edge, Node } from "@xyflow/react";
import { useNodeTypes, useConnections, useWorkflow } from "@/api/hooks";
import { Canvas } from "@/editor/Canvas";
import { NodePalette } from "@/editor/NodePalette";
import { NodeEditorDialog } from "@/editor/NodeEditorDialog";
import { fromGraph, toGraph, type VcNodeData } from "@/editor/graphAdapter";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useInvalidate } from "@/api/hooks";
import { Play, Pause, Save, PlayCircle } from "lucide-react";

function newNodeId() {
  return `n_${Math.random().toString(36).slice(2, 10)}`;
}

export default function EditorPage() {
  const { id } = useParams();
  const wf = useWorkflow(id);
  const nts = useNodeTypes();
  const cs = useConnections();

  const [nodes, setNodes] = useState<Node<VcNodeData>[]>([]);
  const [edges, setEdges] = useState<Edge[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [editingId, setEditingId] = useState<string | null>(null);

  useEffect(() => {
    if (!wf.data) return;
    const { nodes: n, edges: e } = fromGraph(wf.data.graph);
    setNodes(n);
    setEdges(e);
  }, [wf.data]);

  const editingNode = useMemo(
    () => nodes.find((n) => n.id === editingId) ?? null,
    [nodes, editingId],
  );

  function patchNode(nodeId: string, patch: Partial<VcNodeData>) {
    setNodes((ns) => ns.map((n) => n.id === nodeId
      ? { ...n, data: { ...n.data, ...patch } }
      : n));
  }
  function deleteNode(nodeId: string) {
    setNodes((ns) => ns.filter((n) => n.id !== nodeId));
    setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
    if (selectedId === nodeId) setSelectedId(null);
    if (editingId === nodeId) setEditingId(null);
  }
  function dropNodeType(typeId: string, x: number, y: number) {
    const nt = nts.data?.find((t) => t.id === typeId);
    if (!nt) return;
    setNodes((ns) => ns.concat({
      id: newNodeId(),
      type: "vc",
      position: { x, y },
      data: {
        typeId,
        name: nt.displayName,
        config: {},
        connection_id: null,
      },
    }));
  }

  const invalidate = useInvalidate();
  const save = useApiMutation(
    () =>
      apiFetch<void>(`/api/workflows/${id}`, {
        method: "PUT",
        body: { name: wf.data!.name, graph: toGraph(nodes, edges, nts.data ?? []) },
      }),
    { onSuccess: () => id && invalidate(qk.workflow(id)) },
  );
  const activate = useApiMutation(
    () => apiFetch<void>(`/api/workflows/${id}/activate`, { method: "POST" }),
    { onSuccess: () => id && invalidate(qk.workflow(id)) },
  );
  const deactivate = useApiMutation(
    () => apiFetch<void>(`/api/workflows/${id}/deactivate`, { method: "POST" }),
    { onSuccess: () => id && invalidate(qk.workflow(id)) },
  );

  const [testStatus, setTestStatus] = useState<
    | { kind: "idle" }
    | { kind: "running" }
    | { kind: "success"; executionId: string }
    | { kind: "error"; message: string }
  >({ kind: "idle" });
  const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => () => { if (clearTimer.current) clearTimeout(clearTimer.current); }, []);
  function scheduleClear(ms: number) {
    if (clearTimer.current) clearTimeout(clearTimer.current);
    clearTimer.current = setTimeout(() => setTestStatus({ kind: "idle" }), ms);
  }

  const triggerNodeIds = useMemo(
    () => nodes
      .filter((n) => nts.data?.find((t) => t.id === n.data.typeId)?.category === "Trigger")
      .map((n) => n.id),
    [nodes, nts.data],
  );

  const testRun = useApiMutation(
    () => {
      const startFrom = selectedId || triggerNodeIds[0];
      if (!startFrom) throw new Error("Add a trigger node or select a node to start from.");
      return apiFetch<{ executionId: string }>(
        `/api/workflows/${id}/executions`,
        { method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
      );
    },
    {
      onMutate: () => setTestStatus({ kind: "running" }),
      onSuccess: (res) => {
        setTestStatus({ kind: "success", executionId: res.executionId });
        scheduleClear(6000);
      },
      onError: (err) => {
        setTestStatus({ kind: "error", message: err instanceof Error ? err.message : String(err) });
        scheduleClear(8000);
      },
    },
  );

  if (wf.isLoading || !wf.data) {
    return <div className="p-6 text-slate-400 text-sm">Loading…</div>;
  }

  return (
    <div className="h-screen flex flex-col">
      <div className="border-b border-slate-800 px-4 py-2 flex items-center gap-3">
        <span className="font-semibold">{wf.data.name}</span>
        <span className="text-xs text-slate-500">Editor</span>
        <div className="ml-auto flex items-center gap-2">
          <Link
            to={`/workflows/${id}/executions`}
            className="px-3 py-1.5 rounded text-sm text-slate-300 hover:bg-slate-800"
          >
            Executions
          </Link>
          <button
            onClick={() => save.mutate()}
            disabled={save.isPending}
            className="px-3 py-1.5 rounded bg-brand-600 hover:bg-brand-700 text-sm flex items-center gap-1.5"
          >
            <Save size={14} /> Save
          </button>
          <button
            onClick={() => testRun.mutate()}
            disabled={testRun.isPending}
            className="px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-sm flex items-center gap-1.5"
          >
            <PlayCircle size={14} /> Test run
          </button>
          {wf.data.active ? (
            <button
              onClick={() => deactivate.mutate()}
              disabled={deactivate.isPending}
              className="px-3 py-1.5 rounded bg-amber-700/70 hover:bg-amber-700 text-sm flex items-center gap-1.5"
            >
              <Pause size={14} /> Deactivate
            </button>
          ) : (
            <button
              onClick={() => activate.mutate()}
              disabled={activate.isPending}
              className="px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-sm flex items-center gap-1.5"
            >
              <Play size={14} /> Activate
            </button>
          )}
        </div>
      </div>
      {testStatus.kind !== "idle" && (
        <div
          role="status"
          className={
            "px-4 py-2 text-sm border-b flex items-center gap-2 " +
            (testStatus.kind === "success"
              ? "bg-emerald-950/50 border-emerald-900 text-emerald-200"
              : testStatus.kind === "error"
                ? "bg-red-950/50 border-red-900 text-red-200"
                : "bg-slate-900 border-slate-800 text-slate-300")
          }
        >
          {testStatus.kind === "running" && <>Running test execution…</>}
          {testStatus.kind === "success" && (
            <>
              Started execution <code className="font-mono text-xs">{testStatus.executionId}</code>
              <Link
                to={`/workflows/${id}/executions`}
                className="ml-auto underline hover:text-emerald-100"
              >
                View execution →
              </Link>
            </>
          )}
          {testStatus.kind === "error" && <>Test run failed: {testStatus.message}</>}
        </div>
      )}
      <div className="flex-1 flex">
        <NodePalette />
        <div className="flex-1">
          <Canvas
            nodes={nodes} edges={edges}
            onNodesChange={setNodes} onEdgesChange={setEdges}
            onSelectNode={setSelectedId}
            onEditNode={setEditingId}
            onDropNodeType={dropNodeType}
          />
        </div>
      </div>
      {editingNode && (
        <NodeEditorDialog
          node={editingNode}
          nodeTypes={nts.data ?? []}
          connections={cs.data ?? []}
          workflowId={id ?? ""}
          workflowActive={wf.data.active}
          triggerNodeIds={triggerNodeIds}
          onPatch={(patch) => patchNode(editingNode.id, patch)}
          onDelete={() => deleteNode(editingNode.id)}
          onClose={() => setEditingId(null)}
        />
      )}
    </div>
  );
}
  • Step 3: Delete NodeConfigPanel.tsx
rm frontend/src/editor/NodeConfigPanel.tsx
  • Step 4: Type-check + run the full frontend suite
pnpm --dir frontend build
pnpm --dir frontend test

Expected: PASS. pnpm build runs tsc -b; a stale import of NodeConfigPanel anywhere fails the build. If a test file references the deleted panel, remove or update it.

  • Step 5: Commit
git add -A frontend/src/editor/Canvas.tsx frontend/src/pages/EditorPage.tsx
git rm frontend/src/editor/NodeConfigPanel.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): double-click opens node editor dialog; remove side panel"

Task 10: Extend the Playwright smoke test

Prove the whole flow end-to-end: after dropping a node, double-click opens the dialog, editing the label survives save+reload.

Files:

  • Modify: frontend/e2e/smoke.spec.ts

  • Step 1: Read the current smoke test

cat frontend/e2e/smoke.spec.ts

Familiarize yourself with the shape: login → create workflow → drop a node → save → reload.

  • Step 2: Extend the assertion

Add these steps after the node has been dropped and before the final save. Locators use the accessible name where possible; fall back to a data-testid you add if a role/name isn't stable.

// After the drop step, before Save:

// Double-click the just-dropped node's DOM element. React Flow renders
// nodes as `.react-flow__node`; there should be exactly one at this point.
const nodeEl = page.locator(".react-flow__node").first();
await nodeEl.dblclick();

// Dialog appears with the three column headings.
await expect(page.getByRole("heading", { name: /input/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /parameters/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /output/i })).toBeVisible();

// Edit the label to something distinctive.
const label = page.getByLabel("Node label");
await label.fill("Custom smoke node");

// Close the dialog with the Done button.
await page.getByRole("button", { name: /^done$/i }).click();
await expect(page.getByRole("heading", { name: /parameters/i })).not.toBeVisible();

Then, after Save and reload:

// The canvas should show the custom label on the node.
await expect(page.locator(".react-flow__node")).toContainText("Custom smoke node");
  • Step 3: Run the smoke test
pnpm --dir frontend test:e2e

Expected: PASS — full smoke green including dialog edit+reload.

  • Step 4: Commit
git add frontend/e2e/smoke.spec.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "test(e2e): smoke covers double-click dialog and label persistence"

Wrap-up

  • Full test sweep
dotnet build
dotnet test
pnpm --dir frontend test
pnpm --dir frontend test:e2e

Expected: PASS across all four.

  • Update docs/superpowers/followups.md

Append this section (or add if the file doesn't have it):

### From Plan 5 (Node Editor Dialog)

- **Webhook stand-by mode.** Trigger option where an active workflow pauses on
  the webhook node and only continues when a matching HTTP request arrives,
  instead of running through the first request. Needs pause/resume semantics
  in Executor; separate spec.
- **Drag-and-drop insertion** from JsonTree to ExpressionField.
- **Multi-color / semantic highlighting** inside `{…}` (property vs bracket vs
  literal). Single color ships in Plan 5.
- **Cross-node expressions** like `{nodes.<id>.output.body}` and
  `{nodes.<id>.output[0].json.body}`. Requires the executor to expose a
  scoped-view of run state to `TemplateEngine`.
- **Pinned input data** (n8n's "pin this data on a node") — override the input
  the dialog uses for previews and the executor uses for that node.
  • Update project CLAUDE.md

In CLAUDE.md, update the "Current status" section to note Plan 5 shipped and the head commit; update the "Follow-ups" pointer if needed. Add these to the "Invariants (do NOT relearn from scratch)" section:

- **Expression preview and executor interpolation MUST stay in parity.** `frontend/src/expr/templateEngine.ts` is a hand-port of `src/VolcanicCore.Core/TemplateEngine.cs`. When adding a case to the backend `TemplateEngineTests`, add the mirror case to `frontend/src/expr/templateEngine.test.ts` too. Divergence is a bug: the preview line under every ExpressionField reads from the frontend port, and users trust it.
- **`toGraph(nodes, edges, nodeTypes)` needs the node types.** `coerceConfigForSave` looks up each node's schema to decide whether to save `"200"` as `200` or as `"200"`. If you call `toGraph` without node types, everything gets saved as strings and every non-string field silently breaks at run time.
- **`Executor.CoerceToSchema` runs AFTER `InterpolateStrings`.** Reorder them and templated numeric fields stop working because nodes check `JsonValueKind.Number`. The pair must always be `InterpolateStrings → CoerceToSchema → node.ExecuteAsync`.
  • Commit docs
git add docs/superpowers/followups.md CLAUDE.md
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "docs: plan 5 wrap-up (followups, CLAUDE.md invariants)"
  • Deploy (per global CLAUDE.md — fire-and-forget)
git push origin main
APP=$(jq -r '.app.uuid' deploy.json)
curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" \
  -H "Authorization: Bearer $COOLIFY_KEY" \
  | jq -r '.deployments[0].deployment_uuid // .deployment_uuid'

Expected: prints a deployment uuid. Do NOT poll. Report the uuid to the user and stop.