@ai-setting/roy-plugin-task-show 0.6.12 → 0.8.5

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.
package/public/app.js CHANGED
@@ -104,6 +104,510 @@
104
104
  }
105
105
  })();
106
106
 
107
+ /* ------------------------------------------------------------------------- */
108
+ /* v0.8.1+ Mermaid source builder (top-level — shared by both IIFEs) */
109
+ /* ------------------------------------------------------------------------- */
110
+ /**
111
+ * Build a Mermaid `flowchart TD` source string for the per-task page.
112
+ *
113
+ * v0.8.0 kept this function inside the `attachTaskPageTimeline` IIFE,
114
+ * but the `attachToolClickBridge` IIFE's `lifecycle-ops-loaded`
115
+ * listener also calls it. IIFE-local bindings are NOT visible across
116
+ * sibling IIFEs, so the listener threw `ReferenceError:
117
+ * buildMermaidSource is not defined` and the Mermaid diagram silently
118
+ * failed to re-render. The fix is to hoist the function (and the three
119
+ * helpers it uses) to script top-level so both IIFEs can see them via
120
+ * the script-wide closure. We also attach the function to `window` so
121
+ * tests + any future tooling can call it directly.
122
+ *
123
+ * Behaviour unchanged from v0.8.0: when `operations` is empty we
124
+ * emit a linear tool chain (matching the server's initial render);
125
+ * when operations are present we nest each tool under its owning
126
+ * operation subgraph + attach click callbacks.
127
+ */
128
+ function buildMermaidSource(session, operations) {
129
+ const lines = [`flowchart TD`];
130
+ lines.push(` classDef ok fill:#dcfce7,stroke:#16a34a,color:#064e3b`);
131
+ lines.push(` classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d`);
132
+ lines.push(` classDef op fill:#dbeafe,stroke:#2563eb,color:#1e3a8a`);
133
+ lines.push(` classDef runtime fill:#ede9fe,stroke:#6d28d9,color:#3b0764`);
134
+ lines.push(` classDef bookend fill:#f1f5f9,stroke:#475569,color:#0f172a`);
135
+ lines.push(` task_start([Task #${session.taskId} start]):::runtime`);
136
+
137
+ const ops = Array.isArray(operations) ? operations.slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) : [];
138
+ const toolCalls = session.toolCalls || [];
139
+
140
+ if (ops.length === 0) {
141
+ // No operation data yet — fall back to a single linear chain
142
+ // matching the server's initial render.
143
+ let prev = "task_start";
144
+ if (toolCalls.length === 0) {
145
+ lines.push(` task_end([end]):::runtime`);
146
+ lines.push(` ${prev} --> task_end`);
147
+ return lines.join("\n");
148
+ }
149
+ toolCalls.forEach((call) => {
150
+ const nodeId = `t${call.sequence}`;
151
+ const head = escapeMermaid(`${call.sequence}. ${call.toolName}${call.hasAttachment ? " \ud83d\udcce" : ""}`);
152
+ const status = call.success ? "ok" : "FAIL";
153
+ lines.push(` ${nodeId}["${head}<br/><small>${call.durationMs}ms \u00b7 ${status}</small>"]`);
154
+ lines.push(` class ${nodeId} ${call.success ? "ok" : "fail"}`);
155
+ lines.push(` click ${nodeId} call __toolClick("${call.sequence}")`);
156
+ lines.push(` ${prev} --> ${nodeId}`);
157
+ prev = nodeId;
158
+ });
159
+ const endLabel = session.status === "failed" ? `\u274c Task ${session.status}` : (session.status === "completed" ? `\u2705 Task ${session.status}` : "end");
160
+ lines.push(` task_end([${endLabel}]):::runtime`);
161
+ lines.push(` ${prev} --> task_end`);
162
+ return lines.join("\n");
163
+ }
164
+
165
+ // With operations: bucket tools by op and render subgraphs.
166
+ // (Same heuristic as `assignToolsToOps` in the server module.)
167
+ const sortedOps = ops.slice().sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
168
+ const opMs = sortedOps.map((o) => ({ op: o, ms: parseIsoToMs(o.timestamp) }));
169
+ const buckets = { pre: [], post: [], in: {} };
170
+ for (const t of toolCalls) {
171
+ if (opMs.length === 0 || t.timestamp < opMs[0].ms) {
172
+ buckets.pre.push(t);
173
+ } else if (t.timestamp >= opMs[opMs.length - 1].ms) {
174
+ buckets.post.push(t);
175
+ } else {
176
+ let owner = null;
177
+ for (const o of opMs) {
178
+ if (o.ms <= t.timestamp) owner = o.op;
179
+ else break;
180
+ }
181
+ const key = owner ? owner.sequence : "_";
182
+ if (!buckets.in[key]) buckets.in[key] = [];
183
+ buckets.in[key].push(t);
184
+ }
185
+ }
186
+ let prev = "task_start";
187
+ if (buckets.pre.length > 0) {
188
+ lines.push(` subgraph pre_task["pre-task"]`);
189
+ for (const t of buckets.pre) {
190
+ const id = `t${t.sequence}`;
191
+ const label = toolLabel(t);
192
+ lines.push(` ${id}["${label}"]`);
193
+ lines.push(` class ${id} ${t.success ? "ok" : "fail"}`);
194
+ lines.push(` click ${id} call __toolClick("${t.sequence}")`);
195
+ }
196
+ lines.push(` end`);
197
+ lines.push(` ${prev} --> pre_task`);
198
+ prev = "pre_task";
199
+ }
200
+ for (const o of opMs) {
201
+ const sub = `op${o.op.sequence}`;
202
+ // v0.8.2 (fix/mermaid-readable-labels): replace the raw
203
+ // `<seq>. <title>` label (which leaked technical details like
204
+ // `plan_doc: docs/.../plan.md - 485 - worktree: HEAD <hash> from
205
+ // main`) with the concise emoji + friendly-name + short-desc
206
+ // label produced by getPhaseLabel(). The same logic is in
207
+ // `src/task-detail-mermaid.ts` (server side) so the initial
208
+ // server-rendered label and the client-side re-rendered label
209
+ // stay in sync.
210
+ lines.push(` subgraph ${sub}["${getPhaseLabel(o.op)}"]`);
211
+ lines.push(` direction TB`);
212
+ const own = buckets.in[o.op.sequence] || [];
213
+ if (own.length === 0) {
214
+ lines.push(` ${sub}_body["\uff08\u65e0\u5de5\u5177\u8c03\u7528\uff09"]:::bookend`);
215
+ } else {
216
+ for (const t of own) {
217
+ const id = `t${t.sequence}`;
218
+ const label = toolLabel(t);
219
+ lines.push(` ${id}["${label}"]`);
220
+ lines.push(` class ${id} ${t.success ? "ok" : "fail"}`);
221
+ lines.push(` click ${id} call __toolClick("${t.sequence}")`);
222
+ }
223
+ }
224
+ lines.push(` end`);
225
+ lines.push(` ${prev} --> ${sub}`);
226
+ prev = sub;
227
+ }
228
+ if (buckets.post.length > 0) {
229
+ lines.push(` subgraph post_task["post-task"]`);
230
+ for (const t of buckets.post) {
231
+ const id = `t${t.sequence}`;
232
+ const label = toolLabel(t);
233
+ lines.push(` ${id}["${label}"]`);
234
+ lines.push(` class ${id} ${t.success ? "ok" : "fail"}`);
235
+ lines.push(` click ${id} call __toolClick("${t.sequence}")`);
236
+ }
237
+ lines.push(` end`);
238
+ lines.push(` ${prev} --> post_task`);
239
+ prev = "post_task";
240
+ }
241
+ const endLabel = session.status === "failed" ? `\u274c Task ${session.status}` : (session.status === "completed" ? `\u2705 Task ${session.status}` : "end");
242
+ lines.push(` task_end([${endLabel}]):::runtime`);
243
+ lines.push(` ${prev} --> task_end`);
244
+ return lines.join("\n");
245
+ }
246
+
247
+ // Top-level helpers used by buildMermaidSource. Kept here so both
248
+ // IIFEs can call them via the script-wide closure.
249
+ function toolLabel(t) {
250
+ const head = escapeMermaid(`${t.sequence}. ${t.toolName}${t.hasAttachment ? " \ud83d\udcce" : ""}`);
251
+ return `${head}<br/><small>${t.success ? "ok" : "FAIL"} \u00b7 ${t.durationMs}ms</small>`;
252
+ }
253
+
254
+ function parseIsoToMs(s) {
255
+ if (!s) return 0;
256
+ const d = new Date(s);
257
+ if (!isNaN(d.getTime())) return d.getTime();
258
+ const n = Number(s);
259
+ return Number.isFinite(n) ? n : 0;
260
+ }
261
+
262
+ function escapeMermaid(s) {
263
+ return String(s).replace(/[<>"#]/g, "").replace(/[^A-Za-z0-9_.\-]/g, "_");
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // v0.8.2 (fix/mermaid-readable-labels) — human-readable Mermaid
268
+ // phase labels. This block mirrors `src/task-detail-mermaid.ts` so
269
+ // the client-side Mermaid re-render emits the same labels the
270
+ // server-rendered HTML initially shows.
271
+ //
272
+ // When the two implementations drift, the diagram will visibly
273
+ // "snap" between the two label formats on the first re-render. To
274
+ // detect this in tests, we expose `getPhaseLabel` on `window` and
275
+ // also assert label consistency in `test/mermaid-phase-labels.test.ts`.
276
+ // ---------------------------------------------------------------------------
277
+
278
+ const PHASE_LABELS_CLIENT = {
279
+ "init-task": { emoji: "\ud83d\udccb", name: "\u4efb\u52a1\u7ba1\u7406", description: "task_search + task_create" },
280
+ "setup-worktree": { emoji: "\ud83c\udf3f", name: "Worktree \u9694\u79bb", description: "git worktree add" },
281
+ "plan": { emoji: "\ud83d\udcdd", name: "Plan", description: "brainstorming + writing-plans" },
282
+ "tdd-implement": { emoji: "\ud83e\uddea", name: "TDD \u5faa\u73af", description: "RED \u2192 GREEN \u2192 REFACTOR" },
283
+ "code-review": { emoji: "\ud83d\udd0d", name: "Code Review", description: "BLOCKING/IMPORTANT/MINOR/NIT" },
284
+ "cli-e2e-test": { emoji: "\u2699\ufe0f", name: "CLI E2E", description: "build + test + cli smoke" },
285
+ "final-verify": { emoji: "\u2705", name: "\u6700\u7ec8\u9a8c\u8bc1", description: "8 \u9879 checklist" },
286
+ "merge-to-main": { emoji: "\ud83d\udd00", name: "Merge", description: "auto_merge \u94c1\u5f8b" },
287
+ "summary-report": { emoji: "\ud83d\udcca", name: "\u603b\u7ed3\u62a5\u544a", description: "reports/ summary" },
288
+ "task-complete": { emoji: "\ud83c\udfc1", name: "\u4efb\u52a1\u95ed\u73af", description: "task_complete" },
289
+ };
290
+
291
+ const PHASE_ALIASES_CLIENT = {
292
+ // init-task
293
+ "init-task": "init-task", "init_task": "init-task", "inittask": "init-task",
294
+ "\u4efb\u52a1\u7ba1\u7406": "init-task", "\u4efb\u52a1\u521b\u5efa": "init-task",
295
+ "task create": "init-task", "task-create": "init-task", "create task": "init-task",
296
+ // setup-worktree
297
+ "setup-worktree": "setup-worktree", "setup_worktree": "setup-worktree",
298
+ "setupworktree": "setup-worktree", "worktree": "setup-worktree",
299
+ "worktree \u9694\u79bb": "setup-worktree", "worktree\u9694\u79bb": "setup-worktree",
300
+ "worktree isolation": "setup-worktree",
301
+ // plan
302
+ "plan": "plan", "planning": "plan", "\u8ba1\u5212": "plan",
303
+ "\u8bbe\u8ba1\u4e0e\u8ba1\u5212": "plan", "plan/design": "plan",
304
+ "plan doc": "plan", "planner": "plan",
305
+ // tdd-implement
306
+ "tdd": "tdd-implement", "tdd-implement": "tdd-implement",
307
+ "tdd_implement": "tdd-implement", "tddimplement": "tdd-implement",
308
+ "tdd \u5faa\u73af": "tdd-implement", "tdd cycle": "tdd-implement",
309
+ "red \u2192 green \u2192 refactor": "tdd-implement",
310
+ "red green refactor": "tdd-implement",
311
+ "red\u2192green\u2192refactor": "tdd-implement",
312
+ "implement": "tdd-implement",
313
+ // code-review
314
+ "code-review": "code-review", "code_review": "code-review",
315
+ "codereview": "code-review", "code review": "code-review",
316
+ "review": "code-review", "\u5ba1\u67e5": "code-review",
317
+ // cli-e2e-test
318
+ "cli-e2e-test": "cli-e2e-test", "cli_e2e_test": "cli-e2e-test",
319
+ "clie2etest": "cli-e2e-test", "cli e2e": "cli-e2e-test",
320
+ "cli e2e test": "cli-e2e-test", "e2e": "cli-e2e-test",
321
+ "e2e test": "cli-e2e-test", "smoke": "cli-e2e-test",
322
+ // final-verify
323
+ "final-verify": "final-verify", "final_verify": "final-verify",
324
+ "finalverify": "final-verify", "verify": "final-verify",
325
+ "\u6700\u7ec8\u9a8c\u8bc1": "final-verify", "verification": "final-verify",
326
+ // merge-to-main
327
+ "merge-to-main": "merge-to-main", "merge_to_main": "merge-to-main",
328
+ "mergetomain": "merge-to-main", "merge": "merge-to-main",
329
+ "merge to main": "merge-to-main", "merge-to-main.": "merge-to-main",
330
+ // summary-report
331
+ "summary-report": "summary-report", "summary_report": "summary-report",
332
+ "summaryreport": "summary-report", "summary": "summary-report",
333
+ "\u603b\u7ed3": "summary-report", "\u603b\u7ed3\u62a5\u544a": "summary-report",
334
+ "report": "summary-report",
335
+ // task-complete
336
+ "task-complete": "task-complete", "task_complete": "task-complete",
337
+ "taskcomplete": "task-complete", "\u5b8c\u6210": "task-complete",
338
+ "\u95ed\u73af": "task-complete", "done": "task-complete",
339
+ "completed": "task-complete",
340
+ };
341
+
342
+ const NOISE_FIELD_MARKERS_CLIENT = [
343
+ "plan_doc", "plan_doc:", "plan_doc ", "worktree:", "worktree ", "worktree \u5df2",
344
+ "commits:", "commits_reviewed", "commits reviewed", "commit ", "duration",
345
+ "files_changed", "files changed", "tests_passed", "tests_pass",
346
+ "tests/cli/", "tests/", "test_hello_world", "baseline",
347
+ "modified:", "modified ", "new:",
348
+ "tdd-implement", "tdd_implement", "tdd ", "tdd-",
349
+ "code-review", "code review", "code-review:",
350
+ "(test_", "scripts/", "verify-installed", "verify-installed:",
351
+ "HEAD=", "HEAD:",
352
+ ];
353
+
354
+ /** Normalise a phase-name string for alias lookup. */
355
+ function normaliseAliasKeyClient(raw) {
356
+ return String(raw || "")
357
+ .toLowerCase()
358
+ .replace(/[\u2014\u2013_]+/g, "-")
359
+ .replace(/[()\uFF08\uFF09\u3010\u3011\u3010\u3010\u300C\u300D\u3001,,\.\u3002::;;\uFF1B]/g, " ")
360
+ .replace(/\s+/g, " ")
361
+ .trim();
362
+ }
363
+
364
+ /** Extract `Phase N` + name from a title (matches anywhere in the string). */
365
+ function extractPhaseInfoClient(title) {
366
+ if (!title) return null;
367
+ const m = String(title).match(/phase\s+(\d+)\s*[:\uFF1A(\s]*\s*([^\n:\uFF1A(\)\u3001,\uFF0C]+?)(?=$|\s*(?:[:\uFF1A(\)\u3001,\uFF0C]|\u5b8c\u6210|pass|fail|running|pending|[\r\n]))/i);
368
+ if (!m) return null;
369
+ let rawName = (m[2] || "").trim();
370
+ if (!rawName) return null;
371
+ // Defensive trailing-stop strip.
372
+ for (const stop of ["\u5b8c\u6210", "pass", "fail", "pending", "running", ":", "\uFF1A", "\u2014", "-", "\uFF0C", ",", "\u3002"]) {
373
+ const i = rawName.toLowerCase().lastIndexOf(stop.toLowerCase());
374
+ if (i >= 2 && i >= rawName.length - stop.length - 1) {
375
+ rawName = rawName.substring(0, i).trim();
376
+ }
377
+ }
378
+ if (!rawName) return null;
379
+ const cleaned = rawName.replace(/\s*(\u5b8c\u6210|pass|fail|pending|running)\s*$/i, "").trim();
380
+ return { num: m[1], name: cleaned || rawName };
381
+ }
382
+
383
+ /** Resolve a phase-name string to a canonical phaseId. */
384
+ function resolvePhaseIdClient(phaseName) {
385
+ if (!phaseName) return null;
386
+ if (PHASE_ALIASES_CLIENT[phaseName]) return PHASE_ALIASES_CLIENT[phaseName];
387
+ const norm = normaliseAliasKeyClient(phaseName);
388
+ if (!norm) return null;
389
+ if (PHASE_ALIASES_CLIENT[norm]) return PHASE_ALIASES_CLIENT[norm];
390
+ for (const key of Object.keys(PHASE_ALIASES_CLIENT)) {
391
+ if (normaliseAliasKeyClient(key) === norm) return PHASE_ALIASES_CLIENT[key];
392
+ }
393
+ return null;
394
+ }
395
+
396
+ /** Sanitise a noisy title by cutting at the first known noise marker. */
397
+ function sanitiseTitleForLabelClient(title) {
398
+ if (!title) return "";
399
+ let s = String(title);
400
+ const limit = Math.min(s.length, 80);
401
+ let cutAt = -1;
402
+ // 1. Cut at ": commit " / " commit " (common milestone suffix).
403
+ for (const m of [": commit ", " commit "]) {
404
+ const idx = s.indexOf(m);
405
+ if (idx > 0 && idx < limit && (cutAt < 0 || idx < cutAt)) cutAt = idx;
406
+ }
407
+ // 2. Cut at the first noise-marker after a known separator.
408
+ const seps = [" - ", " \u2014 ", " | ", " (", ": "];
409
+ for (const marker of NOISE_FIELD_MARKERS_CLIENT) {
410
+ for (const sep of seps) {
411
+ const idx = s.indexOf(sep + marker, 0);
412
+ if (idx > 0 && idx < limit && (cutAt < 0 || idx < cutAt)) cutAt = idx;
413
+ }
414
+ }
415
+ // 3. Cut at the first " <commit-hash>" or ": <commit-hash>".
416
+ const hashMatch = s.match(/[:\s]\s*[a-f0-9]{7,}\b/i);
417
+ if (hashMatch && hashMatch.index != null && hashMatch.index < limit && (cutAt < 0 || hashMatch.index < cutAt)) {
418
+ cutAt = hashMatch.index + (hashMatch[0].startsWith(":") ? 1 : 0);
419
+ }
420
+ if (cutAt > 0) s = s.substring(0, cutAt);
421
+ return escapeMermaid(s);
422
+ }
423
+
424
+ /**
425
+ * Build a human-readable Mermaid label for one operation. Mirrors
426
+ * `getPhaseLabel()` in `src/task-detail-mermaid.ts`. The two
427
+ * implementations must stay in sync — drift is detected by
428
+ * `test/mermaid-phase-labels.test.ts`'s renderAndParse round-trip.
429
+ */
430
+ function getPhaseLabel(op) {
431
+ const title = (op && op.title != null ? String(op.title) : "");
432
+ const milestoneType = (op && op.milestoneType != null ? String(op.milestoneType) : "op");
433
+ // 1. Structured "Phase N: <name>" path.
434
+ const info = extractPhaseInfoClient(title);
435
+ if (info) {
436
+ const phaseId = resolvePhaseIdClient(info.name);
437
+ if (phaseId && PHASE_LABELS_CLIENT[phaseId]) {
438
+ const meta = PHASE_LABELS_CLIENT[phaseId];
439
+ return `${meta.emoji} Phase ${info.num}: ${meta.name}<br/>${meta.description}`;
440
+ }
441
+ return `\u2699\ufe0f Phase ${info.num}: ${escapeMermaid(info.name)}`;
442
+ }
443
+ // 2. Try matching the entire (short) title as a phase alias.
444
+ if (title.length <= 60) {
445
+ const fullId = resolvePhaseIdClient(title);
446
+ if (fullId && PHASE_LABELS_CLIENT[fullId]) {
447
+ const meta = PHASE_LABELS_CLIENT[fullId];
448
+ return `${meta.emoji} ${meta.name}<br/>${meta.description}`;
449
+ }
450
+ }
451
+ // 3. Substring scan: only when the title STARTS with a phase keyword.
452
+ const lowerStart = title.toLowerCase();
453
+ const startsWithPhaseHits = [
454
+ ["init-task", "init-task"], ["\u4efb\u52a1\u7ba1\u7406", "init-task"],
455
+ ["setup-worktree", "setup-worktree"], ["worktree", "setup-worktree"],
456
+ ["plan", "plan"], ["\u8ba1\u5212", "plan"], ["\u8bbe\u8ba1\u4e0e\u8ba1\u5212", "plan"],
457
+ ["tdd", "tdd-implement"], ["tdd-implement", "tdd-implement"],
458
+ ["review", "code-review"], ["code review", "code-review"], ["code-review", "code-review"],
459
+ ["cli e2e", "cli-e2e-test"], ["cli-e2e-test", "cli-e2e-test"], ["smoke", "cli-e2e-test"],
460
+ ["verify", "final-verify"], ["\u6700\u7ec8\u9a8c\u8bc1", "final-verify"],
461
+ ["merge-to-main", "merge-to-main"], ["merge to main", "merge-to-main"], ["merge", "merge-to-main"],
462
+ ["summary", "summary-report"], ["\u603b\u7ed3\u62a5\u544a", "summary-report"],
463
+ ["\u603b\u7ed3", "summary-report"], ["\u62a5\u544a", "summary-report"],
464
+ ["\u5b8c\u6210", "task-complete"], ["\u95ed\u73af", "task-complete"],
465
+ ];
466
+ for (const [needle, phaseId] of startsWithPhaseHits) {
467
+ if (lowerStart.startsWith(needle.toLowerCase())) {
468
+ const meta = PHASE_LABELS_CLIENT[phaseId];
469
+ return `${meta.emoji} ${meta.name}<br/>${meta.description}`;
470
+ }
471
+ }
472
+ // 4. Last resort: sanitise + neutral ⚙️.
473
+ const sanitised = sanitiseTitleForLabelClient(title);
474
+ if (sanitised) return `\u2699\ufe0f ${sanitised}`;
475
+ // 5. Absolute fallback: milestoneType.
476
+ return `\u2699\ufe0f ${escapeMermaid(milestoneType || "op")}`;
477
+ }
478
+
479
+ // Expose for tests + any tooling that wants to render the Mermaid
480
+ // source on demand.
481
+ if (typeof window !== "undefined") {
482
+ window.buildMermaidSource = buildMermaidSource;
483
+ window.getPhaseLabel = getPhaseLabel;
484
+ }
485
+ /**
486
+ * v0.8.0+ Mermaid ↔ tool-table bridge.
487
+ *
488
+ * The Mermaid diagram emits `click tN __toolClick("N")` callbacks for
489
+ * every tool node. We expose a single global `window.__toolClick` that:
490
+ * 1. Locates the row in `table.toolcalls` with `data-tool-id="N"`.
491
+ * 2. If the row is collapsed, opens the input/result <details>.
492
+ * 3. Scrolls the row into view (smooth, centered).
493
+ * 4. Adds the `highlight` class for 2 seconds so the user sees a
494
+ * yellow flash pointing at the row.
495
+ *
496
+ * If the page has no `table.toolcalls` (e.g. the index page), the
497
+ * function is a no-op. We also defensively no-op on invalid IDs so
498
+ * Mermaid can never break the page by firing a stale callback.
499
+ */
500
+ (function attachToolClickBridge() {
501
+ function scrollAndHighlight(toolId) {
502
+ const id = String(toolId ?? "").trim();
503
+ if (!id) return;
504
+ const row = document.querySelector(`table.toolcalls tr[data-tool-id="${CSS.escape(id)}"]`);
505
+ if (!row) return;
506
+ // Expand the row's input/result <details> so the user can see the
507
+ // payload without an extra click.
508
+ row.querySelectorAll("details.tool-detail").forEach((d) => {
509
+ d.open = true;
510
+ });
511
+ // Scroll into view (smooth, centered). Use block:'center' so the
512
+ // row is roughly in the middle of the viewport.
513
+ try {
514
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
515
+ } catch {
516
+ // Old browsers — try the synchronous variant.
517
+ try { row.scrollIntoView(); } catch (_) { /* noop */ }
518
+ }
519
+ // Flash highlight.
520
+ row.classList.add("highlight");
521
+ setTimeout(() => {
522
+ row.classList.remove("highlight");
523
+ }, 2000);
524
+ }
525
+
526
+ // Expose the global callback. Mermaid emits `click t1 __toolClick("1")`,
527
+ // so the function name is fixed and the string is the tool sequence id.
528
+ window.__toolClick = function (toolId) {
529
+ try {
530
+ scrollAndHighlight(toolId);
531
+ } catch (err) {
532
+ // Never let a click callback throw — Mermaid would log the error
533
+ // and the user would see a broken interaction.
534
+ // eslint-disable-next-line no-console
535
+ console.warn("[task-show] __toolClick failed:", err && err.message || err);
536
+ }
537
+ };
538
+
539
+ /**
540
+ * v0.8.0+ Mermaid re-render hook. When `task-operations.js` finishes
541
+ * a fetch it fires `task-show:lifecycle-ops-loaded`. We re-build the
542
+ * Mermaid source with the freshly-loaded operations so the diagram
543
+ * goes from the linear placeholder to the hierarchical view.
544
+ *
545
+ * We don't have direct access to the live `session` here (the SSE
546
+ * controller owns that), so we trigger a re-render via the same code
547
+ * path the SSE handler uses. If the SSE handler hasn't run yet (no
548
+ * tool calls), we still update the diagram from the operations-only
549
+ * path so the user immediately sees the hierarchy.
550
+ */
551
+ window.addEventListener("task-show:lifecycle-ops-loaded", () => {
552
+ const mermaidDiv = document.querySelector(".mermaid");
553
+ if (!mermaidDiv) return;
554
+ const ops = (window.__lifecycleOps && window.__lifecycleOps.operations) || [];
555
+ // Build a minimal session stub from the operations cache (we don't
556
+ // have the live tool calls here, but for a freshly-loaded page the
557
+ // server-rendered Mermaid is already in the DOM, so the SSE path
558
+ // will reconcile later). For the "operations-only" rebuild we
559
+ // synthesise a tiny session so buildMermaidSource can be called.
560
+ const banner = document.querySelector("[data-live-refresh][data-task-id]");
561
+ const taskId = banner ? Number(banner.getAttribute("data-task-id")) : 0;
562
+ if (!Number.isFinite(taskId) || taskId <= 0) return;
563
+ // Trigger SSE's applyTaskUpdate by dispatching a fake "snapshot"
564
+ // event with the operations (we don't have one, so just re-render
565
+ // directly with an empty session — the SSE handler will replace
566
+ // this on the first real event).
567
+ // Simplest: directly call the controller's update path.
568
+ const session = (window.__pipelineController && window.__pipelineController.lastSession) || {
569
+ taskId,
570
+ toolCalls: [],
571
+ status: "running",
572
+ };
573
+ if (!session.toolCalls || session.toolCalls.length === 0) {
574
+ // No SSE has fired yet — fetch /api/sessions/:taskId so we know
575
+ // what tools belong to the diagram.
576
+ fetch(`/api/sessions/${taskId}`, { headers: { Accept: "application/json" } })
577
+ .then((r) => (r.ok ? r.json() : null))
578
+ .then((s) => {
579
+ if (!s) return;
580
+ const source = buildMermaidSource(s, ops);
581
+ if (window.MermaidRenderer && window.MermaidRenderer.createMermaidController) {
582
+ if (!window.__mermaidController) {
583
+ window.__mermaidController = window.MermaidRenderer.createMermaidController({
584
+ idPrefix: "lifecycle-" + taskId,
585
+ });
586
+ }
587
+ window.__mermaidController.update(mermaidDiv, source);
588
+ } else {
589
+ mermaidDiv.textContent = source;
590
+ }
591
+ })
592
+ .catch(() => {
593
+ // Network down — leave the current diagram in place.
594
+ });
595
+ return;
596
+ }
597
+ const source = buildMermaidSource(session, ops);
598
+ if (window.MermaidRenderer && window.MermaidRenderer.createMermaidController) {
599
+ if (!window.__mermaidController) {
600
+ window.__mermaidController = window.MermaidRenderer.createMermaidController({
601
+ idPrefix: "lifecycle-" + taskId,
602
+ });
603
+ }
604
+ window.__mermaidController.update(mermaidDiv, source);
605
+ } else {
606
+ mermaidDiv.textContent = source;
607
+ }
608
+ });
609
+ })();
610
+
107
611
  /**
108
612
  * Initialize a live timeline on the per-task page (when `data-task-id` is
109
613
  * present). We re-render the timeline on every `tool.recorded` event.
@@ -142,6 +646,15 @@
142
646
  }
143
647
 
144
648
  function applyTaskUpdate(session) {
649
+ // Expose the latest session for the Mermaid re-render hook
650
+ // (task-show:lifecycle-ops-loaded). The hook can use this to
651
+ // re-build the diagram with both the latest tool calls and the
652
+ // latest operations.
653
+ if (window.__pipelineController) {
654
+ window.__pipelineController.lastSession = session;
655
+ } else {
656
+ window.__pipelineController = { lastSession: session };
657
+ }
145
658
  // Update status badge.
146
659
  const badge = document.querySelector(".badge");
147
660
  if (badge) {
@@ -187,7 +700,12 @@
187
700
  // Re-render mermaid diagram.
188
701
  const mermaidDiv = document.querySelector(".mermaid");
189
702
  if (mermaidDiv) {
190
- const source = buildMermaidSource(session);
703
+ // v0.8.0+: prefer the latest operations fetched by
704
+ // task-operations.js (window.__lifecycleOps) so the Mermaid shows
705
+ // the hierarchical view. Falls back to the linear chain when no
706
+ // operations are loaded yet.
707
+ const ops = (window.__lifecycleOps && window.__lifecycleOps.operations) || null;
708
+ const source = buildMermaidSource(session, ops);
191
709
  // The controller handles mermaid availability, async race protection,
192
710
  // error fallback, and idempotency. If Mermaid hasn't loaded yet
193
711
  // (window.MermaidRenderer is undefined), we fall back to writing the
@@ -250,15 +768,27 @@
250
768
  : (call.outputPreview.slice(0, 240) + (call.outputPreview.length > 240 ? "…" : ""));
251
769
  const status = call.success ? "ok" : "fail";
252
770
  const attach = call.hasAttachment ? ' <span class="attach">📎</span>' : "";
771
+ // v0.8.0: `data-tool-id` lets the Mermaid `__toolClick` callback
772
+ // scroll-into-view + highlight the right row. Default state is
773
+ // `collapsed` (input/output `<details>` are closed) so long-running
774
+ // tasks don't drown the page.
253
775
  return (
254
- `<tr class="row-${status}" data-args="${escapeHtmlAttr(argsJson)}">` +
776
+ `<tr class="tool-row row-${status} collapsed" data-tool-id="${call.sequence}" data-args="${escapeHtmlAttr(argsJson)}">` +
255
777
  `<td class="seq">${call.sequence}</td>` +
256
778
  `<td><code>${escapeHtml(call.toolName)}</code>${attach}</td>` +
257
779
  `<td class="status status-${status}">${status}</td>` +
258
780
  `<td>${call.durationMs}</td>` +
259
781
  `<td>${escapeHtml(formatTs(call.timestamp))}</td>` +
260
- `<td class="args">${escapeHtml(argsJson)}</td>` +
261
- `<td class="result">${escapeHtml(preview)}</td>` +
782
+ `<td class="args-cell">` +
783
+ `<details class="tool-detail"><summary>show args</summary>` +
784
+ `<pre class="json">${escapeHtml(argsJson)}</pre>` +
785
+ `</details>` +
786
+ `</td>` +
787
+ `<td class="result-cell">` +
788
+ `<details class="tool-detail"><summary>show result</summary>` +
789
+ `<pre class="json">${escapeHtml(preview)}</pre>` +
790
+ `</details>` +
791
+ `</td>` +
262
792
  `</tr>`
263
793
  );
264
794
  }
@@ -276,32 +806,6 @@
276
806
  return out;
277
807
  }
278
808
 
279
- function buildMermaidSource(session) {
280
- const lines = [`flowchart TD`];
281
- lines.push(` classDef ok fill:#dcfce7,stroke:#16a34a,color:#064e3b`);
282
- lines.push(` classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d`);
283
- lines.push(` classDef runtime fill:#dbeafe,stroke:#2563eb,color:#1e3a8a`);
284
- lines.push(` Start([Task #${session.taskId} start]):::runtime`);
285
- let prev = "Start";
286
- if (session.toolCalls.length === 0) {
287
- lines.push(` End([end]):::runtime`);
288
- lines.push(` Start --> End`);
289
- return lines.join("\n");
290
- }
291
- session.toolCalls.forEach((call) => {
292
- const nodeId = `n${call.sequence}`;
293
- const label = `${call.sequence}. ${escapeMermaid(call.toolName)}${call.hasAttachment ? " 📎" : ""}`;
294
- lines.push(` ${nodeId}["${label}<br/><small>${call.durationMs}ms · ${call.success ? "ok" : "FAIL"}</small>"]`);
295
- lines.push(` class ${nodeId} ${call.success ? "ok" : "fail"}`);
296
- lines.push(` ${prev} --> ${nodeId}`);
297
- prev = nodeId;
298
- });
299
- const endLabel = session.status === "failed" ? `❌ Task ${session.status}` : `✅ Task ${session.status}`;
300
- lines.push(` End([${endLabel}]):::runtime`);
301
- lines.push(` ${prev} --> End`);
302
- return lines.join("\n");
303
- }
304
-
305
809
  function formatTs(ms) {
306
810
  try {
307
811
  return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
@@ -323,10 +827,6 @@
323
827
  return clone;
324
828
  }
325
829
 
326
- function escapeMermaid(s) {
327
- return String(s).replace(/[<>"#]/g, "").replace(/[^A-Za-z0-9_.\-]/g, "_");
328
- }
329
-
330
830
  function escapeHtml(s) {
331
831
  return String(s)
332
832
  .replace(/&/g, "&amp;")