@ai-setting/roy-plugin-task-show 0.6.11 → 0.8.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.
Files changed (43) hide show
  1. package/README.md +121 -1
  2. package/dist/cli-tasks-adapter.d.ts +142 -0
  3. package/dist/cli-tasks-adapter.d.ts.map +1 -0
  4. package/dist/cli-tasks-adapter.js +379 -0
  5. package/dist/cli-tasks-adapter.js.map +1 -0
  6. package/dist/cli-tasks-tree-adapter.d.ts +143 -0
  7. package/dist/cli-tasks-tree-adapter.d.ts.map +1 -0
  8. package/dist/cli-tasks-tree-adapter.js +400 -0
  9. package/dist/cli-tasks-tree-adapter.js.map +1 -0
  10. package/dist/operations-cache.d.ts +44 -0
  11. package/dist/operations-cache.d.ts.map +1 -0
  12. package/dist/operations-cache.js +103 -0
  13. package/dist/operations-cache.js.map +1 -0
  14. package/dist/plugin.d.ts +14 -0
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +82 -0
  17. package/dist/plugin.js.map +1 -1
  18. package/dist/server.d.ts +30 -0
  19. package/dist/server.d.ts.map +1 -1
  20. package/dist/server.js +289 -89
  21. package/dist/server.js.map +1 -1
  22. package/dist/task-detail-mermaid.d.ts +171 -0
  23. package/dist/task-detail-mermaid.d.ts.map +1 -0
  24. package/dist/task-detail-mermaid.js +697 -0
  25. package/dist/task-detail-mermaid.js.map +1 -0
  26. package/dist/task-operations-html.d.ts +50 -0
  27. package/dist/task-operations-html.d.ts.map +1 -0
  28. package/dist/task-operations-html.js +187 -0
  29. package/dist/task-operations-html.js.map +1 -0
  30. package/dist/tasks-tree-cache.d.ts +44 -0
  31. package/dist/tasks-tree-cache.d.ts.map +1 -0
  32. package/dist/tasks-tree-cache.js +111 -0
  33. package/dist/tasks-tree-cache.js.map +1 -0
  34. package/dist/types.d.ts +16 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/package.json +6 -1
  38. package/plugin.json +2 -2
  39. package/public/app.js +534 -34
  40. package/public/index.html +54 -21
  41. package/public/style.css +697 -1
  42. package/public/task-operations.js +290 -0
  43. package/public/tasks-tree.js +449 -0
@@ -0,0 +1,449 @@
1
+ /* ------------------------------------------------------------------------- */
2
+ /* roy-plugin-task-show — tasks tree controller */
3
+ /* ------------------------------------------------------------------------- */
4
+ /* eslint-disable no-undef */
5
+
6
+ /**
7
+ * Renders the hierarchical task list served by `/api/tasks/tree`.
8
+ *
9
+ * Responsibilities:
10
+ * - Fetch the tree on page load and whenever a filter / refresh action
11
+ * changes.
12
+ * - Render an accessible tree with collapse / expand.
13
+ * - Filter by status, priority and free-text query.
14
+ * - Click a task row to open its per-task detail page (`/task/:id`).
15
+ *
16
+ * Storage: a single in-memory `state` object holds the latest envelope
17
+ * + filter selections. The DOM is fully re-rendered on each fetch, which
18
+ * keeps the code path linear (no diff/patch gymnastics) and lets us swap
19
+ * data sources in a single place.
20
+ */
21
+
22
+ (function attachTasksTreeController() {
23
+ const body = document.body;
24
+ if (!body || body.getAttribute("data-view") !== "tasks-tree") return;
25
+
26
+ const root = document.querySelector("[data-tasks-tree]");
27
+ if (!root) return;
28
+
29
+ const summaryEl = document.querySelector("[data-tree-summary]");
30
+ const searchInput = document.querySelector("[data-tree-search]");
31
+ const statusButtons = Array.from(document.querySelectorAll("[data-tree-status]"));
32
+ const priorityButtons = Array.from(document.querySelectorAll("[data-tree-priority]"));
33
+ const actionButtons = Array.from(document.querySelectorAll("[data-tree-action]"));
34
+
35
+ // Per-node expanded state. Survives re-renders so the user doesn't have
36
+ // to re-open branches every time they tweak a filter.
37
+ /** @type {Map<number, boolean>} */
38
+ const expanded = new Map();
39
+
40
+ // Filter state.
41
+ /** @type {{status: string, priority: string, query: string, stale: number}} */
42
+ const filter = {
43
+ status: "",
44
+ priority: "",
45
+ query: "",
46
+ stale: 0, // 0 = allow stale cache, >0 forces refresh each call
47
+ };
48
+
49
+ // Latest envelope.
50
+ /** @type {{total: number, rootCount: number, tree: any[], filter: any, fetchedAt: string, stale: boolean} | null} */
51
+ let envelope = null;
52
+ let inflight = false;
53
+ let inflightPromise = null;
54
+
55
+ // -------------------------------------------------------------------------
56
+ // Filter wiring
57
+ // -------------------------------------------------------------------------
58
+
59
+ function setActiveButton(buttons, attr, value) {
60
+ for (const btn of buttons) {
61
+ const v = btn.getAttribute(attr) ?? "";
62
+ btn.classList.toggle("is-active", v === value);
63
+ btn.setAttribute("aria-pressed", v === value ? "true" : "false");
64
+ }
65
+ }
66
+
67
+ statusButtons.forEach((btn) => {
68
+ btn.addEventListener("click", () => {
69
+ filter.status = btn.getAttribute("data-tree-status") ?? "";
70
+ setActiveButton(statusButtons, "data-tree-status", filter.status);
71
+ void refetch();
72
+ });
73
+ });
74
+ priorityButtons.forEach((btn) => {
75
+ btn.addEventListener("click", () => {
76
+ filter.priority = btn.getAttribute("data-tree-priority") ?? "";
77
+ setActiveButton(priorityButtons, "data-tree-priority", filter.priority);
78
+ void refetch();
79
+ });
80
+ });
81
+ actionButtons.forEach((btn) => {
82
+ btn.addEventListener("click", () => {
83
+ const action = btn.getAttribute("data-tree-action");
84
+ if (action === "expand") {
85
+ for (const id of expanded.keys()) expanded.set(id, true);
86
+ // Also auto-expand any node with children so the first paint
87
+ // shows the full tree after a user-driven "expand all".
88
+ if (envelope) collectExpandable(envelope.tree, true);
89
+ render();
90
+ } else if (action === "collapse") {
91
+ expanded.clear();
92
+ render();
93
+ } else if (action === "refresh") {
94
+ filter.stale = Date.now();
95
+ void refetch(true).finally(() => { filter.stale = 0; });
96
+ }
97
+ });
98
+ });
99
+
100
+ if (searchInput) {
101
+ let t = null;
102
+ searchInput.addEventListener("input", () => {
103
+ const v = searchInput.value ?? "";
104
+ if (t) clearTimeout(t);
105
+ t = setTimeout(() => {
106
+ filter.query = v.trim();
107
+ // Pure client-side filter — no refetch needed. But we should
108
+ // auto-expand any branch that contains a hit, so the user sees
109
+ // their match without manually clicking.
110
+ autoExpandMatches(envelope?.tree ?? []);
111
+ render();
112
+ }, 180);
113
+ });
114
+ }
115
+
116
+ // -------------------------------------------------------------------------
117
+ // Data fetch
118
+ // -------------------------------------------------------------------------
119
+
120
+ function buildUrl() {
121
+ const params = new URLSearchParams();
122
+ if (filter.status) params.set("status", filter.status);
123
+ if (filter.priority) params.set("priority", filter.priority);
124
+ if (filter.stale) params.set("stale", "0");
125
+ return "/api/tasks/tree" + (params.toString() ? "?" + params.toString() : "");
126
+ }
127
+
128
+ async function refetch(force = false) {
129
+ if (!force && inflight) return inflightPromise;
130
+ inflight = true;
131
+ setBusy(true);
132
+ inflightPromise = (async () => {
133
+ try {
134
+ const url = buildUrl();
135
+ const res = await fetch(url, { headers: { accept: "application/json" } });
136
+ if (!res.ok) {
137
+ await renderError(res.status, await safeReadText(res));
138
+ return;
139
+ }
140
+ const json = await res.json();
141
+ envelope = json;
142
+ // v0.8.3: auto-expand the first 3 levels (root + child +
143
+ // grandchild) so the hierarchical nature is visible the moment
144
+ // the page loads — a 1- or 2-level expansion left many roots
145
+ // looking like a flat list. The default is overridable via the
146
+ // expanded map (e.g. by search / filter handlers).
147
+ autoExpandFirstLevels(json.tree || [], 3);
148
+ render();
149
+ } catch (err) {
150
+ await renderError(0, String(err && err.message || err));
151
+ } finally {
152
+ inflight = false;
153
+ setBusy(false);
154
+ }
155
+ })();
156
+ return inflightPromise;
157
+ }
158
+
159
+ async function safeReadText(res) {
160
+ try { return await res.text(); } catch { return ""; }
161
+ }
162
+
163
+ function setBusy(busy) {
164
+ root.setAttribute("aria-busy", busy ? "true" : "false");
165
+ if (summaryEl) {
166
+ // v0.8.3: only overwrite textContent when we are entering the
167
+ // busy state. When we leave busy, `render()` has already filled
168
+ // in the HTML (visible / total / per-depth pills / fetch
169
+ // timestamp) — overwriting textContent with itself would strip
170
+ // the inner `<span>` elements that `renderSummary` emits.
171
+ if (busy) summaryEl.textContent = "Loading…";
172
+ }
173
+ }
174
+
175
+ // -------------------------------------------------------------------------
176
+ // Rendering
177
+ // -------------------------------------------------------------------------
178
+
179
+ function render() {
180
+ if (!envelope) return;
181
+ const tree = envelope.tree || [];
182
+ const filtered = applyFilters(tree, filter);
183
+ if (filtered.length === 0) {
184
+ root.innerHTML = renderEmpty(filter, envelope);
185
+ renderSummary(filtered.length, envelope, filter);
186
+ return;
187
+ }
188
+ const html = filtered
189
+ .map((node) => renderNode(node, 0, { hasSiblingAfter: false }))
190
+ .join("");
191
+ root.innerHTML = `<ul class="tree-list" role="group">${html}</ul>`;
192
+ attachRowHandlers();
193
+ renderSummary(filtered.length, envelope, filter);
194
+ }
195
+
196
+ function renderEmpty(f, env) {
197
+ if (env && env.total === 0) {
198
+ return `<p class="empty">No tasks recorded yet. Create a task with <code>roy-agent task_create</code> and refresh.</p>`;
199
+ }
200
+ return `<p class="empty">No tasks match the current filter (status=<code>${escapeHtml(f.status || "any")}</code>, priority=<code>${escapeHtml(f.priority || "any")}</code>${f.query ? `, query=<code>${escapeHtml(f.query)}</code>` : ""}).</p>`;
201
+ }
202
+
203
+ function renderSummary(visibleCount, env, f) {
204
+ if (!summaryEl) return;
205
+ const totalRoots = env.rootCount ?? 0;
206
+ const total = env.total ?? 0;
207
+ const stale = env.stale ? " <span class='stale-pill'>stale</span>" : "";
208
+ const fetched = env.fetchedAt ? new Date(env.fetchedAt).toISOString().replace("T", " ").slice(0, 19) : "";
209
+ // v0.8.3: include a per-depth breakdown so the user can see at a
210
+ // glance how the hierarchy is distributed (root / child /
211
+ // grandchild / great-grandchild / …). Recursion is bounded by the
212
+ // tree depth, which is tiny in practice.
213
+ const depthCounts = collectDepthCounts(env.tree || []);
214
+ const breakdown = depthCounts.length === 0
215
+ ? ""
216
+ : ` · <span class="tree-depth-breakdown">${depthCounts
217
+ .map((d, i) => `<span class="tree-depth-pill" data-depth="${i}">${escapeHtml(String(d))} ${i === 0 ? "root" : i === 1 ? "child" : i === 2 ? "grandchild" : i === 3 ? "great-grandchild" : "level " + i}</span>`)
218
+ .join(" ")}</span>`;
219
+ const parts = [
220
+ `<strong>${escapeHtml(String(visibleCount))}</strong> visible / <strong>${escapeHtml(String(total))}</strong> total (${escapeHtml(String(totalRoots))} root task${totalRoots === 1 ? "" : "s"})${breakdown}`,
221
+ ];
222
+ if (f.query) parts.push(`query="<code>${escapeHtml(f.query)}</code>"`);
223
+ if (f.status) parts.push(`status=<code>${escapeHtml(f.status)}</code>`);
224
+ if (f.priority) parts.push(`priority=<code>${escapeHtml(f.priority)}</code>`);
225
+ parts.push(`fetched ${escapeHtml(fetched)}${stale}`);
226
+ summaryEl.innerHTML = parts.join(" · ");
227
+ }
228
+
229
+ /**
230
+ * Walk the tree and count how many nodes live at each depth.
231
+ * Returns an array indexed by depth: depthCounts[0] = roots,
232
+ * depthCounts[1] = children, etc. The array length is one greater
233
+ * than the deepest level encountered.
234
+ */
235
+ function collectDepthCounts(tree) {
236
+ const counts = [];
237
+ function walk(nodes, depth) {
238
+ if (depth >= counts.length) counts.push(0);
239
+ for (const n of nodes) {
240
+ counts[depth]++;
241
+ if ((n.children || []).length > 0) walk(n.children, depth + 1);
242
+ }
243
+ }
244
+ walk(tree, 0);
245
+ return counts;
246
+ }
247
+
248
+ function renderNode(node, depth, ctx) {
249
+ const task = node.task;
250
+ const children = node.children || [];
251
+ const id = task.id;
252
+ const hasChildren = children.length > 0;
253
+ const isOpen = expanded.get(id) === true;
254
+ const matches = matchesFilter(task);
255
+ const tagsHtml = (task.tags || [])
256
+ .slice(0, 4)
257
+ .map((t) => `<span class="tree-tag">${escapeHtml(t)}</span>`)
258
+ .join(" ");
259
+ const indentPx = depth * 22;
260
+ const progress = typeof task.progress === "number"
261
+ ? `<span class="tree-progress" title="${task.progress}%">
262
+ <span class="tree-progress-bar" style="width:${task.progress}%"></span>
263
+ <span class="tree-progress-label">${task.progress}%</span>
264
+ </span>`
265
+ : "";
266
+ const toggler = hasChildren
267
+ ? `<button type="button" class="tree-toggler" data-tree-toggle="${id}" aria-expanded="${isOpen ? "true" : "false"}" aria-label="${isOpen ? "Collapse" : "Expand"} task #${id}">
268
+ <span class="tree-toggler-icon">${isOpen ? "▼" : "▶"}</span>
269
+ </button>`
270
+ : `<span class="tree-toggler tree-toggler-leaf" aria-hidden="true">·</span>`;
271
+ const status = escapeHtml(task.status || "unknown");
272
+ const priority = escapeHtml(task.priority || "medium");
273
+ const title = escapeHtml(task.title || "(no title)");
274
+ const currentStatus = task.current_status
275
+ ? `<span class="tree-current-status">${escapeHtml(task.current_status)}</span>`
276
+ : "";
277
+ const updatedAt = task.updatedAt
278
+ ? `<span class="tree-updated" title="${escapeHtml(task.updatedAt)}">${escapeHtml(formatShortDate(task.updatedAt))}</span>`
279
+ : "";
280
+ const dataAttrs = [
281
+ `data-tree-id="${id}"`,
282
+ `data-tree-depth="${depth}"`,
283
+ `data-tree-status="${status}"`,
284
+ `data-tree-priority="${priority}"`,
285
+ ].join(" ");
286
+ const childrenHtml = hasChildren && isOpen
287
+ ? `<ul class="tree-list tree-children" role="group">
288
+ ${children.map((c, i) => renderNode(c, depth + 1, { hasSiblingAfter: i < children.length - 1 })).join("")}
289
+ </ul>`
290
+ : "";
291
+ return `
292
+ <li class="tree-item ${matches ? "" : "tree-item-hidden"} ${hasChildren ? "tree-item-branch" : "tree-item-leaf"} ${isOpen ? "is-open" : "is-closed"}" ${dataAttrs}>
293
+ <div class="tree-row" data-depth="${depth}" style="padding-left:${indentPx}px">
294
+ ${toggler}
295
+ <a class="tree-id" href="/task/${id}" title="Open task #${id}">#${id}</a>
296
+ <span class="badge badge-${status}" title="${status}">${status}</span>
297
+ <span class="badge badge-priority-${priority}" title="priority: ${priority}">${priority}</span>
298
+ <span class="tree-type-badge" title="type">${escapeHtml(task.type || "normal")}</span>
299
+ <a class="tree-title" href="/task/${id}" title="${title}">${title}</a>
300
+ ${tagsHtml ? `<span class="tree-tags">${tagsHtml}</span>` : ""}
301
+ ${progress}
302
+ ${updatedAt}
303
+ </div>
304
+ ${currentStatus ? `<div class="tree-row-meta" style="padding-left:${indentPx + 32}px">${currentStatus}</div>` : ""}
305
+ ${childrenHtml}
306
+ </li>
307
+ `;
308
+ }
309
+
310
+ function attachRowHandlers() {
311
+ const togglers = root.querySelectorAll("[data-tree-toggle]");
312
+ togglers.forEach((btn) => {
313
+ btn.addEventListener("click", (e) => {
314
+ e.preventDefault();
315
+ e.stopPropagation();
316
+ const id = Number(btn.getAttribute("data-tree-toggle"));
317
+ if (!Number.isFinite(id)) return;
318
+ const current = expanded.get(id) === true;
319
+ expanded.set(id, !current);
320
+ render();
321
+ });
322
+ });
323
+ }
324
+
325
+ function applyFilters(tree, f) {
326
+ if (!f.status && !f.priority && !f.query) return tree;
327
+ const visit = (node) => {
328
+ const childHits = (node.children || []).map(visit).filter(Boolean);
329
+ const selfHit = matchesFilter(node.task);
330
+ if (selfHit || childHits.length > 0) {
331
+ // Auto-expand parents when a child hits.
332
+ if (!selfHit && childHits.length > 0) expanded.set(node.task.id, true);
333
+ return { ...node, children: childHits };
334
+ }
335
+ return null;
336
+ };
337
+ return tree.map(visit).filter(Boolean);
338
+ }
339
+
340
+ function matchesFilter(task) {
341
+ if (filter.status && task.status !== filter.status) return false;
342
+ if (filter.priority && task.priority !== filter.priority) return false;
343
+ if (filter.query) {
344
+ const q = filter.query.toLowerCase();
345
+ const idMatch = String(task.id).includes(q);
346
+ const titleMatch = (task.title || "").toLowerCase().includes(q);
347
+ if (!idMatch && !titleMatch) return false;
348
+ }
349
+ return true;
350
+ }
351
+
352
+ function autoExpandFirstLevels(tree, maxDepth) {
353
+ if (maxDepth <= 0) return;
354
+ const walk = (nodes, depth) => {
355
+ if (depth >= maxDepth) return;
356
+ for (const n of nodes) {
357
+ if ((n.children || []).length > 0) {
358
+ expanded.set(n.task.id, true);
359
+ walk(n.children, depth + 1);
360
+ }
361
+ }
362
+ };
363
+ walk(tree, 0);
364
+ }
365
+
366
+ function autoExpandMatches(tree) {
367
+ if (!filter.query) return;
368
+ const q = filter.query.toLowerCase();
369
+ const walk = (nodes, ancestors) => {
370
+ for (const n of nodes) {
371
+ const idMatch = String(n.task.id).includes(q);
372
+ const titleMatch = (n.task.title || "").toLowerCase().includes(q);
373
+ if (idMatch || titleMatch) {
374
+ for (const a of ancestors) expanded.set(a, true);
375
+ expanded.set(n.task.id, true);
376
+ }
377
+ if ((n.children || []).length > 0) {
378
+ walk(n.children, [...ancestors, n.task.id]);
379
+ }
380
+ }
381
+ };
382
+ walk(tree, []);
383
+ }
384
+
385
+ function collectExpandable(tree, val) {
386
+ const walk = (nodes) => {
387
+ for (const n of nodes) {
388
+ if ((n.children || []).length > 0) {
389
+ expanded.set(n.task.id, val);
390
+ walk(n.children);
391
+ }
392
+ }
393
+ };
394
+ walk(tree);
395
+ }
396
+
397
+ // -------------------------------------------------------------------------
398
+ // Error rendering
399
+ // -------------------------------------------------------------------------
400
+
401
+ async function renderError(status, body) {
402
+ let hint = "";
403
+ if (status === 503) {
404
+ hint = "The plugin's <code>roy-agent</code> CLI is not configured (or not on PATH). Set <code>royAgentCliPath</code> in plugin config or place the CLI at one of the resolved locations.";
405
+ } else if (status === 502 || status === 0) {
406
+ hint = "Spawning <code>roy-agent tasks tree --json</code> failed. Is the CLI installed and on PATH?";
407
+ } else {
408
+ hint = `Unexpected status ${status}.`;
409
+ }
410
+ root.innerHTML = `<p class="empty">Failed to load task tree. ${hint}</p>`;
411
+ if (summaryEl) {
412
+ summaryEl.innerHTML = `error: <code>${escapeHtml(String(status))}</code>${body ? ` — ${escapeHtml(body.slice(0, 200))}` : ""}`;
413
+ }
414
+ }
415
+
416
+ // -------------------------------------------------------------------------
417
+ // Helpers
418
+ // -------------------------------------------------------------------------
419
+
420
+ function escapeHtml(s) {
421
+ return String(s ?? "")
422
+ .replace(/&/g, "&amp;")
423
+ .replace(/</g, "&lt;")
424
+ .replace(/>/g, "&gt;")
425
+ .replace(/"/g, "&quot;")
426
+ .replace(/'/g, "&#39;");
427
+ }
428
+
429
+ function formatShortDate(iso) {
430
+ try {
431
+ const d = new Date(iso);
432
+ const now = new Date();
433
+ const sameDay = d.toDateString() === now.toDateString();
434
+ if (sameDay) return d.toISOString().slice(11, 16); // HH:MM
435
+ return d.toISOString().slice(5, 10); // MM-DD
436
+ } catch {
437
+ return iso;
438
+ }
439
+ }
440
+
441
+ // -------------------------------------------------------------------------
442
+ // Boot
443
+ // -------------------------------------------------------------------------
444
+
445
+ // Auto-refresh every 30s while the page is open so newly created /
446
+ // updated tasks show up without a manual reload.
447
+ setInterval(() => { void refetch(); }, 30000);
448
+ void refetch();
449
+ })();