@tidegap/cli 0.0.0-stage → 0.19.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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +136 -2
  3. package/dist/commands/clients.d.ts +20 -0
  4. package/dist/commands/clients.d.ts.map +1 -0
  5. package/dist/commands/clients.js +36 -0
  6. package/dist/commands/clients.js.map +1 -0
  7. package/dist/commands/env-file.d.ts +54 -0
  8. package/dist/commands/env-file.d.ts.map +1 -0
  9. package/dist/commands/env-file.js +153 -0
  10. package/dist/commands/env-file.js.map +1 -0
  11. package/dist/commands/install.d.ts +83 -0
  12. package/dist/commands/install.d.ts.map +1 -0
  13. package/dist/commands/install.js +297 -0
  14. package/dist/commands/install.js.map +1 -0
  15. package/dist/commands/queue.d.ts +139 -0
  16. package/dist/commands/queue.d.ts.map +1 -0
  17. package/dist/commands/queue.js +491 -0
  18. package/dist/commands/queue.js.map +1 -0
  19. package/dist/commands/receipts.d.ts +15 -0
  20. package/dist/commands/receipts.d.ts.map +1 -0
  21. package/dist/commands/receipts.js +70 -0
  22. package/dist/commands/receipts.js.map +1 -0
  23. package/dist/commands/register-wake.d.ts +21 -0
  24. package/dist/commands/register-wake.d.ts.map +1 -0
  25. package/dist/commands/register-wake.js +76 -0
  26. package/dist/commands/register-wake.js.map +1 -0
  27. package/dist/commands/stats.d.ts +31 -0
  28. package/dist/commands/stats.d.ts.map +1 -0
  29. package/dist/commands/stats.js +165 -0
  30. package/dist/commands/stats.js.map +1 -0
  31. package/dist/commands/tick.d.ts +73 -0
  32. package/dist/commands/tick.d.ts.map +1 -0
  33. package/dist/commands/tick.js +294 -0
  34. package/dist/commands/tick.js.map +1 -0
  35. package/dist/commands/ui.d.ts +33 -0
  36. package/dist/commands/ui.d.ts.map +1 -0
  37. package/dist/commands/ui.js +119 -0
  38. package/dist/commands/ui.js.map +1 -0
  39. package/dist/commands/verify.d.ts +32 -0
  40. package/dist/commands/verify.d.ts.map +1 -0
  41. package/dist/commands/verify.js +135 -0
  42. package/dist/commands/verify.js.map +1 -0
  43. package/dist/env.d.ts +57 -0
  44. package/dist/env.d.ts.map +1 -0
  45. package/dist/env.js +105 -0
  46. package/dist/env.js.map +1 -0
  47. package/dist/index.d.ts +25 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +366 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/platform/index.d.ts +30 -0
  52. package/dist/platform/index.d.ts.map +1 -0
  53. package/dist/platform/index.js +66 -0
  54. package/dist/platform/index.js.map +1 -0
  55. package/dist/platform/linux.d.ts +81 -0
  56. package/dist/platform/linux.d.ts.map +1 -0
  57. package/dist/platform/linux.js +158 -0
  58. package/dist/platform/linux.js.map +1 -0
  59. package/dist/platform/macos.d.ts +69 -0
  60. package/dist/platform/macos.d.ts.map +1 -0
  61. package/dist/platform/macos.js +151 -0
  62. package/dist/platform/macos.js.map +1 -0
  63. package/dist/platform/windows.d.ts +31 -0
  64. package/dist/platform/windows.d.ts.map +1 -0
  65. package/dist/platform/windows.js +58 -0
  66. package/dist/platform/windows.js.map +1 -0
  67. package/dist/ui/api.d.ts +66 -0
  68. package/dist/ui/api.d.ts.map +1 -0
  69. package/dist/ui/api.js +613 -0
  70. package/dist/ui/api.js.map +1 -0
  71. package/dist/ui/clients.d.ts +65 -0
  72. package/dist/ui/clients.d.ts.map +1 -0
  73. package/dist/ui/clients.js +179 -0
  74. package/dist/ui/clients.js.map +1 -0
  75. package/dist/ui/discovery.d.ts +25 -0
  76. package/dist/ui/discovery.d.ts.map +1 -0
  77. package/dist/ui/discovery.js +46 -0
  78. package/dist/ui/discovery.js.map +1 -0
  79. package/dist/ui/dispatcher.d.ts +84 -0
  80. package/dist/ui/dispatcher.d.ts.map +1 -0
  81. package/dist/ui/dispatcher.js +150 -0
  82. package/dist/ui/dispatcher.js.map +1 -0
  83. package/dist/ui/events.d.ts +27 -0
  84. package/dist/ui/events.d.ts.map +1 -0
  85. package/dist/ui/events.js +85 -0
  86. package/dist/ui/events.js.map +1 -0
  87. package/dist/ui/server.d.ts +84 -0
  88. package/dist/ui/server.d.ts.map +1 -0
  89. package/dist/ui/server.js +322 -0
  90. package/dist/ui/server.js.map +1 -0
  91. package/dist/ui/summary.d.ts +47 -0
  92. package/dist/ui/summary.d.ts.map +1 -0
  93. package/dist/ui/summary.js +120 -0
  94. package/dist/ui/summary.js.map +1 -0
  95. package/package.json +53 -3
  96. package/ui/agenda.js +256 -0
  97. package/ui/api.js +114 -0
  98. package/ui/app.css +713 -0
  99. package/ui/app.js +599 -0
  100. package/ui/clients.js +104 -0
  101. package/ui/detail.js +431 -0
  102. package/ui/dialog.js +284 -0
  103. package/ui/dnd.js +131 -0
  104. package/ui/dom.js +255 -0
  105. package/ui/index.html +38 -0
  106. package/ui/journal.js +207 -0
  107. package/ui/keys.js +92 -0
  108. package/ui/row.js +153 -0
  109. package/ui/state.js +412 -0
  110. package/ui/timeline.js +202 -0
  111. package/ui/toast.js +69 -0
  112. package/ui/tokens.css +108 -0
  113. package/ui/views.js +31 -0
package/ui/app.js ADDED
@@ -0,0 +1,599 @@
1
+ // tidegap ui front end: bootstrap, router, actions, dispatcher banner.
2
+ // Dependency-free ES modules, no build step. Every task string is rendered
3
+ // through text nodes (dom.js); nothing here writes HTML from data.
4
+
5
+ import { api, ApiError, hasToken, SessionExpired, setToken } from "./api.js";
6
+ import { renderAgenda, ranNow, cleanest } from "./agenda.js";
7
+ import {
8
+ actionMenu,
9
+ closeDialog,
10
+ confirmDialog,
11
+ dayName,
12
+ helpDialog,
13
+ initDialog,
14
+ moveDialog,
15
+ movedText,
16
+ rescheduleDialog,
17
+ } from "./dialog.js";
18
+ import { initDnd, isDragging } from "./dnd.js";
19
+ import {
20
+ closeDetail,
21
+ copyResume,
22
+ detailId,
23
+ detailTask,
24
+ focusReply,
25
+ onDetailEvent,
26
+ openDetail,
27
+ renderDetail,
28
+ retryDetail,
29
+ sendReply,
30
+ trapFocus,
31
+ } from "./detail.js";
32
+ import { initClients } from "./clients.js";
33
+ import { ago, h, isUnread, parse, relDay, swapChildren, taskHref, title } from "./dom.js";
34
+ import { invalidateJournal, renderJournal, toggleExpand } from "./journal.js";
35
+ import { initKeys } from "./keys.js";
36
+ import {
37
+ cancelPending,
38
+ cancelWithUndo,
39
+ findTask,
40
+ finishedTasks,
41
+ flushDelayed,
42
+ notify,
43
+ onTaskEvent,
44
+ perform,
45
+ refresh,
46
+ refreshStatus,
47
+ state,
48
+ stream,
49
+ subscribe,
50
+ } from "./state.js";
51
+ import { renderTimeline, selectMarker } from "./timeline.js";
52
+ import { errorToast, toast } from "./toast.js";
53
+
54
+ const VIEWS = { agenda: renderAgenda, timeline: renderTimeline, journal: renderJournal };
55
+ const HOUR = 3_600_000;
56
+
57
+ let currentView = "agenda";
58
+ let viewHash = "#/agenda";
59
+ let deferred = false;
60
+ let bannerNote = null;
61
+ let renderedOp = null;
62
+
63
+ // ── routing ─────────────────────────────────────────────────────────────
64
+
65
+ function parseRoute() {
66
+ const task = /^#\/task\/([^?]+)$/.exec(location.hash);
67
+ if (task) {
68
+ try {
69
+ return { taskId: decodeURIComponent(task[1]) };
70
+ } catch {
71
+ return { taskId: task[1] };
72
+ }
73
+ }
74
+ const view = /^#\/(agenda|timeline|journal)(\?.*)?$/.exec(location.hash);
75
+ return { view: view ? view[1] : "agenda" };
76
+ }
77
+
78
+ function onRoute() {
79
+ if (/^#token=/.test(location.hash)) {
80
+ location.reload();
81
+ return;
82
+ }
83
+ const r = parseRoute();
84
+ if (r.taskId) {
85
+ void openDetail(r.taskId);
86
+ } else {
87
+ currentView = r.view;
88
+ viewHash = location.hash.startsWith(`#/${r.view}`) ? location.hash : `#/${r.view}`;
89
+ closeDetail();
90
+ }
91
+ render();
92
+ }
93
+
94
+ function go(view) {
95
+ location.hash = view === "journal" && viewHash.startsWith("#/journal") ? viewHash : `#/${view}`;
96
+ }
97
+
98
+ function closeDrawer() {
99
+ if (location.hash.startsWith("#/task/")) location.hash = viewHash;
100
+ else closeDetail();
101
+ }
102
+
103
+ // ── render ──────────────────────────────────────────────────────────────
104
+
105
+ function render() {
106
+ if (state.expired) return showExpired();
107
+ if (isDragging()) {
108
+ deferred = true;
109
+ return;
110
+ }
111
+ const view = document.getElementById("view");
112
+ const focused = document.activeElement?.closest?.("#view .row[data-task-id]")?.dataset.taskId;
113
+ const focusedSelect = document.activeElement?.closest?.("#view select")?.name;
114
+ const wrap = h("div", { class: `view-inner view-${currentView}` });
115
+ // currentView comes from the hash; parseRoute already limits it to the
116
+ // three names, and this lookup refuses anything else on its own.
117
+ const renderView = Object.hasOwn(VIEWS, currentView) ? VIEWS[currentView] : renderAgenda;
118
+ renderView(wrap, { selectedId: detailId(), width: view.clientWidth - 2 * gutter() });
119
+ // Keep the live DOM when nothing changed, so a click or hover in flight
120
+ // is not lost to an identical redraw (the stream fires on every tick).
121
+ if (view.firstElementChild?.isEqualNode(wrap)) {
122
+ finishRender();
123
+ return;
124
+ }
125
+ view.replaceChildren(wrap);
126
+ if (focused) view.querySelector(`.row[data-task-id="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true });
127
+ if (focusedSelect) view.querySelector(`select[name="${focusedSelect}"]`)?.focus({ preventScroll: true });
128
+ finishRender();
129
+ }
130
+
131
+ function finishRender() {
132
+
133
+ for (const v of Object.keys(VIEWS)) {
134
+ const a = document.getElementById(`nav-${v}`);
135
+ if (v === currentView) a.setAttribute("aria-current", "page");
136
+ else a.removeAttribute("aria-current");
137
+ }
138
+ const unread = finishedTasks().filter(isUnread).length;
139
+ const count = document.getElementById("unread-count");
140
+ count.textContent = String(unread);
141
+ count.hidden = unread === 0;
142
+ count.setAttribute("aria-label", `${unread} unread`);
143
+ document.getElementById("conn").hidden = state.connected;
144
+ renderDispatcher();
145
+ syncDetail();
146
+ }
147
+
148
+ /** Re-draw the drawer when the optimistic state of its task changed. */
149
+ function syncDetail() {
150
+ const id = detailId();
151
+ if (!id) return;
152
+ const op = findTask(id)?._op || null;
153
+ if (op !== renderedOp) {
154
+ renderedOp = op;
155
+ renderDetail();
156
+ }
157
+ }
158
+
159
+ function gutter() {
160
+ return window.innerWidth < 720 ? 16 : 32;
161
+ }
162
+
163
+ function showExpired() {
164
+ closeDialog();
165
+ document.getElementById("drawer").hidden = true;
166
+ document.getElementById("scrim").hidden = true;
167
+ document.getElementById("conn").hidden = true;
168
+ document.getElementById("banner").hidden = true;
169
+ document.getElementById("view").replaceChildren(
170
+ h("div", { class: "empty empty-full" },
171
+ h("div", { class: "empty-glyph", "aria-hidden": "true" }, "⏻"),
172
+ h("p", { class: "empty-title" }, "This link expired."),
173
+ h("p", { class: "empty-body" }, "Run ", h("code", {}, "tidegap ui"), " again to get a new link. Links work for one run of the server.")),
174
+ );
175
+ }
176
+
177
+ // ── dispatcher pill and banner ──────────────────────────────────────────
178
+
179
+ function dispatcherInfo() {
180
+ const st = state.status;
181
+ if (!st) return null;
182
+ const d = st.dispatcher || {
183
+ state: st.last_tick_at ? "stale" : "never",
184
+ last_tick_at: st.last_tick_at,
185
+ interval_s: 300,
186
+ ui_child: null,
187
+ };
188
+ return d;
189
+ }
190
+
191
+ function renderDispatcher() {
192
+ const d = dispatcherInfo();
193
+ const pill = document.getElementById("dispatcher-pill");
194
+ const text = document.getElementById("dispatcher-text");
195
+ const banner = document.getElementById("banner");
196
+ if (!d) {
197
+ pill.dataset.state = "unknown";
198
+ text.textContent = "Dispatcher";
199
+ banner.hidden = true;
200
+ return;
201
+ }
202
+ const last = parse(d.last_tick_at);
203
+ const child = d.ui_child?.alive ? d.ui_child : null;
204
+ pill.dataset.state = d.state;
205
+ text.textContent =
206
+ d.state === "running"
207
+ ? child && !(Number.isFinite(last) && Date.now() - last <= 2 * d.interval_s * 1000)
208
+ ? `Dispatcher running · PID ${child.pid}`
209
+ : `Dispatcher ran ${ago(last)}`
210
+ : d.state === "stale"
211
+ ? `Dispatcher idle · ${ago(last)}`
212
+ : "No dispatcher";
213
+ pill.title = "Dispatcher status";
214
+
215
+ if (bannerNote) {
216
+ banner.dataset.tone = "info";
217
+ swapChildren(banner, [
218
+ h("span", { class: "banner-icon", "aria-hidden": "true" }, "●"),
219
+ h("span", { class: "banner-text" }, bannerNote),
220
+ h("button", { type: "button", class: "icon-btn banner-close", "aria-label": "Dismiss", "data-action": "banner-dismiss" }, "×"),
221
+ ]);
222
+ banner.hidden = false;
223
+ return;
224
+ }
225
+ if (d.state === "running") {
226
+ banner.hidden = true;
227
+ return;
228
+ }
229
+ const waiting = state.pending.length;
230
+ banner.dataset.tone = waiting ? "warn" : "quiet";
231
+ const msg =
232
+ d.state === "never"
233
+ ? "No dispatcher has run on this ledger yet."
234
+ : `No dispatcher has run for ${ago(last).replace(" ago", "")}.`;
235
+ swapChildren(banner, [
236
+ h("span", { class: "banner-icon", "aria-hidden": "true" }, waiting ? "!" : "i"),
237
+ h("span", { class: "banner-text" }, msg, " ", waiting ? `${waiting} pending task${waiting === 1 ? "" : "s"} will not run.` : "Nothing is pending."),
238
+ h("button", { type: "button", class: "btn btn-sm btn-primary", "data-action": "start-dispatcher" }, "Start dispatcher"),
239
+ ]);
240
+ banner.hidden = false;
241
+ }
242
+
243
+ async function startDispatcher(button) {
244
+ if (button) button.disabled = true;
245
+ try {
246
+ const meta = await api.startDispatcher();
247
+ bannerNote = `Dispatcher started · PID ${meta.pid} · log ${meta.log}`;
248
+ toast(`Dispatcher started · PID ${meta.pid}`);
249
+ } catch (err) {
250
+ if (err instanceof SessionExpired) {
251
+ state.expired = true;
252
+ } else if (err instanceof ApiError && err.status === 409) {
253
+ // Two refusals share 409: a child of this page runs, or another
254
+ // dispatcher ticked the ledger recently. The server's line says which.
255
+ toast(`Not started: ${err.message}.`);
256
+ } else {
257
+ errorToast(`Could not start the dispatcher${err instanceof ApiError ? `: ${err.message}.` : "."}`);
258
+ }
259
+ } finally {
260
+ if (button) button.disabled = false;
261
+ }
262
+ await refreshStatus();
263
+ }
264
+
265
+ function dispatcherDialog() {
266
+ const d = dispatcherInfo();
267
+ if (!d) return;
268
+ const st = state.status;
269
+ const last = parse(d.last_tick_at);
270
+ const rows = [
271
+ ["state", d.state === "running" ? "running" : d.state === "stale" ? "idle (stale)" : "never ran"],
272
+ ["last tick", Number.isFinite(last) ? ago(last) : "never"],
273
+ ["interval", `${d.interval_s} s`],
274
+ ["daemon", st.daemon === "installed" ? "installed" : st.daemon === "not_installed" ? "not installed" : String(st.daemon)],
275
+ ["started here", d.ui_child ? `PID ${d.ui_child.pid}${d.ui_child.alive ? "" : " (exited)"}` : null],
276
+ ["log", d.ui_child?.log],
277
+ ["ledger", st.db_path],
278
+ ];
279
+ const dl = h("dl", { class: "kv" });
280
+ for (const [k, v] of rows) if (v) dl.append(h("dt", {}, k), h("dd", {}, v));
281
+ const dlg = document.getElementById("dialog");
282
+ if (dlg.open) dlg.close();
283
+ dlg.className = "dialog";
284
+ dlg.replaceChildren(
285
+ h("header", { class: "dialog-head" }, h("h2", { class: "dialog-title", id: "dialog-title" }, "Dispatcher"),
286
+ h("p", { class: "dialog-sub" }, "tidegap ui never runs tasks itself. A dispatcher (", h("code", {}, "tidegap tick"), " or the OpenClaw loop) does.")),
287
+ dl,
288
+ h("footer", { class: "dialog-foot" },
289
+ h("button", { type: "button", class: "btn btn-ghost", onclick: () => dlg.close() }, "Close"),
290
+ d.state !== "running" ? h("button", { type: "button", class: "btn btn-primary", onclick: () => { dlg.close(); void startDispatcher(); } }, "Start dispatcher") : null),
291
+ );
292
+ dlg.showModal();
293
+ }
294
+
295
+ // ── actions ─────────────────────────────────────────────────────────────
296
+
297
+ function taskFor(id) {
298
+ const fromList = findTask(id);
299
+ if (fromList) return fromList;
300
+ const d = detailTask();
301
+ return d && d.task_id === id ? d : null;
302
+ }
303
+
304
+ function nextTickHint() {
305
+ const d = dispatcherInfo();
306
+ if (!d || d.state === "running") return { text: "Runs on the next tick" };
307
+ return {
308
+ text: "Runs on the next tick. No dispatcher is running.",
309
+ action: { label: "Start dispatcher", run: () => void startDispatcher() },
310
+ };
311
+ }
312
+
313
+ async function runNow(task) {
314
+ ranNow.add(task.task_id);
315
+ const out = await perform({
316
+ id: task.task_id,
317
+ action: "run now",
318
+ label: "Queuing to run now…",
319
+ patch: { scheduled_for: new Date().toISOString(), not_before: null },
320
+ send: () => api.runNow(task.task_id),
321
+ confirm: () => {
322
+ const hint = nextTickHint();
323
+ return { text: `“${title(task)}” ${hint.text.charAt(0).toLowerCase()}${hint.text.slice(1)}`, action: hint.action };
324
+ },
325
+ });
326
+ // A refused or failed run-now leaves the task where it was.
327
+ if (out === undefined) {
328
+ ranNow.delete(task.task_id);
329
+ notify();
330
+ }
331
+ return out;
332
+ }
333
+
334
+ /** Rough run time inside a day, for the optimistic row: the cleanest hour. */
335
+ function guessInDay(task, start, bounds) {
336
+ const from = Date.parse(bounds.day_start);
337
+ const to = Date.parse(bounds.day_end);
338
+ const best = cleanest(task.region, from, to);
339
+ if (best) return Math.max(best.ms, from);
340
+ const at = parse(task.scheduled_for);
341
+ const offset = Number.isFinite(at) ? at - new Date(at).setHours(0, 0, 0, 0) : 12 * HOUR;
342
+ return Math.min(Math.max(start + offset, from), to - HOUR);
343
+ }
344
+
345
+ function moveToDay(task, start, bounds) {
346
+ if (!bounds) {
347
+ errorToast("That day is over. Pick a later day.");
348
+ return;
349
+ }
350
+ const guess = guessInDay(task, start, bounds);
351
+ const label = dayName(start);
352
+ return perform({
353
+ id: task.task_id,
354
+ action: "move",
355
+ label: `Moving to ${relDay(start)}…`,
356
+ patch: { scheduled_for: new Date(guess).toISOString(), deadline: bounds.day_end, deadline_kind: "absolute" },
357
+ send: () => api.reschedule(task.task_id, bounds),
358
+ confirm: (fresh) => movedText(fresh, `Moved to ${label}`, true),
359
+ });
360
+ }
361
+
362
+ function reschedule(task) {
363
+ rescheduleDialog(task, (target, summary, ms) => {
364
+ if (target.day_start) return moveToDay(task, ms, target);
365
+ const patch = target.at
366
+ ? { scheduled_for: target.at, deadline: parse(task.deadline) >= ms ? task.deadline : target.at, deadline_kind: "absolute" }
367
+ : { deadline: target.deadline, deadline_kind: "absolute", scheduled_for: new Date(Math.min(parse(task.scheduled_for) || ms, ms)).toISOString() };
368
+ return perform({
369
+ id: task.task_id,
370
+ action: "reschedule",
371
+ label: "Rescheduling…",
372
+ patch,
373
+ send: () => api.reschedule(task.task_id, target),
374
+ confirm: (fresh) => movedText(fresh, summary),
375
+ });
376
+ });
377
+ }
378
+
379
+ function setRead(task, read) {
380
+ return perform({
381
+ id: task.task_id,
382
+ action: read ? "mark read" : "mark unread",
383
+ label: "Saving…",
384
+ patch: { read_at: read ? new Date().toISOString() : null },
385
+ send: () => (read ? api.read(task.task_id) : api.unread(task.task_id)),
386
+ confirm: () => (read ? "Marked read" : "Marked unread"),
387
+ });
388
+ }
389
+
390
+ async function stopSeries(task) {
391
+ const ok = await confirmDialog({
392
+ heading: "Stop this series?",
393
+ body: `“${title(task)}” is cancelled and no further occurrences are created. Earlier runs stay in the Journal.`,
394
+ confirm: "Stop series",
395
+ danger: true,
396
+ });
397
+ if (ok) cancelWithUndo(task, { series: true });
398
+ }
399
+
400
+ function act(name, id, source) {
401
+ if (name === "retry") return void refresh();
402
+ if (name === "start-dispatcher") return void startDispatcher(source);
403
+ if (name === "banner-dismiss") {
404
+ bannerNote = null;
405
+ return render();
406
+ }
407
+ if (name === "close") return closeDrawer();
408
+ if (name === "detail-retry") return retryDetail();
409
+ if (name === "reply-send") return void sendReply();
410
+ if (name === "copy-resume") return void copyResume(source);
411
+ if (!id) return;
412
+ if (name === "expand") return toggleExpand(id);
413
+ if (name === "open") return void (location.hash = taskHref(id));
414
+
415
+ const task = taskFor(id);
416
+ if (!task) return;
417
+ if (task._op || cancelPending(id)) {
418
+ toast("Still saving the last change to this task…");
419
+ return;
420
+ }
421
+ const a = task.actions || {};
422
+ const refuse = (what) => toast(`${what} is not available for a ${task.status} task.`);
423
+ switch (name) {
424
+ case "run-now":
425
+ return a.runNow ? void runNow(task) : refuse("Run now");
426
+ case "cancel":
427
+ if (!a.cancel) return refuse("Cancel");
428
+ keepFocusNear(id);
429
+ return cancelWithUndo(task);
430
+ case "stop-series":
431
+ return a.stopSeries ? void stopSeries(task) : refuse("Stop series");
432
+ case "reschedule":
433
+ return a.reschedule ? reschedule(task) : refuse("Reschedule");
434
+ case "move":
435
+ return a.reschedule
436
+ ? moveDialog(task, {
437
+ runNow: () => act("run-now", id),
438
+ moveTo: (start, bounds) => void moveToDay(task, start, bounds),
439
+ pick: () => reschedule(task),
440
+ })
441
+ : refuse("Move");
442
+ case "read":
443
+ return a.markRead ? void setRead(task, true) : undefined;
444
+ case "unread":
445
+ return a.markRead ? void setRead(task, false) : undefined;
446
+ case "toggle-read":
447
+ return a.markRead ? void setRead(task, !task.read_at) : refuse("Read marker");
448
+ case "reply-focus":
449
+ if (!a.reply) return refuse("Reply");
450
+ if (detailId() !== id) location.hash = taskHref(id);
451
+ return void waitFor(() => focusReply());
452
+ case "menu":
453
+ return actionMenu(task, {
454
+ open: () => (location.hash = taskHref(id)),
455
+ runNow: () => act("run-now", id),
456
+ move: () => act("move", id),
457
+ reschedule: () => act("reschedule", id),
458
+ cancel: () => act("cancel", id),
459
+ stopSeries: () => act("stop-series", id),
460
+ toggleRead: () => act("toggle-read", id),
461
+ });
462
+ }
463
+ }
464
+
465
+ /** When the focused row is about to leave the list, move focus to its neighbour. */
466
+ function keepFocusNear(id) {
467
+ const rows = [...document.querySelectorAll("#view .row[data-task-id]")];
468
+ const i = rows.findIndex((r) => r.dataset.taskId === id);
469
+ if (i < 0 || document.activeElement !== rows[i]) return;
470
+ const next = (rows[i + 1] || rows[i - 1])?.dataset.taskId;
471
+ if (!next) return;
472
+ setTimeout(() => document.querySelector(`#view .row[data-task-id="${CSS.escape(next)}"]`)?.focus({ preventScroll: true }), 0);
473
+ }
474
+
475
+ /** Retry `fn` for a short while until it returns true (drawer still loading). */
476
+ async function waitFor(fn, tries = 20) {
477
+ for (let i = 0; i < tries; i++) {
478
+ if (fn()) return;
479
+ await new Promise((r) => setTimeout(r, 100));
480
+ }
481
+ }
482
+
483
+ function onClick(e) {
484
+ const target = e.target instanceof Element ? e.target : null;
485
+ if (!target) return;
486
+ const marker = target.closest("[data-select]");
487
+ if (marker) return selectMarker(marker.dataset.select);
488
+ const button = target.closest("[data-action]");
489
+ if (button && !button.disabled) {
490
+ if (button.tagName === "BUTTON" && button.type === "submit") return; // the form's submit handler runs
491
+ e.preventDefault();
492
+ const holder = button.closest("[data-task-id]");
493
+ return act(button.dataset.action, holder?.dataset.taskId, button);
494
+ }
495
+ const row = target.closest("#view .row[data-task-id]");
496
+ if (row && !target.closest("a, button, select, input")) location.hash = taskHref(row.dataset.taskId);
497
+ }
498
+
499
+ // ── boot ────────────────────────────────────────────────────────────────
500
+
501
+ function boot() {
502
+ const m = /^#token=([A-Za-z0-9_-]+)$/.exec(location.hash);
503
+ if (m) {
504
+ setToken(m[1]);
505
+ // The token stays in memory only: drop it from the address bar and history.
506
+ history.replaceState(null, "", `${location.pathname}#/agenda`);
507
+ }
508
+ if (!hasToken()) {
509
+ state.expired = true;
510
+ showExpired();
511
+ return;
512
+ }
513
+
514
+ initDialog();
515
+ subscribe(render);
516
+ onTaskEvent((ids) => {
517
+ invalidateJournal(ids);
518
+ onDetailEvent(ids);
519
+ });
520
+ initDnd({
521
+ findTask: taskFor,
522
+ onMove: (task, start, bounds) => void moveToDay(task, start, bounds),
523
+ onRunNow: (task) => void runNow(task),
524
+ onEnd: () => {
525
+ if (deferred) {
526
+ deferred = false;
527
+ render();
528
+ }
529
+ },
530
+ });
531
+ initKeys({
532
+ current: () => detailId(),
533
+ open: (id, opts) => {
534
+ if (opts?.keepFocus) {
535
+ history.replaceState(null, "", taskHref(id));
536
+ void openDetail(id);
537
+ render();
538
+ } else location.hash = taskHref(id);
539
+ },
540
+ close: closeDrawer,
541
+ drawerOpen: () => !!detailId(),
542
+ act: (name, id) => act(name, id),
543
+ view: go,
544
+ filters: () => {
545
+ if (currentView !== "journal") go("journal");
546
+ void waitFor(() => {
547
+ const sel = document.getElementById("filter-status");
548
+ sel?.focus();
549
+ return !!sel;
550
+ });
551
+ },
552
+ help: helpDialog,
553
+ });
554
+
555
+ document.getElementById("view").addEventListener("click", onClick);
556
+ document.getElementById("drawer").addEventListener("click", onClick);
557
+ document.getElementById("banner").addEventListener("click", onClick);
558
+ document.getElementById("drawer").addEventListener("keydown", (e) => {
559
+ trapFocus(e);
560
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && e.target.classList?.contains("reply-input")) {
561
+ e.preventDefault();
562
+ if (!e.repeat) void sendReply();
563
+ }
564
+ });
565
+ document.getElementById("drawer").addEventListener("submit", (e) => {
566
+ e.preventDefault();
567
+ void sendReply();
568
+ });
569
+ document.getElementById("scrim").addEventListener("click", closeDrawer);
570
+ document.getElementById("help-btn").addEventListener("click", helpDialog);
571
+ void initClients();
572
+ document.getElementById("dispatcher-pill").addEventListener("click", dispatcherDialog);
573
+ document.getElementById("view").addEventListener("keydown", (e) => {
574
+ if (e.key === "Enter" || e.key === " ") {
575
+ const marker = e.target instanceof Element ? e.target.closest("[data-select]") : null;
576
+ if (marker) {
577
+ e.preventDefault();
578
+ selectMarker(marker.dataset.select);
579
+ }
580
+ }
581
+ });
582
+
583
+ window.addEventListener("hashchange", onRoute);
584
+ window.addEventListener("pagehide", flushDelayed);
585
+ let resizeTimer = null;
586
+ window.addEventListener("resize", () => {
587
+ clearTimeout(resizeTimer);
588
+ resizeTimer = setTimeout(() => currentView === "timeline" && render(), 150);
589
+ });
590
+ // Relative times ("ran 2 min ago", overdue) and the dispatcher state age.
591
+ setInterval(() => void refreshStatus(), 30_000);
592
+ setInterval(() => notify(), 60_000);
593
+
594
+ onRoute();
595
+ void refresh();
596
+ void stream();
597
+ }
598
+
599
+ boot();
package/ui/clients.js ADDED
@@ -0,0 +1,104 @@
1
+ // "Connected apps": list the browser extension and Mac app clients, mint a
2
+ // token for a new one (shown once), and revoke one. Page token only: with a
3
+ // client token `/api/clients` answers 403 and the button stays hidden.
4
+ // Client names are user text and render as text nodes only.
5
+
6
+ import { api, ApiError } from "./api.js";
7
+ import { closeDialog, confirmDialog, openDialog } from "./dialog.js";
8
+ import { ago, h, parse } from "./dom.js";
9
+ import { errorToast, toast } from "./toast.js";
10
+
11
+ const KIND_LABEL = { extension: "Browser extension", macos: "Mac app" };
12
+
13
+ /** Show the Apps button when this token may manage clients. */
14
+ export async function initClients() {
15
+ const btn = document.getElementById("apps-btn");
16
+ if (!btn) return;
17
+ try {
18
+ await api.clients();
19
+ } catch (err) {
20
+ if (err instanceof ApiError && err.status === 403) return;
21
+ }
22
+ btn.hidden = false;
23
+ btn.addEventListener("click", () => void openApps());
24
+ }
25
+
26
+ function tokenBox(minted) {
27
+ const field = h("input", { class: "input token-field", readonly: true, value: minted.token, "aria-label": "Client token" });
28
+ const copy = async () => {
29
+ try {
30
+ await navigator.clipboard.writeText(minted.token);
31
+ toast("Token copied");
32
+ } catch {
33
+ field.select();
34
+ }
35
+ };
36
+ return h("section", { class: "token-box", role: "status" },
37
+ h("p", { class: "dialog-body" }, `Token for "${minted.client.name}". Paste it into the extension's options. It is shown only now.`),
38
+ h("div", { class: "field-row" },
39
+ field,
40
+ h("button", { type: "button", class: "btn btn-primary", onclick: () => void copy() }, "Copy")));
41
+ }
42
+
43
+ function clientRow(c) {
44
+ const created = parse(c.created_at);
45
+ return h("li", { class: "app-row" },
46
+ h("span", { class: "app-name" }, c.name),
47
+ h("span", { class: "app-meta" }, `${KIND_LABEL[c.kind] || c.kind} · added ${Number.isFinite(created) ? ago(created) : "?"}`),
48
+ h("button", { type: "button", class: "btn btn-sm", onclick: () => void revoke(c) }, "Revoke"));
49
+ }
50
+
51
+ async function openApps(minted) {
52
+ let clients = [];
53
+ let failed = "";
54
+ try {
55
+ clients = (await api.clients()).clients;
56
+ } catch (err) {
57
+ failed = err.message;
58
+ }
59
+ const name = h("input", { class: "input", id: "app-name", name: "name", maxlength: "64", required: true, placeholder: "Chrome on this Mac", autofocus: !minted });
60
+ const submit = h("button", { type: "submit", class: "btn btn-primary" }, "Connect an app");
61
+ const form = h("form", { class: "dialog-form" },
62
+ h("header", { class: "dialog-head" }, h("h2", { id: "dialog-title", class: "dialog-title" }, "Connected apps"),
63
+ h("p", { class: "dialog-sub" }, "Apps with a token can see and queue tasks on this Mac. Revoke one to lock it out at once.")),
64
+ minted ? tokenBox(minted) : null,
65
+ failed ? h("p", { class: "dialog-body" }, failed)
66
+ : clients.length ? h("ul", { class: "app-list" }, clients.map(clientRow))
67
+ : h("p", { class: "dialog-body" }, "No apps connected."),
68
+ h("label", { class: "field", for: "app-name" }, h("span", { class: "field-label" }, "New app name"), name),
69
+ h("footer", { class: "dialog-foot" },
70
+ h("button", { type: "button", class: "btn btn-ghost", onclick: closeDialog }, "Close"),
71
+ submit));
72
+ // One press, one token: a second submit while the first is in flight is ignored.
73
+ form.addEventListener("submit", async (e) => {
74
+ e.preventDefault();
75
+ if (submit.disabled) return;
76
+ submit.disabled = true;
77
+ try {
78
+ const out = await api.addClient({ name: name.value, kind: "extension" });
79
+ await openApps(out);
80
+ } catch (err) {
81
+ errorToast(err.message);
82
+ } finally {
83
+ submit.disabled = false;
84
+ }
85
+ });
86
+ openDialog([form], { label: "Connected apps", wide: true });
87
+ }
88
+
89
+ async function revoke(c) {
90
+ const ok = await confirmDialog({
91
+ heading: `Revoke ${c.name}?`,
92
+ body: "Its token stops working on the next request. Connect it again to get a new one.",
93
+ confirm: "Revoke",
94
+ danger: true,
95
+ });
96
+ if (!ok) return void openApps();
97
+ try {
98
+ await api.revokeClient(c.id);
99
+ toast(`Revoked ${c.name}`);
100
+ } catch (err) {
101
+ errorToast(err.message);
102
+ }
103
+ await openApps();
104
+ }