@ai-setting/roy-plugin-task-show 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * @fileoverview Per-task tool-call detail layer (v2.0.0).
3
+ *
4
+ * This module is the SSR-side companion to the client-side
5
+ * `public/tool-call-detail.js`. It owns:
6
+ *
7
+ * 1. **`computeDiff(oldStr, newStr)`** — LCS-based line diff that
8
+ * replaces the v1.2.0 naive split-and-filter `renderDiffPanel()`
9
+ * (`src/server.ts:1109`). The naive version mis-tagged context
10
+ * lines as +/- whenever a duplicate line existed elsewhere in the
11
+ * file. v2.0.0 uses a proper LCS table to identify the longest
12
+ * common subsequence and only flags lines that actually leave or
13
+ * enter the file.
14
+ *
15
+ * 2. **`renderToolCallDetail(call, opts)`** — the SSR HTML for the
16
+ * right-hand detail panel. Replaces the inline `renderDiffPanel`
17
+ * call inside the tool-row renderer. The HTML carries:
18
+ * - `<header class="tool-call-header">` with tool name + status,
19
+ * - `<div class="monaco-editor" data-file-path="...">` for the
20
+ * Monaco editor (only when a path is known),
21
+ * - `<ol class="diff-body">` with LCS-marked lines.
22
+ *
23
+ * 3. **`extractToolCallFiles(call)`** — the canonical "what file is
24
+ * this tool call touching?" helper. Mirrors `extractArgPath()` in
25
+ * `src/server.ts` but returns the *first* match (used by the
26
+ * detail renderer; the file-tree uses `extractAffectedPaths()`
27
+ * from `src/file-tree.ts` for the *set*).
28
+ *
29
+ * 4. **`summarizeToolCalls(toolCalls)`** — produces a per-tool
30
+ * aggregate used by the stats chip strip at the top of the
31
+ * detail page. Previously this lived inline inside `renderTaskPage`;
32
+ * v2.0.0 extracts it for unit testing.
33
+ *
34
+ * Design doc: §4 (data flow) and §5.1 (component breakdown). The HTML
35
+ * output is XSS-safe: every user-supplied string is run through
36
+ * `escapeHtml()` before injection.
37
+ */
38
+ // ---------------------------------------------------------------------------
39
+ // Constants
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Path-key aliases — must mirror `extractArgPath()` in `src/server.ts`
43
+ * AND `PATH_KEY_ALIASES` in `src/file-tree.ts`. When adding a new
44
+ * alias, update all three sites.
45
+ */
46
+ const PATH_KEY_ALIASES = [
47
+ "file_path",
48
+ "filepath",
49
+ "path",
50
+ "target_file",
51
+ "uri",
52
+ "filename",
53
+ ];
54
+ /**
55
+ * Diff-field aliases — same aliases the v1.2.0 server looked at when
56
+ * deciding whether to render a diff. Order matters: the FIRST
57
+ * non-undefined value wins for each side.
58
+ */
59
+ const DIFF_OLD_ALIASES = [
60
+ "old_string",
61
+ "oldText",
62
+ "from",
63
+ "before",
64
+ ];
65
+ const DIFF_NEW_ALIASES = [
66
+ "new_string",
67
+ "newText",
68
+ "to",
69
+ "after",
70
+ ];
71
+ // ---------------------------------------------------------------------------
72
+ // Diff algorithm
73
+ // ---------------------------------------------------------------------------
74
+ /**
75
+ * Compute an LCS-based line diff between `oldStr` and `newStr`.
76
+ *
77
+ * Algorithm: classic O(N*M) dynamic-programming LCS table, then a
78
+ * single backtrack to produce the diff. N+M ≤ ~400 lines in practice
79
+ * (an edit_file call rarely touches more than that); for the very
80
+ * rare larger inputs the renderer falls back to "context only" mode
81
+ * (no +/- markers) to keep memory bounded. We expose the size
82
+ * decision to the caller via the returned array length — callers can
83
+ * cap the render.
84
+ *
85
+ * Empty inputs return `[]`. Identical inputs return N context lines
86
+ * with no +/- markers. Replaced lines surface as `removed` followed
87
+ * by `added` (the LCS picks which half of the diff to emit; the other
88
+ * half is the same shape).
89
+ */
90
+ export function computeDiff(oldStr, newStr) {
91
+ const oldLines = splitLines(oldStr);
92
+ const newLines = splitLines(newStr);
93
+ if (oldLines.length === 0 && newLines.length === 0)
94
+ return [];
95
+ // Build LCS length table.
96
+ const n = oldLines.length;
97
+ const m = newLines.length;
98
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
99
+ for (let i = 1; i <= n; i++) {
100
+ const oldLine = oldLines[i - 1];
101
+ const row = dp[i];
102
+ const prev = dp[i - 1];
103
+ for (let j = 1; j <= m; j++) {
104
+ if (oldLine === newLines[j - 1]) {
105
+ row[j] = (prev[j - 1] ?? 0) + 1;
106
+ }
107
+ else {
108
+ row[j] = Math.max(prev[j] ?? 0, row[j - 1] ?? 0);
109
+ }
110
+ }
111
+ }
112
+ // Backtrack to produce the diff in reverse order.
113
+ const reversed = [];
114
+ let i = n;
115
+ let j = m;
116
+ while (i > 0 && j > 0) {
117
+ if (oldLines[i - 1] === newLines[j - 1]) {
118
+ reversed.push({
119
+ kind: "context",
120
+ text: oldLines[i - 1],
121
+ oldLine: i,
122
+ newLine: j,
123
+ });
124
+ i--;
125
+ j--;
126
+ }
127
+ else if ((dp[i - 1]?.[j] ?? 0) >= (dp[i]?.[j - 1] ?? 0)) {
128
+ reversed.push({
129
+ kind: "removed",
130
+ text: oldLines[i - 1],
131
+ oldLine: i,
132
+ newLine: null,
133
+ });
134
+ i--;
135
+ }
136
+ else {
137
+ reversed.push({
138
+ kind: "added",
139
+ text: newLines[j - 1],
140
+ oldLine: null,
141
+ newLine: j,
142
+ });
143
+ j--;
144
+ }
145
+ }
146
+ while (i > 0) {
147
+ reversed.push({
148
+ kind: "removed",
149
+ text: oldLines[i - 1],
150
+ oldLine: i,
151
+ newLine: null,
152
+ });
153
+ i--;
154
+ }
155
+ while (j > 0) {
156
+ reversed.push({
157
+ kind: "added",
158
+ text: newLines[j - 1],
159
+ oldLine: null,
160
+ newLine: j,
161
+ });
162
+ j--;
163
+ }
164
+ reversed.reverse();
165
+ return reversed;
166
+ }
167
+ /**
168
+ * Split a string into lines. We split on `\n` and drop a single
169
+ * trailing empty element that arises from a final `\n` (matching how
170
+ * `git diff` and most editors present the result). Empty input → `[]`.
171
+ */
172
+ function splitLines(s) {
173
+ if (s === "")
174
+ return [];
175
+ const parts = s.split("\n");
176
+ if (parts.length > 0 && parts[parts.length - 1] === "")
177
+ parts.pop();
178
+ return parts;
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Path extraction
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Return the first path-like field found on a tool call's `args`, or
185
+ * `null` if none of the canonical aliases are present.
186
+ *
187
+ * Lookup order matches `extractArgPath()` in `src/server.ts` so SSR
188
+ * + CSR stay in sync.
189
+ */
190
+ export function extractToolCallFiles(call) {
191
+ const args = call?.args ?? {};
192
+ for (const key of PATH_KEY_ALIASES) {
193
+ const v = args[key];
194
+ if (typeof v === "string" && v.length > 0)
195
+ return v;
196
+ }
197
+ return null;
198
+ }
199
+ /**
200
+ * Pull the "before" string for a diff-render. Returns the first
201
+ * non-undefined match across `old_string / oldText / from / before`,
202
+ * coerced to a string, or `null` when no candidate exists.
203
+ */
204
+ function pickDiffOld(args) {
205
+ if (!args)
206
+ return null;
207
+ for (const key of DIFF_OLD_ALIASES) {
208
+ const v = args[key];
209
+ if (typeof v === "string")
210
+ return v;
211
+ }
212
+ return null;
213
+ }
214
+ /**
215
+ * Pull the "after" string. Same shape as `pickDiffOld`.
216
+ */
217
+ function pickDiffNew(args) {
218
+ if (!args)
219
+ return null;
220
+ for (const key of DIFF_NEW_ALIASES) {
221
+ const v = args[key];
222
+ if (typeof v === "string")
223
+ return v;
224
+ }
225
+ return null;
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // SSR HTML
229
+ // ---------------------------------------------------------------------------
230
+ /**
231
+ * Render the right-hand detail panel for a single tool call.
232
+ *
233
+ * The output is a self-contained `<section class="tool-call-detail" ...>`
234
+ * block. The block can appear:
235
+ * - inside the existing tool-row `<td class="args-cell">` (replacing
236
+ * the current inline `<details class="diff-panel">`), or
237
+ * - in a new "current call" panel that lives below the toolcalls
238
+ * table (preferred when a mermaid node is clicked — the table row
239
+ * expands AND the dedicated panel scrolls into view).
240
+ *
241
+ * The HTML always carries `data-current-call="<sequence>"` so the
242
+ * client can locate it from a mermaid `__toolClick(N)` callback.
243
+ */
244
+ export function renderToolCallDetail(call, opts) {
245
+ const status = call.success ? "ok" : "fail";
246
+ const filePath = extractToolCallFiles(call);
247
+ const oldStr = pickDiffOld(call.args);
248
+ const newStr = pickDiffNew(call.args);
249
+ const diffLines = oldStr !== null && newStr !== null ? computeDiff(oldStr, newStr) : null;
250
+ const monacoBlock = filePath
251
+ ? `<div class="monaco-editor"
252
+ data-file-path="${escapeHtmlAttr(filePath)}"
253
+ data-language="${escapeHtmlAttr(detectLanguage(filePath))}"
254
+ data-readonly="true"
255
+ data-tool-id="${escapeHtmlAttr(opts.currentCallId)}">
256
+ <div class="monaco-placeholder">Monaco loading…</div>
257
+ </div>`
258
+ : "";
259
+ const diffBlock = diffLines
260
+ ? `<ol class="diff-body" data-tool-id="${escapeHtmlAttr(opts.currentCallId)}">
261
+ ${diffLines.map(renderDiffLine).join("")}
262
+ </ol>`
263
+ : "";
264
+ const fileLink = filePath
265
+ ? `<a class="file-link"
266
+ href="/api/file-content?path=${escapeHtmlAttr(encodeURIComponent(filePath))}"
267
+ target="_blank"
268
+ rel="noopener">View file</a>`
269
+ : "";
270
+ return /* html */ `
271
+ <section class="tool-call-detail"
272
+ data-current-call="${escapeHtmlAttr(opts.currentCallId)}"
273
+ data-tool-id="${escapeHtmlAttr(opts.currentCallId)}"
274
+ data-tool-name="${escapeHtmlAttr(call.toolName)}">
275
+ <header class="tool-call-header">
276
+ <span class="tool-name"><code>${escapeHtml(call.toolName)}</code></span>
277
+ <span class="badge badge-${status}">${escapeHtml(status)}</span>
278
+ <span class="tool-path">${escapeHtml(filePath ?? "")}</span>
279
+ ${fileLink}
280
+ </header>
281
+ ${monacoBlock}
282
+ ${diffBlock}
283
+ </section>
284
+ `.trim();
285
+ }
286
+ function renderDiffLine(line) {
287
+ const lineNum = line.kind === "added"
288
+ ? line.newLine ?? ""
289
+ : line.kind === "removed"
290
+ ? line.oldLine ?? ""
291
+ : line.newLine ?? "";
292
+ const cls = `diff-line diff-line-${line.kind}`;
293
+ const prefix = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " ";
294
+ return `<li class="${cls}" data-line="${escapeHtmlAttr(String(lineNum))}"><span class="diff-gutter">${escapeHtml(prefix)}</span><span class="diff-text">${escapeHtml(line.text)}</span></li>`;
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // Stats
298
+ // ---------------------------------------------------------------------------
299
+ /**
300
+ * Aggregate counts + per-tool stats for a list of tool calls.
301
+ *
302
+ * Used by the per-task stats chip strip. The v1.2.0 implementation
303
+ * inlined this in `renderTaskPage`; v2.0.0 extracts it for unit
304
+ * testing and to share with the client-side `tool-call-detail.js`
305
+ * (which renders the same chips on hydration for live updates).
306
+ */
307
+ export function summarizeToolCalls(toolCalls) {
308
+ const byTool = {};
309
+ let success = 0;
310
+ let totalMs = 0;
311
+ for (const c of toolCalls) {
312
+ const name = c.toolName || "(unknown)";
313
+ let bucket = byTool[name];
314
+ if (!bucket) {
315
+ bucket = { count: 0, success: 0, fail: 0, totalMs: 0 };
316
+ byTool[name] = bucket;
317
+ }
318
+ bucket.count++;
319
+ bucket.totalMs += c.durationMs ?? 0;
320
+ totalMs += c.durationMs ?? 0;
321
+ if (c.success) {
322
+ bucket.success++;
323
+ success++;
324
+ }
325
+ else {
326
+ bucket.fail++;
327
+ }
328
+ }
329
+ return {
330
+ total: toolCalls.length,
331
+ success,
332
+ fail: toolCalls.length - success,
333
+ totalMs,
334
+ byTool,
335
+ };
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // HTML escaping
339
+ // ---------------------------------------------------------------------------
340
+ /**
341
+ * Escape user-controlled strings before injecting them into HTML.
342
+ * Mirrors `htmlEscape()` in `src/server.ts:875` — duplicated here so
343
+ * the module is self-contained and testable without booting the full
344
+ * server.
345
+ */
346
+ function escapeHtml(input) {
347
+ if (input === undefined || input === null)
348
+ return "";
349
+ const s = typeof input === "string" ? input : JSON.stringify(input);
350
+ return s
351
+ .replace(/&/g, "&amp;")
352
+ .replace(/</g, "&lt;")
353
+ .replace(/>/g, "&gt;")
354
+ .replace(/"/g, "&quot;")
355
+ .replace(/'/g, "&#39;");
356
+ }
357
+ /**
358
+ * Attribute-context escape. Same algorithm but also escapes the
359
+ * backtick and `=` to defang attribute-breakout attempts. (For our
360
+ * purposes escapeHtml is sufficient, but having a separate name makes
361
+ * the intent clear at the call site.)
362
+ */
363
+ function escapeHtmlAttr(input) {
364
+ return escapeHtml(input);
365
+ }
366
+ /**
367
+ * Best-effort language detection from a file path. Maps common
368
+ * extensions to Monaco's `language` identifiers. Returns `"plaintext"`
369
+ * for unknown extensions.
370
+ */
371
+ function detectLanguage(path) {
372
+ const lower = path.toLowerCase();
373
+ const idx = lower.lastIndexOf(".");
374
+ if (idx < 0)
375
+ return "plaintext";
376
+ const ext = lower.slice(idx + 1);
377
+ switch (ext) {
378
+ case "ts":
379
+ return "typescript";
380
+ case "tsx":
381
+ return "typescript";
382
+ case "js":
383
+ case "mjs":
384
+ case "cjs":
385
+ return "javascript";
386
+ case "jsx":
387
+ return "javascript";
388
+ case "json":
389
+ return "json";
390
+ case "md":
391
+ case "markdown":
392
+ return "markdown";
393
+ case "css":
394
+ return "css";
395
+ case "html":
396
+ case "htm":
397
+ return "html";
398
+ case "py":
399
+ return "python";
400
+ case "rs":
401
+ return "rust";
402
+ case "go":
403
+ return "go";
404
+ case "java":
405
+ return "java";
406
+ case "sh":
407
+ case "bash":
408
+ case "zsh":
409
+ return "shell";
410
+ case "yml":
411
+ case "yaml":
412
+ return "yaml";
413
+ case "toml":
414
+ return "ini";
415
+ case "sql":
416
+ return "sql";
417
+ case "xml":
418
+ return "xml";
419
+ default:
420
+ return "plaintext";
421
+ }
422
+ }
423
+ //# sourceMappingURL=tool-call-detail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-call-detail.js","sourceRoot":"","sources":["../src/tool-call-detail.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-setting/roy-plugin-task-show",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "roy-agent plugin: visualize task solving process via tool call flow on a local web service",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-setting/roy-plugin-task-show",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "type": "tool-plugin",
5
5
  "description": "v2.0.0: Major visual overhaul of the per-task detail page — VS Code-style tool-call browser powered by Monaco Editor + LCS-based diff + project file tree with mermaid-driven navigation. The detail page now renders a 2-column layout: a sticky left sidebar with the project's git-tracked file tree (chevron-toggle directories, `is-affected` highlight for files touched by the current task), and a main column with the existing Mermaid + stats + toolcalls table + a new dedicated `<section id=\"tool-call-detail-panel\">` that hosts the LCS diff body + Monaco container for the currently-selected tool call. The legacy inline `<details class=\"diff-panel\">` (naive split-and-filter, prone to mis-tagging context lines) is replaced by `<ol class=\"diff-body\">` with proper added/removed/context markers driven by a real LCS dynamic-programming table (`computeDiff()` in `src/tool-call-detail.ts`). Tool calls with a file path get a Monaco Editor placeholder (`<div class=\"monaco-editor\" data-file-path=\"...\" data-language=\"...\">`) which the client-side `public/tool-call-detail.js` lazy-loads from `cdn.jsdelivr.net/npm/monaco-editor@0.45.0` the first time the user clicks a tool row. The Mermaid `__toolClick(toolId)` callback now drives THREE things: (a) the existing row scroll + highlight (preserved from v1.x), (b) the dedicated detail panel re-renders with the matching call, (c) the file-tree sidebar highlights the corresponding file (when `data-file-path` is present). New `GET /api/task/:id/file-tree` endpoint serves the git-tracked file list (`{ files: string[] }`) with a 30-second in-memory TTL + 8-entry FIFO bound. New modules: `src/file-tree.ts` (pure data layer: `buildFileTree / extractAffectedPaths / findNodeByPath / collectAllPaths / gitLsFiles / parseLsFiles`), `src/tool-call-detail.ts` (SSR + LCS diff + HTML escaping), `public/file-tree.js` (vanilla JS hydrator with chevron toggle + keyboard navigation + scrollIntoView), `public/tool-call-detail.js` (Monaco AMD loader + `__toolClick` wrapper + file-content fetch). New tests: `test/file-tree.test.ts` (21 cases — empty/single/nested/dedup/sort/depth/parseLsFiles/gitLsFiles), `test/tool-call-detail.test.ts` (22 cases — LCS diff edges, HTML escape, path aliases, summary stats), `test/tool-call-detail-server-integration.test.ts` (7 cases — SSR HTML contracts + endpoint), `test/tool-call-detail-jsdom.test.ts` (7 cases — client-side hydrators). v1.2.0: CSS context & packaged release hotfix. The plugin's public assets (notably `public/style.css`) and runtime adapters now correctly resolve relative to the installed package directory even when consumed via the published npm tarball. The session-scoped `TaskSessionStore` now preserves the full host session context (parent-child task links, plugin-handle id, env scope) across render cycles — previously the session was collapsed to its `sessionId` on first load and never refreshed, so the home page lost the 「session ancestors」 chain and external tasks from outside the current session silently disappeared from the tree. Adds `src/task-metadata.ts` as the single source of truth for the public `Task` shape exposed by `/api/tasks` + `/api/tasks/:id`, including the v1.0.0+ `processDescription` field, and re-exports it through the CLI adapters (`cli-tasks-adapter.ts` + `cli-tasks-tree-adapter.ts`) so the home page tree + the per-task page render against the same metadata contract. Bundles 244-line regression test (`test/context-and-packed-release.test.ts`) that boots the plugin from the **npm-pack** directory (not the repo working tree), spawns `roy-agent tasks get <id> --json`, and asserts (a) `public/style.css` is present and ≥ 64 lines, (b) the `/api/events` SSE endpoint survives a reload, and (c) `task.session` survives a render cycle. v1.1.0: Full Server-Sent Events realtime subscription across 3 event classes (task.created / operation.updated / tool.called) on both the home page and the per-task /task/<id> page. The per-task pipeline now subscribes to /api/events and patches the DOM in place on operation.updated — no more 5s-poll delay before the user sees a new milestone. The 'Task lifecycle pipeline' header badge is replaced by a 5-state SSE-aware badge (stale / connecting / live / reconnecting / error) so the user can tell at a glance whether real-time updates are flowing, the connection dropped, or 3+ consecutive errors triggered the polling fallback. Legacy boolean `stale` cache-TTL pill and `tool.recorded` event name are preserved for back-compat with v0.9.x / v1.0.0 clients. v1.0.0: First stable release. Replaces the v0.9.x fixture-based verify scripts (which built fake TaskOperationsEnvelope and never invoked the real `roy-agent` CLI, masking regressions in the public-schema `processDescription` field) with a real-CLI scenario test + verify (`test/process-description-real-scenario.test.ts` + `scripts/verify-v100-real-scenario.ts`) that spawns `roy-agent tasks get <id> --operations --json` via `defaultRunner` and asserts the API response carries `processDescription` end-to-end. The 0.9.9 processDescription fix is preserved verbatim — this release only swaps the verify surface. Visualize the tool call chain of a task on a local web service with real-time SSE updates. v0.9.9: Task lifecycle pipeline on /task/<id> now exposes BOTH the milestone badge AND the 「过程描述」 column at a glance — the server-side `/api/tasks/:id/operations` endpoint exposes `processDescription` on every operation (no longer stripped from the public schema), the client-side `renderPipelineHtml` mirrors the server's `.op-desc-block` + `.op-proc-block` block layout so SSR ↔ CSR stay in sync, and a long-standing CSS right-side text-truncation bug in the pipeline timeline (long CJK titles overflowing the panel edge) is fixed via `min-width: 0` on `.op-row1` + `overflow-wrap: anywhere` on `.op-title`. v0.9.0: Session-scoped home page (only show tasks created after plugin load + their external ancestors), with per-row 「显示全部栏位」 toggle and lazy-loaded operations timeline; per-task Mermaid labels now correctly render CJK / mixed-Latin / emoji text (encoded as \\uXXXX before emission, decoded by the browser); detail page layout reordered to lifecycle → pipeline → stats → toolcalls → rawjson. v0.5.0+: page refreshes stream over GET /api/events (Server-Sent Events). Subscribes to tool:before.execute, tool:after.execute, task:before.create, task:after.create, task:after.complete (preferred, 2026-07-10+), and task:after.update (legacy fallback). v0.6.11: Mermaid re-rendering is delegated to a self-contained controller (public/mermaid-renderer.js) that prevents the SVG→raw-source regression on async updates and surfaces recoverable .mermaid-error states. v0.6.12: Task lifecycle pipeline (operations timeline) server now emits data-task-id on the pipeline section; client preserves it on swap, so the page actually fetches /api/tasks/<id>/operations and renders the 7-op timeline (previously silently bailed). v0.7.0: Home page redesigned as a hierarchical task tree (driven by `roy-agent tasks tree --json`); new /api/tasks/tree endpoint with status / priority / type / root-id filters, expand/collapse UI, search, and live 30s polling. v0.8.0: per-task page Mermaid area now renders the hierarchical 'Task lifecycle + tools' view — each operation record owns a subgraph that nests its tool calls, with click callbacks (`window.__toolClick`) that scroll-into-view + highlight + auto-expand the matching row in the tool-call table below. Operation record descriptions (`description` + `processDescription`) are now always rendered inline (no `<details>` collapse) so the user sees the lifecycle state at a glance; a fallback `<details>` kicks in only for descriptions longer than 600 chars. v0.8.1: hotfix for two pre-existing bugs in v0.8.0 (browser smoke test surfaced after merge). (a) Mermaid click directives were emitted as `click t1 __toolClick(1)` (missing `call` keyword) — Mermaid 10's parser rejects this with `got 'PS'`. Fixed to `click t1 call __toolClick(1)` (the v10 grammar requires `call` to invoke a callback with arguments). (b) `buildMermaidSource` lived inside the `attachTaskPageTimeline` IIFE but was also called from a listener in the `attachToolClickBridge` IIFE — sibling IIFEs cannot see each other's locals, so the listener threw `ReferenceError: buildMermaidSource is not defined` and the Mermaid diagram silently failed to re-render after `task-show:lifecycle-ops-loaded`. Fixed by hoisting the function (and its three helpers) to script top-level so both IIFEs can see it via the script-wide closure; the function is also exposed on `window.buildMermaidSource` for tests + tooling. v0.8.3: tree-display fix (Task #2426). The home page used to look like a flat list of root tasks because `autoExpandFirstLevels(..., 2)` only opened the first 2 levels — 30/47 roots were leaf nodes and the remaining 17 collapsed to one level so grandchildren were never visible. Default expand depth is now 3 (root + child + grandchild + great-grandchild are visible on first paint), the summary line now shows per-depth count pills (root / child / grandchild / great-grandchild / level-N), each `tree-row` carries a `data-depth` attribute so CSS can paint coloured left rails per level, and the duplicated 'Live tool-call sessions (legacy view)' panel that made the page look like both a flat table AND a tree is now hidden behind `#legacy-sessions[hidden]` (kept for future debug-toggle restoration). v0.8.10: bug-fix release (Task #2537 + Task #2534). (a) Heap-bounded plugin caches: OperationsCache and TasksTreeCache now enforce a hard maxEntries cap (default 256 / 64). Oldest stale entries are evicted before inserting a new one, so long-lived roy-agent sessions (BackgroundTaskManager + MemorySessionStore) no longer leak Map entries through the plugin's per-task caches — see Task #2537 for the heap-unbounded-state RED→GREEN repro. (b) Mermaid CJK font-family: server.ts renderTaskPage now configures mermaid.initialize({ themeVariables: { fontFamily: '\"PingFang SC\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", \"Source Han Sans CN\", \"WenQuanYi Micro Hei\", sans-serif' } }) so Chinese node labels render correctly in browsers that have at least one of those fonts installed (see Task #2534).",
6
6
  "main": "dist/index.js",
package/public/app.js CHANGED
@@ -732,11 +732,27 @@ if (typeof window !== "undefined") {
732
732
  (session.endedAt ? ` · ended ${new Date(session.endedAt).toISOString().replace("T", " ").slice(0, 19)}` : "");
733
733
  }
734
734
  // Re-render tool calls table in place.
735
+ // v2.0.2 fix (Task #2682 Bug 2): when the existing tbody already
736
+ // contains SSR inline `<section class="tool-call-detail">` blocks
737
+ // (the v2.0.0+ rich diff/Monaco markers), we MUST NOT destroy them
738
+ // by reassigning `tbody.innerHTML`. The previous behaviour used
739
+ // the v1.x `renderToolCallRow` here, which had no inline detail
740
+ // block — so every SSE snapshot wiped out the SSR contents and
741
+ // caused the dedicated `tool-call-detail.js` panel to lose its
742
+ // `section.tool-call-detail[data-tool-id]` source.
743
+ //
744
+ // The guard: if at least one inline detail is already present,
745
+ // skip the bulk re-render. The legacy / fallback path (no inline
746
+ // details) still gets the v1.x row so the page is not stuck on
747
+ // the initial server render.
735
748
  const tbody = document.querySelector("table.toolcalls tbody");
736
749
  if (tbody) {
737
- tbody.innerHTML = session.toolCalls
738
- .map((c) => renderToolCallRow(c))
739
- .join("");
750
+ const hasInlineDetails = tbody.querySelector("section.tool-call-detail") !== null;
751
+ if (!hasInlineDetails) {
752
+ tbody.innerHTML = session.toolCalls
753
+ .map((c) => renderToolCallRow(c))
754
+ .join("");
755
+ }
740
756
  }
741
757
  // Re-render stats table.
742
758
  const statsTbody = document.querySelector("table.stats tbody");