@jevitate/cli 0.1.0
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/LICENSE +21 -0
- package/dist/ai-cli.d.ts +31 -0
- package/dist/ai-cli.js +238 -0
- package/dist/ai-cli.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +50663 -0
- package/dist/bin.js.map +1 -0
- package/dist/data-dir.d.ts +9 -0
- package/dist/data-dir.js +13 -0
- package/dist/data-dir.js.map +1 -0
- package/dist/envelope.d.ts +11 -0
- package/dist/envelope.js +7 -0
- package/dist/envelope.js.map +1 -0
- package/dist/explore-api.d.ts +187 -0
- package/dist/explore-api.js +302 -0
- package/dist/explore-api.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +14 -0
- package/dist/init-keys.js +18 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/init-mcp.d.ts +87 -0
- package/dist/init-mcp.js +198 -0
- package/dist/init-mcp.js.map +1 -0
- package/dist/init-skills.d.ts +80 -0
- package/dist/init-skills.js +256 -0
- package/dist/init-skills.js.map +1 -0
- package/dist/journey-api.d.ts +37 -0
- package/dist/journey-api.js +55 -0
- package/dist/journey-api.js.map +1 -0
- package/dist/load-api.d.ts +48 -0
- package/dist/load-api.js +84 -0
- package/dist/load-api.js.map +1 -0
- package/dist/mcp-api.d.ts +116 -0
- package/dist/mcp-api.js +359 -0
- package/dist/mcp-api.js.map +1 -0
- package/dist/mission-api.d.ts +56 -0
- package/dist/mission-api.js +65 -0
- package/dist/mission-api.js.map +1 -0
- package/dist/program.d.ts +77 -0
- package/dist/program.js +1938 -0
- package/dist/program.js.map +1 -0
- package/dist/record-api.d.ts +74 -0
- package/dist/record-api.js +93 -0
- package/dist/record-api.js.map +1 -0
- package/dist/regression-api.d.ts +17 -0
- package/dist/regression-api.js +16 -0
- package/dist/regression-api.js.map +1 -0
- package/dist/self-heal-adapter.d.ts +31 -0
- package/dist/self-heal-adapter.js +70 -0
- package/dist/self-heal-adapter.js.map +1 -0
- package/dist/source-api.d.ts +147 -0
- package/dist/source-api.js +208 -0
- package/dist/source-api.js.map +1 -0
- package/dist/source-run-api.d.ts +57 -0
- package/dist/source-run-api.js +91 -0
- package/dist/source-run-api.js.map +1 -0
- package/dist/ui-api.d.ts +35 -0
- package/dist/ui-api.js +369 -0
- package/dist/ui-api.js.map +1 -0
- package/dist/ux-api.d.ts +75 -0
- package/dist/ux-api.js +231 -0
- package/dist/ux-api.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +37 -0
- package/dist/version.js.map +1 -0
- package/package.json +85 -0
- package/skills/jevitate-explore/SKILL.md +108 -0
- package/skills/jevitate-load-test/SKILL.md +42 -0
- package/skills/jevitate-mission-scope/SKILL.md +107 -0
- package/skills/jevitate-record/SKILL.md +78 -0
- package/skills/jevitate-run-journey/SKILL.md +72 -0
- package/skills/jevitate-sources/SKILL.md +88 -0
- package/skills/jevitate-ux-review/SKILL.md +68 -0
- package/ui/app.js +538 -0
- package/ui/index.html +546 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// Jevitate UI — vanilla-JS SPA for the local `jevitate ui` inbox dashboard.
|
|
2
|
+
// Served same-origin at /app.js (CSP: default-src 'self' — no inline script,
|
|
3
|
+
// no inline event handlers; everything here is wired with addEventListener).
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
var TOKEN_HEADER = "x-jevitate-token";
|
|
8
|
+
var POLL_MS = 4000;
|
|
9
|
+
|
|
10
|
+
/** Read `?t=` once, then scrub it from the visible URL. The server has
|
|
11
|
+
* already set the auth cookie from this same query param on `GET /`, so
|
|
12
|
+
* same-origin fetches carry it automatically — the header below is sent
|
|
13
|
+
* on every request too, for robustness (belt and suspenders, not the
|
|
14
|
+
* only mechanism). */
|
|
15
|
+
var token = "";
|
|
16
|
+
(function initToken() {
|
|
17
|
+
var params = new URLSearchParams(window.location.search);
|
|
18
|
+
var t = params.get("t");
|
|
19
|
+
if (t) {
|
|
20
|
+
token = t;
|
|
21
|
+
params.delete("t");
|
|
22
|
+
var qs = params.toString();
|
|
23
|
+
var newUrl = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
24
|
+
window.history.replaceState({}, "", newUrl);
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
var els = {
|
|
29
|
+
queueScreen: document.getElementById("screen-queue"),
|
|
30
|
+
detailScreen: document.getElementById("screen-detail"),
|
|
31
|
+
queueList: document.getElementById("queue-list"),
|
|
32
|
+
queueCount: document.getElementById("queue-count"),
|
|
33
|
+
detailRoot: document.getElementById("detail-root"),
|
|
34
|
+
backLink: document.getElementById("back-link"),
|
|
35
|
+
toast: document.getElementById("toast"),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
var state = {
|
|
39
|
+
view: "queue", // "queue" | "detail"
|
|
40
|
+
detailId: null,
|
|
41
|
+
pollTimer: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------- utils
|
|
45
|
+
|
|
46
|
+
function escapeHtml(value) {
|
|
47
|
+
var s = String(value == null ? "" : value);
|
|
48
|
+
return s
|
|
49
|
+
.replace(/&/g, "&")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, """)
|
|
53
|
+
.replace(/'/g, "'");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatTime(iso) {
|
|
57
|
+
if (!iso) return "";
|
|
58
|
+
var d = new Date(iso);
|
|
59
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
60
|
+
return d.toLocaleTimeString(undefined, { hour12: false });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var toastTimer = null;
|
|
64
|
+
function showToast(message) {
|
|
65
|
+
if (!els.toast) return;
|
|
66
|
+
els.toast.textContent = message;
|
|
67
|
+
els.toast.hidden = false;
|
|
68
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
69
|
+
toastTimer = setTimeout(function () {
|
|
70
|
+
els.toast.hidden = true;
|
|
71
|
+
}, 3200);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function apiFetch(path, options) {
|
|
75
|
+
options = options || {};
|
|
76
|
+
var headers = Object.assign({}, options.headers || {});
|
|
77
|
+
// Only attach the header when we actually have a token. On first load
|
|
78
|
+
// (URL carries `?t=`) this is always true. On any later reload the `?t=`
|
|
79
|
+
// has already been scrubbed from the URL, so `token` is `""` — sending
|
|
80
|
+
// an empty-string header would otherwise mask the valid auth cookie
|
|
81
|
+
// (the server treats a *present* header as authoritative), 401-ing every
|
|
82
|
+
// /api/* call and rendering a silently-empty queue.
|
|
83
|
+
if (token) headers[TOKEN_HEADER] = token;
|
|
84
|
+
return fetch(path, Object.assign({}, options, { headers: headers, credentials: "same-origin" }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ kind meta
|
|
88
|
+
|
|
89
|
+
var KIND_META = {
|
|
90
|
+
handback: { label: "HANDBACK", color: "var(--cyan)", ledClass: "led-c" },
|
|
91
|
+
approval: { label: "APPROVAL", color: "var(--magenta)", ledClass: "led-m" },
|
|
92
|
+
review: { label: "REVIEW", color: "var(--amber)", ledClass: "led-y" },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Transition semantics (mirrors `resolveTransition` in @jevitate/inbox):
|
|
96
|
+
* approval -> approve/reject; handback -> resume/reject (+ provide-input,
|
|
97
|
+
* which appends a thread entry rather than resolving); review ->
|
|
98
|
+
* approve/reject. `view` opens the detail screen for every kind. */
|
|
99
|
+
var ACTIONS_BY_KIND = {
|
|
100
|
+
handback: ["resume", "input", "reject", "view"],
|
|
101
|
+
approval: ["approve", "reject", "view"],
|
|
102
|
+
review: ["approve", "reject", "view"],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
var ACTION_LABEL = {
|
|
106
|
+
approve: "Approve",
|
|
107
|
+
reject: "Reject",
|
|
108
|
+
resume: "Resume",
|
|
109
|
+
input: "Provide input",
|
|
110
|
+
view: "View",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
var RESOLVED_TOAST = {
|
|
114
|
+
approve: "approved",
|
|
115
|
+
reject: "rejected",
|
|
116
|
+
resume: "resumed",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function actionButtonClass(action) {
|
|
120
|
+
if (action === "approve" || action === "resume") return "btn-primary";
|
|
121
|
+
if (action === "reject") return "btn-ghost-w";
|
|
122
|
+
return "btn-ghost-c"; // input, view
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ------------------------------------------------------------- queue UI
|
|
126
|
+
|
|
127
|
+
function emptyStateHtml() {
|
|
128
|
+
return (
|
|
129
|
+
'<div class="empty-state">' +
|
|
130
|
+
'<h2 class="disp">NO PENDING ITEMS</h2>' +
|
|
131
|
+
'<div class="mono sub">[ waiting for handbacks… <span class="cursor-blink" style="color:var(--cyan)">█</span> ]</div>' +
|
|
132
|
+
'<p class="body-txt">Runs will hand back here when an agent needs you.</p>' +
|
|
133
|
+
"</div>"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function cardHtml(item) {
|
|
138
|
+
var meta = KIND_META[item.kind] || KIND_META.approval;
|
|
139
|
+
var actions = ACTIONS_BY_KIND[item.kind] || ["view"];
|
|
140
|
+
var buttons = actions
|
|
141
|
+
.map(function (action) {
|
|
142
|
+
return (
|
|
143
|
+
'<button type="button" class="' +
|
|
144
|
+
actionButtonClass(action) +
|
|
145
|
+
'" data-action="' +
|
|
146
|
+
action +
|
|
147
|
+
'" data-id="' +
|
|
148
|
+
escapeHtml(item.id) +
|
|
149
|
+
'">' +
|
|
150
|
+
ACTION_LABEL[action] +
|
|
151
|
+
"</button>"
|
|
152
|
+
);
|
|
153
|
+
})
|
|
154
|
+
.join("");
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
'<article class="card" data-id="' +
|
|
158
|
+
escapeHtml(item.id) +
|
|
159
|
+
'">' +
|
|
160
|
+
'<div class="card-top">' +
|
|
161
|
+
'<span class="badge" style="border-color:' +
|
|
162
|
+
meta.color +
|
|
163
|
+
'"><span class="led ' +
|
|
164
|
+
meta.ledClass +
|
|
165
|
+
'"></span><span class="mono" style="color:' +
|
|
166
|
+
meta.color +
|
|
167
|
+
'">[' +
|
|
168
|
+
meta.label +
|
|
169
|
+
"]</span></span>" +
|
|
170
|
+
'<span class="mono eyebrow">[RUN ' +
|
|
171
|
+
escapeHtml(item.run) +
|
|
172
|
+
"]</span>" +
|
|
173
|
+
"</div>" +
|
|
174
|
+
'<h2 class="head card-title">' +
|
|
175
|
+
escapeHtml(item.step) +
|
|
176
|
+
"</h2>" +
|
|
177
|
+
'<div class="card-meta">' +
|
|
178
|
+
"<span>[JOURNEY] <strong>" +
|
|
179
|
+
escapeHtml(item.journey) +
|
|
180
|
+
"</strong></span>" +
|
|
181
|
+
'<span>[AGENT] <span class="agent">' +
|
|
182
|
+
escapeHtml(item.agent) +
|
|
183
|
+
"</span></span>" +
|
|
184
|
+
"</div>" +
|
|
185
|
+
'<div class="card-actions">' +
|
|
186
|
+
buttons +
|
|
187
|
+
"</div>" +
|
|
188
|
+
"</article>"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderQueue(items) {
|
|
193
|
+
els.queueCount.textContent = String(items.length).padStart(2, "0");
|
|
194
|
+
if (items.length === 0) {
|
|
195
|
+
els.queueList.innerHTML = emptyStateHtml();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
els.queueList.innerHTML = items.map(cardHtml).join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loadQueue() {
|
|
202
|
+
return apiFetch("/api/inbox")
|
|
203
|
+
.then(function (res) {
|
|
204
|
+
if (!res.ok) throw new Error("inbox_fetch_failed");
|
|
205
|
+
return res.json();
|
|
206
|
+
})
|
|
207
|
+
.then(function (data) {
|
|
208
|
+
var items = Array.isArray(data) ? data : data && Array.isArray(data.items) ? data.items : [];
|
|
209
|
+
if (state.view === "queue") renderQueue(items);
|
|
210
|
+
return items;
|
|
211
|
+
})
|
|
212
|
+
.catch(function () {
|
|
213
|
+
// Best-effort poll — a transient failure should not crash the SPA.
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------------------------ detail UI
|
|
218
|
+
|
|
219
|
+
function screenshotPlaceholderHtml() {
|
|
220
|
+
return (
|
|
221
|
+
'<div class="screenshot-placeholder">' +
|
|
222
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">' +
|
|
223
|
+
'<rect x="3" y="3" width="18" height="18"></rect>' +
|
|
224
|
+
'<circle cx="8.5" cy="8.5" r="1.5"></circle>' +
|
|
225
|
+
'<path d="M21 15l-5-5L5 21"></path>' +
|
|
226
|
+
"</svg>" +
|
|
227
|
+
'<span class="mono">[ NO SNAPSHOT ]</span>' +
|
|
228
|
+
"</div>"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function threadHtml(thread) {
|
|
233
|
+
if (!thread || thread.length === 0) return '<div class="mono eyebrow">[ no messages ]</div>';
|
|
234
|
+
return (
|
|
235
|
+
'<div class="thread">' +
|
|
236
|
+
thread
|
|
237
|
+
.map(function (entry) {
|
|
238
|
+
var cls = entry.author === "human" ? "thread-entry human" : "thread-entry";
|
|
239
|
+
return (
|
|
240
|
+
'<div class="' +
|
|
241
|
+
cls +
|
|
242
|
+
'"><div class="who mono">[' +
|
|
243
|
+
escapeHtml(entry.author).toUpperCase() +
|
|
244
|
+
" · " +
|
|
245
|
+
escapeHtml(formatTime(entry.at)) +
|
|
246
|
+
']</div><div class="body-txt">' +
|
|
247
|
+
escapeHtml(entry.text) +
|
|
248
|
+
"</div></div>"
|
|
249
|
+
);
|
|
250
|
+
})
|
|
251
|
+
.join("") +
|
|
252
|
+
"</div>"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findingsHtml(findings) {
|
|
257
|
+
if (!findings || findings.length === 0) return "";
|
|
258
|
+
return (
|
|
259
|
+
'<div class="section-label mono">[FINDINGS]</div>' +
|
|
260
|
+
'<div class="findings">' +
|
|
261
|
+
findings
|
|
262
|
+
.map(function (f) {
|
|
263
|
+
return (
|
|
264
|
+
'<div class="finding ' +
|
|
265
|
+
escapeHtml(f.severity) +
|
|
266
|
+
'"><div class="title">[' +
|
|
267
|
+
escapeHtml(f.severity).toUpperCase() +
|
|
268
|
+
"] " +
|
|
269
|
+
escapeHtml(f.title) +
|
|
270
|
+
"</div>" +
|
|
271
|
+
(f.evidence ? '<div class="evidence mono">' + escapeHtml(f.evidence) + "</div>" : "") +
|
|
272
|
+
"</div>"
|
|
273
|
+
);
|
|
274
|
+
})
|
|
275
|
+
.join("") +
|
|
276
|
+
"</div>"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Agent-controlled data — only ever render `targetUrl` as a real link
|
|
281
|
+
* when it's actually http(s). The CSP already blocks a `javascript:` URI
|
|
282
|
+
* from executing, but this is cheap belt-and-braces so a non-http(s)
|
|
283
|
+
* value (or garbage) never even becomes a clickable anchor. */
|
|
284
|
+
function isHttpUrl(url) {
|
|
285
|
+
return typeof url === "string" && /^https?:\/\//i.test(url);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function detailActionsHtml(item) {
|
|
289
|
+
var actions = (ACTIONS_BY_KIND[item.kind] || []).filter(function (a) {
|
|
290
|
+
return a !== "view"; // already viewing
|
|
291
|
+
});
|
|
292
|
+
return actions
|
|
293
|
+
.map(function (action) {
|
|
294
|
+
return (
|
|
295
|
+
'<button type="button" class="' +
|
|
296
|
+
actionButtonClass(action) +
|
|
297
|
+
'" data-action="' +
|
|
298
|
+
action +
|
|
299
|
+
'" data-id="' +
|
|
300
|
+
escapeHtml(item.id) +
|
|
301
|
+
'">' +
|
|
302
|
+
ACTION_LABEL[action] +
|
|
303
|
+
"</button>"
|
|
304
|
+
);
|
|
305
|
+
})
|
|
306
|
+
.join("");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderDetail(item) {
|
|
310
|
+
var meta = KIND_META[item.kind] || KIND_META.approval;
|
|
311
|
+
var glow = item.kind === "handback" ? "glow-c" : "glow-m";
|
|
312
|
+
var kindActions = ACTIONS_BY_KIND[item.kind] || [];
|
|
313
|
+
// Only handbacks have a resume/provide-input action — approval/review
|
|
314
|
+
// items have nothing to do with typed text, so don't show the control.
|
|
315
|
+
var showInput = kindActions.indexOf("input") !== -1 || kindActions.indexOf("resume") !== -1;
|
|
316
|
+
|
|
317
|
+
var left =
|
|
318
|
+
'<div>' +
|
|
319
|
+
'<span class="badge" style="border-color:' +
|
|
320
|
+
meta.color +
|
|
321
|
+
'"><span class="led ' +
|
|
322
|
+
meta.ledClass +
|
|
323
|
+
'"></span><span class="mono" style="color:' +
|
|
324
|
+
meta.color +
|
|
325
|
+
'">[' +
|
|
326
|
+
meta.label +
|
|
327
|
+
"]</span></span>" +
|
|
328
|
+
'<h2 class="disp ' +
|
|
329
|
+
glow +
|
|
330
|
+
' detail-title">' +
|
|
331
|
+
escapeHtml(item.step) +
|
|
332
|
+
"</h2>" +
|
|
333
|
+
(item.targetUrl
|
|
334
|
+
? '<div class="mono detail-url">' +
|
|
335
|
+
(isHttpUrl(item.targetUrl)
|
|
336
|
+
? '<a href="' +
|
|
337
|
+
escapeHtml(item.targetUrl) +
|
|
338
|
+
'" target="_blank" rel="noopener noreferrer">' +
|
|
339
|
+
escapeHtml(item.targetUrl) +
|
|
340
|
+
"</a>"
|
|
341
|
+
: escapeHtml(item.targetUrl)) +
|
|
342
|
+
"</div>"
|
|
343
|
+
: "") +
|
|
344
|
+
'<div class="mono detail-meta">' +
|
|
345
|
+
"<span>[JOURNEY] <strong>" +
|
|
346
|
+
escapeHtml(item.journey) +
|
|
347
|
+
"</strong></span>" +
|
|
348
|
+
"<span>[RUN] <strong>" +
|
|
349
|
+
escapeHtml(item.run) +
|
|
350
|
+
"</strong></span>" +
|
|
351
|
+
'<span>[AGENT] <span class="agent" style="color:var(--cyan)">' +
|
|
352
|
+
escapeHtml(item.agent) +
|
|
353
|
+
"</span></span>" +
|
|
354
|
+
"<span>[CREATED] <strong>" +
|
|
355
|
+
escapeHtml(formatTime(item.createdAt)) +
|
|
356
|
+
"</strong></span>" +
|
|
357
|
+
"</div>" +
|
|
358
|
+
'<p class="body-txt detail-reason">' +
|
|
359
|
+
escapeHtml(item.reason) +
|
|
360
|
+
"</p>" +
|
|
361
|
+
findingsHtml(item.findings) +
|
|
362
|
+
'<div class="section-label mono">[THREAD]</div>' +
|
|
363
|
+
threadHtml(item.thread) +
|
|
364
|
+
(showInput
|
|
365
|
+
? '<div class="section-label mono">[PROVIDE INPUT]</div>' +
|
|
366
|
+
'<textarea class="ta" id="input-textarea" rows="4" placeholder="type a reply or the value the agent needs…"></textarea>'
|
|
367
|
+
: "") +
|
|
368
|
+
'<div class="detail-actions">' +
|
|
369
|
+
detailActionsHtml(item) +
|
|
370
|
+
"</div>" +
|
|
371
|
+
"</div>";
|
|
372
|
+
|
|
373
|
+
var right =
|
|
374
|
+
'<div>' +
|
|
375
|
+
'<div class="browser-frame">' +
|
|
376
|
+
'<div class="browser-chrome">' +
|
|
377
|
+
'<span class="led" style="background:var(--red)"></span>' +
|
|
378
|
+
'<span class="led" style="background:var(--amber)"></span>' +
|
|
379
|
+
'<span class="led" style="background:var(--green)"></span>' +
|
|
380
|
+
'<span class="mono chrome-url">' +
|
|
381
|
+
escapeHtml(item.targetUrl || "no target") +
|
|
382
|
+
"</span>" +
|
|
383
|
+
"</div>" +
|
|
384
|
+
'<div class="screenshot-body" id="screenshot-body">' +
|
|
385
|
+
'<img id="screenshot-img" src="/api/inbox/' +
|
|
386
|
+
encodeURIComponent(item.id) +
|
|
387
|
+
'/screenshot" alt="Step snapshot" />' +
|
|
388
|
+
"</div>" +
|
|
389
|
+
"</div>" +
|
|
390
|
+
'<div class="mono screenshot-caption">[SNAPSHOT · ' +
|
|
391
|
+
escapeHtml(item.step) +
|
|
392
|
+
"]</div>" +
|
|
393
|
+
"</div>";
|
|
394
|
+
|
|
395
|
+
els.detailRoot.innerHTML = left + right;
|
|
396
|
+
|
|
397
|
+
// Always attempt the fetch and rely on the `error` event (never inline
|
|
398
|
+
// `onerror=`, per CSP) to swap in the placeholder — covers both a
|
|
399
|
+
// missing file (server 404s) and `hasScreenshot: false` alike.
|
|
400
|
+
var img = document.getElementById("screenshot-img");
|
|
401
|
+
if (img) {
|
|
402
|
+
img.addEventListener("error", function onErr() {
|
|
403
|
+
var body = document.getElementById("screenshot-body");
|
|
404
|
+
if (body) body.innerHTML = screenshotPlaceholderHtml();
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function openDetail(id) {
|
|
410
|
+
return apiFetch("/api/inbox/" + encodeURIComponent(id))
|
|
411
|
+
.then(function (res) {
|
|
412
|
+
if (res.status === 404) {
|
|
413
|
+
showToast("item no longer available");
|
|
414
|
+
return loadQueue().then(showQueueView);
|
|
415
|
+
}
|
|
416
|
+
if (!res.ok) throw new Error("detail_fetch_failed");
|
|
417
|
+
return res.json().then(function (item) {
|
|
418
|
+
state.view = "detail";
|
|
419
|
+
state.detailId = id;
|
|
420
|
+
renderDetail(item);
|
|
421
|
+
els.queueScreen.hidden = true;
|
|
422
|
+
els.detailScreen.hidden = false;
|
|
423
|
+
});
|
|
424
|
+
})
|
|
425
|
+
.catch(function () {
|
|
426
|
+
showToast("could not load item");
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function showQueueView() {
|
|
431
|
+
state.view = "queue";
|
|
432
|
+
state.detailId = null;
|
|
433
|
+
els.detailScreen.hidden = true;
|
|
434
|
+
els.queueScreen.hidden = false;
|
|
435
|
+
loadQueue();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ------------------------------------------------------------- actions
|
|
439
|
+
|
|
440
|
+
function performAction(id, action) {
|
|
441
|
+
if (action === "view") {
|
|
442
|
+
openDetail(id);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// "Provide input" needs the textarea, which only exists on the detail
|
|
447
|
+
// screen — from a queue card, route there first instead of submitting
|
|
448
|
+
// an empty value.
|
|
449
|
+
if (action === "input" && (state.view !== "detail" || state.detailId !== id)) {
|
|
450
|
+
openDetail(id);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
var body = {};
|
|
455
|
+
if (action === "input") {
|
|
456
|
+
var ta = document.getElementById("input-textarea");
|
|
457
|
+
var text = ta ? ta.value.trim() : "";
|
|
458
|
+
if (!text) {
|
|
459
|
+
showToast("type something first");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
body.input = text;
|
|
463
|
+
} else if (action === "resume") {
|
|
464
|
+
var resumeTa = document.getElementById("input-textarea");
|
|
465
|
+
var resumeText = resumeTa ? resumeTa.value.trim() : "";
|
|
466
|
+
if (resumeText) body.input = resumeText;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
apiFetch("/api/inbox/" + encodeURIComponent(id) + "/" + action, {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: { "Content-Type": "application/json" },
|
|
472
|
+
body: JSON.stringify(body),
|
|
473
|
+
})
|
|
474
|
+
.then(function (res) {
|
|
475
|
+
if (res.status === 409) {
|
|
476
|
+
showToast("already resolved");
|
|
477
|
+
return refreshCurrentView();
|
|
478
|
+
}
|
|
479
|
+
if (res.status === 404) {
|
|
480
|
+
showToast("item no longer available");
|
|
481
|
+
return refreshCurrentView();
|
|
482
|
+
}
|
|
483
|
+
if (!res.ok) {
|
|
484
|
+
showToast("action failed");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (action === "input") {
|
|
488
|
+
showToast("input sent");
|
|
489
|
+
return openDetail(id);
|
|
490
|
+
}
|
|
491
|
+
showToast(RESOLVED_TOAST[action] || "done");
|
|
492
|
+
return showQueueView();
|
|
493
|
+
})
|
|
494
|
+
.catch(function () {
|
|
495
|
+
showToast("network error");
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function refreshCurrentView() {
|
|
500
|
+
if (state.view === "detail" && state.detailId) return openDetail(state.detailId);
|
|
501
|
+
return showQueueView();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// -------------------------------------------------------------- events
|
|
505
|
+
|
|
506
|
+
els.queueList.addEventListener("click", function (ev) {
|
|
507
|
+
var target = ev.target;
|
|
508
|
+
while (target && target !== els.queueList) {
|
|
509
|
+
if (target.tagName === "BUTTON" && target.dataset.action) {
|
|
510
|
+
performAction(target.dataset.id, target.dataset.action);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
target = target.parentElement;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
els.detailRoot.addEventListener("click", function (ev) {
|
|
518
|
+
var target = ev.target;
|
|
519
|
+
while (target && target !== els.detailRoot) {
|
|
520
|
+
if (target.tagName === "BUTTON" && target.dataset.action) {
|
|
521
|
+
performAction(target.dataset.id, target.dataset.action);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
target = target.parentElement;
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
els.backLink.addEventListener("click", function () {
|
|
529
|
+
showQueueView();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// --------------------------------------------------------------- init
|
|
533
|
+
|
|
534
|
+
showQueueView();
|
|
535
|
+
state.pollTimer = setInterval(function () {
|
|
536
|
+
loadQueue();
|
|
537
|
+
}, POLL_MS);
|
|
538
|
+
})();
|