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

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,321 @@
1
+ /* ------------------------------------------------------------------------- */
2
+ /* roy-plugin-task-show — Mermaid renderer controller (v0.6.11) */
3
+ /* ------------------------------------------------------------------------- */
4
+ /**
5
+ * Self-contained controller that wraps Mermaid rendering for the per-task
6
+ * visualization page. Replaces the brittle `mermaid.run({nodes})` approach
7
+ * that caused the v0.6.10 regression where the SVG was replaced with raw
8
+ * `flowchart TD` source on every async update.
9
+ *
10
+ * Public API (browser global `window.MermaidRenderer` and ES module export):
11
+ *
12
+ * createMermaidController({
13
+ * getMermaid?: () => any, // optional override for tests
14
+ * idPrefix?: string, // default "mmr"
15
+ * onStateChange?: (container, state) => void,
16
+ * }) -> {
17
+ * mount(container, source): Promise<void> // initial render (idempotent)
18
+ * update(container, source): Promise<void> // re-render with new source
19
+ * showError(container, source, error): void // user-visible error fallback
20
+ * getState(container): "rendered" | "error" | "idle" | "loading"
21
+ * destroy(): void // cancel all pending renders
22
+ * }
23
+ *
24
+ * Why this exists
25
+ * ---------------
26
+ * `mermaid.run({nodes})` does NOT reliably re-process an already-rendered
27
+ * node. Once Mermaid has replaced the `.mermaid` container with SVG, calling
28
+ * `run()` on that node again can silently no-op (or worse, leave the
29
+ * container holding the raw source we just wrote with `innerHTML`). The fix
30
+ * is to (1) call `mermaid.render(id, source)` per update with a unique id,
31
+ * (2) swap the *outer* container's child with the freshly produced SVG,
32
+ * (3) catch async rejections so the user sees an error block instead of raw
33
+ * text, and (4) drop the result of any render that completes after a newer
34
+ * update has superseded it.
35
+ *
36
+ * The controller is intentionally framework-free and is loaded by
37
+ * `public/app.js` via a regular `<script>` tag. It also exports its factory
38
+ * for unit tests.
39
+ */
40
+
41
+ (function (root, factory) {
42
+ const api = factory();
43
+ if (typeof module === "object" && module.exports) {
44
+ module.exports = api;
45
+ }
46
+ if (typeof root === "object" && root) {
47
+ root.MermaidRenderer = api;
48
+ }
49
+ // ESM export path — Bun / Rollup / esbuild pick this up.
50
+ if (typeof exports !== "undefined") {
51
+ exports.createMermaidController = api.createMermaidController;
52
+ }
53
+ })(typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : this, function () {
54
+ "use strict";
55
+
56
+ const DEFAULT_ID_PREFIX = "mmr";
57
+
58
+ function escapeHtml(s) {
59
+ return String(s)
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;")
63
+ .replace(/"/g, "&quot;")
64
+ .replace(/'/g, "&#39;");
65
+ }
66
+
67
+ function getMermaidFromGlobal() {
68
+ if (typeof window !== "undefined" && window.mermaid) return window.mermaid;
69
+ if (typeof globalThis !== "undefined" && globalThis.mermaid) return globalThis.mermaid;
70
+ return undefined;
71
+ }
72
+
73
+ function setState(container, state) {
74
+ if (!container) return;
75
+ if (container.__mermaidRendererState !== state) {
76
+ container.__mermaidRendererState = state;
77
+ container.setAttribute("data-mermaid-state", state);
78
+ }
79
+ }
80
+
81
+ function getState(container) {
82
+ return (container && container.__mermaidRendererState) || "idle";
83
+ }
84
+
85
+ function clearChildren(container) {
86
+ // happy-dom and jsdom both support `replaceChildren()`; fall back to
87
+ // manual removal for older runtimes.
88
+ if (typeof container.replaceChildren === "function") {
89
+ container.replaceChildren();
90
+ return;
91
+ }
92
+ while (container.firstChild) container.removeChild(container.firstChild);
93
+ }
94
+
95
+ function renderErrorBlock(container, source, message) {
96
+ clearChildren(container);
97
+ const wrap = container.ownerDocument.createElement("div");
98
+ wrap.className = "mermaid-error";
99
+ wrap.setAttribute("role", "alert");
100
+ wrap.innerHTML =
101
+ `<div class="mermaid-error-header">⚠️ Mermaid render failed</div>` +
102
+ `<div class="mermaid-error-msg">${escapeHtml(message || "Unknown error")}</div>` +
103
+ `<details class="mermaid-error-source">` +
104
+ `<summary>Show source</summary>` +
105
+ `<pre>${escapeHtml(source || "")}</pre>` +
106
+ `</details>`;
107
+ container.appendChild(wrap);
108
+ setState(container, "error");
109
+ }
110
+
111
+ function renderUnavailable(container, source) {
112
+ renderErrorBlock(
113
+ container,
114
+ source,
115
+ "Mermaid library not loaded (window.mermaid is undefined).",
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Build the controller.
121
+ *
122
+ * @param {object} [opts]
123
+ * @param {() => any} [opts.getMermaid] - returns the mermaid library or undefined.
124
+ * @param {string} [opts.idPrefix] - prefix used to make render IDs unique.
125
+ * @param {(container, state) => void} [opts.onStateChange]
126
+ */
127
+ function createMermaidController(opts) {
128
+ const cfg = opts || {};
129
+ const idPrefix = cfg.idPrefix || DEFAULT_ID_PREFIX;
130
+ const getMermaid = cfg.getMermaid || getMermaidFromGlobal;
131
+ const onStateChange = typeof cfg.onStateChange === "function" ? cfg.onStateChange : null;
132
+
133
+ // Track every container this controller has touched so destroy() can
134
+ // iterate and mark each as destroyed. We use a Set so containers can be
135
+ // garbage-collected after they leave the DOM (we also drop them on
136
+ // destroy()).
137
+ const containers = new Set();
138
+ let controllerDestroyed = false;
139
+
140
+ // Per-container state: latest requested source, in-flight render token,
141
+ // sequence counter for unique IDs. We keep this in a WeakMap so we don't
142
+ // leak references after the container is removed.
143
+ const inflight = new WeakMap(); // container -> { token: number, source: string, destroyed: boolean, lastRenderedSource: string }
144
+ const seq = new WeakMap(); // container -> number
145
+
146
+ function nextSeq(container) {
147
+ const cur = seq.get(container) || 0;
148
+ const next = cur + 1;
149
+ seq.set(container, next);
150
+ return next;
151
+ }
152
+
153
+ function beginRender(container) {
154
+ const cur = inflight.get(container) || { token: 0, source: "", destroyed: false, lastRenderedSource: null };
155
+ const token = cur.token + 1;
156
+ inflight.set(container, { token, source: cur.source, destroyed: cur.destroyed, lastRenderedSource: cur.lastRenderedSource });
157
+ return token;
158
+ }
159
+
160
+ function getToken(container) {
161
+ const cur = inflight.get(container);
162
+ return cur ? cur.token : 0;
163
+ }
164
+
165
+ function markDestroyed(container) {
166
+ const cur = inflight.get(container);
167
+ if (cur) inflight.set(container, { token: cur.token, source: cur.source, destroyed: true, lastRenderedSource: cur.lastRenderedSource });
168
+ }
169
+
170
+ function isDestroyed(container) {
171
+ const cur = inflight.get(container);
172
+ return !!(cur && cur.destroyed);
173
+ }
174
+
175
+ function rememberRenderedSource(container, source) {
176
+ const cur = inflight.get(container);
177
+ if (cur) inflight.set(container, { token: cur.token, source: cur.source, destroyed: cur.destroyed, lastRenderedSource: source });
178
+ }
179
+
180
+ function getLastRenderedSource(container) {
181
+ const cur = inflight.get(container);
182
+ return cur ? cur.lastRenderedSource : null;
183
+ }
184
+
185
+ /**
186
+ * Render `source` into `container`. Returns a Promise that resolves when
187
+ * the container reflects the latest source (either an SVG or an error
188
+ * block). If a newer call supersedes this one before the mermaid.render
189
+ * Promise resolves, the result is dropped (no DOM mutation).
190
+ */
191
+ async function renderInternal(container, source) {
192
+ // Idempotency: if the controller was destroyed or the container has
193
+ // already successfully rendered this exact source, skip work entirely.
194
+ if (controllerDestroyed) return;
195
+ if (isDestroyed(container)) return;
196
+ if (typeof source !== "string") source = String(source == null ? "" : source);
197
+ if (getLastRenderedSource(container) === source && getState(container) === "rendered") {
198
+ // Same source, already rendered — nothing to do.
199
+ return;
200
+ }
201
+ containers.add(container);
202
+
203
+ const mermaid = getMermaid();
204
+ if (!mermaid || typeof mermaid.render !== "function") {
205
+ renderUnavailable(container, source);
206
+ if (onStateChange) onStateChange(container, "error");
207
+ return;
208
+ }
209
+
210
+ const myToken = beginRender(container);
211
+ setState(container, "loading");
212
+ const seqNum = nextSeq(container);
213
+ const id = `${idPrefix}-${seqNum}`;
214
+ let result;
215
+ try {
216
+ result = await mermaid.render(id, source);
217
+ } catch (err) {
218
+ // Only surface this error if the user hasn't superseded us.
219
+ if (controllerDestroyed || isDestroyed(container) || getToken(container) !== myToken) return;
220
+ renderErrorBlock(
221
+ container,
222
+ source,
223
+ (err && err.message) || String(err),
224
+ );
225
+ if (onStateChange) onStateChange(container, "error");
226
+ return;
227
+ }
228
+ // Drop stale results: a newer render has already happened, the
229
+ // container was destroyed, or the controller was torn down.
230
+ if (controllerDestroyed || isDestroyed(container) || getToken(container) !== myToken) return;
231
+ const svg = result && result.svg;
232
+ if (!svg) {
233
+ renderErrorBlock(container, source, "mermaid.render returned no svg");
234
+ if (onStateChange) onStateChange(container, "error");
235
+ return;
236
+ }
237
+ // Insert the new SVG. We use a wrapper so happy-dom/jsdom see a single
238
+ // child swap and so subsequent state checks (querySelector("svg"))
239
+ // remain correct.
240
+ clearChildren(container);
241
+ const doc = container.ownerDocument;
242
+ const range = doc.createRange
243
+ ? doc.createRange()
244
+ : null;
245
+ let frag;
246
+ try {
247
+ if (range) {
248
+ range.selectNodeContents(doc.createElement("div"));
249
+ frag = range.createContextualFragment(svg);
250
+ } else {
251
+ // Last resort: parse as HTML via DOMParser.
252
+ const parser = new (doc.defaultView.DOMParser || (doc.defaultView && doc.defaultView.DOMParser))(
253
+ "text/html",
254
+ );
255
+ const doc2 = parser.parseFromString(`<body>${svg}</body>`, "text/html");
256
+ frag = doc.createDocumentFragment
257
+ ? doc.createDocumentFragment()
258
+ : null;
259
+ if (frag && doc2 && doc2.body) {
260
+ while (doc2.body.firstChild) frag.appendChild(doc2.body.firstChild);
261
+ }
262
+ }
263
+ } catch (_) {
264
+ frag = null;
265
+ }
266
+ if (frag && frag.childNodes && frag.childNodes.length > 0) {
267
+ // Defensive: cap to 4 nodes to avoid pathological growth.
268
+ const nodes = Array.from(frag.childNodes).slice(0, 4);
269
+ for (const n of nodes) container.appendChild(n);
270
+ } else {
271
+ // Fallback: write innerHTML directly (acceptable in real browsers).
272
+ container.innerHTML = svg;
273
+ }
274
+ setState(container, "rendered");
275
+ rememberRenderedSource(container, source);
276
+ if (onStateChange) onStateChange(container, "rendered");
277
+ // Bind functions if mermaid provided any (e.g., for click handlers).
278
+ if (typeof result.bindFunctions === "function") {
279
+ try {
280
+ result.bindFunctions(container);
281
+ } catch (_) {
282
+ // ignore — bind failures are non-fatal
283
+ }
284
+ }
285
+ }
286
+
287
+ return {
288
+ mount(container, source) {
289
+ // Idempotent: calling mount twice on the same container should not
290
+ // duplicate SVG. We treat mount and update identically from the
291
+ // rendering pipeline's perspective; the contract difference is only
292
+ // intent (mount for first paint, update for re-renders).
293
+ return renderInternal(container, source);
294
+ },
295
+ update(container, source) {
296
+ return renderInternal(container, source);
297
+ },
298
+ showError(container, source, error) {
299
+ renderErrorBlock(container, source, error && error.message ? error.message : String(error));
300
+ if (onStateChange) onStateChange(container, "error");
301
+ },
302
+ getState,
303
+ destroy() {
304
+ controllerDestroyed = true;
305
+ // Mark every known container as destroyed so any in-flight render
306
+ // for them is dropped. We can't iterate WeakMaps, but we *can*
307
+ // iterate the Set we maintain.
308
+ const list = Array.from(containers);
309
+ for (const c of list) markDestroyed(c);
310
+ containers.clear();
311
+ },
312
+ /** Destroy state for a single container. */
313
+ destroyContainer(container) {
314
+ markDestroyed(container);
315
+ containers.delete(container);
316
+ },
317
+ };
318
+ }
319
+
320
+ return { createMermaidController };
321
+ });
package/public/style.css CHANGED
@@ -252,6 +252,51 @@ table.toolcalls .result {
252
252
  overflow-x: auto;
253
253
  }
254
254
 
255
+ /* v0.6.11: error fallback when Mermaid rendering fails (or Mermaid library
256
+ is unavailable). The error block replaces the bare raw `flowchart TD`
257
+ source so the user always sees a clear, actionable state. */
258
+ .mermaid-error {
259
+ background: #fef2f2;
260
+ border: 1px solid #fca5a5;
261
+ border-radius: 6px;
262
+ padding: 12px 14px;
263
+ color: #7f1d1d;
264
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
265
+ }
266
+
267
+ .mermaid-error-header {
268
+ font-weight: 600;
269
+ margin-bottom: 6px;
270
+ }
271
+
272
+ .mermaid-error-msg {
273
+ font-size: 13px;
274
+ margin-bottom: 8px;
275
+ word-break: break-word;
276
+ }
277
+
278
+ .mermaid-error-source {
279
+ margin-top: 6px;
280
+ }
281
+
282
+ .mermaid-error-source summary {
283
+ cursor: pointer;
284
+ font-size: 12px;
285
+ color: #991b1b;
286
+ }
287
+
288
+ .mermaid-error-source pre {
289
+ background: #fff;
290
+ border: 1px solid #fca5a5;
291
+ border-radius: 4px;
292
+ padding: 8px;
293
+ font-size: 11px;
294
+ color: #1f2937;
295
+ max-height: 240px;
296
+ overflow: auto;
297
+ white-space: pre-wrap;
298
+ }
299
+
255
300
  .hint {
256
301
  margin-top: 8px;
257
302
  font-size: 12px;
@@ -285,4 +330,198 @@ details summary {
285
330
  padding: 14px 14px;
286
331
  margin: 14px 10px;
287
332
  }
288
- }
333
+ }
334
+ /* ------------------------------------------------------------------------- */
335
+ /* Task lifecycle pipeline (v0.7.0+) */
336
+ /* ------------------------------------------------------------------------- */
337
+
338
+ .panel-pipeline {
339
+ /* keeps the existing panel rhythm */
340
+ }
341
+
342
+ .op-timeline {
343
+ list-style: none;
344
+ margin: 0;
345
+ padding: 0;
346
+ position: relative;
347
+ }
348
+
349
+ .op-timeline::before {
350
+ content: "";
351
+ position: absolute;
352
+ left: 11px;
353
+ top: 6px;
354
+ bottom: 6px;
355
+ width: 2px;
356
+ background: var(--border);
357
+ }
358
+
359
+ .op-node {
360
+ position: relative;
361
+ padding: 6px 0 14px 36px;
362
+ list-style: none;
363
+ }
364
+
365
+ .op-bullet {
366
+ position: absolute;
367
+ left: 4px;
368
+ top: 12px;
369
+ width: 16px;
370
+ height: 16px;
371
+ border-radius: 50%;
372
+ background: var(--panel-2);
373
+ border: 2px solid var(--border);
374
+ box-shadow: 0 0 0 3px var(--bg);
375
+ }
376
+
377
+ .op-body {
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: 6px;
381
+ }
382
+
383
+ .op-row1 {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 10px;
387
+ flex-wrap: wrap;
388
+ }
389
+
390
+ .op-seq {
391
+ color: var(--muted);
392
+ font-variant-numeric: tabular-nums;
393
+ min-width: 2.5em;
394
+ }
395
+
396
+ .op-time {
397
+ margin-left: auto;
398
+ color: var(--muted);
399
+ font-size: 12px;
400
+ font-variant-numeric: tabular-nums;
401
+ }
402
+
403
+ .op-title {
404
+ font-weight: 600;
405
+ }
406
+
407
+ .op-badge {
408
+ display: inline-block;
409
+ font-size: 11px;
410
+ padding: 2px 8px;
411
+ border-radius: 10px;
412
+ border: 1px solid var(--border);
413
+ background: var(--panel-2);
414
+ color: var(--text);
415
+ text-transform: uppercase;
416
+ letter-spacing: 0.04em;
417
+ }
418
+
419
+ .op-details {
420
+ background: var(--panel-2);
421
+ border: 1px solid var(--border);
422
+ border-radius: 6px;
423
+ padding: 8px 10px;
424
+ }
425
+
426
+ .op-details summary {
427
+ cursor: pointer;
428
+ color: var(--muted);
429
+ font-size: 12px;
430
+ }
431
+
432
+ .op-desc {
433
+ white-space: pre-wrap;
434
+ word-break: break-word;
435
+ font-size: 13px;
436
+ line-height: 1.5;
437
+ margin-top: 6px;
438
+ }
439
+
440
+ .op-proc {
441
+ margin-top: 6px;
442
+ font-size: 12px;
443
+ color: var(--muted);
444
+ }
445
+
446
+ .op-proc-label {
447
+ display: inline-block;
448
+ background: var(--panel);
449
+ border: 1px solid var(--border);
450
+ padding: 0 6px;
451
+ border-radius: 4px;
452
+ margin-right: 4px;
453
+ }
454
+
455
+ /* Status colors per canonical milestone type */
456
+ .op-status-create .op-bullet { background: #1e40af; border-color: #3b82f6; }
457
+ .op-status-create .op-badge { background: #1e3a8a; color: #dbeafe; border-color: #3b82f6; }
458
+
459
+ .op-status-progress .op-bullet { background: #0e7490; border-color: #06b6d4; }
460
+ .op-status-progress .op-badge { background: #164e63; color: #cffafe; border-color: #06b6d4; }
461
+
462
+ .op-status-milestone .op-bullet { background: #15803d; border-color: #22c55e; }
463
+ .op-status-milestone .op-badge { background: #14532d; color: #dcfce7; border-color: #22c55e; }
464
+
465
+ .op-status-problem .op-bullet { background: #b91c1c; border-color: #ef4444; }
466
+ .op-status-problem .op-badge { background: #7f1d1d; color: #fee2e2; border-color: #ef4444; }
467
+
468
+ .op-status-solution .op-bullet { background: #a16207; border-color: #f59e0b; }
469
+ .op-status-solution .op-badge { background: #78350f; color: #fef3c7; border-color: #f59e0b; }
470
+
471
+ .op-status-decision .op-bullet { background: #6d28d9; border-color: #8b5cf6; }
472
+ .op-status-decision .op-badge { background: #4c1d95; color: #ede9fe; border-color: #8b5cf6; }
473
+
474
+ .op-status-review .op-bullet { background: #0f766e; border-color: #14b8a6; }
475
+ .op-status-review .op-badge { background: #134e4a; color: #ccfbf1; border-color: #14b8a6; }
476
+
477
+ .op-status-completed .op-bullet { background: #166534; border-color: #22c55e; }
478
+ .op-status-completed .op-badge { background: #14532d; color: #dcfce7; border-color: #22c55e; }
479
+
480
+ .op-status-unknown .op-bullet { background: #475569; border-color: #94a3b8; }
481
+ .op-status-unknown .op-badge { background: #334155; color: #e2e8f0; border-color: #94a3b8; }
482
+
483
+ .pipeline-stale {
484
+ display: inline-block;
485
+ background: var(--warn);
486
+ color: #1f2937;
487
+ padding: 1px 8px;
488
+ border-radius: 8px;
489
+ font-size: 11px;
490
+ margin-left: 8px;
491
+ }
492
+
493
+ .pipeline-error {
494
+ background: rgba(239, 68, 68, 0.08);
495
+ border: 1px solid var(--bad);
496
+ border-radius: 6px;
497
+ padding: 10px 12px;
498
+ display: flex;
499
+ align-items: center;
500
+ gap: 12px;
501
+ }
502
+
503
+ .btn {
504
+ background: var(--panel-2);
505
+ color: var(--text);
506
+ border: 1px solid var(--border);
507
+ border-radius: 6px;
508
+ padding: 6px 12px;
509
+ cursor: pointer;
510
+ font-size: 13px;
511
+ }
512
+
513
+ .btn-retry:hover {
514
+ border-color: var(--accent);
515
+ color: var(--accent);
516
+ }
517
+
518
+ @media (max-width: 800px) {
519
+ .op-row1 {
520
+ flex-direction: column;
521
+ align-items: flex-start;
522
+ gap: 4px;
523
+ }
524
+ .op-time {
525
+ margin-left: 0;
526
+ }
527
+ }