@felixdotgo/aidlc-kit 0.2.0 → 0.3.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 (109) hide show
  1. package/DEVELOPMENT.md +13 -26
  2. package/README.md +35 -39
  3. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  4. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  5. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  6. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  7. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
  8. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
  9. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
  32. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  33. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  36. package/dist/assets/ui/changes.js +83 -0
  37. package/dist/assets/ui/common.js +664 -0
  38. package/dist/assets/ui/dashboard.css +281 -59
  39. package/dist/assets/ui/dashboard.js +77 -0
  40. package/dist/assets/ui/detail.js +159 -0
  41. package/dist/assets/ui/live-log.js +332 -0
  42. package/dist/services/aidlc-coordination/package.json +1 -1
  43. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
  44. package/dist/src/cli/argv.js +10 -4
  45. package/dist/src/cli/commands.js +52 -22
  46. package/dist/src/cli/loop-launcher.d.ts +47 -0
  47. package/dist/src/cli/loop-launcher.js +322 -0
  48. package/dist/src/cli.js +3 -0
  49. package/dist/src/installer.js +4 -2
  50. package/dist/src/model/records.d.ts +4 -0
  51. package/dist/src/state/validate.js +5 -0
  52. package/dist/src/task-ui.d.ts +31 -52
  53. package/dist/src/task-ui.js +25 -1562
  54. package/dist/src/ui/assets.d.ts +14 -0
  55. package/dist/src/ui/assets.js +45 -0
  56. package/dist/src/ui/changes.d.ts +83 -0
  57. package/dist/src/ui/changes.js +498 -0
  58. package/dist/src/ui/containers.d.ts +71 -0
  59. package/dist/src/ui/containers.js +214 -0
  60. package/dist/src/ui/daemon.d.ts +75 -0
  61. package/dist/src/ui/daemon.js +232 -0
  62. package/dist/src/ui/data.d.ts +42 -0
  63. package/dist/src/ui/data.js +89 -0
  64. package/dist/src/ui/docs.d.ts +13 -0
  65. package/dist/src/ui/docs.js +39 -0
  66. package/dist/src/ui/files.d.ts +3 -0
  67. package/dist/src/ui/files.js +20 -0
  68. package/dist/src/ui/http.d.ts +14 -0
  69. package/dist/src/ui/http.js +80 -0
  70. package/dist/src/ui/live.d.ts +5 -0
  71. package/dist/src/ui/live.js +43 -0
  72. package/dist/src/ui/markdown.d.ts +2 -0
  73. package/dist/src/ui/markdown.js +127 -0
  74. package/dist/src/ui/options.d.ts +48 -0
  75. package/dist/src/ui/options.js +81 -0
  76. package/dist/src/ui/security.d.ts +34 -0
  77. package/dist/src/ui/security.js +38 -0
  78. package/dist/src/ui/server.d.ts +39 -0
  79. package/dist/src/ui/server.js +347 -0
  80. package/dist/src/ui/sse.d.ts +14 -0
  81. package/dist/src/ui/sse.js +281 -0
  82. package/dist/src/ui/views/board.d.ts +7 -0
  83. package/dist/src/ui/views/board.js +131 -0
  84. package/dist/src/ui/views/changes.d.ts +19 -0
  85. package/dist/src/ui/views/changes.js +154 -0
  86. package/dist/src/ui/views/components.d.ts +56 -0
  87. package/dist/src/ui/views/components.js +113 -0
  88. package/dist/src/ui/views/docs.d.ts +15 -0
  89. package/dist/src/ui/views/docs.js +64 -0
  90. package/dist/src/ui/views/layout.d.ts +44 -0
  91. package/dist/src/ui/views/layout.js +233 -0
  92. package/dist/src/ui/views/task.d.ts +25 -0
  93. package/dist/src/ui/views/task.js +320 -0
  94. package/dist/src/upgrade.js +5 -2
  95. package/dist/src/workflow.d.ts +4 -0
  96. package/dist/src/workflow.js +33 -1
  97. package/docs/README.md +48 -33
  98. package/docs/architecture.md +101 -38
  99. package/docs/command-reference.md +269 -93
  100. package/docs/configuration.md +62 -42
  101. package/docs/coordination-service.md +85 -36
  102. package/docs/design-decisions.md +35 -31
  103. package/docs/development.md +111 -61
  104. package/docs/loop-local-run.md +135 -402
  105. package/docs/loop-runbook.md +84 -105
  106. package/docs/operating-workflow.md +55 -42
  107. package/docs/testing-and-release.md +70 -51
  108. package/docs/troubleshooting.md +165 -69
  109. package/package.json +1 -1
@@ -0,0 +1,664 @@
1
+ /*
2
+ * Loop dashboard: client script shared by every page (classic script, loaded
3
+ * before any page-specific script). Settings store, POST helper, toasts, modals,
4
+ * live-region polling and morphing (/api/live), header bell, task side panel,
5
+ * shared delegated handlers (cards, copy), the Settings dialog, and the new-task
6
+ * panel with its Markdown editor.
7
+ * Client-built text (toasts, log lines) uses textContent. HTML only ever comes
8
+ * from the server's own escaping renderers (live regions, side-panel fragment).
9
+ */
10
+
11
+ /* ---- Settings (localStorage "aidlc-settings") -------------------------------
12
+ * Client-side preferences shared by every page. `aidlcSettings.get()` returns a
13
+ * normalised copy (unknown or malformed values fall back to the defaults),
14
+ * `set(partial)` merges, persists, and notifies `onChange(fn)` listeners with
15
+ * (settings, changedKeys); `reset()` restores the defaults. Other scripts read it
16
+ * through `window.aidlcSettings`. The head script in layout.ts reads the same key
17
+ * before first paint for the theme. */
18
+ const SETTINGS_KEY = "aidlc-settings";
19
+ const SETTINGS_DEFAULTS = Object.freeze({ theme: "system", logFilter: "important", logRaw: false, livePoll: 1500, notify: false, diffWrap: true, diffIgnoreWs: false, logTailKb: 64, openCards: "panel" });
20
+ const SETTINGS_CHOICES = { theme: ["system", "light", "dark"], logFilter: ["important", "all"], livePoll: [1500, 3000, 0], logTailKb: [64, 256, 1024], openCards: ["panel", "page"] };
21
+ const aidlcSettings = (() => {
22
+ const listeners = [];
23
+ const normalize = (raw) => {
24
+ const out = Object.assign({}, SETTINGS_DEFAULTS);
25
+ const source = raw && typeof raw === "object" ? raw : {};
26
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
27
+ const value = source[key];
28
+ if (typeof SETTINGS_DEFAULTS[key] === "boolean") { if (typeof value === "boolean") out[key] = value; continue; }
29
+ const choice = typeof SETTINGS_DEFAULTS[key] === "number" ? Number(value) : value;
30
+ if (SETTINGS_CHOICES[key].includes(choice)) out[key] = choice;
31
+ }
32
+ return out;
33
+ };
34
+ const read = () => { try { return normalize(JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}")); } catch (error) { return normalize({}); } };
35
+ let current = read();
36
+ const get = () => Object.assign({}, current);
37
+ const emit = (changed) => { if (changed.length) for (const fn of listeners) { try { fn(get(), changed); } catch (error) { /* a listener must not break the others */ } } };
38
+ const set = (partial) => {
39
+ const next = normalize(Object.assign({}, current, partial || {}));
40
+ const changed = Object.keys(SETTINGS_DEFAULTS).filter((key) => next[key] !== current[key]);
41
+ current = next;
42
+ try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(current)); } catch (error) { /* storage disabled */ }
43
+ emit(changed);
44
+ return get();
45
+ };
46
+ const onChange = (fn) => { listeners.push(fn); return () => { const index = listeners.indexOf(fn); if (index >= 0) listeners.splice(index, 1); }; };
47
+ // Another tab saved: adopt its values and notify listeners here too.
48
+ window.addEventListener("storage", (event) => {
49
+ if (event.key !== SETTINGS_KEY && event.key !== null) return;
50
+ const next = read();
51
+ const changed = Object.keys(SETTINGS_DEFAULTS).filter((key) => next[key] !== current[key]);
52
+ current = next;
53
+ emit(changed);
54
+ });
55
+ return { get, set, onChange, reset: () => set(SETTINGS_DEFAULTS), defaults: () => Object.assign({}, SETTINGS_DEFAULTS) };
56
+ })();
57
+ window.aidlcSettings = aidlcSettings;
58
+ // Theme: `light`/`dark` pin Bulma's scheme through html[data-theme]; `system` leaves it to the OS.
59
+ const applyTheme = (theme) => { if (theme === "light" || theme === "dark") document.documentElement.setAttribute("data-theme", theme); else document.documentElement.removeAttribute("data-theme"); };
60
+ applyTheme(aidlcSettings.get().theme);
61
+ aidlcSettings.onChange((settings, changed) => { if (changed.includes("theme")) applyTheme(settings.theme); });
62
+
63
+ // Every state-changing request carries the custom header the server requires (it is not a
64
+ // "simple" header, so a cross-site page cannot send it without a CORS preflight).
65
+ const post = async (url, body) => {
66
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-aidlc-request": "1" }, body: JSON.stringify(body) });
67
+ let parsed = {};
68
+ try { parsed = await response.json(); } catch (error) { parsed = { error: "Unexpected response (" + response.status + ")" }; }
69
+ return { status: response.status, body: parsed };
70
+ };
71
+ // Toasts; an optional same-origin href turns the message into a link.
72
+ const toast = (message, kind = "info", detail = "", href = "") => {
73
+ const stack = document.getElementById("toasts");
74
+ if (!stack) return;
75
+ const icons = { success: "✓", danger: "!", warning: "!", info: "i" };
76
+ const item = document.createElement("div");
77
+ item.className = "ad-toast is-" + kind;
78
+ item.setAttribute("role", kind === "danger" ? "alert" : "status");
79
+ const icon = document.createElement("span");
80
+ icon.className = "ad-toast-icon";
81
+ icon.textContent = icons[kind] || "i";
82
+ const body = document.createElement("div");
83
+ body.className = "ad-toast-body";
84
+ if (href && href.startsWith("/")) { const link = document.createElement("a"); link.href = href; link.textContent = message; body.appendChild(link); }
85
+ else body.textContent = message;
86
+ if (detail) { const small = document.createElement("small"); small.textContent = detail; body.appendChild(small); }
87
+ const close = document.createElement("button");
88
+ close.className = "ad-toast-close";
89
+ close.setAttribute("aria-label", "dismiss");
90
+ close.textContent = "×";
91
+ const dismiss = () => { item.classList.add("is-leaving"); setTimeout(() => item.remove(), 220); };
92
+ close.addEventListener("click", dismiss);
93
+ item.append(icon, body, close);
94
+ stack.appendChild(item);
95
+ setTimeout(dismiss, kind === "danger" ? 9000 : href ? 8000 : 4500);
96
+ };
97
+ const failureToast = (res, fallback) => {
98
+ const message = (res.body && res.body.error) || fallback;
99
+ toast(message, "danger", res.status === 403 ? "The dashboard refused the request (HTTP 403): open it at the address printed by aidlc-kit ui." : "HTTP " + res.status);
100
+ };
101
+ const withLoading = async (button, work) => {
102
+ if (button) { button.classList.add("is-loading"); button.disabled = true; }
103
+ try { return await work(); }
104
+ catch (error) { toast("Request failed", "danger", String(error && error.message || error)); return undefined; }
105
+ finally { if (button) { button.classList.remove("is-loading"); button.disabled = false; } }
106
+ };
107
+ /* Overlay timings match the CSS transitions (dashboard.css "Overlays"). The dashboard
108
+ * animates regardless of the OS reduced-motion setting, so these timers always run. */
109
+ const MODAL_CLOSE_MS = 160;
110
+ const DRAWER_CLOSE_MS = 240;
111
+ const modalOpen = () => !!document.querySelector(".modal.is-active:not(.is-closing)");
112
+ const openDrawers = () => [...document.querySelectorAll(".ad-drawer.is-open")];
113
+ const topDrawer = () => { const list = openDrawers(); return list.length ? list[list.length - 1] : null; };
114
+ const anyDrawerOpen = () => openDrawers().length > 0;
115
+ const releaseScroll = () => { if (!modalOpen() && !anyDrawerOpen()) document.documentElement.classList.remove("is-clipped"); };
116
+ // Side panels animate in: unhide, commit the off-screen state with a reflow, then add
117
+ // is-open so the transform/opacity transitions actually run (adding it in the same
118
+ // frame as unhiding skips the transition).
119
+ const showDrawer = (el) => {
120
+ if (!el || el.classList.contains("is-open")) return false;
121
+ if (el.hidden) el._returnFocus = document.activeElement;
122
+ clearTimeout(el._hideTimer);
123
+ el.hidden = false;
124
+ void el.offsetWidth;
125
+ el.classList.add("is-open");
126
+ document.documentElement.classList.add("is-clipped");
127
+ const target = el.querySelector("[data-autofocus]") || el.querySelector(".ad-drawer-panel");
128
+ if (target) setTimeout(() => target.focus(), 60);
129
+ return true;
130
+ };
131
+ // A drawer mid-close counts as closed, so a quick reopen re-adds is-open instead of
132
+ // being hidden by the pending timer.
133
+ const hideDrawer = (el) => {
134
+ if (!el || el.hidden || !el.classList.contains("is-open")) return false;
135
+ el.classList.remove("is-open");
136
+ const finish = () => { if (!el.classList.contains("is-open")) el.hidden = true; };
137
+ el._hideTimer = setTimeout(finish, DRAWER_CLOSE_MS);
138
+ releaseScroll();
139
+ const back = el._returnFocus;
140
+ el._returnFocus = null;
141
+ if (back && typeof back.focus === "function") back.focus();
142
+ return true;
143
+ };
144
+ const openModal = (id) => {
145
+ const el = document.getElementById(id);
146
+ if (!el) return;
147
+ if (el.classList.contains("ad-drawer")) { showDrawer(el); return; }
148
+ clearTimeout(el._hideTimer);
149
+ el.classList.remove("is-closing");
150
+ el.classList.add("is-active");
151
+ document.documentElement.classList.add("is-clipped");
152
+ const focusable = el.querySelector("[data-autofocus]");
153
+ if (focusable) setTimeout(() => focusable.focus(), 30);
154
+ };
155
+ const closeModalEl = (el) => {
156
+ if (!el.classList.contains("is-active") || el.classList.contains("is-closing")) return;
157
+ el.classList.add("is-closing");
158
+ el._hideTimer = setTimeout(() => el.classList.remove("is-active", "is-closing"), MODAL_CLOSE_MS);
159
+ };
160
+ const closeModal = (id) => { const el = document.getElementById(id); if (el) closeModalEl(el); releaseScroll(); };
161
+ const closeAllModals = () => { document.querySelectorAll(".modal").forEach(closeModalEl); releaseScroll(); };
162
+
163
+ /* ---- Reactive regions -------------------------------------------------------
164
+ * The server renders each region ([data-live]) to HTML; the client polls
165
+ * /api/live with the hash it shows per region, gets back only changed regions,
166
+ * and morphs them into the DOM in place (keyed by id / data-key) instead of
167
+ * reloading the page. Morphing keeps focus, typed input, open <details>, and
168
+ * client state named in data-live-preserve; [data-live-ignore] subtrees (the
169
+ * live log) are never touched. Hooks re-apply view state (filters, collapsed
170
+ * columns) after every update. */
171
+ // Poll interval from Settings (`livePoll`: 1500 | 3000 | 0 = paused).
172
+ const liveInterval = () => aidlcSettings.get().livePoll;
173
+ const livePaused = () => liveInterval() === 0;
174
+ // Page size for header notifications; the server owns the value (body data-notify-page).
175
+ const NOTIFY_PAGE = Number(document.body.getAttribute("data-notify-page")) || 1;
176
+ const live = { notify: NOTIFY_PAGE, timer: 0, inflight: null, again: false, failures: 0, reviews: null, reviewCount: null, hooks: [] };
177
+ const keyOf = (node) => node.nodeType === 1 ? (node.id || node.getAttribute("data-key") || "") : "";
178
+ const sameNode = (a, b) => a.nodeType === b.nodeType && a.nodeName === b.nodeName && keyOf(a) === keyOf(b);
179
+ const preservedAttrs = (el) => {
180
+ const names = (el.getAttribute("data-live-preserve") || "").split(/\s+/).filter(Boolean);
181
+ if (el.tagName === "DETAILS") names.push("open");
182
+ return names;
183
+ };
184
+ const syncAttrs = (from, to) => {
185
+ const keep = preservedAttrs(from);
186
+ for (const { name } of [...from.attributes]) if (!keep.includes(name) && !to.hasAttribute(name)) from.removeAttribute(name);
187
+ for (const { name, value } of [...to.attributes]) if (!keep.includes(name) && from.getAttribute(name) !== value) from.setAttribute(name, value);
188
+ };
189
+ const morphNode = (from, to) => {
190
+ if (from.nodeType === 3 || from.nodeType === 8) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return; }
191
+ if (from.nodeType !== 1 || from.hasAttribute("data-live-ignore")) return;
192
+ syncAttrs(from, to);
193
+ // Form controls keep what the user typed or picked.
194
+ if (from.tagName === "INPUT" || from.tagName === "TEXTAREA" || from.tagName === "SELECT") return;
195
+ morphChildren(from, to);
196
+ };
197
+ const morphChildren = (from, to) => {
198
+ const keyed = new Map();
199
+ for (const child of from.children) { const key = keyOf(child); if (key) keyed.set(key, child); }
200
+ let cursor = from.firstChild;
201
+ for (const next of [...to.childNodes]) {
202
+ const key = keyOf(next);
203
+ let match = null;
204
+ if (key) { const found = keyed.get(key); if (found && sameNode(found, next)) { match = found; keyed.delete(key); } }
205
+ else if (cursor && !keyOf(cursor) && sameNode(cursor, next)) match = cursor;
206
+ if (match) {
207
+ if (match === cursor) cursor = cursor.nextSibling; else from.insertBefore(match, cursor);
208
+ morphNode(match, next);
209
+ } else from.insertBefore(next, cursor);
210
+ }
211
+ while (cursor) { const after = cursor.nextSibling; cursor.remove(); cursor = after; }
212
+ };
213
+ const applyRegion = (name, html, hash) => {
214
+ const el = document.querySelector("[data-live='" + name + "']");
215
+ if (!el) return false;
216
+ const template = document.createElement("template");
217
+ template.innerHTML = html;
218
+ morphChildren(el, template.content);
219
+ el.setAttribute("data-live-hash", hash);
220
+ return true;
221
+ };
222
+ let drawerTask = "";
223
+ const liveQuery = () => {
224
+ const params = new URLSearchParams();
225
+ const task = document.body.getAttribute("data-task");
226
+ if (task) params.set("task", task);
227
+ const known = [];
228
+ document.querySelectorAll("[data-live]").forEach((el) => {
229
+ const name = el.getAttribute("data-live");
230
+ if (name === "drawer" && !(drawerOpen() && drawerTask)) return;
231
+ known.push(name + ":" + (el.getAttribute("data-live-hash") || ""));
232
+ });
233
+ if (drawerOpen() && drawerTask) params.set("drawer", drawerTask);
234
+ params.set("notify", String(live.notify));
235
+ params.set("known", known.join(","));
236
+ return params.toString();
237
+ };
238
+ // Header pill: hidden while live updates flow; "reconnecting…" while the server is
239
+ // unreachable; "live updates paused" while Settings has polling off.
240
+ const LIVE_STATUS = {
241
+ reconnecting: ["reconnecting…", "Live updates paused: the dashboard server is unreachable; retrying"],
242
+ paused: ["live updates paused", "Live updates are paused in Settings; actions still refresh the page"],
243
+ };
244
+ const setLiveStatus = (state) => {
245
+ const el = document.getElementById("live-status");
246
+ if (!el) return;
247
+ if (state === "ok") { el.hidden = true; return; }
248
+ const [text, title] = LIVE_STATUS[state] || LIVE_STATUS.reconnecting;
249
+ [...el.childNodes].forEach((node) => { if (node.nodeType === 3) node.remove(); });
250
+ el.append(text);
251
+ el.title = title;
252
+ el.hidden = false;
253
+ };
254
+ const baseTitle = document.title.replace(/^\(\d+\) /, "");
255
+ /* Browser notifications (Settings → notify): one when the number of tasks waiting for
256
+ * review grows; clicking it brings this window back. `tag` collapses duplicates from
257
+ * other open tabs. */
258
+ const notificationsSupported = () => typeof Notification === "function";
259
+ const notificationState = () => !notificationsSupported() ? "unsupported" : Notification.permission;
260
+ const requestNotifications = () => {
261
+ if (!notificationsSupported() || Notification.permission !== "default") return Promise.resolve(notificationState());
262
+ try { return Promise.resolve(Notification.requestPermission()).then(() => notificationState(), () => notificationState()); }
263
+ catch (error) { return Promise.resolve(notificationState()); }
264
+ };
265
+ const notifyReview = (items) => {
266
+ if (!aidlcSettings.get().notify || notificationState() !== "granted") return;
267
+ try {
268
+ const first = items[0];
269
+ const body = items.length === 1 && first ? first.title + " · " + first.gate : items.length + " tasks are waiting for your review";
270
+ const note = new Notification("Task waiting for your review", { body, tag: "aidlc-review" });
271
+ note.onclick = () => { window.focus(); if (first && items.length === 1) location.href = "/tasks/" + encodeURIComponent(first.id); note.close(); };
272
+ } catch (error) { /* notifications unavailable in this context */ }
273
+ };
274
+ aidlcSettings.onChange((settings, changed) => { if (changed.includes("notify") && settings.notify) requestNotifications(); });
275
+ const onReviews = (reviews) => {
276
+ if (!reviews) return;
277
+ document.title = reviews.count ? "(" + reviews.count + ") " + baseTitle : baseTitle;
278
+ const ids = new Set(reviews.items.map((item) => item.id));
279
+ // The first poll only seeds the set: toast reviews that arrive while the page is open.
280
+ const fresh = [];
281
+ if (live.reviews) for (const item of reviews.items) {
282
+ if (!live.reviews.has(item.id)) { fresh.push(item); toast("Review needed: " + item.title, "warning", item.gate + " · " + item.id, "/tasks/" + encodeURIComponent(item.id)); }
283
+ }
284
+ if (live.reviewCount !== null && reviews.count > live.reviewCount) notifyReview(fresh.length ? fresh : reviews.items);
285
+ live.reviews = ids;
286
+ live.reviewCount = reviews.count;
287
+ };
288
+ const liveRefresh = async () => {
289
+ if (live.inflight) { live.again = true; return live.inflight; }
290
+ live.inflight = (async () => {
291
+ try {
292
+ const requestedDrawer = drawerOpen() ? drawerTask : "";
293
+ const response = await fetch("/api/live?" + liveQuery(), { cache: "no-store" });
294
+ if (!response.ok) throw new Error("HTTP " + response.status);
295
+ const data = await response.json();
296
+ const changed = [];
297
+ for (const [name, region] of Object.entries(data.regions || {})) {
298
+ // A drawer answer for a task the user has since switched away from is stale.
299
+ if (name === "drawer" && (!drawerOpen() || drawerTask !== requestedDrawer || data.drawer !== drawerTask)) continue;
300
+ if (applyRegion(name, region.html, region.hash)) changed.push(name);
301
+ }
302
+ if (changed.length) live.hooks.forEach((hook) => hook(changed));
303
+ onReviews(data.reviews);
304
+ live.failures = 0;
305
+ setLiveStatus(livePaused() ? "paused" : "ok");
306
+ } catch (error) {
307
+ live.failures += 1;
308
+ setLiveStatus("reconnecting");
309
+ } finally { live.inflight = null; }
310
+ })();
311
+ await live.inflight;
312
+ if (live.again) { live.again = false; await liveRefresh(); }
313
+ };
314
+ // Polls at the Settings interval (1.5s by default); backs off to 12s while the server
315
+ // is unreachable; does not poll at all while paused (actions still call liveRefresh).
316
+ const scheduleLive = () => {
317
+ clearTimeout(live.timer);
318
+ const interval = liveInterval();
319
+ if (!interval) { setLiveStatus("paused"); return; }
320
+ const delay = live.failures ? Math.min(12000, interval * 2 ** Math.min(live.failures, 3)) : interval;
321
+ live.timer = setTimeout(async () => { await liveRefresh(); scheduleLive(); }, delay);
322
+ };
323
+ document.addEventListener("visibilitychange", () => { if (!document.hidden && !livePaused()) { liveRefresh().then(scheduleLive); } });
324
+ aidlcSettings.onChange((settings, changed) => {
325
+ if (!changed.includes("livePoll")) return;
326
+ if (settings.livePoll) { setLiveStatus(live.failures ? "reconnecting" : "ok"); liveRefresh().then(scheduleLive); }
327
+ else scheduleLive();
328
+ });
329
+
330
+ // Header notifications: the bell toggles a dropdown of tasks waiting at a gate;
331
+ // each item is a plain link to the task detail page.
332
+ const notifyToggle = () => document.getElementById("notify-toggle");
333
+ const notifyOpen = () => { const panel = document.getElementById("notify-panel"); return !!panel && !panel.hidden; };
334
+ const setNotify = (open, options = {}) => {
335
+ const button = notifyToggle();
336
+ const panel = document.getElementById("notify-panel");
337
+ if (!button || !panel) return;
338
+ panel.hidden = !open;
339
+ button.setAttribute("aria-expanded", String(open));
340
+ if (open) { const first = panel.querySelector("a[href]"); if (first && options.focus) first.focus(); }
341
+ else if (options.restore) button.focus();
342
+ };
343
+
344
+ // Task side panel (drawer): slides in from the right, mirrors ?task=<id> in the
345
+ // URL so refresh/share reopens it and Back closes it. Only the newest fetch
346
+ // may write the body when the user switches tasks quickly; live updates then
347
+ // morph it like any other region.
348
+ let drawerRequest = 0;
349
+ const drawerEl = () => document.getElementById("task-drawer");
350
+ const drawerOpen = () => { const el = drawerEl(); return !!el && el.classList.contains("is-open"); };
351
+ const drawerTaskFromUrl = () => new URLSearchParams(location.search).get("task");
352
+ const openTaskDrawer = async (id, options = {}) => {
353
+ const el = drawerEl();
354
+ if (!el || !id) return;
355
+ const body = document.getElementById("task-drawer-body");
356
+ const title = document.getElementById("task-drawer-title");
357
+ const link = document.getElementById("task-drawer-open");
358
+ if (title) { const code = title.querySelector("code"); if (code) code.textContent = id; else title.textContent = id; title.setAttribute("data-copy", id); }
359
+ if (link) link.href = "/tasks/" + encodeURIComponent(id);
360
+ showDrawer(el);
361
+ if (options.push !== false && drawerTaskFromUrl() !== id) history.pushState({ task: id }, "", "?task=" + encodeURIComponent(id));
362
+ const request = ++drawerRequest;
363
+ const switching = drawerTask !== id;
364
+ drawerTask = id;
365
+ if (body && switching) { body.innerHTML = "<p class='has-text-grey'>Loading…</p>"; body.setAttribute("data-live-hash", ""); }
366
+ let html;
367
+ try {
368
+ const response = await fetch("/partial/tasks/" + encodeURIComponent(id));
369
+ html = response.ok ? await response.text() : "<p class='has-text-danger'>Failed to load task detail.</p>";
370
+ } catch (error) {
371
+ html = "<p class='has-text-danger'>Failed to load task detail.</p>";
372
+ }
373
+ if (request === drawerRequest && body) {
374
+ if (switching) { body.innerHTML = html; body.scrollTop = 0; }
375
+ else { const template = document.createElement("template"); template.innerHTML = html; morphChildren(body, template.content); }
376
+ body.setAttribute("data-live-hash", "");
377
+ }
378
+ };
379
+ const closeTaskDrawer = (options = {}) => {
380
+ const el = drawerEl();
381
+ if (!el || el.hidden) return;
382
+ drawerRequest += 1;
383
+ hideDrawer(el);
384
+ // Replace (not push) the ?task entry so Back after closing does not reopen it.
385
+ if (options.push !== false && drawerTaskFromUrl()) history.replaceState({}, "", location.pathname);
386
+ };
387
+ const closeDrawerEl = (el) => { if (el && el === drawerEl()) closeTaskDrawer(); else hideDrawer(el); };
388
+ window.addEventListener("popstate", () => {
389
+ const id = drawerTaskFromUrl();
390
+ if (id) openTaskDrawer(id, { push: false }); else closeTaskDrawer({ push: false });
391
+ });
392
+ const isTyping = (target) => !!target && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
393
+ const REQUIREMENT_LIMIT = 8192;
394
+
395
+ /* ---- Cards: the whole kanban card / list row opens its task. Plain click → side
396
+ * panel, or the task page when Settings → Board says so; ctrl/cmd-click or
397
+ * middle-click → full page in a new tab; shift-click → full page in this tab. ---- */
398
+ const openCard = (card, event) => {
399
+ const href = card.getAttribute("data-card-href");
400
+ if (!href) return;
401
+ if (event.ctrlKey || event.metaKey || event.button === 1) { window.open(href, "_blank", "noopener"); return; }
402
+ if (event.shiftKey || aidlcSettings.get().openCards === "page") { location.href = href; return; }
403
+ openTaskDrawer(card.getAttribute("data-task-id"));
404
+ };
405
+ // Middle-click: links handle it natively; elsewhere on a card open a new tab, and
406
+ // suppress the autoscroll cursor that middle mousedown would start.
407
+ document.addEventListener("mousedown", (event) => {
408
+ if (event.button !== 1 || !(event.target instanceof Element)) return;
409
+ if (event.target.closest("[data-card-href]") && !event.target.closest("a")) event.preventDefault();
410
+ });
411
+ document.addEventListener("auxclick", (event) => {
412
+ if (event.button !== 1 || !(event.target instanceof Element)) return;
413
+ const card = event.target.closest("[data-card-href]");
414
+ if (card && !event.target.closest("a, button")) { event.preventDefault(); openCard(card, event); }
415
+ });
416
+
417
+ /* ---- Copy to clipboard ([data-copy]): task ids on the detail page and side panel. ---- */
418
+ const copyToClipboard = async (text, button) => {
419
+ if (!text) return;
420
+ let copied = false;
421
+ try { await navigator.clipboard.writeText(text); copied = true; }
422
+ catch (error) {
423
+ const area = document.createElement("textarea");
424
+ area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0";
425
+ document.body.appendChild(area); area.select();
426
+ try { copied = document.execCommand("copy"); } catch (fallbackError) { copied = false; }
427
+ area.remove();
428
+ }
429
+ if (!copied) { toast("Could not copy", "warning", text); return; }
430
+ toast("Copied " + text, "success");
431
+ if (button) { button.classList.add("is-copied"); setTimeout(() => button.classList.remove("is-copied"), 1200); }
432
+ };
433
+
434
+ /* ---- Delegated events: controls inside live regions are re-rendered, so no
435
+ * handler is bound to an individual element. ---- */
436
+ document.addEventListener("click", (event) => {
437
+ const target = event.target instanceof Element ? event.target : null;
438
+ if (!target) return;
439
+ if (notifyOpen() && !target.closest("[data-notify]")) setNotify(false);
440
+ const bell = target.closest("#notify-toggle");
441
+ if (bell) { setNotify(!notifyOpen(), { focus: event.detail === 0 }); return; }
442
+ if (target.closest("[data-notify-more]")) {
443
+ live.notify += NOTIFY_PAGE;
444
+ liveRefresh();
445
+ return;
446
+ }
447
+ const copy = target.closest("[data-copy]");
448
+ if (copy) { event.preventDefault(); copyToClipboard(copy.getAttribute("data-copy") || "", copy); return; }
449
+ const opener = target.closest("[data-open]");
450
+ if (opener) { const id = opener.getAttribute("data-open"); if (id === "settings-modal") fillSettingsForm(aidlcSettings.get()); openModal(id); return; }
451
+ if (target.closest("[data-close]")) { closeAllModals(); return; }
452
+ const drawerClose = target.closest("[data-drawer-close]");
453
+ if (drawerClose) { closeDrawerEl(drawerClose.closest(".ad-drawer")); return; }
454
+ const taskLink = target.closest("[data-task-link]");
455
+ const card = target.closest("[data-card-href]");
456
+ if (taskLink) {
457
+ if (event.button !== 0 || event.altKey) return;
458
+ // Inside a card the link follows the card rules (shift = this tab); elsewhere
459
+ // modifier clicks keep the browser's own behaviour.
460
+ if (card && (event.shiftKey || event.ctrlKey || event.metaKey)) { event.preventDefault(); openCard(card, event); return; }
461
+ if (event.ctrlKey || event.metaKey || event.shiftKey) return;
462
+ event.preventDefault();
463
+ openTaskDrawer(taskLink.getAttribute("data-task-link"));
464
+ return;
465
+ }
466
+ if (card && !target.closest("a, button, input, select, textarea, summary, label") && !String(window.getSelection() || "")) { openCard(card, event); return; }
467
+ const resume = target.closest("[data-resume]");
468
+ if (resume) {
469
+ event.preventDefault();
470
+ const taskId = resume.getAttribute("data-resume");
471
+ withLoading(resume, async () => {
472
+ const res = await post("/api/loop/resume", { taskId });
473
+ if (res.status >= 200 && res.status < 300) { toast("Resumed " + taskId, "success", "The daemon will pick it up on its next poll"); await liveRefresh(); }
474
+ else failureToast(res, "Could not resume " + taskId);
475
+ });
476
+ }
477
+ });
478
+
479
+ /* ---- Settings dialog: the form mirrors aidlcSettings; Save applies at once. ---- */
480
+ const settingsControl = (key) => document.getElementById("settings-" + key);
481
+ const NOTIFY_STATE_TEXT = {
482
+ granted: "Notifications are allowed in this browser.",
483
+ denied: "Notifications are blocked by the browser; allow them for this site in its settings.",
484
+ default: "The browser will ask for permission when you save.",
485
+ unsupported: "This browser does not offer notifications here (some need HTTPS or localhost).",
486
+ };
487
+ const renderNotifyState = () => {
488
+ const el = document.getElementById("settings-notify-state");
489
+ const box = settingsControl("notify");
490
+ if (!el) return;
491
+ const state = notificationState();
492
+ el.textContent = box && !box.checked ? "" : NOTIFY_STATE_TEXT[state] || "";
493
+ el.classList.toggle("is-danger", state === "denied" && !!box && box.checked);
494
+ };
495
+ const fillSettingsForm = (values) => {
496
+ if (!document.getElementById("settings-form")) return;
497
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
498
+ if (key === "theme") { const radio = document.getElementById("settings-theme-" + values.theme); if (radio) radio.checked = true; continue; }
499
+ const el = settingsControl(key);
500
+ if (!el) continue;
501
+ if (el.type === "checkbox") el.checked = values[key] === true; else el.value = String(values[key]);
502
+ }
503
+ renderNotifyState();
504
+ };
505
+ const readSettingsForm = () => {
506
+ const values = {};
507
+ const theme = document.querySelector("input[name='settings-theme']:checked");
508
+ if (theme) values.theme = theme.value;
509
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
510
+ if (key === "theme") continue;
511
+ const el = settingsControl(key);
512
+ if (!el) continue;
513
+ values[key] = el.type === "checkbox" ? el.checked : (typeof SETTINGS_DEFAULTS[key] === "number" ? Number(el.value) : el.value);
514
+ }
515
+ return values;
516
+ };
517
+
518
+ document.addEventListener("DOMContentLoaded", () => {
519
+ const settingsSave = document.getElementById("settings-save");
520
+ if (settingsSave) settingsSave.addEventListener("click", () => {
521
+ const values = readSettingsForm();
522
+ aidlcSettings.set(values);
523
+ // Permission prompts need a user gesture: ask here, then report the outcome.
524
+ const asked = values.notify ? requestNotifications() : Promise.resolve(notificationState());
525
+ asked.then((state) => {
526
+ renderNotifyState();
527
+ if (values.notify && state === "denied") toast("Notifications are blocked by the browser", "warning", "Allow them for this site in the browser's settings; the other settings were saved.");
528
+ });
529
+ closeModal("settings-modal");
530
+ toast("Settings saved", "success");
531
+ });
532
+ const settingsReset = document.getElementById("settings-reset");
533
+ if (settingsReset) settingsReset.addEventListener("click", () => { fillSettingsForm(aidlcSettings.reset()); toast("Settings reset to defaults", "info"); });
534
+ const notifyBox = settingsControl("notify");
535
+ if (notifyBox) notifyBox.addEventListener("change", renderNotifyState);
536
+ // The theme radios preview immediately; closing without Save restores the saved value.
537
+ document.querySelectorAll("input[name='settings-theme']").forEach((radio) => radio.addEventListener("change", () => applyTheme(radio.value)));
538
+ const settingsModal = document.getElementById("settings-modal");
539
+ if (settingsModal) new MutationObserver(() => { if (!settingsModal.classList.contains("is-active")) applyTheme(aidlcSettings.get().theme); }).observe(settingsModal, { attributes: true, attributeFilter: ["class"] });
540
+ if (aidlcSettings.get().notify) requestNotifications();
541
+ document.addEventListener("keydown", (event) => {
542
+ if (event.key === "Escape") { if (notifyOpen()) setNotify(false, { restore: true }); else if (modalOpen()) closeAllModals(); else closeDrawerEl(topDrawer()); return; }
543
+ if (event.key === "Tab" && anyDrawerOpen() && !modalOpen()) {
544
+ const panel = topDrawer().querySelector(".ad-drawer-panel");
545
+ const focusable = [...panel.querySelectorAll("a[href], button:not([disabled]), input, select, textarea, summary, [tabindex]:not([tabindex='-1'])")].filter((node) => node.offsetParent !== null);
546
+ if (!focusable.length) { event.preventDefault(); panel.focus(); return; }
547
+ const first = focusable[0], last = focusable[focusable.length - 1];
548
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus(); }
549
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
550
+ return;
551
+ }
552
+ if (event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target) || modalOpen() || anyDrawerOpen()) return;
553
+ if (event.key === "n" && document.getElementById("new-task-drawer")) { event.preventDefault(); openModal("new-task-drawer"); }
554
+ else if (event.key === "/") { const search = document.getElementById("task-search"); if (search) { event.preventDefault(); search.focus(); search.select(); } }
555
+ });
556
+ const requirement = document.getElementById("new-task-requirement");
557
+ const counter = document.getElementById("new-task-count");
558
+ const startBtn = document.getElementById("new-task-submit");
559
+ const preview = document.getElementById("new-task-preview");
560
+ const DRAFT_KEY = "aidlc-new-task-draft";
561
+ const saveDraft = () => { try { if (requirement.value) localStorage.setItem(DRAFT_KEY, requirement.value); else localStorage.removeItem(DRAFT_KEY); } catch (error) { /* storage disabled */ } };
562
+ const updateCounter = () => {
563
+ if (!requirement || !counter) return;
564
+ const used = new TextEncoder().encode(requirement.value).length;
565
+ counter.textContent = used + " / " + REQUIREMENT_LIMIT + " bytes";
566
+ counter.classList.toggle("is-over", used > REQUIREMENT_LIMIT);
567
+ };
568
+ /* Markdown editor: toolbar actions edit the selection through insertText so the
569
+ * browser's undo stack keeps working; setRangeText is the fallback. */
570
+ const replaceSelection = (text, selectFrom, selectTo) => {
571
+ const start = requirement.selectionStart;
572
+ requirement.focus();
573
+ if (!document.execCommand || !document.execCommand("insertText", false, text)) requirement.setRangeText(text, start, requirement.selectionEnd, "end");
574
+ if (selectFrom !== undefined) requirement.setSelectionRange(start + selectFrom, start + selectTo);
575
+ requirement.dispatchEvent(new Event("input"));
576
+ };
577
+ const wrapSelection = (before, after, placeholder) => {
578
+ const selected = requirement.value.slice(requirement.selectionStart, requirement.selectionEnd) || placeholder;
579
+ replaceSelection(before + selected + after, before.length, before.length + selected.length);
580
+ };
581
+ const prefixLines = (prefix) => {
582
+ const value = requirement.value;
583
+ const from = value.lastIndexOf("\n", requirement.selectionStart - 1) + 1;
584
+ let to = value.indexOf("\n", requirement.selectionEnd);
585
+ if (to < 0) to = value.length;
586
+ requirement.setSelectionRange(from, to);
587
+ const lines = value.slice(from, to).split("\n").map((line, index) => (typeof prefix === "function" ? prefix(index) : prefix) + line);
588
+ replaceSelection(lines.join("\n"));
589
+ };
590
+ const MARKDOWN_ACTIONS = {
591
+ heading: () => prefixLines("## "),
592
+ bold: () => wrapSelection("**", "**", "bold text"),
593
+ italic: () => wrapSelection("_", "_", "italic text"),
594
+ code: () => wrapSelection("`", "`", "code"),
595
+ codeblock: () => wrapSelection("\n```\n", "\n```\n", "code"),
596
+ ul: () => prefixLines("- "),
597
+ ol: () => prefixLines((index) => (index + 1) + ". "),
598
+ quote: () => prefixLines("> "),
599
+ link: () => {
600
+ const selected = requirement.value.slice(requirement.selectionStart, requirement.selectionEnd) || "link text";
601
+ replaceSelection("[" + selected + "](https://)", selected.length + 3, selected.length + 11);
602
+ },
603
+ };
604
+ let previewRequest = 0;
605
+ const setEditorTab = async (tab) => {
606
+ if (!requirement || !preview) return;
607
+ document.querySelectorAll("[data-editor-tab]").forEach((button) => button.setAttribute("aria-selected", String(button.getAttribute("data-editor-tab") === tab)));
608
+ const previewing = tab === "preview";
609
+ requirement.hidden = previewing;
610
+ preview.hidden = !previewing;
611
+ document.querySelectorAll("[data-md]").forEach((button) => { button.disabled = previewing; });
612
+ if (!previewing) { requirement.focus(); return; }
613
+ if (!requirement.value.trim()) { preview.innerHTML = "<p class='has-text-grey'>Nothing to preview yet.</p>"; return; }
614
+ preview.innerHTML = "<p class='has-text-grey'>Rendering…</p>";
615
+ const request = ++previewRequest;
616
+ let res;
617
+ try { res = await post("/api/markdown", { markdown: requirement.value }); }
618
+ catch (error) { res = { status: 0, body: { error: "Preview failed: the dashboard server is unreachable" } }; }
619
+ if (request !== previewRequest || preview.hidden) return; // a newer preview or the Write tab won
620
+ // The server renders with the same escaping renderer as task artifacts.
621
+ if (res.status === 200 && typeof res.body.html === "string") preview.innerHTML = res.body.html;
622
+ else {
623
+ preview.textContent = "";
624
+ const note = document.createElement("p");
625
+ note.className = "has-text-danger";
626
+ note.textContent = res.body.error || "Preview failed";
627
+ preview.appendChild(note);
628
+ }
629
+ };
630
+ document.addEventListener("click", (event) => {
631
+ const target = event.target instanceof Element ? event.target : null;
632
+ const action = target && target.closest("[data-md]");
633
+ if (action && requirement && MARKDOWN_ACTIONS[action.getAttribute("data-md")]) { event.preventDefault(); MARKDOWN_ACTIONS[action.getAttribute("data-md")](); return; }
634
+ const tab = target && target.closest("[data-editor-tab]");
635
+ if (tab) { event.preventDefault(); setEditorTab(tab.getAttribute("data-editor-tab")); }
636
+ });
637
+ if (requirement) {
638
+ try { const draft = localStorage.getItem(DRAFT_KEY); if (draft && !requirement.value) requirement.value = draft; } catch (error) { /* storage disabled */ }
639
+ requirement.addEventListener("input", () => { updateCounter(); saveDraft(); });
640
+ requirement.addEventListener("keydown", (event) => {
641
+ if (!(event.ctrlKey || event.metaKey)) return;
642
+ if (event.key === "Enter" && startBtn) { event.preventDefault(); startBtn.click(); return; }
643
+ const shortcut = { b: "bold", i: "italic", k: "link" }[event.key.toLowerCase()];
644
+ if (shortcut && !event.shiftKey && !event.altKey) { event.preventDefault(); MARKDOWN_ACTIONS[shortcut](); }
645
+ });
646
+ updateCounter();
647
+ }
648
+ if (startBtn) startBtn.addEventListener("click", () => withLoading(startBtn, async () => {
649
+ const value = requirement ? requirement.value : "";
650
+ if (!value.trim()) { toast("Describe the requirement first", "warning"); setEditorTab("write"); return; }
651
+ const res = await post("/api/loop/start", { requirement: value });
652
+ if (res.status >= 200 && res.status < 300) {
653
+ const daemon = res.body.daemon || {};
654
+ toast("Task " + res.body.taskId + " created", "success", daemon.started ? "Started the loop daemon (pid " + daemon.pid + ")" : "Picked up by the running daemon", "/tasks/" + encodeURIComponent(res.body.taskId));
655
+ hideDrawer(document.getElementById("new-task-drawer"));
656
+ if (requirement) { requirement.value = ""; updateCounter(); saveDraft(); setEditorTab("write"); }
657
+ await liveRefresh();
658
+ } else failureToast(res, "Could not create the task");
659
+ }));
660
+
661
+ const initialTask = drawerTaskFromUrl();
662
+ if (initialTask && drawerEl()) openTaskDrawer(initialTask, { push: false });
663
+ liveRefresh().then(scheduleLive);
664
+ });