slidemind 0.3.20 → 0.4.1

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 (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1886 @@
1
+ // The workspace page. Plain script, no build step. State lives in this closure;
2
+ // every screen is re-rendered from a fresh GET (SSE only says "re-GET").
3
+ //
4
+ // Boot: exchange `#bootstrap=<key>` (the workspace's link key, valid every time
5
+ // it is clicked) for the session cookie + CSRF token, strip
6
+ // the fragment, then route on location.pathname:
7
+ // / library + inbox
8
+ // /deck/<id>[/plan|/preview] deck tabs — the address follows the tab, so
9
+ // the link an agent posts opens the view itself
10
+ // /deck/<id>/tasks/<taskId> an older task link: opens that task's tab
11
+ //
12
+ // The workspace restarts under the page (a crash, a reboot, every CLI update):
13
+ // the page notices (a dropped event stream, a failed request), says so in ONE
14
+ // sentence, polls /health and picks up where it was — same cookie, same port —
15
+ // without a new link. Nothing the user typed is dropped on the way.
16
+ //
17
+ // Pure helpers hang off window.SlidemindApp so the test can call them in-page.
18
+ (function () {
19
+ "use strict";
20
+ const API = "/api/v1";
21
+ const AUTOSAVE_MS = 500;
22
+ const RETRY_MS = 2000; // a busy deck: the autosave tries again by itself
23
+ const POLL_MS = window.SLIDEMIND_POLL_MS ?? 30_000; // visible shared plan: GET-only refresh, never a PUT
24
+ const HEALTH_MS = window.SLIDEMIND_HEALTH_MS ?? 3_000; // offline: how often the page asks whether the workspace is back
25
+ const BUSY_HINT_MS = 60_000; // a deck still busy after this long: tell the user how to nudge the agent
26
+ const RECENT_TYPING_MS = 15_000; // keys in the Markdown editor this recently: an agent's ask waits in a toast
27
+ const OFFLINE_TEXT = "The workspace stopped — your work is kept. This page reconnects by itself.";
28
+ const SIGNED_OUT_TEXT = "Your workspace signed you out — ask your agent to open the slidemind workspace.";
29
+ const $app = document.getElementById("app");
30
+ const $conn = document.getElementById("conn");
31
+ const $banner = document.getElementById("conn-banner");
32
+ // the shell (top bar + deck sidebar) lives in index.html and is refreshed alongside every screen
33
+ const $library = document.getElementById("library");
34
+ const $more = document.getElementById("more");
35
+ const $search = document.getElementById("library-search");
36
+ const $inboxCount = document.getElementById("inbox-count");
37
+ const $inboxLink = document.getElementById("inbox-link");
38
+ const $metaTitle = document.getElementById("meta-title");
39
+ const $metaSub = document.getElementById("meta-sub");
40
+ const $topActions = document.getElementById("top-actions");
41
+ let csrf = window.SLIDEMIND_CSRF ?? null;
42
+ let sse = null;
43
+ let current = null; // { kind:"home"|"deck", deckId?, taskId?, tab? }
44
+ let planEditor = null; // the core-plan editor state while the Plan tab is open
45
+ let planFrame = null; // the plan iframe while the Plan tab is open (adapter page or the deck's plan.html)
46
+ let planSaving = null; // the plan frame's save in flight, so an answer can wait for it
47
+ let poll = null; // the 30s refresh timer for a visible shared plan
48
+ let planView = null; // { setMode } of the core plan tab, so an agent's ask can bring the page view forward
49
+ const answered = new Set(); // task ids this page answered: their continuation note survives re-renders
50
+ const deckNames = new Map(); // deck id → name, from the library, for toasts about decks not on screen
51
+
52
+ // ---------- pure helpers ----------
53
+ const h = (tag, attrs = {}, ...kids) => {
54
+ const el = document.createElement(tag);
55
+ for (const [k, v] of Object.entries(attrs)) {
56
+ if (v === null || v === undefined || v === false) continue;
57
+ if (k === "class") el.className = v;
58
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
59
+ else el.setAttribute(k, v === true ? "" : v);
60
+ }
61
+ for (const kid of kids.flat()) if (kid !== null && kid !== undefined) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
62
+ return el;
63
+ };
64
+ /** An inline 24×24 icon; `button svg` sizes it. innerHTML is ours, never user text. */
65
+ const icon = (d) => {
66
+ const el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
67
+ el.setAttribute("viewBox", "0 0 24 24");
68
+ el.innerHTML = d;
69
+ return el;
70
+ };
71
+ const ICON_CHEVRON = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6"/>';
72
+ const ICON_REVEAL = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M8 7h9v9"/>';
73
+ const ICON_FOLDER = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>';
74
+ // Lucide copy / check: the copy icon people know from every chat and code block
75
+ const ICON_COPY = '<rect width="14" height="14" x="8" y="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>';
76
+ const ICON_CHECK = '<path fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/>';
77
+ const ICON_TRASH = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M10 11v6M14 11v6"/>';
78
+ const LABEL = { approve: "Approve", "request-changes": "Request changes", done: "Done", comment: "Comment", resolve: "Resolve" };
79
+ /** Which phase (tab) a task belongs to and how the inbox names it; gate kinds are answered inside the gate itself. */
80
+ const PHASE = {
81
+ "plan-approval": { label: "Planning", tab: "plan", gate: true, ask: "The agent asks you to approve the plan. Approve it or send changes in the review below." },
82
+ "plan-edit": { label: "Plan editing", tab: "plan", gate: false, ask: "The agent asks you to edit the plan. Switch to Markdown, edit, then mark it done." },
83
+ "preview-review": { label: "Preview review", tab: "preview", gate: true, ask: "The agent asks you to review the slides. Pin comments, then approve or send changes in the review below." },
84
+ "sync-conflict": { label: "Sync conflict", tab: "plan", gate: true, ask: "The shared plan changed where you changed it. Resolve it in the panel below." },
85
+ };
86
+ const phaseOf = (kind) => PHASE[kind] ?? { label: kind, tab: "plan", gate: false, ask: "The agent asks you to answer this task." };
87
+ /** The tab a question is asked in: its phase's, or the deck's first when the deck has no such tab — never nowhere. */
88
+ const tabOf = (task, tabs) => (tabs.includes(phaseOf(task.kind).tab) ? phaseOf(task.kind).tab : tabs[0]);
89
+ const actionLabel = (a) => LABEL[a] ?? a;
90
+ /**
91
+ * An answer's button: what the click decides, in two words. The click also
92
+ * copies the message the user pastes to their agent; the dialog right after
93
+ * it says so — once, never on the button and never again under the bar (doc 21).
94
+ */
95
+ const answerLabel = (a) => ({ approve: "Approve", "request-changes": "Send changes", comment: "Send comment", done: "Done", resolve: "Resolve" })[a] ?? actionLabel(a);
96
+ const fmtTime = (iso) => (iso ? new Date(iso).toLocaleString() : "never");
97
+ /** The library chip for a deck's layout generation; null for a current one. */
98
+ const formatChip = (d) => (d?.format === "legacy" ? { text: "Legacy", cls: "bad" } : d?.format === "unknown" ? { text: "empty", cls: "" } : null);
99
+ /** Which tabs a deck view offers; only capabilities the view lists. A legacy deck has none (the header says why). */
100
+ function tabsFor(view) {
101
+ const tabs = [];
102
+ if (view.deck?.format === "legacy") return tabs;
103
+ if (view.adapter?.blocker || (view.views ?? []).some((v) => v.id === "plan") || view.adapter?.id === "core") tabs.push("plan");
104
+ if (view.preview !== false) tabs.push("preview");
105
+ return tabs;
106
+ }
107
+ /** What a refused request means to the person at the page: one plain sentence, never a code. */
108
+ const PLAIN = {
109
+ offline: OFFLINE_TEXT,
110
+ workspace_stopping: OFFLINE_TEXT,
111
+ stopping: OFFLINE_TEXT,
112
+ unauthorized: SIGNED_OUT_TEXT,
113
+ csrf_required: SIGNED_OUT_TEXT,
114
+ deck_busy: "Your agent is working on this deck right now — try again in a moment.",
115
+ approval_in_progress: "An approval of this plan is still being saved — your edits are kept; try again in a moment.",
116
+ missing_path: "This deck's folder is gone — it was moved or deleted.",
117
+ unknown_deck: "This deck is no longer in your library.",
118
+ not_found: "This isn't there any more.",
119
+ not_implemented: "This needs a newer workspace — ask your agent to open the slidemind workspace.",
120
+ extension_missing: "This deck needs a slidemind extension that isn't installed on this computer — ask your agent to set it up.",
121
+ extension_incompatible: "This deck needs a newer version of its slidemind extension — ask your agent to update it.",
122
+ login_required: "Your Microsoft 365 sign-in is missing or expired — ask your agent to sign you in to Microsoft 365, then try again.",
123
+ };
124
+ const plainError = (err) => PLAIN[err?.code] ?? err?.message ?? "Something went wrong.";
125
+ /** A deck whose plan lives on SharePoint (polls, refreshes on focus). */
126
+ const isShared = (view) => ["shared", "blocked"].includes(view?.remote?.plan?.state);
127
+ /** The queue's conflict record, if the plan is blocked on one. */
128
+ const conflictOf = (view) => view?.sync?.conflict ?? view?.remote?.sync?.conflict ?? null;
129
+ const jitter = (ms) => ms + Math.floor(Math.random() * ms * 0.2) - Math.floor(ms * 0.1);
130
+ /**
131
+ * Save status chip from a PUT result / sync state. `dirty`: true while a save
132
+ * is on its way or retries by itself, "failed" when it stopped and the
133
+ * message next to it says why. A plan nobody shares is simply "Saved" — the
134
+ * user's computer is the only place it could be.
135
+ */
136
+ function saveStatus(sync, dirty) {
137
+ if (dirty === "failed") return { text: "Not saved", cls: "warn" };
138
+ if (dirty) return { text: "Saving…", cls: "" };
139
+ if (!sync) return { text: "Saved", cls: "ok" };
140
+ if (sync.state === "pending" || sync.state === "sending" || sync.state === "applying") return { text: "Saving to SharePoint…", cls: "" };
141
+ if (sync.state === "blocked" || sync.state === "outcome-unknown" || sync.conflict) return { text: "Action needed", cls: "bad" };
142
+ if (sync.state === "idle" && sync.lastConfirmedAt) return { text: "Saved to SharePoint", cls: "ok" };
143
+ return { text: "Saved", cls: "ok" };
144
+ }
145
+ /**
146
+ * Copy text to the clipboard: the clipboard API, else a selected textarea
147
+ * (no permission needed — it works in Codex's own browser pane too). Resolves
148
+ * true only when a path REALLY copied: the page never says "Copied" otherwise.
149
+ */
150
+ function copyText(text) {
151
+ const fallback = () => {
152
+ try {
153
+ const ta = document.createElement("textarea");
154
+ ta.value = text;
155
+ ta.setAttribute("readonly", "");
156
+ ta.style.cssText = "position:fixed;top:-1000px;opacity:0;";
157
+ document.body.append(ta);
158
+ ta.select();
159
+ const ok = document.execCommand("copy") === true;
160
+ ta.remove();
161
+ return ok;
162
+ } catch {
163
+ return false;
164
+ }
165
+ };
166
+ try {
167
+ if (!navigator.clipboard?.writeText) return Promise.resolve(fallback());
168
+ return navigator.clipboard.writeText(text).then(() => true, () => fallback());
169
+ } catch {
170
+ return Promise.resolve(fallback());
171
+ }
172
+ }
173
+ /** A deck is unfinished until a .pptx sits at its root (what build-pptx / adopt leave there). */
174
+ const unfinished = (d) => !d.built;
175
+ const newestFirst = (tasks) => [...tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
176
+ const backupKey = (deckId, baseHash) => `plan:${deckId}:${baseHash}`;
177
+
178
+ const SEP = /[\\/]/;
179
+ /** `/Users/me/x` -> `~/x`; a path outside home is left alone. The page has no homedir of its own — the deck list ships it. */
180
+ function tilde(p, home) {
181
+ if (!home || !p.startsWith(home)) return p;
182
+ const rest = p.slice(home.length);
183
+ return rest === "" || SEP.test(rest[0]) ? "~" + rest : p;
184
+ }
185
+ /** Sidebar headings: the shortest trailing segments that tell these folders apart.
186
+ * Every deck folder is called "decks" (SLIDEMIND_DECKS / ./decks), so the basename alone names nothing. */
187
+ function groupLabels(dirs, home) {
188
+ if (dirs.length < 2) return dirs.map((d) => tilde(d, home)); // nothing to tell apart: name the folder in full
189
+ const segs = dirs.map((d) => tilde(d, home).split(SEP).filter(Boolean));
190
+ // ponytail: O(groups² · depth); groups are a handful — index the tails if that ever changes
191
+ return dirs.map((_, i) => {
192
+ for (let n = 1; n <= segs[i].length; n++) {
193
+ const tail = segs[i].slice(-n).join("/");
194
+ if (segs.some((s, j) => j !== i && s.slice(-n).join("/") === tail)) continue;
195
+ if (n < segs[i].length) return "…/" + tail;
196
+ return segs[i][0] === "~" ? tail : "/" + tail;
197
+ }
198
+ return tilde(dirs[i], home);
199
+ });
200
+ }
201
+ /** What a folder is CALLED in the sidebar: the distinguishing label minus its "…/" and the
202
+ * "decks" basename every deck folder shares, so "…/alpha/decks" reads "alpha". The full path
203
+ * stays on the row's tooltip; a folder that is only "decks" keeps that name. */
204
+ function folderTitle(label) {
205
+ const segs = label.replace(/^…\//, "").split(SEP).filter(Boolean);
206
+ if (segs.length > 1 && segs[segs.length - 1].toLowerCase() === "decks" && segs[segs.length - 2] !== "~") segs.pop();
207
+ return segs.length ? segs.join("/") : label;
208
+ }
209
+ /** Where a deck lives, in the top bar: the folder holding it, by its own name (the
210
+ * sidebar heading's word for it); the full path is the tooltip. */
211
+ function deckFolder(p, home) {
212
+ const segs = tilde(p, home).split(SEP).filter(Boolean).slice(0, -1);
213
+ if (segs.length > 1 && segs[segs.length - 1].toLowerCase() === "decks" && segs[segs.length - 2] !== "~") segs.pop();
214
+ return segs.length ? segs[segs.length - 1] : "/";
215
+ }
216
+ const revealLabel = () => (/Mac/i.test(navigator.platform) ? "Reveal in Finder" : /Win/i.test(navigator.platform) ? "Show in Explorer" : "Open folder");
217
+
218
+ window.SlidemindApp = { tabsFor, saveStatus, answerLabel, newestFirst, actionLabel, backupKey, isShared, conflictOf, formatChip, unfinished, phaseOf, tabOf, groupLabels, folderTitle, deckFolder, tilde, plainError };
219
+
220
+ // ---------- transport ----------
221
+ class ApiError extends Error {
222
+ constructor(status, error) {
223
+ super(error?.message ?? `HTTP ${status}`);
224
+ this.status = status;
225
+ this.code = error?.code ?? "http_error";
226
+ this.details = error?.details ?? null;
227
+ this.retryable = !!error?.retryable;
228
+ }
229
+ }
230
+ async function api(method, p, body) {
231
+ const headers = {};
232
+ if (body !== undefined) headers["Content-Type"] = "application/json";
233
+ if (method !== "GET") headers["X-CSRF-Token"] = csrf ?? "";
234
+ let res;
235
+ try {
236
+ res = await fetch(API + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: "same-origin" });
237
+ } catch (err) {
238
+ checkConnection(); // the banner + the /health poll take it from here
239
+ throw new ApiError(0, { code: "offline", message: OFFLINE_TEXT });
240
+ }
241
+ let json = null;
242
+ try {
243
+ json = await res.json();
244
+ } catch {
245
+ // not JSON (e.g. 404 from the shell)
246
+ }
247
+ if (!res.ok || json?.ok === false) {
248
+ const err = new ApiError(res.status, json?.error ?? { code: res.status === 501 ? "not_implemented" : "http_error", message: `HTTP ${res.status}` });
249
+ // signed out, or a workspace on its way out: either way the connection says so, once
250
+ if (res.status === 401 || err.code === "workspace_stopping" || err.code === "stopping") checkConnection();
251
+ throw err;
252
+ }
253
+ return json;
254
+ }
255
+ /** A failure the connection banner already explains: the screen keeps what it shows. */
256
+ const connectionLost = (err) => ["offline", "unauthorized", "workspace_stopping", "stopping"].includes(err?.code);
257
+ const errorBox = (err, extra) => h("p", { class: "error", "data-code": err.code }, plainError(err), extra ? " " : "", extra ?? "");
258
+
259
+ // ---------- connection: live / offline / signed out ----------
260
+ // One owner for "can this page talk to its workspace": the event stream's
261
+ // error and every failed request end up here. Offline polls /health; back,
262
+ // it re-reads the session (the cookie survives a restart) and reconnects.
263
+ let connState = "connecting"; // connecting | live | offline | signed-out
264
+ let started = false; // the page is up (start() ran): only then is there an event stream to bring back
265
+ let connTimer = null;
266
+ let connChecking = null;
267
+ const reconnectHooks = new Set(); // work to resume once the workspace answers again (an unsaved plan)
268
+ function setBanner(text, cls) {
269
+ if (!$banner) return;
270
+ $banner.textContent = text ?? "";
271
+ $banner.className = `conn-banner ${cls ?? ""}`;
272
+ $banner.hidden = !text;
273
+ }
274
+ function setConn(text, cls) {
275
+ $conn.textContent = text;
276
+ $conn.className = `chip ${cls}`;
277
+ }
278
+ function goOffline() {
279
+ connState = "offline";
280
+ setConn("Workspace offline — reconnecting…", "warn");
281
+ setBanner(OFFLINE_TEXT, "warn");
282
+ clearTimeout(connTimer);
283
+ connTimer = setTimeout(checkConnection, HEALTH_MS);
284
+ }
285
+ function goSignedOut() {
286
+ connState = "signed-out";
287
+ if (sse) sse.close();
288
+ sse = null;
289
+ setConn("Signed out", "bad");
290
+ setBanner(SIGNED_OUT_TEXT, "bad");
291
+ // a sign-in in another tab of this browser sets the same cookie: notice it
292
+ clearTimeout(connTimer);
293
+ connTimer = setTimeout(checkConnection, HEALTH_MS * 2);
294
+ }
295
+ function goLive() {
296
+ connState = "live";
297
+ clearTimeout(connTimer);
298
+ setBanner(null);
299
+ if (started && !sse) connectEvents(); // its onopen refreshes what the page shows
300
+ // whatever failed while it was away (or on a one-off blip) goes again now
301
+ for (const fn of [...reconnectHooks]) fn();
302
+ }
303
+ /** Is the workspace up, and is this page still signed in to it? Settles the connection state. */
304
+ function checkConnection() {
305
+ if (connChecking) return connChecking;
306
+ connChecking = (async () => {
307
+ let health = null;
308
+ try {
309
+ const r = await fetch("/health", { cache: "no-store" });
310
+ health = r.ok ? await r.json() : null;
311
+ } catch {
312
+ health = null;
313
+ }
314
+ if (!health || health.draining) return goOffline();
315
+ let r;
316
+ try {
317
+ r = await fetch(API + "/session", { credentials: "same-origin", cache: "no-store" });
318
+ } catch {
319
+ return goOffline();
320
+ }
321
+ if (r.status === 401) return goSignedOut();
322
+ if (!r.ok) return goOffline();
323
+ const j = await r.json().catch(() => null);
324
+ if (j?.csrfToken) {
325
+ csrf = j.csrfToken;
326
+ try {
327
+ sessionStorage.setItem("slidemind.csrf", csrf);
328
+ } catch {
329
+ // per tab only
330
+ }
331
+ }
332
+ goLive();
333
+ })().finally(() => (connChecking = null));
334
+ return connChecking;
335
+ }
336
+
337
+ // ---------- attention: the agent just asked ----------
338
+ // "● " in the tab title until the user is back on the page.
339
+ let unseen = false;
340
+ let baseTitle = document.title;
341
+ function setTitle(t) {
342
+ baseTitle = t;
343
+ document.title = (unseen ? "● " : "") + t;
344
+ }
345
+ function markSeen() {
346
+ if (!unseen || document.visibilityState === "hidden") return;
347
+ unseen = false;
348
+ document.title = baseTitle;
349
+ }
350
+ // One toast per deck + kind: a repeated ask replaces it, never stacks.
351
+ const attnToasts = new Map();
352
+ function deckToast(key, text, run) {
353
+ attnToasts.get(key)?.remove();
354
+ attnToasts.set(key, toast(text, "attn", { label: "Open", run, sticky: true }));
355
+ }
356
+ function attentionToast(e, run) {
357
+ const what = e.kind === "preview-review" ? "slides" : "plan";
358
+ deckToast(`${e.deckId}:${e.kind}`, `Your agent asks you to review the ${what} of ${e.deckName ?? "a deck"}`, run);
359
+ }
360
+ const deckName = async (deckId) => deckNames.get(deckId) ?? (await api("GET", `/decks/${deckId}`).then((v) => v.deck.name, () => null)) ?? "a deck";
361
+ /**
362
+ * The agent opened a deck (`slidemind workspace <deck>` with this page already
363
+ * up): the page goes there — unless the user is typing in the Markdown
364
+ * editor, then the deck waits in a toast.
365
+ */
366
+ async function focusDeck(e) {
367
+ unseen = true;
368
+ setTitle(baseTitle);
369
+ if (current?.kind === "deck" && current.deckId === e.deckId) return;
370
+ const href = `/deck/${e.deckId}`;
371
+ if (!userEditing()) return navigate(href);
372
+ deckToast(`${e.deckId}:focus`, `Your agent opened ${await deckName(e.deckId)}`, () => navigate(href));
373
+ }
374
+ /** Typing in the Markdown editor (unsaved, or keys in the last seconds): nothing may move the page under it. */
375
+ function userEditing() {
376
+ const ed = planEditor;
377
+ return !!ed && (ed.dirty || (ed.editing && Date.now() - (ed.typedAt ?? 0) < RECENT_TYPING_MS));
378
+ }
379
+ /** Move to a tab of the deck on screen; edits typed in the plan are saved first, never dropped. */
380
+ async function showTab(t) {
381
+ if (planEditor?.dirty) await planEditor.flush?.();
382
+ if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
383
+ current.tab = t;
384
+ refresh(true);
385
+ }
386
+ async function attention(e) {
387
+ const tab = e.kind === "preview-review" ? "preview" : "plan";
388
+ unseen = true;
389
+ setTitle(baseTitle);
390
+ const href = `/deck/${e.deckId}/${tab}`;
391
+ const here = () => current?.kind === "deck" && current.deckId === e.deckId;
392
+ // bring the question forward: its tab, and the page view of the plan
393
+ const show = () => {
394
+ if (!here()) return navigate(href);
395
+ if (current.tab !== tab) return showTab(tab);
396
+ if (tab === "plan") planView?.setMode("view");
397
+ };
398
+ if (here() && !userEditing()) {
399
+ // already on its tab, the gate's own heartbeat moves it into the new round (keeping what the user typed)
400
+ show();
401
+ return refreshDeckChrome();
402
+ }
403
+ attentionToast({ ...e, deckName: await deckName(e.deckId) }, show);
404
+ if (here()) refreshDeckChrome();
405
+ }
406
+
407
+ /** Every screen renders into one scrolling page column under the top bar. */
408
+ const page = (...kids) => h("div", { class: "page" }, ...kids);
409
+ // the deck page lays out as rows + one flexible tab body, so the plan/preview frame keeps the screen
410
+ const deckPage = (...kids) => h("div", { class: "page deck" }, ...kids);
411
+ /** replaceChildren that drops null/undefined (the DOM would print them as "null"). */
412
+ const fill = ($el, ...kids) => $el.replaceChildren(...kids.flat().filter((k) => k !== null && k !== undefined));
413
+ const chip = (text, cls = "", attrs = {}) => h("span", { class: `chip ${cls}`, ...attrs }, text);
414
+ /** The SharePoint mark + label; a link when the item has one. */
415
+ const sharepointChip = (link, text = "SharePoint", note = null) => h(link ? "a" : "span", { class: "chip sp", "data-tip": [link ? "Open on SharePoint" : null, note].filter(Boolean).join("\n") || null, ...(link ? { href: link, target: "_blank", rel: "noopener" } : {}) }, h("img", { src: "/ui/sharepoint.svg", alt: "" }), h("span", { class: "lbl" }, text));
416
+ /** The top bar is the page's one header: its name, a line under it (text or a node), and the page's own buttons and chips beside it. */
417
+ const setMeta = (title, sub, actions = []) => {
418
+ $metaTitle.textContent = title;
419
+ fill($metaSub, sub);
420
+ fill($topActions, actions);
421
+ };
422
+
423
+ async function boot() {
424
+ // index.html already exchanged the link key (window.SLIDEMIND_CSRF)
425
+ // or re-read this tab's token from the cookie's session.
426
+ if (!csrf) {
427
+ await checkConnection();
428
+ if (connState !== "live") {
429
+ $app.replaceChildren(page(h("p", { class: "error", id: "signed-out" }, connState === "signed-out" ? SIGNED_OUT_TEXT : OFFLINE_TEXT)));
430
+ // the connection loop keeps trying; once it answers, the page starts for real
431
+ reconnectHooks.add(function started() {
432
+ reconnectHooks.delete(started);
433
+ start();
434
+ });
435
+ return;
436
+ }
437
+ }
438
+ start();
439
+ }
440
+ function start() {
441
+ started = true;
442
+ document.getElementById("add-folder").addEventListener("click", addFolder);
443
+ sidebarGrip();
444
+ $search.addEventListener("input", () => {
445
+ libraryQuery = $search.value;
446
+ loadLibrary();
447
+ });
448
+ $more.addEventListener("click", () => loadLibrary(true));
449
+ sidebarKeys();
450
+ for (const b of document.querySelectorAll("#library-filter button")) {
451
+ b.addEventListener("click", () => {
452
+ libraryFilter = b.dataset.filter;
453
+ try {
454
+ localStorage.setItem("slidemind.library.filter", libraryFilter);
455
+ } catch {
456
+ // ignore
457
+ }
458
+ loadLibrary();
459
+ });
460
+ }
461
+ connectEvents();
462
+ window.addEventListener("popstate", route);
463
+ document.addEventListener("click", (e) => {
464
+ const a = e.target.closest("a[data-nav]");
465
+ if (!a || e.metaKey || e.ctrlKey) return;
466
+ e.preventDefault();
467
+ navigate(a.getAttribute("href"));
468
+ });
469
+ const onVisible = () => {
470
+ if (document.visibilityState === "hidden") return;
471
+ markSeen();
472
+ if (current?.kind === "deck" && current.shared) remoteRefresh(current.deckId);
473
+ };
474
+ window.addEventListener("focus", onVisible);
475
+ document.addEventListener("visibilitychange", onVisible);
476
+ document.addEventListener("pointerdown", markSeen);
477
+ document.addEventListener("keydown", markSeen);
478
+ window.addEventListener("beforeunload", (e) => {
479
+ if (planEditor?.dirty) {
480
+ e.preventDefault();
481
+ e.returnValue = "";
482
+ }
483
+ });
484
+ route();
485
+ }
486
+
487
+ // The stream is recreated by us, never left to the browser: after a restart
488
+ // its retry would meet a 401 or a closed stream and stop for good while the
489
+ // chip still said "reconnecting". An error hands over to checkConnection,
490
+ // which brings it back once the workspace answers (with a growing pause
491
+ // while it keeps failing, so a flapping workspace is not hammered).
492
+ let sseFailures = 0;
493
+ let sseOpened = 0;
494
+ function connectEvents() {
495
+ if (sse) sse.close();
496
+ const es = new EventSource(API + "/events");
497
+ sse = es;
498
+ es.onopen = () => {
499
+ sseOpened = Date.now();
500
+ connState = "live";
501
+ setConn("Connected", "ok");
502
+ // reconnect: never trust what we may have missed
503
+ refresh();
504
+ };
505
+ es.onerror = () => {
506
+ if (sse !== es) return;
507
+ es.close();
508
+ sse = null;
509
+ // a stream that held for a while was healthy: start the pause over
510
+ sseFailures = Date.now() - sseOpened > 30_000 ? 1 : sseFailures + 1;
511
+ setConn("Reconnecting…", "warn");
512
+ setTimeout(checkConnection, Math.min(1000 * 2 ** (sseFailures - 1), 15_000) - 1000);
513
+ };
514
+ es.onmessage = (ev) => {
515
+ let e = null;
516
+ try {
517
+ e = JSON.parse(ev.data);
518
+ } catch {
519
+ return;
520
+ }
521
+ if (!current) return;
522
+ if (e.type === "attention") return attention(e);
523
+ if (e.type === "focus") return focusDeck(e);
524
+ if (current.kind === "home") return refresh();
525
+ if (e.deckId && e.deckId !== current.deckId) return refreshShell(); // another deck moved: sidebar + inbox badge only
526
+ if (e.type === "tasks") {
527
+ // a task moved: redraw the header + "your turn" and the shell, never the gate frame
528
+ refreshShell();
529
+ refreshDeckChrome();
530
+ return;
531
+ }
532
+ // a plan / preview change: the header follows; a live gate frame follows by itself
533
+ // (its heartbeat sees the new round and reloads when the user is not mid-edit)
534
+ refresh();
535
+ };
536
+ }
537
+
538
+ function navigate(href) {
539
+ if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
540
+ history.pushState(null, "", href);
541
+ route();
542
+ }
543
+ function route() {
544
+ planEditor = null;
545
+ planFrame = null;
546
+ planView = null;
547
+ clearTimeout(poll);
548
+ poll = null;
549
+ // /deck/<id>/plan|preview names the tab; an older /tasks/<id> link opens that task's tab
550
+ const m = location.pathname.match(/^\/deck\/([^/]+)(?:\/(plan|preview)\b|\/tasks\/([^/]+))?/);
551
+ current = m ? { kind: "deck", deckId: m[1], taskId: m[3] ?? null, tab: m[2] ?? (m[3] ? null : current?.deckId === m[1] ? current.tab : "plan") } : { kind: "home" };
552
+ refresh(true);
553
+ }
554
+ let refreshing = null;
555
+ /**
556
+ * Re-GET and re-render. An advisory refresh never wipes a dirty plan buffer
557
+ * and never reloads a live gate frame (it keeps its own state and follows its
558
+ * own heartbeat): it redraws the deck's header, tab marks and "your turn".
559
+ */
560
+ function refresh(force = false) {
561
+ if (!current) return Promise.resolve();
562
+ refreshShell();
563
+ if (!force && current?.kind === "deck" && hasLiveFrame()) return refreshDeckChrome();
564
+ if (refreshing) return refreshing;
565
+ refreshing = (current.kind === "home" ? renderHome() : renderDeck())
566
+ .catch((err) => {
567
+ if (connectionLost(err)) return; // the banner says it; what is on screen stays until the workspace is back
568
+ $app.replaceChildren(page(errorBox(err)));
569
+ })
570
+ .finally(() => (refreshing = null));
571
+ return refreshing;
572
+ }
573
+
574
+ // ---------- shell: deck sidebar + inbox badge ----------
575
+ let libraryQuery = "";
576
+ let libraryCursor = null;
577
+ let libraryFilter = "all"; // all | unfinished — remembered per browser
578
+ let libraryHome = null; // the daemon's home dir, for ~-collapsing a path outside the sidebar
579
+ let collapsed = new Set(); // folder paths folded shut — remembered per browser
580
+ try {
581
+ libraryFilter = localStorage.getItem("slidemind.library.filter") === "unfinished" ? "unfinished" : "all";
582
+ collapsed = new Set(JSON.parse(localStorage.getItem("slidemind.library.collapsed") || "[]"));
583
+ } catch {
584
+ // per-viewer convenience only
585
+ }
586
+ const rememberCollapsed = () => {
587
+ try {
588
+ localStorage.setItem("slidemind.library.collapsed", JSON.stringify([...collapsed]));
589
+ } catch {
590
+ // ignore
591
+ }
592
+ };
593
+ /** The tab on screen holds a gate frame (or a dirty plan buffer) that a re-render would reset. */
594
+ function hasLiveFrame() {
595
+ if (current?.tab === "plan") return !!(planEditor?.dirty || planFrame);
596
+ if (current?.tab === "preview") return !!document.getElementById("preview-view");
597
+ return false;
598
+ }
599
+ function refreshShell() {
600
+ $inboxLink.setAttribute("aria-current", current?.kind === "home" ? "page" : "false");
601
+ loadLibrary();
602
+ api("GET", "/tasks?status=pending").then((r) => ($inboxCount.textContent = r.tasks.length ? String(r.tasks.length) : ""), () => ($inboxCount.textContent = ""));
603
+ }
604
+ function deckRow(d, { nested = false } = {}) {
605
+ const fmt = formatChip(d);
606
+ const active = current?.kind === "deck" && current.deckId === d.id;
607
+ // the link carries only the name (its ::after stretches over the row); the status and the bin stay outside it
608
+ return h(
609
+ "li",
610
+ { class: `deck-row${d.missing ? " missing" : ""}${nested ? " nested" : ""}`, "data-format": d.format ?? "", "aria-current": active ? "page" : "false", "data-tip": d.missing ? `${d.path}\nThis folder is not there any more` : d.path },
611
+ h("span", { class: `deck-ico${d.remote ? " sp" : ""}`, "data-tip": d.remote ? "On SharePoint" : null }),
612
+ h("a", { href: `/deck/${d.id}`, "data-nav": true, "data-side-item": true, class: "deck-name", "data-tip": d.path }, d.name ?? d.path),
613
+ d.remote ? h("img", { src: "/ui/sharepoint.svg", alt: "On SharePoint", "data-tip": "On SharePoint", class: "sp-mark" }) : null,
614
+ h(
615
+ "span",
616
+ { class: "deck-sub" },
617
+ d.taskCount ? h("span", { class: "badge", "data-tip": `${d.taskCount} question${d.taskCount === 1 ? "" : "s"} waiting for you` }, String(d.taskCount)) : null,
618
+ d.missing ? h("span", { class: "deck-state" }, "missing") : null,
619
+ fmt ? chip(fmt.text, fmt.cls, { "data-legacy": d.format === "legacy" ? "" : null }) : null,
620
+ ),
621
+ // drops the registry entry only — the deck folder is the user's, and no route deletes it
622
+ h("button", { class: "deck-x", "data-tip": "Remove from library", "aria-label": `Remove ${d.name ?? d.path} from the library`, onclick: (e) => {
623
+ e.preventDefault(); // the row's stretched link would otherwise open the deck
624
+ e.stopPropagation();
625
+ removeDeckDialog(d);
626
+ } }, icon(ICON_TRASH)),
627
+ );
628
+ }
629
+ /** A folder heading: the disclosure toggles its decks, the arrow reveals it in the OS file manager. */
630
+ function groupRow(dir, decks, label, open) {
631
+ const title = folderTitle(label);
632
+ const full = tilde(dir, libraryHome);
633
+ const $toggle = h(
634
+ "button",
635
+ { class: "side-group-name", "data-side-item": true, "aria-expanded": String(open), "data-tip": full, onclick: () => {
636
+ if (collapsed.has(dir)) collapsed.delete(dir);
637
+ else collapsed.add(dir);
638
+ rememberCollapsed();
639
+ loadLibrary();
640
+ } },
641
+ icon(ICON_CHEVRON),
642
+ h("span", { class: "side-group-txt" }, title),
643
+ h("span", { class: "side-group-n" }, String(decks.length)),
644
+ );
645
+ return h(
646
+ "li",
647
+ { class: `side-group${open ? "" : " closed"}`, "data-dir": dir },
648
+ $toggle,
649
+ // the folder is revealed through one of its OWN decks: no route takes a bare path
650
+ h("button", { class: "icon side-group-reveal", "data-tip": `${revealLabel()}\n${full}`, "aria-label": `${revealLabel()}: ${full}`, onclick: () => api("POST", `/decks/${decks[0].id}/reveal`, { parent: true }).catch((err) => toast(plainError(err), "bad")) }, icon(ICON_REVEAL)),
651
+ );
652
+ }
653
+ async function loadLibrary(append = false) {
654
+ try {
655
+ const r = await api("GET", `/decks?q=${encodeURIComponent(libraryQuery)}&limit=100${append && libraryCursor ? `&cursor=${libraryCursor}` : ""}`);
656
+ libraryCursor = r.nextCursor;
657
+ libraryHome = r.home ?? libraryHome;
658
+ for (const d of r.decks) deckNames.set(d.id, d.name ?? d.path);
659
+ // grouped by parent folder: the folder heading is what tells two "q3-review"s apart
660
+ const parent = (p) => p.replace(/[\\/][^\\/]+$/, "");
661
+ // a deck whose folder is gone is not a deck to open: it stays out of the list
662
+ // (its registry entry is forgotten from the page its link still reaches)
663
+ const present = r.decks.filter((d) => !d.missing);
664
+ const shown = libraryFilter === "unfinished" ? present.filter(unfinished) : present;
665
+ const groups = new Map();
666
+ for (const d of shown) {
667
+ const key = parent(d.path);
668
+ if (!groups.has(key)) groups.set(key, []);
669
+ groups.get(key).push(d);
670
+ }
671
+ // one folder only = nothing to tell apart: no heading
672
+ const entries = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
673
+ const labels = groupLabels(entries.map(([dir]) => dir), r.home);
674
+ // a folded folder hides its decks — except while searching (a match must show) and
675
+ // the one holding the deck on screen (it is revealed, as any file tree does)
676
+ const rows = entries.flatMap(([dir, decks], i) => {
677
+ if (groups.size < 2) return decks.map((d) => deckRow(d));
678
+ const open = !collapsed.has(dir) || !!libraryQuery || decks.some((d) => current?.kind === "deck" && current.deckId === d.id);
679
+ return [groupRow(dir, decks, labels[i], open), ...(open ? decks.map((d) => deckRow(d, { nested: true })) : [])];
680
+ });
681
+ const scanning = r.scanning?.length ? h("li", { class: "side-empty", id: "library-scanning" }, h("span", { class: "spin" }), " Scanning your decks…") : null;
682
+ const scanned = r.roots?.length ? ` Scanned: ${r.roots.join(", ")}.` : "";
683
+ if (append) $library.append(...rows);
684
+ else $library.replaceChildren(...[scanning, ...rows, rows.length || scanning ? null : h("li", { class: "side-empty", id: "library-empty" }, libraryQuery ? "No decks match." : libraryFilter === "unfinished" && present.length ? "Every deck here has its .pptx." : `No decks yet. Add a local folder or run a slidemind command in a deck.${scanned}`)].filter(Boolean));
685
+ for (const b of document.querySelectorAll("#library-filter button")) b.setAttribute("aria-pressed", String(b.dataset.filter === libraryFilter));
686
+ $more.hidden = !libraryCursor;
687
+ } catch (err) {
688
+ if (!connectionLost(err)) $library.replaceChildren(h("li", { class: "side-empty" }, errorBox(err)));
689
+ }
690
+ }
691
+
692
+ // ---------- home: the inbox ----------
693
+ async function renderHome() {
694
+ // fetched before anything is replaced: offline, the page on screen stays, its header with it
695
+ const r = await api("GET", "/tasks?status=pending");
696
+ setTitle("Inbox · slidemind");
697
+ setMeta("Inbox", "Everything your agent asked you, newest first");
698
+ const rows = newestFirst(r.tasks).map((t) =>
699
+ h(
700
+ "li",
701
+ { class: "task", "data-kind": t.kind, "data-tip": t.deckPath ?? null },
702
+ // in-page navigation to the card itself (it opens its tab, marked as the one clicked)
703
+ h("div", { class: "card-head" }, h("a", { href: `/deck/${t.deckId}/tasks/${t.id}`, "data-nav": true }, `${phaseOf(t.kind).label} · ${t.deckName ?? "deck"}`), h("span", { class: "path" }, fmtTime(t.createdAt))),
704
+ ),
705
+ );
706
+ const $inbox = h("ul", { class: "list", id: "inbox" }, ...(rows.length ? rows : [h("li", { class: "muted", id: "inbox-empty" }, "Nothing waiting for you.")]));
707
+ $app.replaceChildren(page($inbox));
708
+ }
709
+ // ---------- sidebar keyboard: ⌘K or / finds, ↑↓ walk the list, Enter opens ----------
710
+ function sidebarKeys() {
711
+ const typing = (el) => el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
712
+ const items = () => [...document.querySelectorAll("#side [data-side-item]")].filter((el) => el.offsetParent !== null);
713
+ const step = (from, dir) => {
714
+ const all = items();
715
+ const i = all.indexOf(from);
716
+ const next = all[i < 0 ? (dir > 0 ? 0 : all.length - 1) : Math.min(Math.max(i + dir, 0), all.length - 1)];
717
+ next?.focus();
718
+ };
719
+ document.addEventListener("keydown", (e) => {
720
+ if (e.defaultPrevented) return;
721
+ const inSearch = e.target === $search;
722
+ if (((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") || (e.key === "/" && !typing(e.target) && !e.metaKey && !e.ctrlKey && !e.altKey)) {
723
+ e.preventDefault();
724
+ // on a small screen the list is a drawer: open it first so the field is on screen
725
+ if (window.innerWidth <= 1000 && !document.body.classList.contains("side-open")) document.getElementById("side-toggle")?.click();
726
+ $search.focus();
727
+ $search.select();
728
+ return;
729
+ }
730
+ if (inSearch) {
731
+ if (e.key === "Escape") {
732
+ if ($search.value) {
733
+ $search.value = "";
734
+ libraryQuery = "";
735
+ loadLibrary();
736
+ } else $search.blur();
737
+ e.preventDefault();
738
+ } else if (e.key === "ArrowDown") {
739
+ e.preventDefault();
740
+ step(null, 1);
741
+ }
742
+ return;
743
+ }
744
+ const item = e.target.closest?.("#side [data-side-item]");
745
+ if (!item) return;
746
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
747
+ e.preventDefault();
748
+ step(item, e.key === "ArrowDown" ? 1 : -1);
749
+ } else if (e.key === "ArrowRight" && item.getAttribute("aria-expanded") === "false") item.click();
750
+ else if (e.key === "ArrowLeft" && item.getAttribute("aria-expanded") === "true") item.click();
751
+ else if (e.key === "ArrowLeft" && item.matches("a.deck-name")) {
752
+ let li = item.closest("li")?.previousElementSibling; // up to the folder this deck sits in
753
+ while (li && !li.classList.contains("side-group")) li = li.previousElementSibling;
754
+ li?.querySelector(".side-group-name")?.focus();
755
+ }
756
+ });
757
+ }
758
+
759
+ // ---------- sidebar width: drag the full-height grip on its right edge ----------
760
+ function sidebarGrip() {
761
+ const $side = document.getElementById("side");
762
+ const $grip = document.getElementById("side-grip");
763
+ if (!$side || !$grip) return;
764
+ // Small screens: the list is a drawer behind the top-bar button; picking a
765
+ // deck or tapping outside closes it.
766
+ const $toggle = document.getElementById("side-toggle");
767
+ const setOpen = (open) => {
768
+ document.body.classList.toggle("side-open", open);
769
+ $toggle?.setAttribute("aria-expanded", String(open));
770
+ };
771
+ $toggle?.addEventListener("click", () => setOpen(!document.body.classList.contains("side-open")));
772
+ document.addEventListener("click", (e) => {
773
+ if (!document.body.classList.contains("side-open")) return;
774
+ if (e.target.closest("#side-toggle")) return;
775
+ if (!e.target.closest("#side") || e.target.closest("a[data-nav]")) setOpen(false);
776
+ });
777
+ const apply = (w) => document.documentElement.style.setProperty("--sm-side", `${Math.round(Math.min(Math.max(w, 200), window.innerWidth / 2))}px`);
778
+ try {
779
+ const saved = Number(localStorage.getItem("slidemind.sidebar"));
780
+ if (saved) apply(saved);
781
+ } catch {
782
+ // per-viewer convenience only
783
+ }
784
+ $grip.addEventListener("pointerdown", (e) => {
785
+ if (e.button !== 0) return;
786
+ e.preventDefault();
787
+ $grip.setPointerCapture(e.pointerId);
788
+ document.body.classList.add("resizing");
789
+ const move = (ev) => apply(ev.clientX);
790
+ const up = () => {
791
+ $grip.removeEventListener("pointermove", move);
792
+ $grip.removeEventListener("pointerup", up);
793
+ $grip.removeEventListener("pointercancel", up);
794
+ document.body.classList.remove("resizing");
795
+ try {
796
+ localStorage.setItem("slidemind.sidebar", String($side.getBoundingClientRect().width));
797
+ } catch {
798
+ // ignore
799
+ }
800
+ };
801
+ $grip.addEventListener("pointermove", move);
802
+ $grip.addEventListener("pointerup", up);
803
+ $grip.addEventListener("pointercancel", up);
804
+ });
805
+ $grip.addEventListener("dblclick", () => {
806
+ apply(272);
807
+ try {
808
+ localStorage.removeItem("slidemind.sidebar");
809
+ } catch {
810
+ // ignore
811
+ }
812
+ });
813
+ }
814
+
815
+ // ---------- dialogs (native <dialog>, styled like the gate popover) ----------
816
+ /**
817
+ * One text field + Cancel/submit. `run(value)` is awaited on submit: it
818
+ * resolves to close, or throws to show the error inside the dialog and keep
819
+ * what was typed. Closing without submitting runs nothing.
820
+ */
821
+ function dialog({ title, hint, placeholder, submit = "OK", multiline = false, body = null, input = true, danger = false, run }) {
822
+ // `input: false` makes it a confirm — same popover, no field, `run()` takes no value
823
+ const $in = input ? h(multiline ? "textarea" : "input", { type: "text", id: "dialog-input", placeholder, spellcheck: multiline ? "true" : "false", autocomplete: "off" }) : null;
824
+ const $err = h("p", { class: "error", id: "dialog-error", hidden: true });
825
+ const $ok = h("button", { class: danger ? "danger" : "primary", type: "submit" }, submit);
826
+ const $dlg = h(
827
+ "dialog",
828
+ { class: "dlg", id: "dialog" },
829
+ h(
830
+ "form",
831
+ {
832
+ onsubmit: async (e) => {
833
+ e.preventDefault();
834
+ const value = $in ? $in.value.trim() : null;
835
+ if ($in && !value) return $in.focus();
836
+ $ok.disabled = true;
837
+ $err.hidden = true;
838
+ try {
839
+ await run(value);
840
+ $dlg.close();
841
+ } catch (err) {
842
+ $err.textContent = plainError(err);
843
+ $err.hidden = false;
844
+ ($in ?? $ok).focus();
845
+ } finally {
846
+ $ok.disabled = false;
847
+ }
848
+ },
849
+ },
850
+ h("h3", {}, title),
851
+ hint ? h("p", { class: "muted" }, hint) : null,
852
+ $in,
853
+ body ? body($in) : null,
854
+ $err,
855
+ h("div", { class: "row end" }, h("button", { type: "button", onclick: () => $dlg.close() }, "Cancel"), $ok),
856
+ ),
857
+ );
858
+ $dlg.addEventListener("close", () => $dlg.remove());
859
+ document.body.append($dlg);
860
+ $dlg.showModal();
861
+ ($in ?? $ok).focus();
862
+ return $dlg;
863
+ }
864
+ /**
865
+ * A short bottom-right note that fades by itself. With `action` it carries one
866
+ * button (`{ label, run }`); `sticky` keeps it until clicked or dismissed.
867
+ */
868
+ function toast(text, cls = "", action = null) {
869
+ const $t = h("div", { class: `toast ${cls}`, role: "status" }, h("span", {}, text));
870
+ if (action) {
871
+ $t.append(
872
+ h("button", { class: "mini primary", onclick: () => ($t.remove(), action.run()) }, action.label),
873
+ h("button", { class: "icon toast-x", "data-tip": "Dismiss", "aria-label": "Dismiss", onclick: () => $t.remove() }, icon('<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/>')),
874
+ );
875
+ }
876
+ document.body.append($t);
877
+ if (!action?.sticky) setTimeout(() => $t.remove(), 5000);
878
+ return $t;
879
+ }
880
+
881
+ // ---------- tooltip ----------
882
+ // Every hint on this page is a `data-tip`, never a native `title` (the OS
883
+ // draws that one late, small and unstyled). One tooltip serves them all: it
884
+ // appears after a short delay — at once when the pointer moves from one hint
885
+ // to the next — sits under its element (over it when there is no room; at the
886
+ // pointer for a tall element like the sidebar grip), and goes on any click,
887
+ // key, scroll or re-render. Keyboard focus shows it too. It lives in the top
888
+ // layer when the browser has one, so a hint inside a modal dialog is not
889
+ // painted under it.
890
+ const TIP_DELAY_MS = 450;
891
+ const TIP_WARM_MS = 500; // a hint closed this recently: the next one shows without the delay
892
+ const TIP_GAP = 6;
893
+ const TIP_MARGIN = 8;
894
+ const $tip = h("div", { class: "tip", id: "sm-tip", role: "tooltip", popover: "manual" });
895
+ const tipLayer = typeof $tip.showPopover === "function";
896
+ let tipFor = null; // the element whose hint is showing, or waiting out the delay
897
+ let tipMuted = null; // the element just clicked: no hint until the pointer has left it
898
+ let tipTimer = 0;
899
+ let tipWatch = 0;
900
+ let tipClosedAt = 0; // when the pointer last moved off a showing hint
901
+ let tipPointer = null; // { x, y } while the pointer is on the page
902
+ function armTip(el, byPointer) {
903
+ if (el === tipFor) return;
904
+ if ($tip.classList.contains("on")) tipClosedAt = Date.now();
905
+ hideTip();
906
+ if (!el) return;
907
+ tipFor = el;
908
+ tipTimer = setTimeout(() => showTip(el, byPointer), Date.now() - tipClosedAt < TIP_WARM_MS ? 0 : TIP_DELAY_MS);
909
+ }
910
+ function showTip(el, byPointer) {
911
+ const text = el.getAttribute("data-tip");
912
+ if (!text || !el.isConnected) return hideTip();
913
+ // a path wraps after its separators, not inside a folder name
914
+ $tip.replaceChildren(...text.split(/(?<=[\\/])/).flatMap((part, i) => (i ? [h("wbr"), part] : [part])));
915
+ $tip.classList.remove("on");
916
+ if (!$tip.isConnected) document.body.append($tip);
917
+ if (tipLayer) {
918
+ // re-opened, so it lands above a modal dialog opened since it last showed
919
+ if ($tip.matches(":popover-open")) $tip.hidePopover();
920
+ $tip.showPopover();
921
+ }
922
+ // measured at the origin: where it last stood would narrow its fit-content width
923
+ $tip.style.left = $tip.style.top = "0px";
924
+ const r = el.getBoundingClientRect();
925
+ const at = byPointer && tipPointer && r.height > 48 ? { left: tipPointer.x, right: tipPointer.x, top: tipPointer.y, bottom: tipPointer.y + 16 } : r;
926
+ const t = $tip.getBoundingClientRect();
927
+ const vw = document.documentElement.clientWidth;
928
+ const vh = document.documentElement.clientHeight;
929
+ const left = Math.max(TIP_MARGIN, Math.min((at.left + at.right) / 2 - t.width / 2, vw - TIP_MARGIN - t.width));
930
+ const below = at.bottom + TIP_GAP;
931
+ const above = at.top - TIP_GAP - t.height;
932
+ const top = below + t.height <= vh - TIP_MARGIN || above < TIP_MARGIN ? below : above;
933
+ $tip.style.left = `${Math.round(left)}px`;
934
+ $tip.style.top = `${Math.round(top)}px`;
935
+ $tip.classList.add("on");
936
+ if (!el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", "sm-tip");
937
+ // a re-render replaces the element under the pointer without a pointer event
938
+ clearInterval(tipWatch);
939
+ tipWatch = setInterval(() => {
940
+ if (!el.isConnected || !el.getAttribute("data-tip")) hideTip();
941
+ else if (el.getAttribute("data-tip") !== $tip.textContent) showTip(el, byPointer);
942
+ }, 250);
943
+ }
944
+ function hideTip() {
945
+ clearTimeout(tipTimer);
946
+ clearInterval(tipWatch);
947
+ $tip.classList.remove("on");
948
+ if (tipLayer && $tip.matches(":popover-open")) $tip.hidePopover();
949
+ if (tipFor?.getAttribute("aria-describedby") === "sm-tip") tipFor.removeAttribute("aria-describedby");
950
+ tipFor = null;
951
+ }
952
+ document.addEventListener("pointermove", (e) => (tipPointer = { x: e.clientX, y: e.clientY }), { passive: true });
953
+ document.addEventListener("pointerover", (e) => {
954
+ if (e.pointerType === "touch") return;
955
+ const el = e.target.closest?.("[data-tip]") ?? null;
956
+ if (el && el === tipMuted) return;
957
+ tipMuted = null;
958
+ armTip(el, true);
959
+ });
960
+ document.addEventListener("pointerout", (e) => {
961
+ if (e.relatedTarget) return;
962
+ tipPointer = null; // left the window
963
+ hideTip();
964
+ });
965
+ document.addEventListener("pointerdown", (e) => {
966
+ tipMuted = e.target.closest?.("[data-tip]") ?? null;
967
+ hideTip();
968
+ }, { capture: true, passive: true });
969
+ document.addEventListener("focusin", (e) => {
970
+ const el = e.target.closest?.("[data-tip]");
971
+ if (el && e.target.matches(":focus-visible")) armTip(el, false);
972
+ });
973
+ document.addEventListener("focusout", (e) => {
974
+ if (tipFor && tipFor.contains(e.target)) hideTip();
975
+ });
976
+ for (const type of ["keydown", "scroll", "wheel"]) document.addEventListener(type, () => hideTip(), { capture: true, passive: true });
977
+ if (typeof window.addEventListener === "function") for (const type of ["resize", "blur"]) window.addEventListener(type, () => hideTip());
978
+
979
+ /** A Browse… button: the daemon opens the OS folder dialog and the answer fills the field. */
980
+ function browseButton($in) {
981
+ const $msg = h("span", { class: "muted", id: "fs-pick-note" }, "opens your system's folder dialog");
982
+ const $browse = h("button", { type: "button", id: "fs-browse", onclick: async () => {
983
+ $browse.disabled = true;
984
+ $msg.textContent = "Choose the folder in the dialog that just opened…";
985
+ try {
986
+ const r = await api("POST", "/fs/pick", {});
987
+ if (r.path) $in.value = r.path;
988
+ $msg.textContent = r.path ? "" : "No folder chosen.";
989
+ } catch (err) {
990
+ $msg.textContent = err.status === 404 ? PLAIN.not_implemented : plainError(err);
991
+ } finally {
992
+ $browse.disabled = false;
993
+ }
994
+ } }, "Browse…");
995
+ return h("div", { class: "row" }, $browse, $msg);
996
+ }
997
+ /** The deck goes to the OS trash, entry and folder; `trash: false` drops the entry only. */
998
+ function removeDeckDialog(deck, { trash = true, after = null } = {}) {
999
+ dialog({
1000
+ title: trash ? `Delete ${deck.name ?? deck.path}?` : `Remove ${deck.name ?? deck.path} from the library?`,
1001
+ input: false,
1002
+ danger: true,
1003
+ submit: trash ? "Move to Trash" : "Remove",
1004
+ body: () => h("p", { class: "path" }, tilde(deck.path, libraryHome)),
1005
+ run: async () => {
1006
+ await api("DELETE", `/decks/${deck.id}`, trash ? { files: "trash" } : {});
1007
+ if (after) after();
1008
+ else if (current?.kind === "deck" && current.deckId === deck.id) navigate("/");
1009
+ else loadLibrary();
1010
+ },
1011
+ });
1012
+ }
1013
+ function addFolder() {
1014
+ dialog({
1015
+ title: "Add a local folder",
1016
+ hint: "The deck folder is the one that holds .slidemind/.",
1017
+ placeholder: "/Users/you/decks/q3-review",
1018
+ submit: "Add deck",
1019
+ body: browseButton,
1020
+ run: async (p) => {
1021
+ const r = await api("POST", "/decks", { path: p });
1022
+ navigate(`/deck/${r.deckId}`);
1023
+ },
1024
+ });
1025
+ }
1026
+ /** Local deck → SharePoint folder: the share link of the folder is all the daemon needs. */
1027
+ function connectSharePoint(view) {
1028
+ dialog({
1029
+ title: "Connect a SharePoint folder",
1030
+ hint: "Paste the share link of the folder this deck should live in. In SharePoint open the folder, click Share, then Copy link. The plan record and the published PPTX go there.",
1031
+ placeholder: "https://contoso.sharepoint.com/:f:/s/Projects/EiXk3…?e=abc123",
1032
+ submit: "Connect",
1033
+ run: async (url) => {
1034
+ try {
1035
+ await api("POST", `/decks/${view.deck.id}/remote/connect`, { folderRef: url });
1036
+ } catch (err) {
1037
+ if (err.code === "not_implemented") throw new Error("Connecting a folder needs a newer workspace — ask your agent to open the slidemind workspace.");
1038
+ throw new Error(plainError(err));
1039
+ }
1040
+ toast("Folder connected. The plan is shared there once it is editable.");
1041
+ refresh(); // the header says where it lives now; the frame keeps its state
1042
+ },
1043
+ });
1044
+ }
1045
+
1046
+ // ---------- view chrome: a framed view's MESSAGES, shown by this page ----------
1047
+ // A framed view (the plan gate, the review gate, a pack's plan page) keeps its
1048
+ // own navigation — its tabs, its title line, its switches — but what it has to
1049
+ // SAY to the user is this page's to show: the status line ("your agent is
1050
+ // listening"), every banner and notice (an approval and when, a warning, a
1051
+ // failed save), and the hand-off dialog. The view publishes them as one
1052
+ // `chrome` object and this page renders them in its chrome — the status beside
1053
+ // the save state, notices in a stack above the frame — so a message never sits
1054
+ // inside the frame it is about. A click on any of it goes back to the view as
1055
+ // an event. The CLI's own gates post it on the window (same origin, checked by
1056
+ // source); a pack page posts it on its MessagePort (plan-bridge.js). Every
1057
+ // publish replaces the last.
1058
+ const CHROME_EVENT = "slidemind-view-chrome-event";
1059
+ let viewChrome = { frame: null, send: null, chrome: null, dismissed: new Set() };
1060
+ const str = (v, max = 400) => (typeof v === "string" || typeof v === "number" ? String(v).slice(0, max) : "");
1061
+ const few = (v, max = 12) => (Array.isArray(v) ? v.slice(0, max).filter((x) => x && typeof x === "object") : []);
1062
+ const TONES = new Set(["info", "ok", "warn", "error"]);
1063
+ /** Only plain strings and known shapes get through: a view never injects markup or unbounded text. */
1064
+ function cleanChrome(c) {
1065
+ if (!c || typeof c !== "object") return null;
1066
+ const tone = (t) => (TONES.has(t) ? t : "info");
1067
+ const acts = (a) => few(a, 4).map((x) => ({ id: str(x.id, 60), label: str(x.label, 80), primary: !!x.primary })).filter((x) => x.id && x.label);
1068
+ return {
1069
+ status: c.status && typeof c.status === "object" && str(c.status.text) ? cleanStatus(c.status) : null,
1070
+ notices: few(c.notices).map((n) => ({ id: str(n.id, 60), tone: tone(n.tone), title: str(n.title, 300), text: str(n.text, 1000), say: str(n.say, 40), spin: !!n.spin, dismiss: !!n.dismiss, copy: str(n.copy, 4000), actions: acts(n.actions) })).filter((n) => n.id && (n.title || n.text)),
1071
+ dialog: c.dialog && typeof c.dialog === "object" ? { title: str(c.dialog.title, 200), text: str(c.dialog.text, 1000), say: str(c.dialog.say, 40), copy: str(c.dialog.copy, 4000), message: str(c.dialog.message, 4000), actions: acts(c.dialog.actions) } : null,
1072
+ };
1073
+ }
1074
+ /** The status line, with what it stands for: `items` ({tag, text, kind}) preferred, else `detail`'s lines ("heading\ntag · text\n…"). */
1075
+ function cleanStatus(st) {
1076
+ const kind = (k) => (["add", "remove", "edit", "deck"].includes(k) ? k : "");
1077
+ let items = few(st.items, 40).map((i) => ({ tag: str(i.tag, 40), text: str(i.text, 300), kind: kind(i.kind) })).filter((i) => i.text);
1078
+ let heading = str(st.heading, 200);
1079
+ const detail = str(st.detail, 2000);
1080
+ if (!items.length && detail.includes("\n")) {
1081
+ const [first, ...rest] = detail.split("\n");
1082
+ heading ||= first;
1083
+ items = rest.filter(Boolean).slice(0, 40).map((line) => {
1084
+ const at = line.indexOf(" · ");
1085
+ return at > 0 ? { tag: line.slice(0, at), text: line.slice(at + 3), kind: "" } : { tag: "", text: line, kind: "" };
1086
+ });
1087
+ }
1088
+ return { text: str(st.text, 200), tone: ["ok", "warn", "bad", "accent"].includes(st.tone) ? st.tone : "", spin: !!st.spin, detail, heading, items, action: str(st.action, 60) };
1089
+ }
1090
+ /** Bind the view frame on screen: `send(event)` is how its chrome talks back. */
1091
+ function bindViewChrome(frame, send) {
1092
+ viewChrome = { frame, send, chrome: null, dismissed: new Set() };
1093
+ renderViewChrome();
1094
+ }
1095
+ function unbindViewChrome() {
1096
+ viewChrome = { frame: null, send: null, chrome: null, dismissed: new Set() };
1097
+ renderViewChrome();
1098
+ }
1099
+ function setViewChrome(frame, chrome) {
1100
+ if (viewChrome.frame !== frame) return;
1101
+ viewChrome.chrome = cleanChrome(chrome);
1102
+ renderViewChrome();
1103
+ }
1104
+ const chromeEvent = (kind, id) => viewChrome.send?.({ kind, id });
1105
+ // the CLI's own gates: same origin, and only the frame on screen is heard
1106
+ if (typeof window.addEventListener === "function") window.addEventListener("message", (e) => {
1107
+ const d = e.data;
1108
+ if (!d || d.type !== "slidemind-view-chrome" || e.origin !== location.origin) return;
1109
+ if (!viewChrome.frame || e.source !== viewChrome.frame.contentWindow) return;
1110
+ setViewChrome(viewChrome.frame, d.chrome);
1111
+ });
1112
+ const copyButton = (text, label = "Copy message") => {
1113
+ const $b = h("button", { class: "mini", onclick: async () => ($b.textContent = (await copyText(text)) ? "Copied" : "Select it and press ⌘C") }, label);
1114
+ return $b;
1115
+ };
1116
+ const sayNode = (say) => (say ? h("span", {}, " ", h("kbd", { class: "say" }, say)) : null);
1117
+ function renderViewChrome() {
1118
+ const c = viewChrome.chrome;
1119
+ const $meta = document.getElementById("view-meta");
1120
+ if ($meta) {
1121
+ const st = c?.status;
1122
+ const wasOpen = !!document.getElementById("view-status-pop");
1123
+ const listed = st && (st.items.length || st.heading);
1124
+ // a status that names an action (the hand-off: see the message again) is a button that runs it
1125
+ const acts = st && !listed && st.action;
1126
+ const $chip = st
1127
+ ? h(listed || acts ? "button" : "span", { class: `chip view-status ${st.tone}${listed || acts ? " listed" : ""}`, id: "view-status", "data-tip": st.text + (st.detail && !listed ? `\n${st.detail}` : ""), ...(listed ? { type: "button", "aria-haspopup": "dialog", "aria-expanded": String(wasOpen), onclick: (e) => { e.stopPropagation(); toggleStatusPop(); } } : acts ? { type: "button", "data-view-action": st.action, onclick: () => chromeEvent("action", st.action) } : {}) }, st.spin ? h("span", { class: "spin" }) : null, h("span", { class: "view-status-txt" }, st.text))
1128
+ : null;
1129
+ fill($meta, $chip);
1130
+ if (wasOpen && listed) renderStatusPop(); // the list follows the chrome while it is open
1131
+ else document.getElementById("view-status-pop")?.remove();
1132
+ }
1133
+ const $notices = document.getElementById("view-notices");
1134
+ if ($notices) {
1135
+ const shown = (c?.notices ?? []).filter((n) => !viewChrome.dismissed.has(n.id));
1136
+ fill(
1137
+ $notices,
1138
+ shown.map((n) =>
1139
+ h(
1140
+ "div",
1141
+ { class: `view-notice ${n.tone}`, "data-notice": n.id, role: n.tone === "error" || n.tone === "warn" ? "alert" : "status" },
1142
+ n.spin ? h("span", { class: "spin" }) : null,
1143
+ h("div", { class: "txt" }, n.title ? h("b", {}, n.title) : null, n.text || n.say ? h("span", {}, n.text, sayNode(n.say)) : null),
1144
+ h(
1145
+ "div",
1146
+ { class: "acts" },
1147
+ n.actions.map((a) => h("button", { class: `mini${a.primary ? " primary" : ""}`, "data-view-action": a.id, onclick: () => chromeEvent("action", a.id) }, a.label)),
1148
+ n.copy ? copyButton(n.copy) : null,
1149
+ n.dismiss ? h("button", { class: "icon notice-x", "data-tip": "Dismiss", "aria-label": "Dismiss", onclick: () => {
1150
+ viewChrome.dismissed.add(n.id);
1151
+ chromeEvent("dismiss", n.id);
1152
+ renderViewChrome();
1153
+ } }, "×") : null,
1154
+ ),
1155
+ ),
1156
+ ),
1157
+ );
1158
+ $notices.hidden = !shown.length;
1159
+ }
1160
+ renderViewDialog(c?.dialog ?? null);
1161
+ }
1162
+ // the status line's list (what the changes are), opened from the chip, closed by a click elsewhere or Escape
1163
+ function renderStatusPop() {
1164
+ const st = viewChrome.chrome?.status;
1165
+ document.getElementById("view-status-pop")?.remove();
1166
+ if (!st) return;
1167
+ const $pop = h(
1168
+ "div",
1169
+ { class: "view-pop", id: "view-status-pop", role: "dialog", "aria-label": st.heading || st.text },
1170
+ st.heading ? h("p", { class: "view-pop-head" }, st.heading) : null,
1171
+ st.items.length ? h("ul", { class: "view-pop-list" }, st.items.map((i) => h("li", { "data-kind": i.kind || null }, i.tag ? h("b", {}, i.tag) : null, h("span", {}, i.text)))) : h("p", { class: "muted" }, st.detail || st.text),
1172
+ );
1173
+ document.getElementById("view-meta")?.append($pop);
1174
+ document.getElementById("view-status")?.setAttribute("aria-expanded", "true");
1175
+ }
1176
+ function toggleStatusPop() {
1177
+ if (document.getElementById("view-status-pop")) {
1178
+ document.getElementById("view-status-pop").remove();
1179
+ document.getElementById("view-status")?.setAttribute("aria-expanded", "false");
1180
+ } else renderStatusPop();
1181
+ }
1182
+ document.addEventListener("click", (e) => {
1183
+ if (!document.getElementById("view-status-pop") || e.target.closest("#view-status-pop")) return;
1184
+ toggleStatusPop();
1185
+ });
1186
+ document.addEventListener("keydown", (e) => {
1187
+ if (e.key === "Escape" && document.getElementById("view-status-pop")) toggleStatusPop();
1188
+ });
1189
+ // the view's one modal (the hand-off: "now tell your agent"), as this page's own dialog
1190
+ let viewDialogKey = null;
1191
+ function renderViewDialog(d) {
1192
+ const key = d ? JSON.stringify(d) : null;
1193
+ if (key === viewDialogKey) return;
1194
+ viewDialogKey = key;
1195
+ document.getElementById("view-dialog")?.remove();
1196
+ document.getElementById("handoff-dialog")?.remove();
1197
+ if (!d) return;
1198
+ // the gate's hand-over: the same dialog a pack's task card shows
1199
+ if (d.message) {
1200
+ const close = d.actions.find((a) => a.id === "handoff-close") ?? d.actions[d.actions.length - 1];
1201
+ showHandoff({ message: d.message, onClose: () => close && chromeEvent("action", close.id) });
1202
+ return;
1203
+ }
1204
+ const $dlg = h(
1205
+ "dialog",
1206
+ { class: "dlg view-dialog", id: "view-dialog" },
1207
+ h("h3", {}, d.title),
1208
+ d.text || d.say ? h("p", { class: "big" }, d.text, sayNode(d.say)) : null,
1209
+ h(
1210
+ "div",
1211
+ { class: "row end" },
1212
+ d.copy ? copyButton(d.copy) : null,
1213
+ d.actions.map((a) => h("button", { class: a.primary ? "primary" : "", "data-view-action": a.id, onclick: () => chromeEvent("action", a.id) }, a.label)),
1214
+ ),
1215
+ );
1216
+ // Escape = the view's own close action, so the view's state follows
1217
+ $dlg.addEventListener("cancel", (e) => {
1218
+ e.preventDefault();
1219
+ const close = d.actions.find((a) => a.id === "close") ?? d.actions[d.actions.length - 1];
1220
+ if (close) chromeEvent("action", close.id);
1221
+ });
1222
+ document.body.append($dlg);
1223
+ $dlg.showModal();
1224
+ }
1225
+
1226
+ // ---------- deck ----------
1227
+ // Opening a deck and the focus the browser gives the new page arrive
1228
+ // together: one refresh in flight answers both.
1229
+ const remoteInFlight = new Set();
1230
+ /** GET-only: ask the daemon to fetch the latest shared plan and merge; it never PUTs. */
1231
+ async function remoteRefresh(deckId) {
1232
+ if (remoteInFlight.has(deckId)) return;
1233
+ remoteInFlight.add(deckId);
1234
+ try {
1235
+ await api("POST", `/decks/${deckId}/remote/refresh`, {});
1236
+ } catch (err) {
1237
+ if (err.code !== "not_implemented") console.warn("refresh", err.code, err.message);
1238
+ } finally {
1239
+ remoteInFlight.delete(deckId);
1240
+ }
1241
+ }
1242
+ function schedulePoll(view) {
1243
+ clearTimeout(poll);
1244
+ poll = null;
1245
+ if (!isShared(view) || current?.tab !== "plan") return;
1246
+ poll = setTimeout(async () => {
1247
+ if (document.visibilityState !== "hidden" && current?.kind === "deck" && current.deckId === view.deck.id && current.tab === "plan") await remoteRefresh(view.deck.id);
1248
+ schedulePoll(view);
1249
+ }, jitter(POLL_MS));
1250
+ }
1251
+ /** Blocker text for an adapter the deck cannot use here. */
1252
+ const blockerText = (b) => PLAIN[b.code] ?? b.message ?? "This deck's plan can't be opened here.";
1253
+ const LEGACY_BANNER = "This deck uses an older folder layout that couldn't be moved to the new one automatically. Its files are untouched — ask your agent to open this deck, it can finish the move.";
1254
+
1255
+ // Why a connected folder does not hold the presentation yet — plain words, the next build retries.
1256
+ const UPLOAD_FAILED = {
1257
+ name_exists: "A file with this name is already in the folder, so nothing was replaced. Ask your agent to publish under another name.",
1258
+ deck_busy: "Your agent was working on the deck. The next build uploads it.",
1259
+ login_required: "Your Microsoft 365 sign-in has expired. Ask your agent to sign you in again; the next build uploads it.",
1260
+ write_disabled: "Saving to SharePoint is switched off for your organization.",
1261
+ publish_failed: "The presentation could not be uploaded yet. The next build uploads it.",
1262
+ };
1263
+ /**
1264
+ * The deck's header is the top bar — its name, the folder it lives in (a click
1265
+ * reveals it) and its chips beside them — and the banners are the page's, under
1266
+ * it. Built the same way by the first render and by every advisory refresh,
1267
+ * which swaps it in place without touching the tab body (a gate frame keeps its state).
1268
+ */
1269
+ function deckHead(view) {
1270
+ const remote = view.remote;
1271
+ const planState = remote?.plan?.state ?? "none";
1272
+ const pptxState = remote?.pptx?.state ?? "none";
1273
+ // one SharePoint chip, linking to the item, worded by what lives there
1274
+ const spLink = remote?.pptx?.link ?? remote?.plan?.link ?? remote?.destination?.webUrl ?? null;
1275
+ // never a code: a blocked sync's reason is in the conflict panel / the agent's output, the chip only says it needs a look
1276
+ // a folder connected after the build uploads that build; how it went is the folder's chip
1277
+ const upload = pptxState === "none" ? remote?.destination?.upload ?? null : null;
1278
+ const spText = pptxState === "published" ? "SharePoint · published" : pptxState === "bound" ? "SharePoint · linked" : planState === "shared" ? "SharePoint · plan shared" : planState === "blocked" ? "SharePoint · needs attention" : planState === "destination-only" ? (upload?.state === "uploading" ? "SharePoint · uploading" : upload?.state === "failed" ? "SharePoint · not uploaded" : "SharePoint · folder connected") : null;
1279
+ const legacy = view.deck.format === "legacy";
1280
+ const where = tilde(view.deck.path, libraryHome);
1281
+ // when it last synced is the chip's tooltip: the save chip by the tabs already says whether it did
1282
+ const synced = upload?.state === "failed" ? UPLOAD_FAILED[upload.reason] ?? UPLOAD_FAILED.publish_failed : upload?.state === "uploading" ? "Uploading the presentation to this folder" : spText ? `Last synced ${fmtTime(remote?.plan?.lastConfirmedAt ?? remote?.pptx?.lastConfirmedAt ?? view.sync?.lastConfirmedAt)}` : null;
1283
+ return {
1284
+ title: view.deck.name ?? where,
1285
+ sub: h("button", { class: "meta-folder", id: "reveal", "data-tip": `${revealLabel()}\n${where}`, "aria-label": `${revealLabel()}: ${where}`, onclick: () => api("POST", `/decks/${view.deck.id}/reveal`, {}).catch((err) => toast(plainError(err), "bad")) }, icon(ICON_FOLDER), deckFolder(view.deck.path, libraryHome)),
1286
+ actions: [
1287
+ view.pptx ? openPptxButton(view) : null,
1288
+ spText ? sharepointChip(spLink, spText, synced) : null,
1289
+ !spText && !legacy ? h("button", { class: "mini", id: "connect-sharepoint", "aria-label": "Connect SharePoint folder", onclick: () => connectSharePoint(view) }, h("img", { src: "/ui/sharepoint.svg", alt: "", class: "sp-ico" }), h("span", { class: "lbl" }, "Connect SharePoint folder")) : null,
1290
+ // the PRESENTATION's policy: a deployment without the shared-plan service still saves the .pptx
1291
+ remote?.publishWrites === "disabled" ? chip("SharePoint · read-only", "warn", { "data-tip": "slidemind can read from SharePoint here but does not save to it" }) : null,
1292
+ view.adapter.blocker ? chip("Can't open here", "bad") : null,
1293
+ legacy ? h("button", { class: "mini", id: "remove-deck", onclick: () => removeDeckDialog(view.deck, { trash: false, after: () => navigate("/") }) }, "Remove from library") : null,
1294
+ ],
1295
+ banners: [
1296
+ view.adapter.blocker ? h("p", { class: "error", id: "deck-blocker", "data-code": view.adapter.blocker.code }, blockerText(view.adapter.blocker)) : null,
1297
+ legacy ? h("p", { class: "error", id: "legacy-banner", "data-code": "unsupported_format" }, LEGACY_BANNER) : null,
1298
+ ],
1299
+ };
1300
+ }
1301
+ /** The tab buttons, each with its ✓ once that phase is approved and the count of questions waiting in it (the sidebar's badge). */
1302
+ function deckTabs(view, tabs) {
1303
+ const waiting = (t) => view.tasks.filter((k) => k.status === "pending" && tabOf(k, tabs) === t).length;
1304
+ return h(
1305
+ "div",
1306
+ { class: "tabs", role: "tablist" },
1307
+ tabs.map((t) => {
1308
+ const n = waiting(t);
1309
+ return h("button", { role: "tab", "aria-selected": String(t === current.tab), "data-tab": t, onclick: () => {
1310
+ if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
1311
+ current.tab = t;
1312
+ refresh(true);
1313
+ } }, t[0].toUpperCase() + t.slice(1), (t === "plan" && view.stage?.plan?.approved) || (t === "preview" && view.stage?.review?.approved) ? h("span", { class: "tab-ok", "data-tip": "Approved" }, "✓") : null, n ? h("span", { class: "badge", "data-tip": `${n} question${n === 1 ? "" : "s"} waiting for you` }, String(n)) : null);
1314
+ }),
1315
+ );
1316
+ }
1317
+ async function renderDeck() {
1318
+ const { deckId } = current;
1319
+ let view;
1320
+ try {
1321
+ view = await api("GET", `/decks/${deckId}`);
1322
+ } catch (err) {
1323
+ if (connectionLost(err)) throw err; // the banner says it; the deck on screen stays
1324
+ const gone = err.code === "missing_path" ? { id: deckId, name: err.details?.name ?? deckNames.get(deckId) ?? null, path: err.details?.path ?? "" } : null;
1325
+ setMeta(gone?.name ?? "Deck", "");
1326
+ $app.replaceChildren(
1327
+ page(
1328
+ errorBox(err, err.code === "missing_path" || err.code === "unknown_deck" ? h("a", { href: "/", "data-nav": true }, "Back to the inbox") : null),
1329
+ gone ? h("div", { class: "row" }, h("button", { id: "forget-deck", onclick: () => removeDeckDialog(gone, { trash: false, after: () => navigate("/") }) }, "Forget this deck")) : null,
1330
+ ),
1331
+ );
1332
+ return;
1333
+ }
1334
+ setTitle(`${view.deck.name ?? "deck"} · slidemind`);
1335
+ const head = deckHead(view);
1336
+ setMeta(head.title, head.sub, head.actions);
1337
+ const tabs = tabsFor(view);
1338
+ if (current.taskId && !current.tab) current.tab = phaseOf(view.tasks.find((t) => t.id === current.taskId)?.kind).tab; // a task link opens its phase
1339
+ if (!tabs.includes(current.tab)) current.tab = tabs[0];
1340
+ // the address names the tab on screen, so a reload or a copied address opens the same view
1341
+ if (current.tab) {
1342
+ const want = `/deck/${deckId}/${current.tab}`;
1343
+ if (location.pathname !== want) history.replaceState(null, "", want);
1344
+ }
1345
+ const opened = !current.shared && current.opened !== deckId;
1346
+ current.shared = isShared(view);
1347
+ if (opened && current.shared) {
1348
+ current.opened = deckId;
1349
+ remoteRefresh(deckId); // on open: fetch the latest, merge under lock, then SSE re-renders
1350
+ }
1351
+ // the tab's tools sit by the tabs; the right end holds only the two state chips, so a
1352
+ // chip whose text changes moves nothing but its own edge
1353
+ const $tabRow = tabs.length ? h("div", { class: "row tab-row" }, deckTabs(view, tabs), h("div", { class: "row", id: "tab-tools" }), h("div", { class: "row", id: "tab-meta" }, h("span", { class: "row", id: "view-meta" }))) : null;
1354
+ const $notices = h("div", { id: "view-notices", class: "view-notices", hidden: true });
1355
+ const $body = h("section", { id: "tab-body" });
1356
+ // the tab body is rebuilt: a plan editor or frame from before is gone with it (renderPlan sets them anew)
1357
+ planEditor = null;
1358
+ planFrame = null;
1359
+ planView = null;
1360
+ unbindViewChrome();
1361
+ fill($app, deckPage(...head.banners, $tabRow, $notices, $body));
1362
+ if (!tabs.length) {
1363
+ renderYourTurn(view, $body, null);
1364
+ return;
1365
+ }
1366
+ if (current.tab === "plan") await renderPlan(view, $body);
1367
+ else if (current.tab === "preview") renderPreview(view, $body);
1368
+ renderYourTurn(view, $body, current.tab);
1369
+ schedulePoll(view);
1370
+ }
1371
+ /**
1372
+ * An advisory refresh of the deck on screen: header chips, tab marks, the
1373
+ * conflict panel and "your turn" follow the server;
1374
+ * the tab body's frame is left alone.
1375
+ */
1376
+ let chromeSeq = 0;
1377
+ async function refreshDeckChrome() {
1378
+ if (current?.kind !== "deck") return;
1379
+ const { deckId } = current;
1380
+ const seq = ++chromeSeq;
1381
+ let view;
1382
+ try {
1383
+ view = await api("GET", `/decks/${deckId}`);
1384
+ } catch {
1385
+ return; // the next event or the reconnect tries again
1386
+ }
1387
+ if (seq !== chromeSeq || current?.kind !== "deck" || current.deckId !== deckId) return;
1388
+ const $page = document.querySelector(".page.deck");
1389
+ if (!$page) return;
1390
+ const head = deckHead(view);
1391
+ setMeta(head.title, head.sub, head.actions);
1392
+ for (const id of ["deck-blocker", "legacy-banner"]) document.getElementById(id)?.remove();
1393
+ $page.prepend(...head.banners.filter(Boolean));
1394
+ const tabs = tabsFor(view);
1395
+ const $tabs = document.querySelector(".tab-row .tabs");
1396
+ if ($tabs && tabs.includes(current.tab)) $tabs.replaceWith(deckTabs(view, tabs));
1397
+ const $body = document.getElementById("tab-body");
1398
+ if (!$body) return;
1399
+ if (current.tab === "plan" && planFrame) {
1400
+ const $panel = document.getElementById("conflict-panel");
1401
+ const c = conflictOf(view);
1402
+ if (($panel?.dataset.conflict ?? null) !== (c ? `${c.conflictId}:${c.expectedHash ?? ""}` : null)) {
1403
+ $panel?.remove();
1404
+ renderConflict(view, $body);
1405
+ }
1406
+ }
1407
+ redrawYourTurn(view);
1408
+ }
1409
+
1410
+ // ---------- conflicts (7.5) ----------
1411
+ /**
1412
+ * Mine / theirs / merged from the queue's conflict record. Resolving sends the
1413
+ * CURRENT conflict id + expected local hash; a stale one is refused by the
1414
+ * daemon, the panel re-renders with the new ids and nothing typed is lost.
1415
+ */
1416
+ function renderConflict(view, $body) {
1417
+ const c = conflictOf(view);
1418
+ if (!c) return null;
1419
+ const $msg = h("p", { class: "muted", id: "conflict-message" });
1420
+ const show = (v) => (typeof v === "string" ? v : JSON.stringify(v ?? null, null, 2));
1421
+ const paths = (c.conflicts ?? []).map((x) => h("li", {}, h("code", {}, x.path ?? "?"), x.mine !== undefined ? ` — mine: ${show(x.mine).slice(0, 80)}` : "", x.theirs !== undefined ? ` · theirs: ${show(x.theirs).slice(0, 80)}` : ""));
1422
+ const pick = (label, value) => h("button", { "data-resolve": label, onclick: () => resolve(value) }, label);
1423
+ async function resolve(value) {
1424
+ for (const b of $panel.querySelectorAll("button")) b.disabled = true;
1425
+ $msg.textContent = "";
1426
+ try {
1427
+ await api("POST", `/decks/${view.deck.id}/conflicts/resolve`, { conflictId: c.conflictId, expectedHash: c.expectedHash, value });
1428
+ $msg.textContent = "Resolved. Syncing the chosen version.";
1429
+ } catch (err) {
1430
+ for (const b of $panel.querySelectorAll("button")) b.disabled = false;
1431
+ $msg.textContent = err.code === "stale_subject" ? "The plan changed again while you decided; refreshing the conflict. Your unsaved text is kept." : plainError(err);
1432
+ if (err.code === "stale_subject") setTimeout(refreshDeckChrome, 500); // the panel follows the new conflict; the gate frame stays
1433
+ }
1434
+ }
1435
+ const $panel = h(
1436
+ "div",
1437
+ { class: "banner conflict", id: "conflict-panel", "data-conflict": `${c.conflictId}:${c.expectedHash ?? ""}` },
1438
+ h("strong", {}, "The shared plan changed where you changed it."),
1439
+ h("p", { class: "muted" }, "Pick the version to keep. Nothing is lost until you choose."),
1440
+ paths.length ? h("ul", { class: "list", id: "conflict-paths" }, paths) : null,
1441
+ h("div", { class: "row" }, pick("Keep mine", c.local ?? c.current?.value ?? c.mine), pick("Take theirs", c.current?.value ?? c.theirs ?? c.current), pick("Use merged", c.merged)),
1442
+ h("details", {}, h("summary", {}, "Show both versions"), h("pre", { class: "copy", id: "conflict-mine" }, show(c.local ?? c.mine)), h("pre", { class: "copy", id: "conflict-theirs" }, show(c.current?.value ?? c.theirs ?? c.current))),
1443
+ $msg,
1444
+ );
1445
+ $body.prepend($panel);
1446
+ return $panel;
1447
+ }
1448
+
1449
+ async function renderPlan(view, $body) {
1450
+ if (view.adapter.blocker) {
1451
+ $body.replaceChildren(h("p", { class: "error", "data-code": view.adapter.blocker.code }, blockerText(view.adapter.blocker)));
1452
+ return;
1453
+ }
1454
+ if (view.adapter.id !== "core") return renderAdapterPlan(view, $body);
1455
+ // core deck: plain Markdown editor over PUT /plan
1456
+ let plan;
1457
+ try {
1458
+ plan = await api("GET", `/decks/${view.deck.id}/plan`);
1459
+ } catch (err) {
1460
+ if (connectionLost(err)) throw err;
1461
+ $body.replaceChildren(errorBox(err));
1462
+ return;
1463
+ }
1464
+ // No plan.md yet: the agent writes the first one (the plan route has no
1465
+ // "create" — a save needs the version it replaces), so there is nothing to
1466
+ // frame or edit; the page says so and refreshes when the plan arrives.
1467
+ if (plan.value === null || plan.value === undefined) {
1468
+ fill($body, h("div", { class: "empty", id: "plan-empty" }, h("div", { class: "brand" }, ...brandMark()), h("p", { class: "muted" }, "Your agent hasn't written a plan for this deck yet.")));
1469
+ return;
1470
+ }
1471
+ const ed = { base: { hash: plan.hash, value: plan.value ?? "" }, dirty: false, timer: null, sync: plan.sync ?? null, saving: false, busySince: null };
1472
+ planEditor = ed;
1473
+ const restored = (() => {
1474
+ try {
1475
+ return sessionStorage.getItem(backupKey(view.deck.id, plan.hash));
1476
+ } catch {
1477
+ return null;
1478
+ }
1479
+ })();
1480
+ const $status = h("span", { class: "chip save", id: "save-status" });
1481
+ const $msg = h("p", { class: "muted", id: "save-message" });
1482
+ const $ta = h("textarea", { class: "plan", id: "plan-editor", spellcheck: "false" });
1483
+ $ta.value = restored ?? ed.base.value;
1484
+ if (restored !== null && restored !== ed.base.value) ed.dirty = true;
1485
+ const setStatus = () => {
1486
+ const s = saveStatus(ed.sync, ed.dirty && ed.failed ? "failed" : ed.dirty);
1487
+ $status.textContent = s.text;
1488
+ $status.className = `chip save ${s.cls}`;
1489
+ };
1490
+ const backup = () => {
1491
+ try {
1492
+ if (ed.dirty) sessionStorage.setItem(backupKey(view.deck.id, ed.base.hash), $ta.value);
1493
+ else sessionStorage.removeItem(backupKey(view.deck.id, ed.base.hash));
1494
+ } catch {
1495
+ // storage may be unavailable; the buffer stays in the textarea
1496
+ }
1497
+ };
1498
+ async function save() {
1499
+ clearTimeout(ed.timer);
1500
+ if (ed.saving) return;
1501
+ if ($ta.value === ed.base.value) {
1502
+ ed.dirty = false;
1503
+ backup();
1504
+ setStatus();
1505
+ return;
1506
+ }
1507
+ ed.saving = true;
1508
+ ed.failed = false;
1509
+ $msg.textContent = "";
1510
+ try {
1511
+ const r = await api("PUT", `/decks/${view.deck.id}/plan`, { operationId: crypto.randomUUID(), base: ed.base, value: $ta.value });
1512
+ const oldKey = backupKey(view.deck.id, ed.base.hash);
1513
+ ed.base = { hash: r.hash, value: r.value };
1514
+ ed.sync = r.sync ?? null;
1515
+ ed.dirty = $ta.value !== r.value;
1516
+ ed.busySince = null;
1517
+ try {
1518
+ sessionStorage.removeItem(oldKey);
1519
+ } catch {
1520
+ // ignore
1521
+ }
1522
+ backup();
1523
+ // the gate page shows the saved plan by itself (a new subject is a new round for it)
1524
+ if (ed.dirty) ed.timer = setTimeout(save, AUTOSAVE_MS);
1525
+ } catch (err) {
1526
+ // keep the buffer, say why
1527
+ ed.dirty = true;
1528
+ backup();
1529
+ if (err.code === "deck_busy" || err.code === "approval_in_progress") {
1530
+ ed.busySince ??= Date.now();
1531
+ const long = Date.now() - ed.busySince > BUSY_HINT_MS;
1532
+ $msg.textContent = "Your agent is working on this deck right now — your text is kept and saved automatically as soon as it is free.";
1533
+ ed.timer = setTimeout(save, RETRY_MS); // autosave retries by itself
1534
+ } else if (err.code === "local_conflict") {
1535
+ $msg.replaceChildren("Someone else changed the plan. Your text is kept. ", h("button", { onclick: () => discardTo(err.details?.current) }, "Discard mine and take theirs"));
1536
+ } else if (connectionLost(err)) {
1537
+ // saved by itself once the workspace answers again (or signs this tab back in)
1538
+ $msg.textContent = "Your text is kept in this tab and saved as soon as the workspace is back.";
1539
+ reconnectHooks.add(resume);
1540
+ } else $msg.textContent = plainError(err);
1541
+ if (err.code !== "deck_busy" && err.code !== "approval_in_progress") ed.busySince = null;
1542
+ // busy and offline retry by themselves ("Saving…"); anything else waits for the user ("Not saved")
1543
+ ed.failed = !(err.code === "deck_busy" || err.code === "approval_in_progress" || connectionLost(err));
1544
+ } finally {
1545
+ ed.saving = false;
1546
+ setStatus();
1547
+ }
1548
+ }
1549
+ function resume() {
1550
+ reconnectHooks.delete(resume);
1551
+ if (planEditor === ed && ed.dirty) save();
1552
+ }
1553
+ // wait out an autosave already on the wire, then save what is left
1554
+ ed.flush = async () => {
1555
+ for (let i = 0; i < 100 && ed.saving; i++) await new Promise((r) => setTimeout(r, 50));
1556
+ if (ed.dirty) await save();
1557
+ };
1558
+ function discardTo(cur) {
1559
+ if (!cur) return refresh();
1560
+ ed.base = { hash: cur.hash, value: cur.value };
1561
+ $ta.value = cur.value;
1562
+ ed.dirty = false;
1563
+ ed.failed = false;
1564
+ backup();
1565
+ $msg.textContent = "";
1566
+ setStatus();
1567
+ }
1568
+ $ta.addEventListener("input", () => {
1569
+ ed.typedAt = Date.now();
1570
+ ed.dirty = $ta.value !== ed.base.value;
1571
+ backup();
1572
+ setStatus();
1573
+ clearTimeout(ed.timer);
1574
+ ed.timer = setTimeout(save, AUTOSAVE_MS);
1575
+ });
1576
+ $ta.addEventListener("blur", () => ed.dirty && save());
1577
+ // The page is the deck's own plan.html carrying the live gate; the Markdown
1578
+ // editor sits behind a toggle. This page never reloads that frame: the gate
1579
+ // follows every change itself (its heartbeat), keeping what the user typed.
1580
+ // same-origin on purpose: this is the CLI's own page carrying the live gate, whose decision POST rides the session cookie
1581
+ const $frame = h("iframe", { class: "view", id: "plan-view", sandbox: "allow-scripts allow-same-origin allow-modals", title: "Plan", src: `${API}/decks/${view.deck.id}/render/plan` });
1582
+ const $editor = h("div", { class: "plan-editor", id: "plan-editor-panel", hidden: true }, $msg, $ta);
1583
+ // page ⟷ markdown: a real switch between two icons; the Markdown side saves by itself
1584
+ const ICON_PAGE = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M7 8h10M7 12h10M7 16h6"/>';
1585
+ const ICON_CODE = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>';
1586
+ const $check = h("input", { type: "checkbox", role: "switch", id: "plan-edit-toggle", "aria-label": "Edit the plan as Markdown", onchange: () => setMode($check.checked ? "edit" : "view") });
1587
+ const $iconPage = h("span", { class: "sw-ico on", "data-tip": "Plan page" }, icon(ICON_PAGE));
1588
+ const $iconCode = h("span", { class: "sw-ico", "data-tip": "Markdown" }, icon(ICON_CODE));
1589
+ const $toggle = h("label", { class: "switch" }, $iconPage, $check, h("span", { class: "sw-track" }), $iconCode);
1590
+ function setMode(mode) {
1591
+ const editing = mode === "edit";
1592
+ ed.editing = editing;
1593
+ $editor.hidden = !editing;
1594
+ $frame.hidden = editing;
1595
+ $check.checked = editing;
1596
+ $iconPage.classList.toggle("on", !editing);
1597
+ $iconCode.classList.toggle("on", editing);
1598
+ if (editing) $ta.focus();
1599
+ else if (ed.dirty) save();
1600
+ }
1601
+ planFrame = $frame;
1602
+ planView = { setMode };
1603
+ bindViewChrome($frame, (event) => $frame.contentWindow?.postMessage({ type: CHROME_EVENT, event }, location.origin));
1604
+ fill(document.getElementById("tab-tools") ?? h("div"), $toggle);
1605
+ fill(document.getElementById("tab-meta") ?? h("div"), h("span", { class: "row", id: "view-meta" }), $status);
1606
+ $body.replaceChildren($editor, $frame);
1607
+ renderConflict(view, $body);
1608
+ setStatus();
1609
+ }
1610
+
1611
+ function renderAdapterPlan(view, $body) {
1612
+ const $frame = h("iframe", { class: "view", id: "plan-view", sandbox: "allow-scripts", title: "Plan" });
1613
+ const $status = h("span", { class: "chip save ok", id: "save-status" }, "Saved");
1614
+ const $msg = h("p", { class: "muted", id: "save-message" });
1615
+ fill(document.getElementById("tab-meta") ?? h("div"), h("span", { class: "row", id: "view-meta" }), $status);
1616
+ $body.replaceChildren($msg, $frame);
1617
+ if (!window.PlanBridge) {
1618
+ $msg.className = "error";
1619
+ $msg.textContent = "This plan view could not load — reload the page; if it stays, ask your agent to open the slidemind workspace.";
1620
+ $msg.dataset.code = "plan_bridge_missing";
1621
+ return;
1622
+ }
1623
+ const deckId = view.deck.id;
1624
+ const setStatus = (r, dirty) => {
1625
+ const s = saveStatus(r?.sync ?? null, dirty);
1626
+ $status.textContent = s.text;
1627
+ $status.className = `chip save ${s.cls}`;
1628
+ };
1629
+ const wrap = (fn) => async (...args) => {
1630
+ try {
1631
+ const r = await fn(...args);
1632
+ $msg.textContent = "";
1633
+ return r;
1634
+ } catch (err) {
1635
+ const plain = err.code === "deck_busy" ? "Your agent is working on this deck right now — your edits are kept; they save once it is free." : plainError(err);
1636
+ // a view that shows its own messages (it publishes chrome) says it once, in its notice;
1637
+ // the line above the frame is for a view that does not
1638
+ $msg.textContent = viewChrome.frame === $frame && viewChrome.chrome ? "" : plain;
1639
+ setStatus(null, err.code === "deck_busy" || connectionLost(err) ? true : "failed");
1640
+ // the view gets the plain sentence, never a server's wording; the code still says what happened —
1641
+ // a lost connection as the transport's own "disconnected", which this page's banner already says
1642
+ throw { code: connectionLost(err) ? "disconnected" : err.code, message: plain, details: err.details };
1643
+ }
1644
+ };
1645
+ const bridgeApi = {
1646
+ read: wrap(() => api("GET", `/decks/${deckId}/plan`)),
1647
+ save: wrap(async ({ base, value }) => {
1648
+ const saving = api("PUT", `/decks/${deckId}/plan`, { operationId: crypto.randomUUID(), base, value });
1649
+ planSaving = saving;
1650
+ const r = await saving.finally(() => planSaving === saving && (planSaving = null));
1651
+ setStatus(r, false);
1652
+ return r;
1653
+ }),
1654
+ validate: wrap((payload) => api("POST", `/decks/${deckId}/actions/plan.validate`, { operationId: crypto.randomUUID(), payload: payload ?? {} })),
1655
+ action: wrap((name, payload) => api("POST", `/decks/${deckId}/actions/${encodeURIComponent(name)}`, { operationId: crypto.randomUUID(), payload: payload ?? {} })),
1656
+ respondTask: wrap((taskId, { action, payload, expectedHash }) => api("POST", `/decks/${deckId}/tasks/${encodeURIComponent(taskId)}/respond`, { operationId: crypto.randomUUID(), expectedHash: expectedHash ?? null, action, payload: payload ?? {} })),
1657
+ dirty: (d) => setStatus(null, d),
1658
+ };
1659
+ $frame.src = `${API}/decks/${deckId}/render/plan`;
1660
+ planFrame = $frame;
1661
+ const session = window.PlanBridge.connect($frame, { viewId: "plan", deckId, api: bridgeApi, onChrome: (chrome) => setViewChrome($frame, chrome), onDirty: (d) => setStatus(null, d) });
1662
+ bindViewChrome($frame, (event) => session.notify({ type: "chrome-event", event }));
1663
+ renderConflict(view, $body);
1664
+ }
1665
+
1666
+ /** The header's own mark + wordmark, cloned for an empty state. */
1667
+ const brandMark = () => {
1668
+ const brand = document.querySelector("header .brand");
1669
+ return brand ? [...brand.children].map((n) => n.cloneNode(true)) : [];
1670
+ };
1671
+
1672
+ /** Hand the built .pptx to PowerPoint on this machine (the daemon owns the shell). */
1673
+ function openPptxButton(view) {
1674
+ const $b = h("button", { class: "mini primary", id: "open-pptx", "data-tip": view.pptx, onclick: async () => {
1675
+ $b.disabled = true;
1676
+ try {
1677
+ await api("POST", `/decks/${view.deck.id}/open-pptx`, {});
1678
+ toast("Opening in PowerPoint…");
1679
+ } catch (err) {
1680
+ toast(plainError(err), "bad");
1681
+ } finally {
1682
+ $b.disabled = false;
1683
+ }
1684
+ } }, "Open in PowerPoint");
1685
+ return $b;
1686
+ }
1687
+
1688
+ function renderPreview(view, $body) {
1689
+ // no presentation.html on disk: the frame would show the API's 404 body
1690
+ if (!view.preview) {
1691
+ fill($body, h("div", { class: "empty", id: "preview-empty" }, h("div", { class: "brand" }, ...brandMark()), h("p", { class: "muted" }, "No preview has been created yet.")));
1692
+ return;
1693
+ }
1694
+ // a preview older than its slides is the review gate's to say (it knows whether the agent is listening) — never a second banner here
1695
+ // same-origin on purpose: the CLI's own preview page — its per-slide thumbnail frames need the session cookie, and the review gate inside it POSTs the decision
1696
+ const $frame = h("iframe", { class: "view", id: "preview-view", sandbox: "allow-scripts allow-same-origin allow-modals", title: "Preview", src: `${API}/decks/${view.deck.id}/preview/presentation.html` });
1697
+ fill($body, $frame);
1698
+ bindViewChrome($frame, (event) => $frame.contentWindow?.postMessage({ type: CHROME_EVENT, event }, location.origin));
1699
+ $frame.addEventListener("error", () => $body.prepend(h("p", { class: "error" }, "No preview built yet.")));
1700
+ }
1701
+
1702
+ /**
1703
+ * The words of the hand-over: the same after every answer (doc 21). The title
1704
+ * is the one thing to do; the line under it follows the copy icon — nothing is
1705
+ * copied until the user clicks it (the clipboard is theirs), and "Copied" only
1706
+ * when a copy path really succeeded.
1707
+ */
1708
+ const pasteKey = () => (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || "") ? "⌘V" : "Ctrl+V");
1709
+ const HANDOFF_TITLE = "Paste this into your agent chat";
1710
+ const handoffLine = (copied) =>
1711
+ copied === true ? `Copied — press ${pasteKey()} there and send.` : copied === false ? "Select the message, copy it, paste it there and send." : `Copy it, press ${pasteKey()} there and send.`;
1712
+ /** The copy icon: a click copies, the icon turns into a check for a moment; `onCopied(ok)` reports the outcome. */
1713
+ function copyIcon(message, { onCopied = null } = {}) {
1714
+ const tip = (t) => {
1715
+ $copy.dataset.tip = t;
1716
+ $copy.setAttribute("aria-label", t === "Copy" ? "Copy message" : t);
1717
+ };
1718
+ const $copy = h("button", { class: "icon copy-icon", id: "copy-message", type: "button", "data-message": message, onclick: async () => {
1719
+ const ok = await copyText(message);
1720
+ onCopied?.(ok);
1721
+ if (!ok) return tip("Copy failed — select the message");
1722
+ $copy.replaceChildren(icon(ICON_CHECK));
1723
+ $copy.classList.add("is-copied");
1724
+ tip("Copied");
1725
+ clearTimeout($copy._reset);
1726
+ $copy._reset = setTimeout(() => {
1727
+ $copy.replaceChildren(icon(ICON_COPY));
1728
+ $copy.classList.remove("is-copied");
1729
+ tip("Copy");
1730
+ }, 2000);
1731
+ } }, icon(ICON_COPY));
1732
+ tip("Copy");
1733
+ return $copy;
1734
+ }
1735
+ /**
1736
+ * Once a task was answered here: the state, with the message's copy icon.
1737
+ * Nothing about whether an agent is running — the user does the same thing
1738
+ * every time.
1739
+ */
1740
+ function doneNote(message) {
1741
+ return h("div", { class: "done handoff" }, h("p", { class: "ok", id: "task-done" }, h("strong", {}, "Answer saved")), message ? copyIcon(message) : null);
1742
+ }
1743
+ /** The hand-over dialog: the one thing to do and the message, its copy icon in the corner; `onClose` runs on Done / Escape. */
1744
+ function showHandoff({ message, onClose = null }) {
1745
+ document.getElementById("handoff-dialog")?.remove();
1746
+ const $msg = h("textarea", { class: "handoff-msg", id: "handoff-message", readonly: true, rows: 2 });
1747
+ $msg.value = message;
1748
+ const $line = h("p", { class: "big" }, handoffLine(null));
1749
+ const $copy = copyIcon(message, {
1750
+ onCopied: (ok) => {
1751
+ $line.textContent = handoffLine(ok);
1752
+ $dlg.dataset.copied = String(ok);
1753
+ if (!ok) $msg.select();
1754
+ },
1755
+ });
1756
+ const close = () => {
1757
+ $dlg.close();
1758
+ $dlg.remove();
1759
+ onClose?.();
1760
+ };
1761
+ const $dlg = h(
1762
+ "dialog",
1763
+ { class: "dlg view-dialog handoff-dialog", id: "handoff-dialog", "data-copied": "none" },
1764
+ h("h3", {}, HANDOFF_TITLE),
1765
+ $line,
1766
+ h("div", { class: "msgbox" }, $msg, message ? $copy : null),
1767
+ h("div", { class: "row end" }, h("button", { class: "primary", id: "handoff-done", onclick: close }, "Done")),
1768
+ );
1769
+ $dlg.addEventListener("cancel", (e) => {
1770
+ e.preventDefault();
1771
+ close();
1772
+ });
1773
+ document.body.append($dlg);
1774
+ $dlg.showModal();
1775
+ // the whole message shows, however long its path: the box grows to fit it
1776
+ $msg.style.height = `${$msg.scrollHeight + 2}px`;
1777
+ // the next thing to do is the copy: the icon has the focus, so Enter copies
1778
+ if (message) $copy.focus();
1779
+ return $dlg;
1780
+ }
1781
+
1782
+ /**
1783
+ * "Your turn" — the pending tasks of this phase, at the top of the tab, so the
1784
+ * human sees what is asked before the page below. A gate kind is answered in
1785
+ * the gate itself (approve / comments live there); other kinds carry their
1786
+ * buttons here.
1787
+ */
1788
+ function renderYourTurn(view, $body, tab) {
1789
+ const tabs = tabsFor(view);
1790
+ const mine = newestFirst(view.tasks.filter((t) => t.status === "pending" && (tab === null || tabOf(t, tabs) === tab)));
1791
+ if (!mine.length) return;
1792
+ const $box = h(
1793
+ "div",
1794
+ { class: "your-turn", id: "your-turn" },
1795
+ mine.map((t) => {
1796
+ const ph = phaseOf(t.kind);
1797
+ const $out = h("div", { class: "task-result" });
1798
+ return h(
1799
+ "div",
1800
+ { class: `task turn${t.id === current.taskId ? " mine" : ""}`, id: `task-${t.id}` },
1801
+ // a core deck's gate frame shows the agent's state as its own badge right above: not twice
1802
+ h("div", { class: "card-head" }, h("span", { class: "kind" }, `Your turn · ${ph.label}`), h("span", { class: "path" }, fmtTime(t.createdAt))),
1803
+ h("p", { class: "ask" }, ph.gate && view.adapter?.id !== "core" ? ph.ask.replace(/ in the review below\.| in the panel below\./, ".") : ph.ask),
1804
+ // a core deck answers a gate kind inside the embedded gate; a pack's page may not carry approve controls, so its card keeps the buttons
1805
+ ph.gate && view.adapter?.id === "core" ? null : h("div", { class: "actions row" }, (t.actions ?? []).map((a) => h("button", { "data-action": a, class: a === "approve" || a === "done" ? "primary" : null, onclick: (e) => respond(view, t, a, e.target, $out) }, answerLabel(a)))),
1806
+ // put it away without answering it — the agent that asked is long gone
1807
+ h("button", { class: "icon dismiss", "data-dismiss": t.id, "data-tip": "Put this question away without answering", "aria-label": "Put this question away without answering", onclick: () => dismiss(view, t) }, icon('<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/>')),
1808
+ $out,
1809
+ );
1810
+ }),
1811
+ );
1812
+ $body.prepend($box);
1813
+ }
1814
+ /** Redraw only the "your turn" box from a fresh deck view (the gate frame stays). */
1815
+ function redrawYourTurn(view) {
1816
+ if (current?.kind !== "deck") return;
1817
+ const $body = document.getElementById("tab-body");
1818
+ if (!$body) return;
1819
+ const old = document.getElementById("your-turn");
1820
+ // keep a box that is showing an answer's note until the user moves on
1821
+ if (old && old.querySelector("#task-done")) return;
1822
+ if (old) old.remove();
1823
+ renderYourTurn(view, $body, current.tab);
1824
+ }
1825
+ /**
1826
+ * An answered card is not a question any more: the ask, the buttons and the
1827
+ * dismiss go, and what is left is one row saying what to do now.
1828
+ */
1829
+ function settleCard(task) {
1830
+ const $card = document.getElementById(`task-${task.id}`);
1831
+ if (!$card) return;
1832
+ $card.classList.add("answered");
1833
+ for (const sel of [".ask", ".actions", ".dismiss"]) $card.querySelector(sel)?.remove();
1834
+ $card.querySelector(".card-head .kind")?.after(chip("Saved", "ok"));
1835
+ }
1836
+
1837
+ /** Dismiss: the ask is put away (cancelled) without an answer, after the page's own confirm. */
1838
+ function dismiss(view, task) {
1839
+ dialog({
1840
+ title: "Put this question away?",
1841
+ hint: "Your agent asked this earlier. It will not get an answer to it.",
1842
+ input: false,
1843
+ danger: true,
1844
+ submit: "Put away",
1845
+ run: async () => {
1846
+ await api("POST", `/decks/${view.deck.id}/tasks/${encodeURIComponent(task.id)}/dismiss`, { operationId: crypto.randomUUID() });
1847
+ document.getElementById(`task-${task.id}`)?.remove();
1848
+ },
1849
+ });
1850
+ }
1851
+
1852
+ /** Ask the plan frame to save what is still debounced, then wait for the save. */
1853
+ async function settlePlanEdits() {
1854
+ if (planFrame && window.PlanBridge?.notify(planFrame, { type: "flush" })) await new Promise((r) => setTimeout(r, 150));
1855
+ while (planSaving) await planSaving.catch(() => {});
1856
+ }
1857
+
1858
+ async function respond(view, task, action, $btn, $out) {
1859
+ if (action === "comment" || action === "request-changes") {
1860
+ return dialog({ title: actionLabel(action), hint: "Your note goes to the agent with the answer.", placeholder: "What should change?", submit: actionLabel(action), multiline: true, run: (comment) => send({ comment }) });
1861
+ }
1862
+ return send({});
1863
+ async function send(payload) {
1864
+ for (const b of $btn.parentElement.querySelectorAll("button")) b.disabled = true;
1865
+ try {
1866
+ // Edits typed just before the click land first; the task follows them
1867
+ // on the server, so the answer covers the plan as it is on screen.
1868
+ await settlePlanEdits();
1869
+ const r = await api("POST", `/decks/${view.deck.id}/tasks/${encodeURIComponent(task.id)}/respond`, { operationId: crypto.randomUUID(), expectedHash: task.subject?.hash ?? null, action, payload });
1870
+ answered.add(task.id);
1871
+ settleCard(task);
1872
+ // the same hand-over after every answer: the message to paste, copied only by the user's own click
1873
+ const message = r.message ?? "";
1874
+ $out.replaceChildren(doneNote(message));
1875
+ showHandoff({ message });
1876
+ } catch (err) {
1877
+ for (const b of $btn.parentElement.querySelectorAll("button")) b.disabled = false;
1878
+ if (err.code === "stale_subject") $out.replaceChildren(h("p", { class: "error", "data-code": err.code }, "Your agent changed this since it asked — showing the new version."));
1879
+ else $out.replaceChildren(errorBox(err));
1880
+ if (err.code === "stale_subject") setTimeout(refreshDeckChrome, 1500);
1881
+ }
1882
+ }
1883
+ }
1884
+
1885
+ boot();
1886
+ })();