@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.
- package/README.md +121 -1
- package/dist/cli-tasks-adapter.d.ts +142 -0
- package/dist/cli-tasks-adapter.d.ts.map +1 -0
- package/dist/cli-tasks-adapter.js +379 -0
- package/dist/cli-tasks-adapter.js.map +1 -0
- package/dist/cli-tasks-tree-adapter.d.ts +143 -0
- package/dist/cli-tasks-tree-adapter.d.ts.map +1 -0
- package/dist/cli-tasks-tree-adapter.js +400 -0
- package/dist/cli-tasks-tree-adapter.js.map +1 -0
- package/dist/operations-cache.d.ts +44 -0
- package/dist/operations-cache.d.ts.map +1 -0
- package/dist/operations-cache.js +103 -0
- package/dist/operations-cache.js.map +1 -0
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +82 -0
- package/dist/plugin.js.map +1 -1
- package/dist/server.d.ts +30 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +289 -89
- package/dist/server.js.map +1 -1
- package/dist/task-detail-mermaid.d.ts +171 -0
- package/dist/task-detail-mermaid.d.ts.map +1 -0
- package/dist/task-detail-mermaid.js +697 -0
- package/dist/task-detail-mermaid.js.map +1 -0
- package/dist/task-operations-html.d.ts +50 -0
- package/dist/task-operations-html.d.ts.map +1 -0
- package/dist/task-operations-html.js +187 -0
- package/dist/task-operations-html.js.map +1 -0
- package/dist/tasks-tree-cache.d.ts +44 -0
- package/dist/tasks-tree-cache.d.ts.map +1 -0
- package/dist/tasks-tree-cache.js +111 -0
- package/dist/tasks-tree-cache.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +6 -1
- package/plugin.json +2 -2
- package/public/app.js +534 -34
- package/public/index.html +54 -21
- package/public/style.css +697 -1
- package/public/task-operations.js +290 -0
- 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, "&")
|
|
423
|
+
.replace(/</g, "<")
|
|
424
|
+
.replace(/>/g, ">")
|
|
425
|
+
.replace(/"/g, """)
|
|
426
|
+
.replace(/'/g, "'");
|
|
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
|
+
})();
|