dsh-workflow-map 0.0.0-stage → 0.3.4

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,545 @@
1
+ /**
2
+ * NodeCard.js — one flow node (a tool call, a skill, a file operation…) as a card.
3
+ *
4
+ * The card answers four questions at a glance ( §2):
5
+ * what kind → a glyph per category (file / command / skill / subagent / other)
6
+ * what -> "read · .md" - verb · target, not the raw tool name
7
+ * how → subline: kind chip · tool · duration · file count
8
+ * how did it → a status dot: running pulses, done is green, error is red
9
+ *
10
+ * Everything here is PURE and locale-injected: `h` (React.createElement) and `say`
11
+ * are parameters, so tools/selftest-panel.mjs drives the exact tree the frame mounts
12
+ * without React, a browser or a DOM.
13
+ *
14
+ * Inlined into client.js by tools/build-client.mjs (single-file browser half).
15
+ */
16
+
17
+ /** Category → glyph. Text glyphs, not emoji: the panel's font stack is the harness's. */
18
+ var WF_NODE_GLYPH = {
19
+ file: "▤",
20
+ command: "⌘",
21
+ skill: "✦",
22
+ subagent: "⧉",
23
+ tool: "⚙",
24
+ message: "≡",
25
+ report: "◍",
26
+ question: "?",
27
+ approval: "⚑",
28
+ other: "·",
29
+ };
30
+
31
+ /** Category → the locale key of the verb shown before the target. */
32
+ var WF_NODE_VERB_KEY = {
33
+ command: "panel.node.verb.command",
34
+ skill: "panel.node.verb.skill",
35
+ subagent: "panel.node.verb.subagent",
36
+ };
37
+
38
+ /** Tool name → locale key of the verb, for the file-ish tools. */
39
+ var WF_TOOL_VERB_KEY = {
40
+ read: "panel.node.verb.read",
41
+ write: "panel.node.verb.write",
42
+ edit: "panel.node.verb.edit",
43
+ glob: "panel.node.verb.glob",
44
+ grep: "panel.node.verb.grep",
45
+ undo_last_edit: "panel.node.verb.undo",
46
+ };
47
+
48
+ /** Tools that act on a file path, so the card can title itself with a basename. */
49
+ var WF_FILE_TOOLS = {
50
+ read: true,
51
+ write: true,
52
+ edit: true,
53
+ glob: true,
54
+ grep: true,
55
+ undo_last_edit: true,
56
+ };
57
+
58
+ function wfBaseName(path) {
59
+ if (typeof path !== "string" || path === "") return "";
60
+ var flat = path.replace(/\\/g, "/").replace(/\/+$/, "");
61
+ var at = flat.lastIndexOf("/");
62
+ return at === -1 ? flat : flat.slice(at + 1);
63
+ }
64
+
65
+ /** Drop a leading "tool " prefix from a host title; "" when the title IS the prefix. */
66
+ function wfStripToolPrefix(title, tool) {
67
+ if (typeof title !== "string") return "";
68
+ if (typeof tool !== "string" || tool === "") return title;
69
+ if (title === tool) return "";
70
+ if (title.indexOf(tool + " ") === 0) return title.slice(tool.length + 1);
71
+ return title;
72
+ }
73
+
74
+ /**
75
+ * The category the icon and the verb come from. `kind` wins for the non-tool nodes
76
+ * (message / report / question / approval); a tool node is split by tool name so
77
+ * `read`/`edit`/`pwsh`/`skill` are not all drawn as a generic tool.
78
+ */
79
+ function wfNodeCategory(node) {
80
+ var kind = node && typeof node.kind === "string" ? node.kind : "";
81
+ var tool = node && typeof node.tool === "string" ? node.tool : "";
82
+ if (kind === "message" || kind === "report" || kind === "question" || kind === "approval") return kind;
83
+ if (kind === "subagent" || tool === "subagent" || tool === "subagent_fork" || tool === "workflow") {
84
+ return "subagent";
85
+ }
86
+ if (WF_FILE_TOOLS[tool] === true) return "file";
87
+ if (tool === "pwsh" || tool === "bash" || tool === "shell") return "command";
88
+ if (tool === "skill") return "skill";
89
+ if (kind === "tool" || tool !== "") return "tool";
90
+ return "other";
91
+ }
92
+
93
+ /** The verb to print, or "" when the card should show the title alone. */
94
+ function wfNodeVerb(node, category, say) {
95
+ var tool = node && typeof node.tool === "string" ? node.tool : "";
96
+ var key = category === "file" ? WF_TOOL_VERB_KEY[tool] : WF_NODE_VERB_KEY[category];
97
+ if (typeof key === "string" && typeof say === "function") return say(key);
98
+ // An unknown tool keeps its own name as the verb: "plugin_manager · install_bundle…"
99
+ // says more than a generic "tool".
100
+ if (category === "tool" && tool !== "") return tool;
101
+ return "";
102
+ }
103
+
104
+ /** What the verb points at: a file basename, a command, a description… */
105
+ function wfNodeTarget(node, category, verb) {
106
+ var title = node && typeof node.title === "string" ? node.title : "";
107
+ var detail = node && typeof node.detail === "string" ? node.detail : "";
108
+ var path = node && typeof node.path === "string" ? node.path : "";
109
+ var tool = node && typeof node.tool === "string" ? node.tool : "";
110
+
111
+ if (category === "file") return wfBaseName(path) || wfStripToolPrefix(title, tool) || title || "—";
112
+ if (category === "command") return detail || wfStripToolPrefix(title, tool) || title || "—";
113
+ if (category === "skill") return wfStripToolPrefix(title, "skill") || detail || "—";
114
+ if (category === "subagent") return wfStripToolPrefix(title, tool) || detail || "—";
115
+ if (category === "tool") {
116
+ // When the verb already IS the tool name, the target must add something: the
117
+ // argument summary, else the title if the host gave a different one.
118
+ var rest = wfStripToolPrefix(title, tool);
119
+ if (rest !== "") return rest;
120
+ if (detail !== "") return detail;
121
+ return "—";
122
+ }
123
+ return title || detail || "—";
124
+ }
125
+
126
+ /**
127
+ * The two halves of the title. A localized verb is followed by " · " only when
128
+ * there IS a verb: a message's own text must not read "message · message".
129
+ */
130
+ function wfNodeTitleParts(node, category, say) {
131
+ var verb = wfNodeVerb(node, category, say);
132
+ var target = wfNodeTarget(node, category, verb);
133
+ return { verb: verb, target: target };
134
+ }
135
+
136
+ /**
137
+ * The dot's state. Four values, because "no result recorded yet" is not the same
138
+ * claim as "finished": error → red, the active node without a result → pulsing
139
+ * blue, a node with a result → green, anything else → neutral.
140
+ */
141
+ function wfNodeStatus(node, activeNodeId) {
142
+ if (node && node.error === true) return "error";
143
+ var active = typeof activeNodeId === "string" && activeNodeId !== "" && node && node.id === activeNodeId;
144
+ if (active && node.result !== true) return "running";
145
+ if (node && node.result === true) return "done";
146
+ return "pending";
147
+ }
148
+
149
+ /** 8 → "8ms", 1234 → "1.2s", 83000 → "1m23s". Empty for an unknown duration. */
150
+ function wfFormatDuration(ms) {
151
+ var value = wfFinite(ms);
152
+ if (value === null || value <= 0) return "";
153
+ if (value < 1000) return Math.round(value) + "ms";
154
+ if (value < 60000) {
155
+ var seconds = Math.round(value / 100) / 10;
156
+ return (seconds % 1 === 0 ? String(Math.round(seconds)) : seconds.toFixed(1)) + "s";
157
+ }
158
+ var minutes = Math.floor(value / 60000);
159
+ var rest = Math.round((value % 60000) / 1000);
160
+ return minutes + "m" + rest + "s";
161
+ }
162
+
163
+ /** Event time → local "21:03:07". Empty (no invented clock) for an unusable stamp. */
164
+ function wfFormatClock(ms) {
165
+ var value = wfFinite(ms);
166
+ if (value === null || value <= 0) return "";
167
+ var date = new Date(value);
168
+ var time = date.getTime();
169
+ if (!isFinite(time)) return "";
170
+ return wfPad2(date.getHours()) + ":" + wfPad2(date.getMinutes()) + ":" + wfPad2(date.getSeconds());
171
+ }
172
+
173
+ /** The subline: kind chip · tool · duration · files. Empty parts are dropped. */
174
+ function wfNodeMetaBits(node, say) {
175
+ var bits = [];
176
+ if (node && typeof node.kindKey === "string" && node.kindKey !== "") bits.push(say(node.kindKey));
177
+ if (node && typeof node.tool === "string" && node.tool !== "") bits.push(node.tool);
178
+ var duration = wfFormatDuration(node ? node.durationMs : null);
179
+ if (duration !== "") bits.push(duration);
180
+ if (node && wfFinite(node.files) !== null && node.files > 0) bits.push(say("panel.files", { count: node.files }));
181
+ return bits;
182
+ }
183
+ /**
184
+ * the three round-icon categories of a branch, and their glyph + locale key.
185
+ *
186
+ * WHY THREE AND NOT ONE PER TOOL: the step asks for "files read / files changed / tools called", which is exactly
187
+ * the split the host's `files[].action` labels already carry (read/write/edit/query) plus the plain tool
188
+ * call. Deriving them from fields the host ALREADY sends means no new wire field and no host
189
+ * round-trip: a browser-only change (F5), which is what the step promised.
190
+ */
191
+ var WF_CHAIN_KINDS = ["read", "edit", "tool"];
192
+
193
+ var WF_CHAIN_GLYPH = {
194
+ read: "▤",
195
+ edit: "✎",
196
+ tool: "⌘",
197
+ };
198
+
199
+ var WF_CHAIN_KEY = {
200
+ read: "panel.flow.icon.read",
201
+ edit: "panel.flow.icon.edit",
202
+ tool: "panel.flow.icon.tool",
203
+ };
204
+
205
+ /**
206
+ * A host file action label -> the icon category. The host writes Chinese labels (read/write/edit/query,
207
+ * src/fold.mjs filesFor) and 's wfDetailAction reads the same four; the English spellings
208
+ * are accepted too so a re-labelled payload does not silently lose a branch.
209
+ */
210
+ function wfChainActionKind(action) {
211
+ var label = typeof action === "string" ? action.trim().toLowerCase() : "";
212
+ if (label === "读" || label === "read") return "read";
213
+ if (label === "改" || label === "edit" || label === "修改") return "edit";
214
+ if (label === "写" || label === "write" || label === "写入") return "edit";
215
+ if (label === "查" || label === "glob" || label === "grep" || label === "list" || label === "query") return "read";
216
+ return "tool";
217
+ }
218
+
219
+ /**
220
+ * The branches a node shows: one entry per category (read / edit / tool) that has evidence, in
221
+ * that order, each {kind, count, items:[{path, base, action}]}. PURE, and it never invents a
222
+ * category: a `read` of three files is one branch with three items, not three branches.
223
+ *
224
+ * The caller decides WHEN to draw them (only the node being called), which is what keeps the
225
+ * "finished / not started steps produce no branches" rule in one place instead of two.
226
+ * called right now — the step is explicit that finished and not-yet-started steps produce NO
227
+ * branches, so the caller's status decides and this function never second-guesses it.
228
+ *
229
+ * Items are the observations of ONE category: a file path (with its host action) or the tool name
230
+ * itself. They are what the opened circle renders, so no new host field is needed for the detail.
231
+ */
232
+ function wfChainBranches(node) {
233
+ var out = [];
234
+ if (!node || typeof node !== "object") return out;
235
+ var tool = typeof node.tool === "string" ? node.tool : "";
236
+ var buckets = Object.create(null);
237
+
238
+ function slot(kind) {
239
+ var bucket = buckets[kind];
240
+ if (!bucket) {
241
+ bucket = { kind: kind, count: 0, items: [], seen: Object.create(null) };
242
+ buckets[kind] = bucket;
243
+ }
244
+ return bucket;
245
+ }
246
+
247
+ var files = Array.isArray(node.fileList) ? node.fileList : [];
248
+ for (var i = 0; i < files.length; i += 1) {
249
+ var file = files[i];
250
+ if (!file || typeof file.path !== "string" || file.path === "") continue;
251
+ var kind = wfChainActionKind(file.action);
252
+ var bucket = slot(kind);
253
+ if (bucket.seen[file.path] === true) continue;
254
+ bucket.seen[file.path] = true;
255
+ bucket.items.push({ path: file.path, base: wfBaseName(file.path), action: typeof file.action === "string" ? file.action : "" });
256
+ }
257
+
258
+ // The call itself is always worth a branch: "tools called" is the third category the step names.
259
+ if (tool !== "") {
260
+ var toolBucket = slot("tool");
261
+ toolBucket.items.push({ path: "", base: tool, action: "" });
262
+ }
263
+
264
+ for (var k = 0; k < WF_CHAIN_KINDS.length; k += 1) {
265
+ var found = buckets[WF_CHAIN_KINDS[k]];
266
+ if (!found) continue;
267
+ found.count = found.items.length;
268
+ out.push(found);
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * The two branch ends, in the icon's own coordinate space (the stroke SVG is WF_CHAIN_BRANCH wide
275
+ * and one icon tall): from the spine at the node's edge, curving into the circle's left rim.
276
+ *
277
+ * A cubic with its control points pushed HORIZONTALLY (the classic S) — the same shape wfFxCurve
278
+ * draws between the two columns, so the whole panel's curves come from one idea.
279
+ */
280
+ /** Two decimals, the same rounding the curve layer writes into its `d`. Local on purpose: this
281
+ * chunk is inlined BEFORE FlowEffects.js, and a helper that only exists later in the file would
282
+ * work only through hoisting — a dependency the reader cannot see is a bug waiting to ship. */
283
+ function wfChainRound(value) {
284
+ var n = wfFinite(value);
285
+ return n === null ? 0 : Math.round(n * 100) / 100;
286
+ }
287
+ function wfChainStrokePath(width, height) {
288
+ var w = wfFinite(width);
289
+ var hh = wfFinite(height);
290
+ if (w === null || hh === null || w <= 0 || hh <= 0) return "";
291
+ var x1 = 0;
292
+ var y1 = wfChainRound(hh / 2);
293
+ var x2 = wfChainRound(w);
294
+ var bow = wfChainRound(w * 0.55);
295
+ return (
296
+ "M" + x1 + " " + y1 + "C" + bow + " " + y1 + " " + wfChainRound(w - bow) + " " + y1 + " " + x2 + " " + y1
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Round icon, one per branch category ( §4). `<circle>` in the stroke layer plus this
302
+ * button as the glyph on top: 22 px (WF_CHAIN_ICON), the step allows 20–24.
303
+ *
304
+ * The click is `actions.onChainBranch(kind)`, NOT onSelectNode: the circle is not a node, it opens
305
+ * a category view under the card (see wfRenderChainBranch).
306
+ */
307
+ function wfRenderChainBranch(h, branch, say, actions, ctx) {
308
+ var fire = actions || {};
309
+ var context = ctx || {};
310
+ var kind = branch && typeof branch.kind === "string" ? branch.kind : "tool";
311
+ var open = context.openChain === kind;
312
+ var count = wfFinite(branch && branch.count) === null ? 0 : branch.count;
313
+ var label = say("panel.flow.branch", { count: count, kind: say(WF_CHAIN_KEY[kind] || WF_CHAIN_KEY.tool) });
314
+
315
+ return h(
316
+ "div",
317
+ {
318
+ className: "wf-branch-row" + (open ? " wf-branch-open" : ""),
319
+ key: "chain-" + kind,
320
+ "data-dsh-workflow-map": "chain-branch",
321
+ "data-kind": kind,
322
+ "data-count": String(count),
323
+ "data-live": context.chainLive === true ? "true" : "false",
324
+ },
325
+ // The dash: the layer is an SVG so the path is a real `d` (the browser measurement reads the
326
+ // `C` out of it), and it is pointer-events:none so it can never eat the icon's click.
327
+ h(
328
+ "svg",
329
+ {
330
+ className: "wf-branch-stroke",
331
+ key: "stroke",
332
+ "aria-hidden": "true",
333
+ focusable: "false",
334
+ "data-dsh-workflow-map": "chain-stroke",
335
+ "data-kind": kind,
336
+ },
337
+ h("path", {
338
+ className: "wf-branch-core",
339
+ key: "d",
340
+ d: wfChainStrokePath(WF_CHAIN_BRANCH, WF_CHAIN_ICON),
341
+ })
342
+ ),
343
+ h(
344
+ "button",
345
+ {
346
+ type: "button",
347
+ className: "wf-branch-icon",
348
+ key: "icon",
349
+ "data-dsh-workflow-map": "chain-icon",
350
+ "data-kind": kind,
351
+ "aria-expanded": open ? "true" : "false",
352
+ "aria-label": label,
353
+ title: label,
354
+ onClick: function () {
355
+ if (typeof fire.onChainBranch === "function") fire.onChainBranch(kind);
356
+ },
357
+ },
358
+ h("span", { className: "wf-branch-glyph", key: "glyph", "aria-hidden": "true" }, WF_CHAIN_GLYPH[kind] || WF_CHAIN_GLYPH.tool)
359
+ ),
360
+ h("span", { className: "wf-branch-label", key: "label" }, label)
361
+ );
362
+ }
363
+
364
+ /**
365
+ * The node the opened circle's detail renders. It is NOT a new renderer (the step forbids a second
366
+ * one): it is the SAME node, narrowed to the observations of ONE category, so 's
367
+ * wfRenderNodeDetail draws its usual blocks — head, args, file rows, result, meta — from fields
368
+ * that already exist on the wire. Nothing is invented: the paths come from the host's fileList and
369
+ * the args/result are the call's own.
370
+ */
371
+ function wfChainDetailNode(node, branch) {
372
+ var source = node && typeof node === "object" ? node : {};
373
+ var items = branch && Array.isArray(branch.items) ? branch.items : [];
374
+ var files = [];
375
+ for (var i = 0; i < items.length; i += 1) {
376
+ var item = items[i];
377
+ if (!item || typeof item.path !== "string" || item.path === "") continue;
378
+ files.push({ path: item.path, action: typeof item.action === "string" ? item.action : "" });
379
+ }
380
+ return {
381
+ id: typeof source.id === "string" ? source.id : "",
382
+ kind: typeof source.kind === "string" ? source.kind : "tool",
383
+ tool: typeof source.tool === "string" ? source.tool : "",
384
+ title: typeof source.title === "string" ? source.title : "",
385
+ detail: typeof source.detail === "string" ? source.detail : "",
386
+ path: typeof source.path === "string" ? source.path : "",
387
+ argsText: typeof source.argsText === "string" ? source.argsText : "",
388
+ resultLine: typeof source.resultLine === "string" ? source.resultLine : "",
389
+ errorText: typeof source.errorText === "string" ? source.errorText : "",
390
+ seq: wfFinite(source.seq),
391
+ at: wfFinite(source.at),
392
+ durationMs: wfFinite(source.durationMs),
393
+ error: source.error === true,
394
+ result: source.result === true,
395
+ fileList: files,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * The opened category view: the circle's own observations, rendered by wfRenderNodeDetail.
401
+ * Rendered INSIDE the card's <li> (see wfRenderNodeCard), so the strip and its detail share one
402
+ * disclosure region and a screen reader hears them together.
403
+ */
404
+ function wfRenderChainDetail(h, node, branches, kind, say, actions) {
405
+ var picked = null;
406
+ var list = Array.isArray(branches) ? branches : [];
407
+ for (var i = 0; i < list.length; i += 1) {
408
+ if (list[i] && list[i].kind === kind) {
409
+ picked = list[i];
410
+ break;
411
+ }
412
+ }
413
+ if (!picked) return null;
414
+ return h(
415
+ "div",
416
+ {
417
+ className: "wf-branch-body",
418
+ key: "chain-detail-" + kind,
419
+ "data-dsh-workflow-map": "chain-detail",
420
+ "data-kind": kind,
421
+ },
422
+ wfRenderNodeDetail(h, wfChainDetailNode(node, picked), say, actions, { detail: "summary" })
423
+ );
424
+ }
425
+ /**
426
+ * One node card. `ctx` carries `{activeNodeId, selectedNodeId}`; `actions.onSelectNode`
427
+ * is called with the node id; opens the inline detail for the selected id, and the
428
+ * card itself keeps its one-line summary (no popover, no second window).
429
+ */
430
+ function wfRenderNodeCard(h, node, say, actions, ctx) {
431
+ var context = ctx || {};
432
+ var fire = actions || {};
433
+ var category = wfNodeCategory(node);
434
+ var status = wfNodeStatus(node, context.activeNodeId);
435
+ var parts = wfNodeTitleParts(node, category, say);
436
+ var bits = wfNodeMetaBits(node, say);
437
+ var selected = context.selectedNodeId !== null && context.selectedNodeId === node.id;
438
+ // "Title only": no meta line, no live chip, no inline detail. The card keeps
439
+ // its icon/title/status so the flow is still scannable.
440
+ var compact = context.detail === "title";
441
+
442
+ var titleChildren = [];
443
+ if (parts.verb !== "") {
444
+ titleChildren.push(h("span", { className: "wf-node-verb", key: "verb" }, parts.verb));
445
+ titleChildren.push(" · ");
446
+ }
447
+ titleChildren.push(h("span", { className: "wf-node-target", key: "target" }, parts.target));
448
+ // §4: the branch strip exists ONLY under the node that is being called right now.
449
+ // `status` already answers that question (wfNodeStatus: active && no result → running), so a
450
+ // finished card and a not-yet-started card both render `null` here — no branch DOM at all,
451
+ // which is what the acceptance check looks for rather than a hidden-but-present element.
452
+ var branches = status === "running" && !compact ? wfChainBranches(node) : [];
453
+ var branching =
454
+ branches.length > 0
455
+ ? h(
456
+ "div",
457
+ {
458
+ // NOT "chain": that name belongs to the measured curve layer, and two elements with
459
+ // the same anchor would make every querySelector('chain') ambiguous.
460
+ "data-dsh-workflow-map": "chain-strip",
461
+ key: "branching",
462
+ "data-node-id": node.id,
463
+ "data-count": String(branches.length),
464
+ "data-open": typeof context.openChain === "string" ? context.openChain : "",
465
+ role: "group",
466
+ "aria-label": say("panel.flow.branch.aria", { count: branches.length }),
467
+ },
468
+ branches.map(function (branch) {
469
+ return wfRenderChainBranch(h, branch, say, actions, context);
470
+ }),
471
+ context.openChain === null || typeof context.openChain !== "string"
472
+ ? null
473
+ : wfRenderChainDetail(h, node, branches, context.openChain, say, actions)
474
+ )
475
+ : null;
476
+ return h(
477
+ "li",
478
+ {
479
+ className: "wf-flow-item",
480
+ key: "item-" + node.id,
481
+ "data-status": status,
482
+ "data-node-id": node.id,
483
+ },
484
+ h(
485
+ "button",
486
+ {
487
+ type: "button",
488
+ className: "wf-node",
489
+ "data-dsh-workflow-map": "node",
490
+ "data-node-id": node.id,
491
+ "data-kind": node.kind,
492
+ "data-category": category,
493
+ "data-status": status,
494
+ "data-error": node.error === true ? "true" : "false",
495
+ "data-selected": selected ? "true" : "false",
496
+ "data-compact": compact ? "true" : "false",
497
+ "aria-pressed": selected ? "true" : "false",
498
+ title: parts.verb !== "" ? parts.verb + " · " + parts.target : parts.target,
499
+ onClick: function () {
500
+ if (typeof fire.onSelectNode === "function") fire.onSelectNode(node.id);
501
+ },
502
+ },
503
+ h("span", { className: "wf-node-icon", key: "icon", "aria-hidden": "true" }, WF_NODE_GLYPH[category] || WF_NODE_GLYPH.other),
504
+ h(
505
+ "span",
506
+ { className: "wf-node-main", key: "main" },
507
+ h("span", { className: "wf-node-title", key: "title" }, titleChildren),
508
+ bits.length === 0 || compact
509
+ ? null
510
+ : h(
511
+ "span",
512
+ { className: "wf-node-meta", key: "meta" },
513
+ h("span", { className: "wf-kind", key: "kind" }, bits[0]),
514
+ bits.slice(1).map(function (bit, position) {
515
+ return h("span", { key: "bit-" + position }, bit);
516
+ })
517
+ ),
518
+ // "working ..." on the node that is being called this instant. Only the active
519
+ // node gets it: every finished card stays quiet, so a long flow does not blink all over.
520
+ status === "running" && !compact ? wfRenderLive(h, say, "node") : null
521
+ ),
522
+ h("span", {
523
+ className: "wf-flow-status",
524
+ key: "status",
525
+ "data-status": status,
526
+ "aria-hidden": "true",
527
+ title: say(
528
+ status === "error"
529
+ ? "panel.status.failed"
530
+ : status === "running"
531
+ ? "panel.status.running"
532
+ : status === "done"
533
+ ? "panel.status.done"
534
+ : "panel.node.pending"
535
+ ),
536
+ })
537
+ ),
538
+ // the selected node opens its detail INLINE, inside the same <li>, so the
539
+ // spine dot and the card stay where the eye left them (no popover, no new window).
540
+ selected && !compact ? wfRenderNodeDetail(h, node, say, actions, context) : null,
541
+ // The branch strip hangs under the CARD but inside the same <li>, so the link drawn by
542
+ // FlowColumn's channel (or the spine) still points at the cards, never at the branches.
543
+ branching
544
+ );
545
+ }