dsh-workflow-map 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * FlowColumn.js — the right column: the selected step's flow, node by node.
3
+ *
4
+ * Structure of the column :
5
+ * header "step flow · step 8 · title" - OUTSIDE the scroller, so it never moves
6
+ * scroller one independently scrolling area (`overflow:auto`)
7
+ * strip "n earlier rows folded" + "expand all" - only when the cap bites
8
+ * list - every item hangs off one vertical spine (the tree spine)
9
+ * item = ONE node card (NodeCard.js) or ONE folded run of same-tool calls
10
+ *
11
+ * The fold: `view.steps[i].groups` (host, ) is authoritative when it resolves
12
+ * against the node list; otherwise the browser folds consecutive same-tool calls
13
+ * itself with the same threshold (WF_GROUP_MIN = the host's GROUP_THRESHOLD). A group
14
+ * hit by the 300-node host cap loses its missing members but never invents one.
15
+ *
16
+ * Branches : `wfRenderBranches` (Branches.js) draws the selected step's subagent /
17
+ * workflow / background-job lanes under the list, from `model.flow.branches`. A lane is NOT
18
+ * a node: the node count, the fold, the node window and the current-activity highlight all
19
+ * ignore it — only the lane's own status dot breathes.
20
+ *
21
+ * The cap: by default only the NEWEST WF_FLOW_CAP nodes are drawn (the list opens at
22
+ * its bottom); a group is never split at the boundary. "expand all" lifts the cap until
23
+ * the step changes — that reset is `wfFlowUiFor`, keyed by step index, so it is a pure
24
+ * function and tools/selftest-panel.mjs can prove it.
25
+ *
26
+ * Pure: `h` and `say` are injected; no React import, no DOM.
27
+ *
28
+ * Inlined into client.js by tools/build-client.mjs (single-file browser half).
29
+ */
30
+
31
+ /* ------------------------------------------------------------------------- */
32
+ /* grouping */
33
+ /* ------------------------------------------------------------------------- */
34
+
35
+ /** Fold consecutive same-tool calls with the host's threshold. */
36
+ function wfFlowFold(nodes) {
37
+ var runs = [];
38
+ var run = null;
39
+ for (var i = 0; i < nodes.length; i += 1) {
40
+ var node = nodes[i];
41
+ var tool = node && node.kind === "tool" && typeof node.tool === "string" ? node.tool : "";
42
+ if (tool === "") {
43
+ run = null;
44
+ continue;
45
+ }
46
+ if (run !== null && run.tool === tool) {
47
+ run.to = i;
48
+ run.count = run.to - run.from + 1;
49
+ run.lastAt = wfFinite(node.at);
50
+ continue;
51
+ }
52
+ run = {
53
+ key: tool,
54
+ tool: tool,
55
+ from: i,
56
+ to: i,
57
+ count: 1,
58
+ firstAt: wfFinite(node.at),
59
+ lastAt: wfFinite(node.at),
60
+ };
61
+ runs.push(run);
62
+ }
63
+ var out = [];
64
+ for (var r = 0; r < runs.length; r += 1) {
65
+ if (runs[r].count >= WF_GROUP_MIN) out.push(runs[r]);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Group ranges over `nodes`.
72
+ *
73
+ * Host groups win when they resolve; a group whose first or last node did not survive
74
+ * a cap is dropped (its remaining members still render, as individual cards). Groups
75
+ * are sorted and must not overlap — a malformed payload can hide a fold, never a node.
76
+ */
77
+ function wfFlowGroups(nodes, hostGroups) {
78
+ var list = Array.isArray(nodes) ? nodes : [];
79
+ var position = Object.create(null);
80
+ for (var i = 0; i < list.length; i += 1) {
81
+ if (list[i] && typeof list[i].id === "string") position[list[i].id] = i;
82
+ }
83
+
84
+ var out = [];
85
+ if (Array.isArray(hostGroups)) {
86
+ var used = Object.create(null);
87
+ for (var g = 0; g < hostGroups.length; g += 1) {
88
+ var group = hostGroups[g];
89
+ if (!group || typeof group !== "object") continue;
90
+ var from = position[String(group.firstId)];
91
+ var to = position[String(group.lastId)];
92
+ if (typeof from !== "number" || typeof to !== "number") continue;
93
+ if (to - from < 1) continue;
94
+ var clash = false;
95
+ for (var p = from; p <= to; p += 1) {
96
+ if (used[p] === true) {
97
+ clash = true;
98
+ break;
99
+ }
100
+ }
101
+ if (clash) continue;
102
+ for (p = from; p <= to; p += 1) used[p] = true;
103
+ out.push({
104
+ key: typeof group.key === "string" && group.key !== "" ? group.key : String(group.tool || "group"),
105
+ tool: typeof group.tool === "string" ? group.tool : "",
106
+ from: from,
107
+ to: to,
108
+ count: to - from + 1,
109
+ firstAt: wfFinite(group.firstAt),
110
+ lastAt: wfFinite(group.lastAt),
111
+ });
112
+ }
113
+ }
114
+ if (out.length === 0) out = wfFlowFold(list);
115
+ out.sort(function (a, b) {
116
+ return a.from - b.from;
117
+ });
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * A flat item list: one entry per rendered row. A group carries its member nodes so
123
+ * the renderer never re-derives the range (and the window can count it as N nodes).
124
+ */
125
+ function wfFlowItems(nodes, groups) {
126
+ var list = Array.isArray(nodes) ? nodes : [];
127
+ var byStart = Object.create(null);
128
+ var folds = Array.isArray(groups) ? groups : [];
129
+ for (var g = 0; g < folds.length; g += 1) {
130
+ var fold = folds[g];
131
+ if (!fold || typeof fold.from !== "number" || typeof fold.to !== "number") continue;
132
+ if (fold.from < 0 || fold.to >= list.length || fold.to < fold.from) continue;
133
+ byStart[fold.from] = fold;
134
+ }
135
+
136
+ var items = [];
137
+ var p = 0;
138
+ while (p < list.length) {
139
+ var group = byStart[p];
140
+ if (group) {
141
+ var members = list.slice(group.from, group.to + 1);
142
+ items.push({
143
+ type: "group",
144
+ key: group.key,
145
+ tool: group.tool,
146
+ from: group.from,
147
+ to: group.to,
148
+ count: members.length,
149
+ firstAt: group.firstAt,
150
+ lastAt: group.lastAt,
151
+ nodes: members,
152
+ });
153
+ p = group.to + 1;
154
+ } else {
155
+ items.push({ type: "node", node: list[p] });
156
+ p += 1;
157
+ }
158
+ }
159
+ return items;
160
+ }
161
+
162
+ /* ------------------------------------------------------------------------- */
163
+ /* the cap */
164
+ /* ------------------------------------------------------------------------- */
165
+
166
+ function wfFlowItemSize(item) {
167
+ return item && item.type === "group" ? item.count : 1;
168
+ }
169
+
170
+ function wfFlowNodeCount(items) {
171
+ var total = 0;
172
+ for (var i = 0; i < (items ? items.length : 0); i += 1) total += wfFlowItemSize(items[i]);
173
+ return total;
174
+ }
175
+
176
+ /**
177
+ * Which items the column draws, and how many nodes that hides.
178
+ *
179
+ * The walk starts at the NEWEST item and takes whole items until adding the next one
180
+ * would pass `cap`. A group is atomic: a run of 40 calls counts as 40 and is never cut
181
+ * in half, so the visible node count can exceed the cap by at most (group size - 1).
182
+ */
183
+ function wfFlowWindow(items, cap, showAll) {
184
+ var list = Array.isArray(items) ? items : [];
185
+ var limit = wfFinite(cap) === null ? WF_FLOW_CAP : Math.max(1, Math.round(cap));
186
+ var total = wfFlowNodeCount(list);
187
+ if (showAll === true || total <= limit) return { items: list, hiddenNodes: 0, hiddenItems: 0, capped: false };
188
+
189
+ var acc = 0;
190
+ var start = list.length;
191
+ for (var i = list.length - 1; i >= 0; i -= 1) {
192
+ var size = wfFlowItemSize(list[i]);
193
+ if (acc > 0 && acc + size > limit) break;
194
+ acc += size;
195
+ start = i;
196
+ if (acc >= limit) break;
197
+ }
198
+ return { items: list.slice(start), hiddenNodes: total - acc, hiddenItems: start, capped: start > 0 };
199
+ }
200
+
201
+ /* ------------------------------------------------------------------------- */
202
+ /* per-step UI state (expanded groups · show-all · selected node) */
203
+ /* ------------------------------------------------------------------------- */
204
+
205
+ function wfFlowEmptyUi(stepIndex) {
206
+ return {
207
+ stepIndex: stepIndex,
208
+ expanded: [],
209
+ showAll: false,
210
+ selectedNode: null,
211
+ closedBranches: [],
212
+ // which round branch icon is open under the running node (null = none). It is per
213
+ // step, like everything else here, so switching steps closes it instead of leaving a detail
214
+ // hanging under a card the user did not click.
215
+ openChain: null,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Read the UI state AS IT APPLIES to `stepIndex`. A state from another step is not
221
+ * "merged" or reused: switching steps starts from the defaults, which is exactly the
222
+ * "collapse + expand-all cleared + selection cleared" reset the step asks for (and it happens on the
223
+ * first render of the new step, not one frame later).
224
+ */
225
+ function wfFlowUiFor(ui, stepIndex) {
226
+ var index = wfFinite(stepIndex) === null ? 0 : Math.round(stepIndex);
227
+ if (!ui || typeof ui !== "object" || wfFinite(ui.stepIndex) === null || Math.round(ui.stepIndex) !== index) {
228
+ return wfFlowEmptyUi(index);
229
+ }
230
+ var expanded = [];
231
+ if (Array.isArray(ui.expanded)) {
232
+ for (var i = 0; i < ui.expanded.length; i += 1) {
233
+ if (typeof ui.expanded[i] === "string" && expanded.indexOf(ui.expanded[i]) === -1) expanded.push(ui.expanded[i]);
234
+ }
235
+ }
236
+ // Branch lanes are open by default (their whole point is to show the parallel work),
237
+ // so the remembered list is the FOLDED ones. Both lists are per step and reset together.
238
+ var closedBranches = [];
239
+ if (Array.isArray(ui.closedBranches)) {
240
+ for (var c = 0; c < ui.closedBranches.length; c += 1) {
241
+ if (typeof ui.closedBranches[c] === "string" && closedBranches.indexOf(ui.closedBranches[c]) === -1) {
242
+ closedBranches.push(ui.closedBranches[c]);
243
+ }
244
+ }
245
+ }
246
+ return {
247
+ stepIndex: index,
248
+ expanded: expanded,
249
+ showAll: ui.showAll === true,
250
+ selectedNode: typeof ui.selectedNode === "string" && ui.selectedNode !== "" ? ui.selectedNode : null,
251
+ closedBranches: closedBranches,
252
+ // A category name, never an arbitrary string: anything else collapses to null, so a hand-edited
253
+ // state cannot open a detail block that has no branch behind it.
254
+ openChain: WF_CHAIN_KINDS.indexOf(ui.openChain) >= 0 ? ui.openChain : null,
255
+ };
256
+ }
257
+
258
+ function wfFlowToggleGroup(ui, stepIndex, key) {
259
+ var base = wfFlowUiFor(ui, stepIndex);
260
+ if (typeof key !== "string" || key === "") return base;
261
+ var expanded = base.expanded.slice();
262
+ var at = expanded.indexOf(key);
263
+ if (at >= 0) expanded.splice(at, 1);
264
+ else expanded.push(key);
265
+ return {
266
+ stepIndex: base.stepIndex,
267
+ expanded: expanded,
268
+ showAll: base.showAll,
269
+ selectedNode: base.selectedNode,
270
+ closedBranches: base.closedBranches,
271
+ openChain: base.openChain,
272
+ };
273
+ }
274
+
275
+ function wfFlowExpandAll(ui, stepIndex) {
276
+ var base = wfFlowUiFor(ui, stepIndex);
277
+ return {
278
+ stepIndex: base.stepIndex,
279
+ expanded: base.expanded,
280
+ showAll: true,
281
+ selectedNode: base.selectedNode,
282
+ closedBranches: base.closedBranches,
283
+ openChain: base.openChain,
284
+ };
285
+ }
286
+
287
+ function wfFlowSelectNode(ui, stepIndex, nodeId) {
288
+ var base = wfFlowUiFor(ui, stepIndex);
289
+ var next = typeof nodeId === "string" && nodeId !== "" ? nodeId : null;
290
+ return {
291
+ stepIndex: base.stepIndex,
292
+ expanded: base.expanded,
293
+ showAll: base.showAll,
294
+ selectedNode: base.selectedNode === next ? null : next,
295
+ closedBranches: base.closedBranches,
296
+ openChain: base.openChain,
297
+ };
298
+ }
299
+ /** Hide the selected node's detail (Escape). Keeps groups / the cap where they are. */
300
+ function wfFlowClearSelection(ui, stepIndex) {
301
+ var base = wfFlowUiFor(ui, stepIndex);
302
+ return {
303
+ stepIndex: base.stepIndex,
304
+ expanded: base.expanded,
305
+ showAll: base.showAll,
306
+ selectedNode: null,
307
+ closedBranches: base.closedBranches,
308
+ // Escape closes the branch detail too: it is the same "put the flow back" gesture.
309
+ openChain: null,
310
+ };
311
+ }
312
+
313
+ /** Fold / unfold one branch lane. The click target is the lane's HEAD ( §5). */
314
+ function wfFlowToggleBranch(ui, stepIndex, branchId) {
315
+ var base = wfFlowUiFor(ui, stepIndex);
316
+ if (typeof branchId !== "string" || branchId === "") return base;
317
+ var closed = base.closedBranches.slice();
318
+ var at = closed.indexOf(branchId);
319
+ if (at >= 0) closed.splice(at, 1);
320
+ else closed.push(branchId);
321
+ return {
322
+ stepIndex: base.stepIndex,
323
+ expanded: base.expanded,
324
+ showAll: base.showAll,
325
+ selectedNode: base.selectedNode,
326
+ closedBranches: closed,
327
+ openChain: base.openChain,
328
+ };
329
+ }
330
+
331
+ function wfFlowIsExpanded(state, key) {
332
+ return !!state && Array.isArray(state.expanded) && state.expanded.indexOf(key) >= 0;
333
+ }
334
+
335
+ /**
336
+ * open / close one round branch icon's detail. `kind` is one of WF_CHAIN_KINDS; clicking
337
+ * the same circle again closes it (a toggle, like every other disclosure in the panel), and an
338
+ * unknown kind is a no-op rather than an error.
339
+ */
340
+ function wfFlowToggleChain(state, kind) {
341
+ if (WF_CHAIN_KINDS.indexOf(kind) < 0) return state;
342
+ var base = wfFlowUiFor(state, state ? state.stepIndex : 0);
343
+ return {
344
+ stepIndex: base.stepIndex,
345
+ expanded: base.expanded,
346
+ showAll: base.showAll,
347
+ selectedNode: base.selectedNode,
348
+ closedBranches: base.closedBranches,
349
+ openChain: base.openChain === kind ? null : kind,
350
+ };
351
+ }
352
+
353
+ /* ------------------------------------------------------------------------- */
354
+ /* rendering */
355
+ /* ------------------------------------------------------------------------- */
356
+
357
+ /** A folded run's dot: error beats running beats pending beats done. */
358
+ function wfFlowGroupStatus(item, activeNodeId) {
359
+ var status = "done";
360
+ var members = item && Array.isArray(item.nodes) ? item.nodes : [];
361
+ for (var i = 0; i < members.length; i += 1) {
362
+ var one = wfNodeStatus(members[i], activeNodeId);
363
+ if (one === "error") return "error";
364
+ if (one === "running") status = "running";
365
+ else if (one === "pending" && status === "done") status = "pending";
366
+ }
367
+ return status;
368
+ }
369
+
370
+ function wfRenderFlowGroup(h, item, state, say, actions, ctx) {
371
+ var fire = actions || {};
372
+ var reopened = wfFlowIsExpanded(state, item.key);
373
+ var members = Array.isArray(item.nodes) ? item.nodes : [];
374
+ var first = wfFormatClock(item.firstAt);
375
+ var last = wfFormatClock(item.lastAt);
376
+ var range = first === "" && last === "" ? "" : say("panel.flow.range", { first: first, last: last });
377
+ var category = members.length > 0 && members.every(function (node) {
378
+ return wfNodeCategory(node) === wfNodeCategory(members[0]);
379
+ })
380
+ ? wfNodeCategory(members[0])
381
+ : "tool";
382
+
383
+ // the disclosure triangle is a real <button>, a SIBLING of the row button
384
+ // (a button inside a button is invalid HTML). The wrapper is a one-column grid, so the
385
+ // triangle and the row share cell 1/1 and the whole 20 px band down the row is clickable.
386
+ var headRow = h(
387
+ "div",
388
+ { className: "wf-group-row", key: "row" },
389
+ wfTri(h, reopened, function () {
390
+ if (typeof fire.onToggleGroup === "function") fire.onToggleGroup(item.key);
391
+ }, {
392
+ key: "tri",
393
+ data: "group-tri",
394
+ label: say(reopened ? "panel.flow.collapseGroup" : "panel.flow.expandGroup"),
395
+ controls: "wf-group-body-" + item.key + "-" + item.from,
396
+ }),
397
+ h(
398
+ "button",
399
+ {
400
+ type: "button",
401
+ className: "wf-group-head",
402
+ "data-dsh-workflow-map": "group",
403
+ "data-group-key": item.key,
404
+ "aria-expanded": reopened ? "true" : "false",
405
+ // Mirrored on the head too: the head is the element carrying
406
+ // data-dsh-workflow-map="group", so a selector or a test sees the state here.
407
+ "data-expanded": reopened ? "true" : "false",
408
+ title: say(reopened ? "panel.flow.collapseGroup" : "panel.flow.expandGroup"),
409
+ onClick: function () {
410
+ if (typeof fire.onToggleGroup === "function") fire.onToggleGroup(item.key);
411
+ },
412
+ },
413
+ h("span", { className: "wf-node-icon", key: "icon", "aria-hidden": "true" }, WF_NODE_GLYPH[category] || WF_NODE_GLYPH.other),
414
+ h(
415
+ "span",
416
+ { className: "wf-group-main", key: "main" },
417
+ h(
418
+ "span",
419
+ { className: "wf-group-label", key: "label" },
420
+ say("panel.flow.group", { count: item.count, tool: item.tool || say("panel.kind.other") })
421
+ ),
422
+ range === "" ? null : h("span", { className: "wf-group-meta", key: "range" }, range)
423
+ )
424
+ )
425
+ );
426
+
427
+ return h(
428
+ "li",
429
+ {
430
+ className: "wf-flow-item wf-group",
431
+ key: "group-" + item.key + "-" + item.from,
432
+ "data-status": wfFlowGroupStatus(item, ctx.activeNodeId),
433
+ "data-group-key": item.key,
434
+ "data-expanded": reopened ? "true" : "false",
435
+ "data-group-count": item.count,
436
+ },
437
+ headRow,
438
+ reopened
439
+ ? h(
440
+ "ul",
441
+ {
442
+ className: "wf-group-body",
443
+ key: "body",
444
+ id: "wf-group-body-" + item.key + "-" + item.from,
445
+ role: "list",
446
+ },
447
+ members.map(function (node) {
448
+ return wfRenderNodeCard(h, node, say, actions, ctx);
449
+ })
450
+ )
451
+ : null
452
+ );
453
+ }
454
+
455
+ /**
456
+ * The whole right column. `ui` is the flow UI state (see wfFlowUiFor); it is passed
457
+ * raw so a state from the previous step cannot leak into this one.
458
+ */
459
+ function wfRenderFlowColumn(h, model, say, actions, ui) {
460
+ var flow = model && model.flow && typeof model.flow === "object" ? model.flow : null;
461
+ var nodes = flow && Array.isArray(flow.nodes) ? flow.nodes : [];
462
+ var stepIndex = model && wfFinite(model.selected) !== null ? Math.round(model.selected) : 0;
463
+ var state = wfFlowUiFor(ui, stepIndex);
464
+ var items = wfFlowItems(nodes, wfFlowGroups(nodes, flow ? flow.groups : null));
465
+ // the node window is a display pref (30 / 60 / 120 / all). 0 means all,
466
+ // and "expand all" still lifts any finite cap for this step.
467
+ var cap = model && model.settings && wfFinite(model.settings.nodeLimit) !== null
468
+ ? Math.max(0, Math.round(model.settings.nodeLimit))
469
+ : WF_FLOW_CAP;
470
+ var view = wfFlowWindow(items, cap, state.showAll || cap === 0);
471
+ var total = flow && wfFinite(flow.stepNodeTotal) !== null ? flow.stepNodeTotal : wfFlowNodeCount(items);
472
+ var truncated = flow && wfFinite(flow.truncated) !== null && flow.truncated > 0 ? flow.truncated : 0;
473
+ var ctx = {
474
+ activeNodeId: flow && typeof flow.activeNodeId === "string" ? flow.activeNodeId : null,
475
+ selectedNodeId: state.selectedNode,
476
+ detail: model && model.settings && model.settings.detail === "title" ? "title" : "summary",
477
+ // which branch category the user opened under the running node, and whether that
478
+ // strip is allowed to animate. `chainLive` is the same `running` flag the living layer uses,
479
+ // so an idle session freezes the dashes with everything else (CSS pauses them).
480
+ openChain: state.openChain,
481
+ chainLive: !!(model && model.running === true),
482
+ };
483
+
484
+ var header = h(
485
+ "div",
486
+ { className: "wf-col-h wf-flow-h", key: "h", "data-dsh-workflow-map": "flow-head" },
487
+ h("span", { key: "label" }, say("panel.flow")),
488
+ h(
489
+ "span",
490
+ { className: "wf-col-sub", key: "step", title: flow ? flow.stepTitle : "" },
491
+ say("panel.flow.step", { n: stepIndex + 1, title: (flow && flow.stepTitle) || "—" })
492
+ ),
493
+ h("span", { className: "wf-count", key: "count" }, say("panel.nodes", { count: total }))
494
+ );
495
+
496
+ // The strip is a SIBLING of the scroller: pinned under the header, so "expand all"
497
+ // stays reachable exactly while the list is open at its bottom. It also carries
498
+ // the host-cap note, which "expand all" CANNOT undo (those nodes were never sent).
499
+ var stripText = view.hiddenNodes > 0 ? say("panel.flow.more", { count: view.hiddenNodes }) : "";
500
+ if (truncated > 0) stripText = stripText === "" ? say("panel.truncated", { count: truncated }) : stripText + " · " + say("panel.truncated", { count: truncated });
501
+ var more =
502
+ view.hiddenNodes > 0 || truncated > 0
503
+ ? h(
504
+ "div",
505
+ { className: "wf-flow-more", key: "more", "data-dsh-workflow-map": "flow-more" },
506
+ h("span", { key: "text" }, stripText),
507
+ view.hiddenNodes > 0
508
+ ? h(
509
+ "button",
510
+ {
511
+ type: "button",
512
+ className: "wf-link",
513
+ key: "all",
514
+ onClick: function () {
515
+ if (typeof (actions || {}).onShowAll === "function") actions.onShowAll();
516
+ },
517
+ },
518
+ say("panel.flow.showAll")
519
+ )
520
+ : null
521
+ )
522
+ : null;
523
+
524
+ var body;
525
+ if (nodes.length === 0) {
526
+ body = h("div", { className: "wf-empty", key: "empty" }, say("panel.flow.empty"));
527
+ } else {
528
+ body = h(
529
+ "ul",
530
+ {
531
+ className: "wf-flow-list",
532
+ key: "list",
533
+ role: "list",
534
+ "data-empty": "false",
535
+ "data-truncated": truncated > 0 ? "true" : "false",
536
+ "data-items": view.items.length,
537
+ // the chain layer is a child of the LIST (so its user space is the list's box and
538
+ // its links follow the cards on every scroll/resize — FlowEffects measures it) and it is
539
+ // FIRST, so it paints under the cards instead of over their text.
540
+ "data-chain": "curve",
541
+ },
542
+ wfRenderChain(h),
543
+ view.items.map(function (item, position) {
544
+ if (item.type === "group") return wfRenderFlowGroup(h, item, state, say, actions, ctx);
545
+ return wfRenderNodeCard(h, item.node, say, actions, ctx);
546
+ })
547
+ );
548
+ }
549
+
550
+ return h(
551
+ "div",
552
+ {
553
+ className: "wf-col wf-col-flow wf-flow",
554
+ key: "right",
555
+ "data-dsh-workflow-map": "flow",
556
+ "data-capped": view.capped ? "true" : "false",
557
+ "data-show-all": state.showAll ? "true" : "false",
558
+ "data-step-index": stepIndex,
559
+ },
560
+ header,
561
+ more,
562
+ h(
563
+ "div",
564
+ { className: "wf-scroll wf-flow-scroll", key: "list", "data-dsh-workflow-map": "flow-scroll" },
565
+ body,
566
+ // Branch lanes (Branches.js) hang off the same track AFTER the nodes: they are
567
+ // parallel work, not part of the step's own sequence. NULL when nothing runs in
568
+ // parallel, so a plain session renders no branch shell at all.
569
+ wfRenderBranches(h, model, say, actions, state),
570
+ )
571
+ );
572
+ }