Skip to content

feat: a group can be marked as the one the flow starts in - #173

Merged
endrix merged 25 commits into
mainfrom
feat/group-start
Nov 12, 2026
Merged

endrix merged 25 commits into
mainfrom
feat/group-start

Conversation

@endrix

@endrix endrix commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

The state machine of the actor view had no way to say which state is the initial one. (#168 to #172 are merged; the base is main.)

What it does

A producer marks a group with initial: true on its scope: the initial state of a state machine, the first stage of a pipeline. The group's frame gets a filled dot and a short arrow before its title, as a statechart marks its initial state. A group with no title has the mark alone, in the room every frame keeps for a header. The mark is inside the frame, so the layout is not touched, and it says what the group is, not where it goes.

Where

  • shared: WorkflowDiagramMetadata.GROUP_INITIAL (wf:groupInitial).
  • diagram-server: PyGraphScope.initial, the group's argument. Only a plain true counts.
  • sidecar-toolkit graph-payload-to-doc.ts: passes initial through, only a true one.
  • diagram-client: group-initial-mark.ts, drawn by GroupNodeView; a product colours it with .workflow-node-group .group-initial-mark { color: … }.

Checked

  • Tests in the server (group-scopes), the toolkit (graph-payload-scope-label) and the client (group-initial-mark, which renders the group's view: the mark's place with a title, without one, after an open node's toggle, and not for anything but true).
  • npm run typecheck, npm run check:neutrality, npm test: all pass.
  • Rendered a real CalPy actor with its initial state marked, in the dark and the light theme, and looked at it.

After review

An independent review rendered eleven documents (titled, untitled, long title, two marked groups, nested, empty, pinned, with a loop, rows, a cycle, light theme) and found nothing to fix before merge. Taken from it:

  • Renamed from start / GROUP_START: it read as the pair of GROUP_END (where an edge meets a frame) and, beside pin and row, as a placement.
  • A dot and an arrow instead of a dot alone, which was the glyph of a port.
  • An untitled group draws the mark instead of dropping it silently.
  • The client test renders the view instead of reading its source.

Limits

  • A frame is not made wider for its title, with the mark or without (as before this PR). The mark takes 24 px of the title's room, so a long title in a narrow frame runs past it sooner. Giving every frame a minimum width from its title would change the layout of existing diagrams of every product; it is left for a change of its own.
  • Not tried in a running VS Code.

https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv

An edge whose end names a group's scope ends on the group's frame instead
of at one of its members: a transition into a state, where the state is a
group of what can happen in it. The transform resolves a scope id as an
end; `splitEdgesAtGroups` gives every such edge one place on the frame
(`GROUP_END`, left for edges ending there, right for edges starting there),
shared by all of them, crossing the frames around the group as any edge
does. The client keeps the arrowhead of an edge that ends there and draws
no dot.

An edge between a group and something inside it (or itself) goes nowhere
the frame does not already hold. It is taken out of the model and the group
is marked (`GROUP_LOOP`): one loop on the frame for all of them.

A group whose label is empty has no title and no header band; only a scope
with no label at all is named by its id. A scope's label now reaches the
document on the sidecar-export path, which dropped it.

Reopening a saved layout puts the place where edges end back where the
edge outside the group reaches it, there being no piece inside to go by.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
A producer puts the members of a group in rows with `meta.row` (a whole
number from zero, the first row on top). The members of a row sit side by
side in the order the producer gave them, the rows one under another; a
member with no row comes after the last.

The layers of a layout that runs downward are rows, and a member's row is
its partition: such a group is laid out with `elk.direction: DOWN`,
partitioning on, and its members as one component (on their own, members
with nothing between them would each be a component, packed wherever it
fits). The engine forces every nested layout to the right; a group in rows
is the exception.

The pass that pins sources and sinks to the outer layers when a diagram
opens leaves a row's members alone: a layer constraint would contradict
their row. It also no longer pins a node that has no port at all, which is
neither a source nor a sink.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
Reopening a saved layout: a port where edges end at a group has no member
to be level with, and the layout may put it below the members. The frame
was rebuilt around the members alone, so such a port was clamped to the new
bottom edge and its edges turned diagonal. The frame now reaches down past
every port its saved routes meet. A real lay out, save and reopen of such a
document is tested.

The selection trace took the port where edges end at a group for one more
port to pass through, with nothing behind it: the group was never on a
path. That port is the group's own; the group is on the path, and what it
holds is left as it is instead of faded.

A group that holds nothing was laid out 20 by 20, its title running over
its edges. Groups have a minimum size, on layout and on reopening.

The loop of a group keeps what its edges said: their captions and error
messages are its tooltip, and it is drawn in the error colour when one of
them is in error. Before, they went with the edges.

Where an edge starts at a group the dot stays (the line began 4 px off the
frame without it); only where edges arrive does the arrowhead replace it.
The property panel names the group an edge ends at by its title.

Tests: the loop from a group to a member and to itself, sharing on the way
out, group to group, an empty group's size, the trace through a group.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
A producer pins a group with `pin: 'top'` on its scope: what belongs to the
whole picture rather than to a place in its flow, as a legend does, or the
variables a state machine works on. Laid out with the rest, such a group is
one more node of the layered layout: it takes a layer, and its width moves
everything in it.

Pinned groups are taken out of the graph the layout sees, laid out apart
(their inside, and so their size), then stacked in a band above what the
layout made: the first on top, left edges on the diagram's. The rest ends
up exactly where it would without them. A pinned group that an edge crosses
or ends at stays in the flow, since its edges need it there. Routes of
edges inside a pinned group move with it.

The scope's `pin` reaches the document from a sidecar's export.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
Some relations are too many to draw all at once: every state variable an
action reads, every action that takes a token from a port. A producer marks
such an edge `meta.onSelection`. The transform sets it aside: it is no edge
of the model, takes no part in the layout and crosses no group frame (so a
pinned group it touches stays pinned). It reaches the client as data on the
root (`SELECTION_LINKS`), its ends resolved to the model's nodes: a node by
its id, a port by its node, a group scope by its group.

The client's root view draws the links of the selection over the diagram
(selection-links.ts): every link the selected node is an end of, the node
at its other end outlined, the diagram's own edges faded behind. A link
leaves a node by the side that faces the other end, or by the side across
when a neighbour of the node's own frame is in the way there, so it runs
beside a frame's rows and not over them; links sharing a side are spread
along it in the order of where they lead, so none crosses another at the
node. `meta.role` is the class `selection-link-<role>` a product colours,
`meta.label` a word on the link (hidden when zoomed out),
`meta.bidirectional` an arrowhead at each end.

A node's `meta.sameAs` names what it is one drawing of. Selecting it
outlines the other nodes that draw the same thing.

The links run straight over whatever lies between their ends; routing them
around other frames is not done.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
Rows are for members that relate only to what is outside their group. With
an edge between two members the layering puts its ends in different layers,
which split a row, and keeping the producer's order within a row sent the
edge through the members between its ends. A group with such an edge now
keeps the plain layout, laid out by its edges as before.

Which groups lay out in rows is decided once the groups are nested and the
edges cut (`arrangeGroupRows`), not while each group is built. A group
nested in a rows group is a member with a row of its own (a scope's `row`),
or after the last; a member opened in place keeps its row on the frame it
becomes. Before, both landed in the top row.

A group laid out in rows says so with `GROUP_ROWS`; `GROUP_ROW` is the row
of a member only. A row past what a layout can count is no row.

The changelog entry had been merged into the middle of its neighbour.

Left for later: a short row is not left-aligned over a long one, and a
frame grows by a track for every member's leaving edge.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
A producer puts lines of text in a node with meta.lines: what a reader
zooms in for, such as the condition a step waits on or what it takes and
gives. Each line is a band across the bottom of the node's body, which is
taller by them and as wide as the longest. The band is always drawn and
carries node-line-<kind> for a product to colour; its text is detail,
drawn only close in.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
A producer marks a group with start: true on its scope: the initial
state of a state machine, the first stage of a pipeline. Its title gets
a filled dot before it, as a statechart marks its initial state. A group
with no title has no band for it and draws none.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
… group is not pinned

From the review of #170.

The band was placed from the root's node boxes alone. The layout runs an
edge that goes back over the top row, and the band sat on it. Its bottom
is now a gap above the highest node or route point, whichever is higher.

pin on a group nested in another was passed to the client and then
ignored by the layout. It is now dropped where the group is nested.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
…rossing, and end where a node is drawn

From the review of #171.

- The trace faded the nodes the selection's links lead to, and the links'
  dimming hid the trace. What the links lead to, and the other drawings of
  what is selected, are now left unfaded by the trace; an edge on the
  trace, selected or in error is not dimmed.
- Links sharing a side were ordered by where they lead across it, which
  ties for a row of nodes seen from a side, and then by id: the fan
  crossed itself. They are ordered by the direction they lead in; two
  links between the same two nodes run side by side.
- A link's end the platform draws as something else was lost: the links
  are taken out before the document is rewritten and end at the frame of a
  node opened in place, or at the bus a port of the unit is folded into.
  No bus counts a link among its wires. A link between a frame and what
  is in it is dropped.
- Each side is sorted once, a node's neighbours found once, and a model
  with no link and no sameAs is not walked on every frame.
- The edges are dimmed only when a link is drawn, not for outlines alone.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
The merge of main left the entry's earlier wording beside the new one.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
…and rows stand on their tops

From the review of #172.

- The cut at 48 fell between the two halves of a character outside the
  basic plane, and the headless render died on the lone half. The cut is
  now by character and by column: a wide character (East Asian, an emoji)
  takes two, in the estimate of the node's width as well.
- Control characters reached the exported SVG raw and made it no XML;
  they, the marks that turn the direction of the text, and the spaces of
  no width are taken out of a line.
- An if or a loop took lines on the server, which its own view never
  drew: it takes none.
- The members of a row of a group stand on one line along their tops: a
  node with lines beside one without was centred on it.
- The parser sits with the other meta parsers.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
… arrow, title or no title

From the review of #173.

- The scope's field is initial and the argument GROUP_INITIAL: start read
  as the pair of GROUP_END, which is where an edge meets a frame, and as
  a place the layout would put the group in. It does neither.
- The mark is a dot and a short arrow, as a statechart draws it: a dot
  alone was the glyph of a port.
- A group with no title has the mark alone, in the room every frame keeps
  for a header; it was dropped without a word.
- The test renders the group's view and checks what is drawn and where,
  where it read the view's source.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
@endrix endrix changed the title feat: a group can be marked as where the flow starts feat: a group can be marked as the one the flow starts in Nov 12, 2026
@endrix
endrix changed the base branch from feat/node-lines to main November 12, 2026 16:21
@endrix
endrix merged commit b3209ff into main Nov 12, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant