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,1090 @@
1
+ /**
2
+ * store.js — pure data shaping + the polling loop for the workflow panel.
3
+ *
4
+ * Everything that can be decided WITHOUT React lives here, so tools/selftest-panel.mjs
5
+ * can exercise it in Node with no DOM and no React: preferences, the poll request and
6
+ * its 304/200/4xx/5xx outcomes, session-id resolution, and the view → model build.
7
+ *
8
+ * Inlined into client.js by tools/build-client.mjs (single-file browser half).
9
+ */
10
+
11
+ /* ------------------------------------------------------------------------- */
12
+ /* numbers */
13
+ /* ------------------------------------------------------------------------- */
14
+
15
+ /** A finite number, or null. Used so a missing `percent` never becomes NaN. */
16
+ function wfFinite(value) {
17
+ return typeof value === "number" && isFinite(value) ? value : null;
18
+ }
19
+
20
+ function wfClamp(value, lo, hi) {
21
+ var n = wfFinite(value);
22
+ if (n === null) return lo;
23
+ return n < lo ? lo : n > hi ? hi : n;
24
+ }
25
+
26
+ function wfRound(value) {
27
+ var n = wfFinite(value);
28
+ return n === null ? null : Math.round(n);
29
+ }
30
+
31
+ /* ------------------------------------------------------------------------- */
32
+ /* preferences — keeps ONLY what the panel still owns */
33
+ /* ------------------------------------------------------------------------- */
34
+
35
+ /**
36
+ * `width` / `height` are no longer written by the UI — the layout owns both —
37
+ * but they are still READ and sanitised, because a record written by ..12 sits in
38
+ * every user's localStorage. Keeping the two fields means an upgrade never throws away
39
+ * `collapsed` / `selectedStep` / the presentation settings just because an old record
40
+ * happens to carry a size.
41
+ */
42
+ function wfDefaultPrefs() {
43
+ var display = wfDefaultSettings();
44
+ return {
45
+ width: WF_DEFAULT_WIDTH,
46
+ height: WF_DEFAULT_HEIGHT,
47
+ collapsed: WF_DEFAULT_COLLAPSED,
48
+ selectedStep: null,
49
+ palette: display.palette,
50
+ motion: display.motion,
51
+ detail: display.detail,
52
+ nodeLimit: display.nodeLimit,
53
+ // the single-page layout's right-hand page. `rightOpen` says the page is shown (the
54
+ // user clicked a main-flow unit, a round satellite, or hot injection); `rightKind` / `rightKey` say
55
+ // WHAT it shows. A stale (kind, key) pair is harmless — the page finds no content and
56
+ // renders the empty hint — so nothing here is re-validated against the live view.
57
+ rightOpen: false,
58
+ rightKind: "step",
59
+ rightKey: null,
60
+ };
61
+ }
62
+ /**
63
+ * Sanitise one stored record. Bad input never throws and never yields NaN:
64
+ * a hand-edited or stale (older step) record degrades to the defaults.
65
+ */
66
+ function wfSanitizePrefs(input) {
67
+ var base = wfDefaultPrefs();
68
+ if (!input || typeof input !== "object") return base;
69
+ if (wfFinite(input.width) !== null) {
70
+ base.width = Math.round(wfClamp(input.width, WF_MIN_WIDTH, WF_MAX_WIDTH));
71
+ }
72
+ if (wfFinite(input.height) !== null) {
73
+ var h = wfClamp(input.height, 0, WF_MAX_HEIGHT);
74
+ base.height = h > 0 && h < WF_MIN_HEIGHT ? WF_MIN_HEIGHT : Math.round(h);
75
+ }
76
+ if (typeof input.collapsed === "boolean") base.collapsed = input.collapsed;
77
+ if (wfFinite(input.selectedStep) !== null) {
78
+ var index = Math.round(input.selectedStep);
79
+ base.selectedStep = index >= 0 ? index : null;
80
+ }
81
+ if (typeof input.rightOpen === "boolean") base.rightOpen = input.rightOpen;
82
+ if (WF_RIGHT_KINDS.indexOf(input.rightKind) >= 0) base.rightKind = input.rightKind;
83
+ if (typeof input.rightKey === "string" && input.rightKey !== "") base.rightKey = input.rightKey;
84
+ var display = wfSanitizeSettings(input, base);
85
+ base.palette = display.palette;
86
+ base.motion = display.motion;
87
+ base.detail = display.detail;
88
+ base.nodeLimit = display.nodeLimit;
89
+ return base;
90
+ }
91
+
92
+ /** Parse the raw localStorage string (or an already-parsed object). */
93
+ function wfParsePrefs(raw) {
94
+ if (raw && typeof raw === "object") return wfSanitizePrefs(raw);
95
+ if (typeof raw !== "string" || raw === "") return wfDefaultPrefs();
96
+ try {
97
+ return wfSanitizePrefs(JSON.parse(raw));
98
+ } catch (error) {
99
+ return wfDefaultPrefs();
100
+ }
101
+ }
102
+
103
+ function wfLoadPrefs(storage) {
104
+ try {
105
+ var store = storage;
106
+ if (!store) {
107
+ store = typeof window !== "undefined" && window ? window.localStorage : null;
108
+ }
109
+ if (!store || typeof store.getItem !== "function") return wfDefaultPrefs();
110
+ return wfParsePrefs(store.getItem(WF_PREFS_KEY));
111
+ } catch (error) {
112
+ // A disabled / quota-exhausted / partitioned localStorage is NOT an error the
113
+ // panel may die on: it just means "no memory this session".
114
+ return wfDefaultPrefs();
115
+ }
116
+ }
117
+
118
+ function wfSavePrefs(prefs, storage) {
119
+ try {
120
+ var store = storage;
121
+ if (!store) {
122
+ store = typeof window !== "undefined" && window ? window.localStorage : null;
123
+ }
124
+ if (!store || typeof store.setItem !== "function") return false;
125
+ store.setItem(WF_PREFS_KEY, JSON.stringify(wfSanitizePrefs(prefs)));
126
+ return true;
127
+ } catch (error) {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Merge one patch into the previous prefs. `patch` may be a plain object or a
134
+ * function of the previous value (the same shape React's updater takes), so the
135
+ * keyboard shortcut and the drag handlers never race on a stale closure.
136
+ */
137
+ function wfApplyPrefsPatch(prev, patch) {
138
+ var base = wfSanitizePrefs(prev);
139
+ var next = typeof patch === "function" ? patch(base) : patch;
140
+ if (!next || typeof next !== "object") return base;
141
+ var display = wfSanitizeSettings(next, base);
142
+ var merged = {
143
+ width: wfFinite(next.width) === null ? base.width : next.width,
144
+ height: wfFinite(next.height) === null ? base.height : next.height,
145
+ collapsed: typeof next.collapsed === "boolean" ? next.collapsed : base.collapsed,
146
+ selectedStep:
147
+ next.selectedStep === null
148
+ ? null
149
+ : wfFinite(next.selectedStep) === null
150
+ ? base.selectedStep
151
+ : next.selectedStep,
152
+ palette: display.palette,
153
+ motion: display.motion,
154
+ detail: display.detail,
155
+ nodeLimit: display.nodeLimit,
156
+ // the right page. `rightKey: null` means "keep whatever was open" — closing is
157
+ // expressed with `rightOpen: false`, so clearing the key is never ambiguous.
158
+ rightOpen: typeof next.rightOpen === "boolean" ? next.rightOpen : base.rightOpen,
159
+ rightKind: WF_RIGHT_KINDS.indexOf(next.rightKind) >= 0 ? next.rightKind : base.rightKind,
160
+ rightKey:
161
+ next.rightKey === null
162
+ ? null
163
+ : typeof next.rightKey === "string" && next.rightKey !== ""
164
+ ? next.rightKey
165
+ : base.rightKey,
166
+ };
167
+ return wfSanitizePrefs(merged);
168
+ }
169
+
170
+ /* ------------------------------------------------------------------------- */
171
+ /* the poll request */
172
+ /* ------------------------------------------------------------------------- */
173
+
174
+ function wfPollUrl(sessionId) {
175
+ return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
176
+ }
177
+
178
+ /**
179
+ * One conditional GET. Never throws: every outcome is a value, so the loop can
180
+ * always decide what to render and how long to wait.
181
+ *
182
+ * @returns {Promise<{status:number, etag:string, payload:any, error:string}>}
183
+ * 304 → keep the current view; 200 → new payload; anything else → `error`.
184
+ */
185
+ function wfPollOnce(fetchImpl, url, etag, signal) {
186
+ var headers = {};
187
+ if (typeof etag === "string" && etag !== "") headers["If-None-Match"] = etag;
188
+ var init = { cache: "no-store", headers: headers };
189
+ if (signal) init.signal = signal;
190
+ return Promise.resolve()
191
+ .then(function () {
192
+ if (typeof fetchImpl !== "function") throw new Error("fetch unavailable");
193
+ return fetchImpl(url, init);
194
+ })
195
+ .then(function (response) {
196
+ var nextTag = etag || "";
197
+ if (response && response.headers && typeof response.headers.get === "function") {
198
+ var tag = response.headers.get("ETag");
199
+ if (typeof tag === "string" && tag !== "") nextTag = tag;
200
+ }
201
+ if (response && response.status === 304) {
202
+ return { status: 304, etag: etag || "", payload: null, error: "" };
203
+ }
204
+ if (!response || response.ok !== true) {
205
+ var status = response && wfFinite(response.status) !== null ? response.status : 0;
206
+ return {
207
+ status: status,
208
+ etag: nextTag,
209
+ payload: null,
210
+ error: status === 0 ? "network error" : "HTTP " + status,
211
+ };
212
+ }
213
+ return response.json().then(function (payload) {
214
+ if (!payload || payload.ok !== true) {
215
+ return { status: response.status, etag: nextTag, payload: null, error: "bad payload" };
216
+ }
217
+ return { status: 200, etag: nextTag, payload: payload, error: "" };
218
+ });
219
+ })
220
+ .catch(function (error) {
221
+ // An aborted request is NOT a failure: the caller cancelled it (visibility
222
+ // change or unmount). status -1 says "nothing to report" so the loop neither
223
+ // paints an offline badge nor schedules a duplicate poll.
224
+ var aborted = !!error && error.name === "AbortError";
225
+ return {
226
+ status: aborted ? -1 : 0,
227
+ etag: etag || "",
228
+ payload: null,
229
+ error: (error && error.message) || String(error),
230
+ };
231
+ });
232
+ }
233
+
234
+ /* ------------------------------------------------------------------------- */
235
+ /* ------------------------------------------------------------------------- */
236
+ /* the write request */
237
+ /* ------------------------------------------------------------------------- */
238
+
239
+ // `wfPostSteer` lives in HotInject.js (it is the inject column's own request shape, and the
240
+ // self-test drives it from there). This comment marks the section boundary only: the panel's
241
+ // ONE write is defined exactly once in the bundle.
242
+
243
+ /* ------------------------------------------------------------------------- */
244
+ /* session identity */
245
+ /* ------------------------------------------------------------------------- */
246
+
247
+ /* ------------------------------------------------------------------------- */
248
+ /**
249
+ * `useSessions` hands the component a selector result. `snapshot.current` is the
250
+ * active session id (verified against the live SessionsSnapshot: it is an index
251
+ * into `byId`, not a record). When there is no current session — e.g. the frame
252
+ * outlives the chat column — fall back to the session the main view is retaining
253
+ * (`retainedBy.mainView`), choosing the most-retained, then the most recent.
254
+ */
255
+ function wfSessionIdFromSnapshot(snapshot) {
256
+ if (!snapshot || typeof snapshot !== "object") return null;
257
+ var current = snapshot.current;
258
+ if (typeof current === "string" && current !== "") return current;
259
+ var byId = snapshot.byId;
260
+ if (!byId || typeof byId !== "object") return null;
261
+ var bestId = null;
262
+ var bestViews = 0;
263
+ var bestAt = -1;
264
+ for (var id in byId) {
265
+ if (!Object.prototype.hasOwnProperty.call(byId, id)) continue;
266
+ var record = byId[id];
267
+ if (!record || typeof record !== "object") continue;
268
+ var views = wfFinite(record.retainedBy && record.retainedBy.mainView);
269
+ if (views === null || views <= 0) continue;
270
+ var at = wfFinite(record.lastActivityAt);
271
+ if (at === null) at = wfFinite(record.updatedAt);
272
+ if (at === null) at = 0;
273
+ if (views > bestViews || (views === bestViews && at > bestAt)) {
274
+ bestId = id;
275
+ bestViews = views;
276
+ bestAt = at;
277
+ }
278
+ }
279
+ return bestId;
280
+ }
281
+
282
+ /* ------------------------------------------------------------------------- */
283
+ /* view → model */
284
+ /* ------------------------------------------------------------------------- */
285
+
286
+ var WF_STATUS_KEYS = {
287
+ done: "panel.status.done",
288
+ running: "panel.status.running",
289
+ failed: "panel.status.failed",
290
+ skipped: "panel.status.skipped",
291
+ pending: "panel.status.pending",
292
+ };
293
+ var WF_KIND_KEYS = {
294
+ tool: "panel.kind.tool",
295
+ message: "panel.kind.message",
296
+ report: "panel.kind.report",
297
+ subagent: "panel.kind.subagent",
298
+ question: "panel.kind.question",
299
+ approval: "panel.kind.approval",
300
+ };
301
+
302
+ function wfStatusKey(status) {
303
+ return WF_STATUS_KEYS[status] || "panel.status.pending";
304
+ }
305
+
306
+ function wfKindKey(kind) {
307
+ return WF_KIND_KEYS[kind] || "panel.kind.other";
308
+ }
309
+
310
+ function wfTrimText(value, limit) {
311
+ if (typeof value !== "string") return "";
312
+ var text = value.replace(/\s+/g, " ").trim();
313
+ // The ellipsis counts toward the limit, so a 300-char budget stays 300 chars.
314
+ if (typeof limit === "number" && text.length > limit) return text.slice(0, Math.max(0, limit - 1)) + "…";
315
+ return text;
316
+ }
317
+
318
+ /**
319
+ * The header's number for one step. A step the plan closed is 100 % by
320
+ * definition; everything else contributes whatever its own estimate says, and a
321
+ * step with no estimate contributes 0 rather than NaN.
322
+ */
323
+ function wfEffectivePercent(row) {
324
+ if (row.status === "done") return 100;
325
+ var value = wfFinite(row.percent);
326
+ return value === null ? 0 : wfClamp(value, 0, 100);
327
+ }
328
+
329
+ /**
330
+ * The plan section reduced to what the cards need. Every field is optional on
331
+ * the wire (a plan written by an older PAE has no `skipped`), so each one has an
332
+ * honest fallback instead of a dereference.
333
+ */
334
+ function wfPlanSummary(plan) {
335
+ if (!plan || typeof plan !== "object") return null;
336
+ var confirmations = Object.create(null);
337
+ if (Array.isArray(plan.steps)) {
338
+ for (var i = 0; i < plan.steps.length; i += 1) {
339
+ var row = plan.steps[i];
340
+ if (row && wfFinite(row.index) !== null && row.requiresConfirmation === true) {
341
+ confirmations[String(Math.round(row.index))] = true;
342
+ }
343
+ }
344
+ }
345
+ var stepReports = [];
346
+ if (Array.isArray(plan.stepReports)) {
347
+ for (var r = 0; r < plan.stepReports.length && stepReports.length < 60; r += 1) {
348
+ var report = plan.stepReports[r];
349
+ if (!report || wfFinite(report.stepIndex) === null) continue;
350
+ var artifacts = [];
351
+ if (Array.isArray(report.artifacts)) {
352
+ for (var a = 0; a < report.artifacts.length && artifacts.length < 40; a += 1) {
353
+ if (typeof report.artifacts[a] === "string" && report.artifacts[a] !== "") {
354
+ artifacts.push(wfTrimText(report.artifacts[a], 300));
355
+ }
356
+ }
357
+ }
358
+ stepReports.push({
359
+ stepIndex: Math.round(report.stepIndex),
360
+ status: typeof report.status === "string" ? wfTrimText(report.status, 40) : "",
361
+ summary: wfTrimText(report.summary, 400),
362
+ artifacts: artifacts,
363
+ exitCode: wfFinite(report.exitCode) === null ? null : Math.round(report.exitCode),
364
+ });
365
+ }
366
+ }
367
+ return {
368
+ statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
369
+ anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
370
+ skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
371
+ stepIndex: wfFinite(plan.stepIndex) === null ? null : Math.round(plan.stepIndex),
372
+ confirmations: confirmations,
373
+ stepReports: stepReports,
374
+ };
375
+ }
376
+
377
+ /** The PAE report for a step ordinal (1-based `planIndex`), or null. */
378
+ function wfStepReport(plan, planIndex) {
379
+ if (!plan || !Array.isArray(plan.stepReports) || wfFinite(planIndex) === null) return null;
380
+ var wanted = Math.round(planIndex);
381
+ for (var i = 0; i < plan.stepReports.length; i += 1) {
382
+ var report = plan.stepReports[i];
383
+ if (report && wfFinite(report.stepIndex) !== null && Math.round(report.stepIndex) === wanted) return report;
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /**
389
+ * The selected step's detail: its report (PAE) and honest statistics from the nodes the
390
+ * host actually sent. `truncated` is the difference to the step's OWN nodeCount, so a
391
+ * 300-node cap is stated rather than silently under-counted.
392
+ */
393
+ function wfBuildStepDetail(row, nodes, plan) {
394
+ if (!row) return null;
395
+ var list = Array.isArray(nodes) ? nodes : [];
396
+ var seen = Object.create(null);
397
+ var files = 0;
398
+ var tools = 0;
399
+ var firstAt = null;
400
+ var lastAt = null;
401
+ for (var i = 0; i < list.length; i += 1) {
402
+ var node = list[i];
403
+ if (!node) continue;
404
+ if (node.kind === "tool") tools += 1;
405
+ var fileList = Array.isArray(node.fileList) ? node.fileList : [];
406
+ for (var f = 0; f < fileList.length; f += 1) {
407
+ var path = fileList[f] && fileList[f].path;
408
+ if (typeof path !== "string" || path === "" || seen[path] === true) continue;
409
+ seen[path] = true;
410
+ files += 1;
411
+ }
412
+ var at = wfFinite(node.at);
413
+ if (at !== null && at > 0) {
414
+ if (firstAt === null) firstAt = at;
415
+ lastAt = at;
416
+ }
417
+ }
418
+ var startedAt = wfFinite(row.startedAt);
419
+ if (startedAt === null || startedAt <= 0) startedAt = firstAt;
420
+ var endedAt = wfFinite(row.endedAt);
421
+ if (endedAt === null || endedAt <= 0) endedAt = row.status === "done" ? lastAt : null;
422
+ var nodeCount = wfFinite(row.nodeCount) === null ? list.length : Math.round(row.nodeCount);
423
+ return {
424
+ index: row.index,
425
+ planIndex: row.planIndex === undefined ? null : row.planIndex,
426
+ title: row.title,
427
+ status: row.status,
428
+ statusKey: row.statusKey,
429
+ tools: tools,
430
+ files: files,
431
+ nodes: nodeCount,
432
+ truncated: Math.max(0, nodeCount - list.length),
433
+ startedAt: startedAt,
434
+ endedAt: endedAt,
435
+ report: wfStepReport(plan, row.planIndex),
436
+ };
437
+ }
438
+
439
+ /** The todo table, bounded and repaired: only content/status are ever read. */
440
+ function wfTodoRows(todos) {
441
+ if (!Array.isArray(todos)) return [];
442
+ var out = [];
443
+ for (var i = 0; i < todos.length && out.length < 200; i += 1) {
444
+ var todo = todos[i];
445
+ if (!todo || typeof todo !== "object") continue;
446
+ out.push({
447
+ content: wfTrimText(todo.content, 200),
448
+ status: typeof todo.status === "string" ? todo.status : "",
449
+ });
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /**
455
+ * Build the render model, or null when the panel must not exist at all.
456
+ *
457
+ * Empty state rules ( contract §3):
458
+ * - no steps and no PAE plan still being written → null (NOTHING is rendered)
459
+ * - plan.phase === "planning" with no steps yet -> a one-line "planning..." card
460
+ * - otherwise → right/left two-column model
461
+ */
462
+ /* ------------------------------------------------------------------------- */
463
+ /* tool FILE TYPES (the satellites' data source) */
464
+ /* ------------------------------------------------------------------------- */
465
+
466
+ /**
467
+ * Compound extensions that must stay one token: `a.step` and `a.txt` are different tool files,
468
+ * and `cadflow-A3FIX-20261011.md` is a markdown file, not a `.20261011` file.
469
+ */
470
+ var WF_TYPE_COMPOUND = [
471
+ ".tar.gz",
472
+ ".tar.bz2",
473
+ ".tar.xz",
474
+ ".test.ts",
475
+ ".spec.ts",
476
+ ".min.js",
477
+ ];
478
+
479
+ /**
480
+ * A tool call's file extension → the badge printed inside its circle.
481
+ *
482
+ * `.md` is printed as `md`, `.dxf` as `dxf`, `.tar.gz` as `tar` — the badge must fit a 26 px
483
+ * circle, so it is the extension WITHOUT the dot. Anything longer than four characters keeps its
484
+ * first three letters. No extension → `file`.
485
+ */
486
+ function wfTypeBadge(ext) {
487
+ if (typeof ext !== "string" || ext === "") return "file";
488
+ var bare = ext.replace(/^\./, "");
489
+ if (bare === "tar.gz" || bare === "tar.bz2" || bare === "tar.xz") return "tar";
490
+ if (bare.length <= 4) return bare;
491
+ return bare.slice(0, 3);
492
+ }
493
+
494
+ /* ------------------------------------------------------------------------- */
495
+ /* hot-inject history (pure shaping, no DOM) */
496
+ /* ------------------------------------------------------------------------- */
497
+
498
+ /** How many rows the composer page lists; the oldest fall off with a stated count. */
499
+ var WF_INJECT_HISTORY = 40;
500
+
501
+ /**
502
+ * The wire's `view.injects` → what the composer page renders.
503
+ *
504
+ * Each row keeps the three facts the user asked for: WHAT was injected (the text), WHEN
505
+ * (`at`, the splice's own event time — never a browser clock), and its STATUS, which is one
506
+ * of two words with different meanings:
507
+ * `pending` — the message is in the session's inbox; the turn has not picked it up yet.
508
+ * `picked` — the session's own `user/message` with the same id is in the log, i.e. the
509
+ * running turn HAS taken it. This is measured, not assumed.
510
+ *
511
+ * A row without an id cannot be matched, so it is dropped rather than listed as status-less.
512
+ */
513
+ function wfInjectRows(list) {
514
+ if (!Array.isArray(list)) return [];
515
+ var rows = [];
516
+ for (var i = 0; i < list.length; i += 1) {
517
+ var row = list[i];
518
+ if (!row || typeof row !== "object") continue;
519
+ var id = typeof row.id === "string" ? row.id : "";
520
+ if (id === "") continue;
521
+ rows.push({
522
+ id: id,
523
+ at: wfFinite(row.at),
524
+ pickedUpAt: wfFinite(row.pickedUpAt),
525
+ target: typeof row.target === "string" ? row.target : "",
526
+ text: wfTrimText(row.text, 300),
527
+ // three states, not two: a message the loop DISCARDED (durable cancel) is neither
528
+ // "waiting" nor "taken" — it will never be read. Only the host's own
529
+ // `agent/inbox/discarded` event sets this, so it is never inferred.
530
+ status: row.pickedUp === true ? "picked" : row.canceled === true ? "canceled" : "pending",
531
+ canceledAt: wfFinite(row.canceledAt),
532
+ });
533
+ }
534
+ if (rows.length > WF_INJECT_HISTORY) {
535
+ return rows.slice(rows.length - WF_INJECT_HISTORY);
536
+ }
537
+ return rows;
538
+ }
539
+
540
+ /** `.md` → `.md`; a path with no dot → `""` (the "no extension" bucket). */
541
+ function wfTypeOfPath(path) {
542
+ if (typeof path !== "string" || path === "") return "";
543
+ var flat = path.replace(/\\/g, "/");
544
+ var base = flat.slice(flat.lastIndexOf("/") + 1).toLowerCase();
545
+ if (base === "") return "";
546
+ for (var i = 0; i < WF_TYPE_COMPOUND.length; i += 1) {
547
+ var compound = WF_TYPE_COMPOUND[i];
548
+ if (base.length > compound.length && base.slice(-compound.length) === compound) return compound;
549
+ }
550
+ var at = base.lastIndexOf(".");
551
+ // A leading dot is a dotfile (`.gitignore`): its own type, not "no extension".
552
+ if (at === 0) return base;
553
+ if (at <= 0) return "";
554
+ return base.slice(at);
555
+ }
556
+
557
+ /** Which side a host file action lives on: read/query -> left (input), write/edit -> right (output). */
558
+ function wfTypeSide(action) {
559
+ var label = typeof action === "string" ? action.trim().toLowerCase() : "";
560
+ if (
561
+ label === "读" ||
562
+ label === "read" ||
563
+ label === "查" ||
564
+ label === "glob" ||
565
+ label === "grep" ||
566
+ label === "list" ||
567
+ label === "query"
568
+ ) {
569
+ return "left";
570
+ }
571
+ return "right";
572
+ }
573
+
574
+ /**
575
+ * Bucket ONE step's nodes into file types (the satellites), split by side.
576
+ *
577
+ * Input is the browser model's node list: every node carries `fileList[{path, action}]` and a
578
+ * `tool`. Pure — it reads nothing else and never touches the DOM, so selftest-panel can assert
579
+ * the buckets without a browser.
580
+ *
581
+ * A type is a KEY: `file:.md` for a file extension, `tool:pwsh` for a call whose only evidence is
582
+ * the tool name. A call that touched a file counts under that file's type only (its tool is
583
+ * already visible in the call's own row); a call that touched nothing gets a tool circle on the
584
+ * right - that is where "tools called" lives.
585
+ */
586
+ function wfTypeBuckets(nodes, activeNodeId) {
587
+ var list = Array.isArray(nodes) ? nodes : [];
588
+ var active = typeof activeNodeId === "string" && activeNodeId !== "" ? activeNodeId : null;
589
+ var byKey = Object.create(null);
590
+
591
+ function slot(key, kind, label) {
592
+ var bucket = byKey[key];
593
+ if (!bucket) {
594
+ bucket = {
595
+ key: key,
596
+ kind: kind,
597
+ label: label,
598
+ side: kind === "tool" ? "right" : "left",
599
+ count: 0,
600
+ error: false,
601
+ running: false,
602
+ nodes: [],
603
+ seen: Object.create(null),
604
+ };
605
+ byKey[key] = bucket;
606
+ }
607
+ return bucket;
608
+ }
609
+
610
+ for (var i = 0; i < list.length; i += 1) {
611
+ var node = list[i];
612
+ if (!node || typeof node !== "object") continue;
613
+ var id = typeof node.id === "string" ? node.id : "";
614
+ var tool = typeof node.tool === "string" && node.tool !== "" ? node.tool : "";
615
+ var files = Array.isArray(node.fileList) ? node.fileList : [];
616
+ var touched = false;
617
+ for (var f = 0; f < files.length; f += 1) {
618
+ var file = files[f];
619
+ if (!file || typeof file.path !== "string" || file.path === "") continue;
620
+ var ext = wfTypeOfPath(file.path);
621
+ var bucket = slot("file:" + ext, "file", ext === "" ? "file" : wfTypeBadge(ext));
622
+ if (wfTypeSide(file.action) === "right") bucket.side = "right";
623
+ touched = true;
624
+ if (bucket.count >= WF_SIDE_FILES) continue;
625
+ bucket.count += 1;
626
+ if (node.error === true) bucket.error = true;
627
+ if (id !== "" && bucket.seen[id] !== true) {
628
+ bucket.seen[id] = true;
629
+ bucket.nodes.push(id);
630
+ if (id === active) bucket.running = true;
631
+ }
632
+ }
633
+ if (!touched && tool !== "") {
634
+ var toolBucket = slot("tool:" + tool, "tool", tool.length > 6 ? tool.slice(0, 6) : tool);
635
+ toolBucket.side = "right";
636
+ if (toolBucket.count < WF_SIDE_FILES) {
637
+ toolBucket.count += 1;
638
+ if (node.error === true) toolBucket.error = true;
639
+ if (id !== "" && toolBucket.seen[id] !== true) {
640
+ toolBucket.seen[id] = true;
641
+ toolBucket.nodes.push(id);
642
+ if (id === active) toolBucket.running = true;
643
+ }
644
+ }
645
+ }
646
+ }
647
+ return byKey;
648
+ }
649
+
650
+ /**
651
+ * The buckets of ONE step, in the order they are drawn, split into the two sides.
652
+ *
653
+ * Ordering is by count (the type that matters most sits closest to the unit), ties broken by
654
+ * label so two renders of the same data never swap places. Each side keeps WF_SIDE_SLOTS
655
+ * circles; `dropped` states how many types did not fit — the card prints that number, so a
656
+ * crowded step is honest rather than silently trimmed.
657
+ */
658
+ function wfTypeSides(nodes, activeNodeId) {
659
+ var byKey = wfTypeBuckets(nodes, activeNodeId);
660
+ var all = [];
661
+ var keys = Object.keys(byKey);
662
+ for (var i = 0; i < keys.length; i += 1) {
663
+ var bucket = byKey[keys[i]];
664
+ all.push({
665
+ key: bucket.key,
666
+ kind: bucket.kind,
667
+ label: bucket.label,
668
+ side: bucket.side === "right" ? "right" : "left",
669
+ tone: bucket.kind === "tool" ? "tool" : bucket.side === "left" ? "read" : "edit",
670
+ count: bucket.count,
671
+ running: bucket.running === true,
672
+ error: bucket.error === true,
673
+ nodes: bucket.nodes.slice(),
674
+ });
675
+ }
676
+ all.sort(function (left, right) {
677
+ if (left.count !== right.count) return right.count - left.count;
678
+ return left.label < right.label ? -1 : left.label > right.label ? 1 : 0;
679
+ });
680
+ var sides = { left: [], right: [], dropped: { left: 0, right: 0 }, all: all };
681
+ for (var b = 0; b < all.length; b += 1) {
682
+ var one = all[b];
683
+ var side = one.side === "right" ? "right" : "left";
684
+ if (sides[side].length >= WF_SIDE_SLOTS) {
685
+ sides.dropped[side] += 1;
686
+ continue;
687
+ }
688
+ sides[side].push(one);
689
+ }
690
+ return sides;
691
+ }
692
+
693
+ /* ------------------------------------------------------------------------- */
694
+ /* the model */
695
+ /* ------------------------------------------------------------------------- */
696
+
697
+ function wfBuildModel(view, prefs, nowMs) {
698
+ if (!view || typeof view !== "object" || !Array.isArray(view.steps)) return null;
699
+
700
+ var plan = view.plan && typeof view.plan === "object" ? view.plan : null;
701
+ var planSteps = plan && Array.isArray(plan.steps) ? plan.steps : [];
702
+ var planning = !!(plan && plan.phase === "planning" && planSteps.length === 0);
703
+ var task = "";
704
+ if (plan) task = wfTrimText(plan.taskTitle || plan.task, 300);
705
+ if (task === "") task = wfTrimText(view.task, 300);
706
+
707
+ // the single page keeps ONE selected step: the unit the user last opened. The policy is
708
+ // unchanged (a remembered index wins, then the running step, then the active one, then the last
709
+ // done step, then the first) — what changed is only that the page no longer needs a second
710
+ // "selected" notion for the flow column, because the flow column is gone.
711
+ var steps = view.steps;
712
+ if (steps.length === 0) {
713
+ if (!planning) return null;
714
+ return {
715
+ kind: "planning",
716
+ planning: true,
717
+ task: task,
718
+ progress: { done: 0, total: 0, percent: 0 },
719
+ activeStep: null,
720
+ selected: null,
721
+ // palette / motion / detail depth / node window travel with the model,
722
+ // so a preference change is one ordinary re-render (no second state channel).
723
+ settings: wfSettingsOf(prefs),
724
+ // Is the session actually working? The curve, both wrapped frames and the
725
+ // breathing dot all freeze when it is not — see pulse.js wfSessionRunning for the
726
+ // three signals and why the host's own `view.running` wins when it exists.
727
+ running: wfSessionRunning(view, null, nowMs),
728
+ steps: [],
729
+ flow: {
730
+ stepTitle: "",
731
+ nodes: [],
732
+ total: 0,
733
+ truncated: 0,
734
+ stepNodeTotal: 0,
735
+ groups: [],
736
+ activeNodeId: null,
737
+ branches: [],
738
+ hiddenBranches: 0,
739
+ branchDropped: 0,
740
+ },
741
+ // the single page has nothing to draw while the plan is still being written; the
742
+ // shape is present so the renderer never has to branch on "does page exist".
743
+ page: { units: [], runningIndex: null, toolTypes: [] },
744
+ counts: view.counts && typeof view.counts === "object" ? view.counts : null,
745
+ plan: wfPlanSummary(plan),
746
+ // the hot-inject history travels with the model, so the composer page can list
747
+ // what was sent and whether the session admitted it (see wfInjectRows).
748
+ injects: wfInjectRows(view.injects),
749
+ };
750
+ }
751
+
752
+ var activeIndex = null;
753
+ if (view.active && wfFinite(view.active.stepIndex) !== null) {
754
+ activeIndex = Math.round(view.active.stepIndex);
755
+ if (activeIndex < 0 || activeIndex >= steps.length) activeIndex = null;
756
+ }
757
+
758
+ var nodeById = Object.create(null);
759
+ if (Array.isArray(view.nodes)) {
760
+ for (var n = 0; n < view.nodes.length; n += 1) {
761
+ var node = view.nodes[n];
762
+ if (node && typeof node.id === "string") nodeById[node.id] = node;
763
+ }
764
+ }
765
+
766
+ var planSummary = wfPlanSummary(plan);
767
+ var todos = wfTodoRows(view.todos);
768
+
769
+ var rows = [];
770
+ for (var i = 0; i < steps.length; i += 1) {
771
+ var step = steps[i] && typeof steps[i] === "object" ? steps[i] : {};
772
+ var status = typeof step.status === "string" ? step.status : "pending";
773
+ var percent = wfFinite(step.percent);
774
+ var clampedPercent = percent === null ? null : wfClamp(percent, 0, 100);
775
+ rows.push({
776
+ index: i,
777
+ title: wfTrimText(step.title, 200) || "—",
778
+ status: status,
779
+ statusKey: wfStatusKey(status),
780
+ percent: clampedPercent === null ? null : Math.round(clampedPercent),
781
+ percentSource: typeof step.percentSource === "string" ? step.percentSource : "none",
782
+ // when the last progress estimate arrived (0 = never). The rail uses it for the
783
+ // "estimated N minutes ago" segment and prints nothing at all when it is 0.
784
+ lastEstimateAt: wfFinite(step.lastEstimateAt) === null ? 0 : Math.max(0, Math.round(wfFinite(step.lastEstimateAt))),
785
+ nodeCount: wfFinite(step.nodeCount) === null ? 0 : step.nodeCount,
786
+ planIndex: wfFinite(step.planIndex) === null ? null : step.planIndex,
787
+ anchorSeq: wfFinite(step.anchorSeq),
788
+ reportSummary: wfTrimText(step.reportSummary, 200),
789
+ startedAt: wfFinite(step.startedAt),
790
+ endedAt: wfFinite(step.endedAt),
791
+ outcome: typeof step.outcome === "string" ? wfTrimText(step.outcome, 200) : "",
792
+ requiresConfirmation: false,
793
+ statusSource: "host",
794
+ rawStatus: status,
795
+ active: activeIndex === i,
796
+ selected: false,
797
+ });
798
+ }
799
+
800
+ // The status policy (PAE > todo > anchorSeqs > host, see StepColumn.js) lives
801
+ // in the step column but is applied HERE, so the header's done-count, the
802
+ // running-step selection and every card's badge cannot disagree.
803
+ rows = wfApplyDerivedStatuses(rows, planSummary, todos);
804
+
805
+ var done = 0;
806
+ var percentSum = 0;
807
+ var runningIndex = null;
808
+ for (var r = 0; r < rows.length; r += 1) {
809
+ if (rows[r].status === "done") done += 1;
810
+ if (rows[r].status === "running" && runningIndex === null) runningIndex = r;
811
+ // Overall progress = the mean of every step's own estimate, so a long running
812
+ // step moves the header between completions instead of freezing at done/total.
813
+ percentSum += wfEffectivePercent(rows[r]);
814
+ }
815
+ // ONE live row. The host already closes stray running rows (fold.mjs soleRunning);
816
+ // this is the browser's own guard, because the PAE/todo status policy applied just above can
817
+ // ALSO promote a row to 'running'. Everything that animates (ring, rail, connector, the
818
+ // flowing tool type) keys off `live`, so at most one of each can ever light up.
819
+ var liveIndex = runningIndex;
820
+ if (liveIndex === null && activeIndex !== null) liveIndex = activeIndex;
821
+ for (var lr = 0; lr < rows.length; lr += 1) rows[lr].live = lr === liveIndex;
822
+
823
+ var percentOverall = Math.round(wfClamp(percentSum / steps.length, 0, 100));
824
+
825
+ // Selection: remember the user's pick, drop it if it no longer exists, then fall
826
+ // back to the running step, the active step, the last done step, or the first.
827
+ var selected = null;
828
+ var wanted = prefs && wfFinite(prefs.selectedStep) !== null ? Math.round(prefs.selectedStep) : null;
829
+ if (wanted !== null && wanted >= 0 && wanted < rows.length) selected = wanted;
830
+ if (selected === null && runningIndex !== null) selected = runningIndex;
831
+ if (selected === null && activeIndex !== null) selected = activeIndex;
832
+ if (selected === null) {
833
+ for (var d = rows.length - 1; d >= 0; d -= 1) {
834
+ if (rows[d].status === "done") {
835
+ selected = d;
836
+ break;
837
+ }
838
+ }
839
+ }
840
+ if (selected === null) selected = 0;
841
+ rows[selected].selected = true;
842
+
843
+ var step = steps[selected] && typeof steps[selected] === "object" ? steps[selected] : {};
844
+ var order = Array.isArray(step.nodeIds) ? step.nodeIds : [];
845
+ var flowNodes = [];
846
+ for (var k = 0; k < order.length; k += 1) {
847
+ var found = nodeById[order[k]];
848
+ if (!found) continue; // a globally-truncated node is simply absent, never a hole
849
+ var firstFile = Array.isArray(found.files) && found.files.length > 0 ? found.files[0] : null;
850
+ flowNodes.push({
851
+ id: typeof found.id === "string" ? found.id : "n" + k,
852
+ kind: typeof found.kind === "string" ? found.kind : "other",
853
+ kindKey: wfKindKey(found.kind),
854
+ tool: wfTrimText(found.tool, 40),
855
+ title: wfTrimText(found.title, 160) || wfTrimText(found.detail, 160) || "—",
856
+ detail: wfTrimText(found.detail, 200),
857
+ files: Array.isArray(found.files) ? found.files.length : 0,
858
+ // The first path is what the card titles itself with ("read · x.js"): basename
859
+ // only, the full path stays in the tooltip.
860
+ path: firstFile && typeof firstFile.path === "string" ? firstFile.path : "",
861
+ // The detail block needs the whole file list (paths + host action labels) — the card
862
+ // above only counts them. `fileList` is a whitelist copy: `files` on the wire could
863
+ // carry anything, and only path/action ever reach the renderer.
864
+ fileList: Array.isArray(found.files)
865
+ ? found.files
866
+ .filter(function (file) {
867
+ return file && typeof file.path === "string" && file.path !== "";
868
+ })
869
+ .map(function (file) {
870
+ return { path: file.path, action: typeof file.action === "string" ? file.action : "" };
871
+ })
872
+ : [],
873
+ // The args digest / result head / error text are bounded by the host (200 chars).
874
+ // They are copied verbatim — never flattened — because the detail renders them in a
875
+ // pre-wrap block and a command's newlines are part of what the user asked to see.
876
+ argsText: typeof found.argsText === "string" ? found.argsText.slice(0, 200) : "",
877
+ resultLine: typeof found.resultHead === "string" ? found.resultHead.slice(0, 200) : "",
878
+ errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
879
+ seq: wfFinite(found.seq),
880
+ error: typeof found.error === "string" && found.error !== "",
881
+ result: typeof found.resultHead === "string" && found.resultHead !== "",
882
+ durationMs: wfFinite(found.durationMs),
883
+ at: wfFinite(found.at),
884
+ });
885
+ }
886
+
887
+ // The selected step's own detail : the report + statistics from the nodes above.
888
+ var detail = wfBuildStepDetail(rows[selected], flowNodes, planSummary);
889
+
890
+ // Host fold records are copied field by field: they are only RANGES over the node
891
+ // list above (FlowColumn resolves them), so a group whose ids are missing here can
892
+ // never hide a node.
893
+ var flowGroups = [];
894
+ if (Array.isArray(step.groups)) {
895
+ for (var g = 0; g < step.groups.length; g += 1) {
896
+ var rawGroup = step.groups[g];
897
+ if (!rawGroup || typeof rawGroup !== "object") continue;
898
+ flowGroups.push({
899
+ key: typeof rawGroup.key === "string" ? rawGroup.key : "",
900
+ tool: typeof rawGroup.tool === "string" ? rawGroup.tool : "",
901
+ firstId: typeof rawGroup.firstId === "string" ? rawGroup.firstId : "",
902
+ lastId: typeof rawGroup.lastId === "string" ? rawGroup.lastId : "",
903
+ count: wfFinite(rawGroup.count) === null ? 0 : rawGroup.count,
904
+ firstAt: wfFinite(rawGroup.firstAt),
905
+ lastAt: wfFinite(rawGroup.lastAt),
906
+ });
907
+ }
908
+ }
909
+
910
+ // Branch lanes — the parallel work of THIS step, reduced to what Branches.js
911
+ // draws. A lane that started before any step existed (stepIndex null) belongs to the
912
+ // first step. Only the newest WF_BRANCH_SHOWN lanes are drawn; the rest are counted by
913
+ // the renderer, and lanes the HOST dropped at its own cap are counted separately — the
914
+ // two numbers have different causes and must not be merged.
915
+ var branchRows = [];
916
+ var allBranches = Array.isArray(view.branches) ? view.branches : [];
917
+ for (var bi = 0; bi < allBranches.length; bi += 1) {
918
+ var rawBranch = allBranches[bi];
919
+ if (!rawBranch || typeof rawBranch !== "object") continue;
920
+ var branchStep = wfFinite(rawBranch.stepIndex);
921
+ if (branchStep !== null && Math.round(branchStep) !== selected) continue;
922
+ if (branchStep === null && selected !== 0) continue;
923
+ var branchNodes = [];
924
+ if (Array.isArray(rawBranch.nodes)) {
925
+ for (var bn = 0; bn < rawBranch.nodes.length; bn += 1) {
926
+ var rawNode = rawBranch.nodes[bn];
927
+ if (!rawNode || typeof rawNode !== "object") continue;
928
+ branchNodes.push({
929
+ id: typeof rawNode.id === "string" ? rawNode.id : "b" + bi + "n" + bn,
930
+ kind: typeof rawNode.kind === "string" ? rawNode.kind : "row",
931
+ tool: wfTrimText(rawNode.tool, 40),
932
+ title: wfTrimText(rawNode.title, 160) || "—",
933
+ detail: wfTrimText(rawNode.detail, 200),
934
+ at: wfFinite(rawNode.at),
935
+ status: typeof rawNode.status === "string" ? rawNode.status : "",
936
+ });
937
+ }
938
+ }
939
+ var branchCount = wfFinite(rawBranch.count);
940
+ var branchTrunc = wfFinite(rawBranch.truncated);
941
+ branchRows.push({
942
+ id: typeof rawBranch.id === "string" ? rawBranch.id : "branch-" + bi,
943
+ kind: typeof rawBranch.kind === "string" ? rawBranch.kind : "subagent",
944
+ label: wfTrimText(rawBranch.label, 160),
945
+ status: typeof rawBranch.status === "string" ? rawBranch.status : "running",
946
+ startedAt: wfFinite(rawBranch.startedAt),
947
+ endedAt: wfFinite(rawBranch.endedAt),
948
+ count: branchCount === null ? branchNodes.length : branchCount,
949
+ truncated:
950
+ branchTrunc === null
951
+ ? Math.max(0, (branchCount === null ? branchNodes.length : branchCount) - branchNodes.length)
952
+ : Math.max(0, branchTrunc),
953
+ nodes: branchNodes,
954
+ });
955
+ }
956
+ branchRows.sort(function (left, right) {
957
+ var leftAt = left.startedAt === null ? 0 : left.startedAt;
958
+ var rightAt = right.startedAt === null ? 0 : right.startedAt;
959
+ return leftAt - rightAt;
960
+ });
961
+ var hiddenBranches = 0;
962
+ if (branchRows.length > WF_BRANCH_SHOWN) {
963
+ hiddenBranches = branchRows.length - WF_BRANCH_SHOWN;
964
+ branchRows = branchRows.slice(branchRows.length - WF_BRANCH_SHOWN);
965
+ }
966
+ var branchDropped = wfFinite(view.droppedBranches) === null ? 0 : Math.max(0, view.droppedBranches);
967
+
968
+ // The pulsing node is only "current" while its OWN step is the selected one.
969
+ // The pulsing node is only "current" while its OWN step is the selected one.
970
+ var activeNodeId = null;
971
+ if (activeIndex !== null && activeIndex === selected && view.active && typeof view.active.nodeId === "string") {
972
+ activeNodeId = view.active.nodeId;
973
+ }
974
+
975
+ // the single page. EVERY step becomes one unit on the serial main flow, with its own
976
+ // satellites, so the page needs each step's nodes — not only the selected step's. The same
977
+ // whitelist copy is used (path/action only), so a satellite can never carry anything the
978
+ // selected step's flow would refuse to show.
979
+ var pageUnits = [];
980
+ for (var u = 0; u < rows.length; u += 1) {
981
+ var unitStep = steps[u] && typeof steps[u] === "object" ? steps[u] : {};
982
+ var unitIds = Array.isArray(unitStep.nodeIds) ? unitStep.nodeIds : [];
983
+ var unitNodes = [];
984
+ var unitTools = Object.create(null);
985
+ var unitFiles = Object.create(null);
986
+ var unitToolCount = 0;
987
+ for (var ui = 0; ui < unitIds.length; ui += 1) {
988
+ var unitRaw = nodeById[unitIds[ui]];
989
+ if (!unitRaw) continue;
990
+ if (unitRaw.kind === "tool") {
991
+ unitToolCount += 1;
992
+ if (typeof unitRaw.tool === "string" && unitRaw.tool !== "") unitTools[unitRaw.tool] = true;
993
+ }
994
+ var unitFilesWire = Array.isArray(unitRaw.files) ? unitRaw.files : [];
995
+ for (var uf = 0; uf < unitFilesWire.length; uf += 1) {
996
+ var unitFile = unitFilesWire[uf];
997
+ if (unitFile && typeof unitFile.path === "string" && unitFile.path !== "") unitFiles[unitFile.path] = true;
998
+ }
999
+ unitNodes.push({
1000
+ id: typeof unitRaw.id === "string" ? unitRaw.id : "n" + u + "-" + ui,
1001
+ tool: wfTrimText(unitRaw.tool, 40),
1002
+ error: typeof unitRaw.error === "string" && unitRaw.error !== "",
1003
+ fileList: unitFilesWire
1004
+ .filter(function (file) {
1005
+ return file && typeof file.path === "string" && file.path !== "";
1006
+ })
1007
+ .map(function (file) {
1008
+ return { path: file.path, action: typeof file.action === "string" ? file.action : "" };
1009
+ }),
1010
+ });
1011
+ }
1012
+ // The running step's own active call lights its satellite; every other step gets none.
1013
+ var unitActive = activeIndex === u && view.active && typeof view.active.nodeId === "string" ? view.active.nodeId : "";
1014
+ var unitSides = wfTypeSides(unitNodes, unitActive);
1015
+ pageUnits.push({
1016
+ index: u,
1017
+ title: rows[u].title,
1018
+ status: rows[u].status,
1019
+ statusKey: rows[u].statusKey,
1020
+ statusSource: rows[u].statusSource,
1021
+ planIndex: rows[u].planIndex,
1022
+ active: rows[u].active,
1023
+ // the ONE flag every animated piece reads (ring, rail, connector, tool type).
1024
+ live: rows[u].live === true,
1025
+ percent: rows[u].percent,
1026
+ percentSource: rows[u].percentSource,
1027
+ lastEstimateAt: rows[u].lastEstimateAt,
1028
+ startedAt: rows[u].startedAt,
1029
+ toolCount: unitToolCount,
1030
+ requiresConfirmation: rows[u].requiresConfirmation === true,
1031
+ pendingConfirm: Array.isArray(rows[u].pendingConfirm) ? rows[u].pendingConfirm : [],
1032
+ fileCount: Object.keys(unitFiles).length,
1033
+ left: unitSides.left,
1034
+ right: unitSides.right,
1035
+ dropped: unitSides.dropped,
1036
+ types: unitSides.all,
1037
+ });
1038
+ }
1039
+ var pageRunningIndex = runningIndex === null ? null : runningIndex;
1040
+
1041
+ return {
1042
+ kind: "ready",
1043
+ planning: false,
1044
+ task: task,
1045
+ progress: { done: done, total: steps.length, percent: percentOverall },
1046
+ activeStep: activeIndex,
1047
+ selected: selected,
1048
+ // the one flag the living layer switches on. Derived AFTER activeNodeId, because
1049
+ // "a node is mid-call right now" is the strongest non-host signal there is.
1050
+ running: wfSessionRunning(view, activeNodeId, nowMs),
1051
+ steps: rows,
1052
+ flow: {
1053
+ stepTitle: rows[selected].title,
1054
+ nodes: flowNodes,
1055
+ total: flowNodes.length,
1056
+ // "omitted" count must cover EVERY missing node, not only the per-step cap: the
1057
+ // global 1200-node cap also prunes a step's id list while leaving
1058
+ // step.truncatedNodes at 0. The honest gap is nodeCount − drawn.
1059
+ truncated: Math.max(
1060
+ wfFinite(step.truncatedNodes) === null ? 0 : step.truncatedNodes,
1061
+ (wfFinite(step.nodeCount) === null ? flowNodes.length : step.nodeCount) - flowNodes.length
1062
+ ),
1063
+ stepNodeTotal: wfFinite(step.nodeCount) === null ? flowNodes.length : step.nodeCount,
1064
+ groups: flowGroups,
1065
+ activeNodeId: activeNodeId,
1066
+ branches: branchRows,
1067
+ hiddenBranches: hiddenBranches,
1068
+ branchDropped: branchDropped,
1069
+ },
1070
+ // the single page: one unit per step, its satellites, and the index of the step the
1071
+ // chain should bring to the middle. `types` is every bucket (not only the drawn ones), so the
1072
+ // right page can show the ones the card could not fit.
1073
+ page: {
1074
+ units: pageUnits,
1075
+ runningIndex: pageRunningIndex,
1076
+ toolTypes: WF_TYPE_COMPOUND.slice(),
1077
+ },
1078
+ counts: view.counts && typeof view.counts === "object" ? view.counts : null,
1079
+ plan: planSummary,
1080
+ // the selected step's report + statistics, or null for the planning card.
1081
+ detail: detail,
1082
+ settings: wfSettingsOf(prefs),
1083
+ todos: todos,
1084
+ // the hot-inject history (the host's own fold of `agent/inbox/spliced` + the
1085
+ // admitting `user/message`). Oldest first, bounded, never invented.
1086
+ injects: wfInjectRows(view.injects),
1087
+ asOf: wfFinite(view.asOf),
1088
+ now: wfFinite(nowMs),
1089
+ };
1090
+ }