feat(flow editor): Once / Cooldown / Delay palette nodes#205
Conversation
Surface the three time-control command nodes in the flow editor so they can be authored (codegen for them shipped in flow-codegen#47/#48). All three are `.other`-kind command nodes with a single `body` exec output, matching ForRange/While's exec-pin shape and rectangular silhouette. - flow_doc.controlExecPinNames: Once/Cooldown/Delay -> {"body"}, which flows into isControlNode / execPinValidFor / validateExecEdge. - flow_doc render: new `.other` arm draws the `body` exec-out; extend needs_exec_in to cover any control node so a freshly-placed node always exposes its exec-in drop target (else the first control arrow can't land — also fixes Branch/ForRange/While/Switch on first placement). - flow_doc palette: new "Time" cluster — + Once, + Cooldown, + Delay. Cooldown/Delay seed `seconds` = 1.0. - flow_io.other_field_specs: Cooldown/Delay expose `seconds` as a `.literal` inspector field so the f64 round-trips to .flow.jsonc. - tests: appendOtherNode shape + seeded seconds, body exec-edge validity for each, and parse/render round-trip (1.0 normalises to 1, then load -> save is byte-stable).
PR SummaryLow Risk Overview Exec-in fix — Tests — Coverage for creation, Reviewed by Cursor Bugbot for commit 266223e. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request adds support for three new time-control command nodes (Once, Cooldown, and Delay) to the flow editor, including their field specifications, execution pin definitions, rendering logic, palette entries, and corresponding tests. The review feedback suggests grouping the execution pin checks for these nodes into a single conditional block to reduce duplication, seeding the default seconds value as "1" instead of "1.0" to avoid a one-time normalization diff on save, and updating related comments to reflect this normalized value.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Time-control command nodes (flow-codegen#47, #48). `Cooldown` and | ||
| // `Delay` each carry one `seconds` field (a JSON-native `f64` literal, | ||
| // e.g. `1.0`) edited verbatim through the `.literal` widget so it | ||
| // normalises + round-trips like any other numeric extra. `Once` has no | ||
| // field, so it needs no spec — its inspector shows the verbatim view. | ||
| .{ .type_name = "Cooldown", .key = "seconds", .label = "Seconds", .widget = .literal }, | ||
| .{ .type_name = "Delay", .key = "seconds", .label = "Seconds", .widget = .literal }, |
There was a problem hiding this comment.
Since the editor's numeric canonical form normalizes 1.0 to 1 on first parse/save, updating the comment to use 1 instead of 1.0 keeps the documentation aligned with the actual serialized output.
// Time-control command nodes (flow-codegen#47, #48). `Cooldown` and
// `Delay` each carry one `seconds` field (a JSON-native `f64` literal,
// e.g. `1`) edited verbatim through the `.literal` widget so it
// normalises + round-trips like any other numeric extra. `Once` has no
// field, so it needs no spec — its inspector shows the verbatim view.
.{ .type_name = "Cooldown", .key = "seconds", .label = "Seconds", .widget = .literal },
.{ .type_name = "Delay", .key = "seconds", .label = "Seconds", .widget = .literal }
There was a problem hiding this comment.
Addressed at the source: the seed is now "1", so the value is canonical from creation and there's no one-time 1.0→1 churn on first load.
| if (std.mem.eql(u8, type_name, "Once")) return &.{"body"}; | ||
| if (std.mem.eql(u8, type_name, "Cooldown")) return &.{"body"}; | ||
| if (std.mem.eql(u8, type_name, "Delay")) return &.{"body"}; |
There was a problem hiding this comment.
These three time-control nodes all return the same body exec pin. We can group their checks into a single if statement to reduce duplication and improve readability.
if (std.mem.eql(u8, type_name, "Once") or
std.mem.eql(u8, type_name, "Cooldown") or
std.mem.eql(u8, type_name, "Delay")) return &.{"body"};
There was a problem hiding this comment.
Leaving as separate if lines — it matches the existing convention in this function (ForRange/While are already one-per-line), which keeps each node type greppable. The duplication is one short line each; consolidating only the body-returners would read as inconsistent with the Branch/Switch arms around them.
| // time; `Cooldown`/`Delay` carry a `seconds` (f64) field seeded to | ||
| // `1.0` — the same canonical JSON value text codegen parses | ||
| // (`{ "type": "Cooldown", "seconds": 1.0 }`). Like the other control | ||
| // nodes their wiring is the `body` exec edge; `Delay`'s `body` is meant | ||
| // to reach a `Subflow` (enforced by codegen, not the editor). | ||
| zgui.text("Time", .{}); | ||
| if (zgui.button("+ Once", .{})) { | ||
| addOtherNode(s, "Once", &.{}) catch |err| nodeAddErr(err); | ||
| } | ||
| zgui.sameLine(.{}); | ||
| if (zgui.button("+ Cooldown", .{})) { | ||
| addOtherNode(s, "Cooldown", &.{.{ .key = "seconds", .value_text = "1.0" }}) catch |err| nodeAddErr(err); | ||
| } | ||
| zgui.sameLine(.{}); | ||
| if (zgui.button("+ Delay", .{})) { | ||
| addOtherNode(s, "Delay", &.{.{ .key = "seconds", .value_text = "1.0" }}) catch |err| nodeAddErr(err); | ||
| } |
There was a problem hiding this comment.
Seeding the default value as "1" instead of "1.0" avoids a one-time normalization diff on the first save of a newly placed node, keeping the file immediately stable and clean.
// time; `Cooldown`/`Delay` carry a `seconds` (f64) field seeded to
// `1` — the same canonical JSON value text codegen parses
// (`{ "type": "Cooldown", "seconds": 1 }`). Like the other control
// nodes their wiring is the `body` exec edge; `Delay`'s `body` is meant
// to reach a `Subflow` (enforced by codegen, not the editor).
zgui.text("Time", .{});
if (zgui.button("+ Once", .{})) {
addOtherNode(s, "Once", &.{}) catch |err| nodeAddErr(err);
}
zgui.sameLine(.{});
if (zgui.button("+ Cooldown", .{})) {
addOtherNode(s, "Cooldown", &.{.{ .key = "seconds", .value_text = "1" }}) catch |err| nodeAddErr(err);
}
zgui.sameLine(.{});
if (zgui.button("+ Delay", .{})) {
addOtherNode(s, "Delay", &.{.{ .key = "seconds", .value_text = "1" }}) catch |err| nodeAddErr(err);
}
There was a problem hiding this comment.
Done — seeding "1" (already the canonical f64 form) for both Cooldown and Delay, so there's no first-parse normalization.
Seeding "1" (already the editor's canonical f64 form) instead of "1.0" avoids a one-time parse-normalization on first load. JSON-native f64, so flow-codegen parses it identically.
Adds the three time-control built-in nodes to the flow editor palette so they're usable in the editor (codegen shipped in flow-codegen#47/#48).
What
+ Once,+ Cooldown/+ Delay(seedingseconds = 1.0).bodyexec-out, viacontrolExecPinNames(whichisControlNode/execPinValidFor/validateExecEdgeall derive from). New render arm draws thebodyout.secondsfield —Cooldown/Delayexpose an editablesecondsvia the generic.other-field path (.literalwidget, same asLiteral.value); round-trips to.flow.jsoncmatching the codegen contract ({"type":"Cooldown","seconds":1.0}).Oncehas no fields.Latent bug fixed along the way
needs_exec_inonly coveredisCommandNode(true for plugin/CustomNodes), but the built-in control nodes (Branch/ForRange/While/Switchand now the time nodes) are.other-kind →isCommandNodewas false, so a freshly-placed control node had no exec-in anchor until something already targeted it. Now gated onisControlNodetoo — fixes the new nodes and that pre-existing gap.Tests
zig build test→ 456/456;zig build gui-test→ 26/26;zig buildclean. New tests: add each node (type + seededseconds), exec-pin validity returnsbody, and save/load round-trip preservingseconds.Parent: #187. Pairs with the demo ticket #204.