slidemind 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ // Shared browser-side session machinery for the CLI's two blocking gates —
2
+ // the plan sign-off gate (plan-overlay.js) and the spatial preview gate
3
+ // (review-overlay.js). Both overlays used to carry byte-identical copies of the
4
+ // sessionStorage ledger, the backlog flush, the round-transition detection, the
5
+ // round-ready announcement and the heartbeat poll; a fix landed in one and not
6
+ // the other is exactly how the two drifted apart. This module is the ONE copy.
7
+ //
8
+ // It is plain dependency-free ESM so a test can `import` it directly, and the
9
+ // gates inline it into their page (import-free, `export ` stripped) as
10
+ // window.__SLIDEMIND_GATE__ — see bundleGateSession() in gate-server.mjs.
11
+ //
12
+ // STATE OWNERSHIP: the overlay owns its `ledger` array and its UI mode flags;
13
+ // this module owns the STORAGE and the wire. Every function takes the ledger it
14
+ // operates on, so nothing here has to be kept in sync with the overlay's vars.
15
+ //
16
+ // ROUND TRANSITION: driven by the server's per-run nonce (CFG.round, minted by
17
+ // gate-server's createHeartbeat), NOT by a content hash. A content hash is not
18
+ // a reliable round marker — hashRender() returns "" when slides/ can't be read,
19
+ // and a revision round that changed no file produces an identical hash — so a
20
+ // hash-driven transition silently fails to flip "sent" rows to "addressed" and
21
+ // (with the old approve guard) permanently disabled Approve. Content hashes are
22
+ // still fine for the COSMETIC "updated" badges, which is all the plan gate uses
23
+ // its cached previous body for.
24
+
25
+ // The wire shape each gate's server persists and prints. Per-gate because the
26
+ // anchors differ (spatial pin vs. slide-anchored comment), shared here because
27
+ // flushBacklog posts it and the gate's normalizer has to accept it.
28
+ const WIRE = {
29
+ // Preview pins carry FULL-PRECISION 0..1 fractions. Rounding to whole percent
30
+ // on the wire cost ±6px on a 1280px stage and collapsed sub-1% offsets; the
31
+ // harness output is the only place a percent is the right unit.
32
+ review(c) {
33
+ return {
34
+ id: c.id,
35
+ round: c.round,
36
+ jsxPath: c.jsxPath,
37
+ resolvedTarget: c.resolvedTarget,
38
+ x: c.x,
39
+ y: c.y,
40
+ text: c.text,
41
+ status: "sent",
42
+ at: c.at,
43
+ };
44
+ },
45
+ plan(c) {
46
+ return {
47
+ id: c.id,
48
+ round: c.round,
49
+ scope: c.scope,
50
+ slideIndex: c.slideIndex,
51
+ slideHeading: c.slideHeading,
52
+ text: c.text,
53
+ at: c.at,
54
+ };
55
+ },
56
+ };
57
+
58
+ // How long an unsent draft may sit un-persisted. The draft write used to run on
59
+ // every keystroke; with the plan body split out of the context blob the payload
60
+ // is tiny, but a debounce still keeps typing off the storage path entirely.
61
+ const DRAFT_DEBOUNCE_MS = 300;
62
+
63
+ export function createGateSession(cfg) {
64
+ const name = cfg.name; // "review" | "plan" — storage prefix + wire shape
65
+ const deck = cfg.deck || "";
66
+ const store = cfg.storage;
67
+ const doFetch = cfg.fetch;
68
+ const wire = cfg.wire || WIRE[name];
69
+ // Deck-scoped, NOT round-scoped: the rows carry their own round, and keying
70
+ // the store by the round would strand the queue on exactly the reload that
71
+ // has to flush it (a new round's key differs by definition).
72
+ const LEDGER_KEY = "slidemind-" + name + "-ledger:" + deck;
73
+ const CTX_KEY = "slidemind-" + name + "-ctx:" + deck;
74
+ const ROUND_READY_FLAG = "slidemind-" + name + "-round-ready";
75
+ let draftTimer = null;
76
+ let pollTimer = null;
77
+
78
+ function read(key, fallback) {
79
+ try {
80
+ const raw = store.getItem(key);
81
+ return raw ? JSON.parse(raw) : fallback;
82
+ } catch {
83
+ return fallback; // private mode / storage blocked — degrade to in-memory
84
+ }
85
+ }
86
+ function write(key, value) {
87
+ try {
88
+ store.setItem(key, JSON.stringify(value));
89
+ } catch {
90
+ /* storage blocked — the session just doesn't survive a reload */
91
+ }
92
+ }
93
+ function saveLedger(ledger) {
94
+ write(LEDGER_KEY, ledger);
95
+ }
96
+ // The context the NEXT round restores from. The server nonce rides along as
97
+ // the round marker; everything else is the caller's (selection, draft, slide).
98
+ // Deliberately SMALL — anything page-sized (the plan body) belongs in its own
99
+ // key written once per page load, not in the blob a keystroke rewrites.
100
+ function saveCtx(extra) {
101
+ if (draftTimer) {
102
+ clearTimeout(draftTimer);
103
+ draftTimer = null;
104
+ }
105
+ write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
106
+ }
107
+ // Same write, coalesced — for the per-keystroke callers.
108
+ function saveCtxSoon(extra) {
109
+ if (draftTimer) clearTimeout(draftTimer);
110
+ draftTimer = setTimeout(function () {
111
+ draftTimer = null;
112
+ write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
113
+ }, DRAFT_DEBOUNCE_MS);
114
+ }
115
+
116
+ // The backlog: written in this tab, not yet delivered to any gate run.
117
+ function queued(ledger) {
118
+ return (ledger || []).filter(function (c) {
119
+ return c.status === "queued";
120
+ });
121
+ }
122
+
123
+ // Restore this tab's session and work out whether THIS load is a new round.
124
+ // Returns the ledger plus the round state; the caller applies its own ctx
125
+ // fields from the returned `ctx` and its own round-arrival cosmetics.
126
+ function restore(seed) {
127
+ let ledger = read(LEDGER_KEY, []) || [];
128
+ if (!Array.isArray(ledger)) ledger = [];
129
+ let ctx = read(CTX_KEY, null);
130
+ if (!ctx || typeof ctx !== "object") ctx = null;
131
+ let roundNo = ctx && typeof ctx.round === "number" ? ctx.round : 1;
132
+ // A different SERVER nonce = a genuinely newer run served this page, i.e. a
133
+ // new round. Same nonce = a plain refresh of the round already on screen.
134
+ const newRound = !!(ctx && ctx.nonce && cfg.round && ctx.nonce !== cfg.round);
135
+ if (newRound) {
136
+ roundNo += 1;
137
+ ledger.forEach(function (c) {
138
+ if (c.status === "sent") {
139
+ c.status = "addressed";
140
+ c.addressedInRound = roundNo;
141
+ }
142
+ });
143
+ }
144
+ // Seed a tab with no session of its own (a fresh window) from the deck's
145
+ // persisted round. These are the rows the agent has JUST been handed — they
146
+ // are "sent", not "addressed": nothing has answered them yet, and labelling
147
+ // them addressed claimed a fix that never happened. Session rows win: they
148
+ // carry live status.
149
+ (seed || []).forEach(function (s) {
150
+ if (!s || !s.id) return;
151
+ for (let i = 0; i < ledger.length; i++) if (ledger[i].id === s.id) return;
152
+ const row = {};
153
+ for (const k in s) row[k] = s[k];
154
+ row.status = s.status === "addressed" ? "addressed" : "sent";
155
+ ledger.push(row);
156
+ });
157
+ // Queued rows mean the agent is still working on the round they were
158
+ // written against — this load is a drain round, not a park.
159
+ return { ledger, roundNo, working: queued(ledger).length > 0, newRound, ctx };
160
+ }
161
+
162
+ // POST the queued backlog to the gate. A non-empty backlog resolves that run
163
+ // at once (drain round), so the rows flip to "sent". A failure (the command
164
+ // already exited) leaves them queued for the next run — nothing is dropped.
165
+ function flushBacklog(ledger) {
166
+ const pending = queued(ledger);
167
+ if (!pending.length) return Promise.resolve(false);
168
+ return doFetch(cfg.backlogEndpoint, {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json" },
171
+ body: JSON.stringify({ comments: pending.map(wire) }),
172
+ })
173
+ .then(function (r) {
174
+ return r.ok ? r.json() : null;
175
+ })
176
+ .then(function (data) {
177
+ if (!data || data.state !== "drained") return false;
178
+ pending.forEach(function (c) {
179
+ c.status = "sent";
180
+ });
181
+ saveLedger(ledger);
182
+ return true;
183
+ })
184
+ .catch(function () {
185
+ return false; // gate gone — the rows stay queued for the next run
186
+ });
187
+ }
188
+
189
+ // Where a row stands, for the ledger rail / sidebar chip.
190
+ function statusLabel(c) {
191
+ if (c.status === "queued") return "queued";
192
+ if (c.status === "addressed") {
193
+ return "addressed" + (c.addressedInRound ? " · round " + c.addressedInRound : "");
194
+ }
195
+ return cfg.roundInSentLabel ? "sent · round " + c.round : "sent";
196
+ }
197
+
198
+ // ---------- next-round attention ----------
199
+ // A background tab cannot steal focus (browser design), but it CAN fire a
200
+ // system notification whose click is allowed to focus it, and flag itself in
201
+ // the tab bar until it is looked at. The permission prompt only shows inside
202
+ // a user gesture, so it is requested at the send click; the "announce" wish
203
+ // survives the reload via sessionStorage (the reload wipes the JS context).
204
+ function notifyPermission() {
205
+ try {
206
+ return "Notification" in window ? Notification.permission : "unsupported";
207
+ } catch {
208
+ return "unsupported";
209
+ }
210
+ }
211
+ function requestNotifyPermission() {
212
+ try {
213
+ if (!("Notification" in window)) return Promise.resolve("unsupported");
214
+ if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
215
+ return Promise.resolve(Notification.requestPermission());
216
+ } catch {
217
+ return Promise.resolve("unsupported");
218
+ }
219
+ }
220
+ function markRoundReadyAndReload() {
221
+ try {
222
+ store.setItem(ROUND_READY_FLAG, "1");
223
+ } catch {
224
+ /* storage blocked — the reload still lands, just without the announce */
225
+ }
226
+ location.reload();
227
+ }
228
+ // Always consumed at boot (a drain round must not leave the flag armed for a
229
+ // later parked load), but only ACTED on when this round parks.
230
+ function consumeRoundFlag() {
231
+ try {
232
+ const flagged = store.getItem(ROUND_READY_FLAG) === "1";
233
+ if (flagged) store.removeItem(ROUND_READY_FLAG);
234
+ return flagged;
235
+ } catch {
236
+ return false; // no storage — nothing to announce
237
+ }
238
+ }
239
+ function announceNewRound(flagged) {
240
+ if (!flagged) return;
241
+ try {
242
+ window.focus(); // best-effort; most browsers ignore it for background tabs
243
+ } catch {
244
+ /* ignore */
245
+ }
246
+ if (!document.hidden) return; // user is already looking at it
247
+ const origTitle = document.title;
248
+ const restoreTitle = function () {
249
+ document.title = origTitle;
250
+ document.removeEventListener("visibilitychange", onVis);
251
+ window.removeEventListener("focus", restoreTitle);
252
+ };
253
+ const onVis = function () {
254
+ if (!document.hidden) restoreTitle();
255
+ };
256
+ document.title = cfg.attentionTitle;
257
+ document.addEventListener("visibilitychange", onVis);
258
+ window.addEventListener("focus", restoreTitle);
259
+ try {
260
+ if (notifyPermission() === "granted") {
261
+ const n = new Notification(cfg.notifyTitle, {
262
+ body: cfg.notifyBody,
263
+ tag: "slidemind-" + name + "-round",
264
+ });
265
+ n.onclick = function () {
266
+ try {
267
+ window.focus();
268
+ } catch {
269
+ /* ignore */
270
+ }
271
+ n.close();
272
+ };
273
+ }
274
+ } catch {
275
+ /* notification construction can throw on some platforms — title stands */
276
+ }
277
+ }
278
+
279
+ // After a comments round the tab stays open and polls the heartbeat so the
280
+ // NEXT run (which binds the same deterministic port) reloads this tab instead
281
+ // of opening a fresh browser window. The poll reports this tab's round nonce
282
+ // and its visibility: only a VISIBLE tab may claim the next round for reuse —
283
+ // a tab buried in a background window must not swallow the round invisibly,
284
+ // so the server opens a fresh browser tab and answers `action: "close"` here,
285
+ // which retires this tab rather than reloading it into a hidden duplicate.
286
+ function startNextRoundPolling(handlers) {
287
+ if (pollTimer) return; // one poll per page life (working mode can re-enter)
288
+ const startedAt = Date.now();
289
+ const ttl = cfg.pollTtlMs || 35 * 60 * 1000;
290
+ const hb = cfg.heartbeat;
291
+ const timer = setInterval(function () {
292
+ if (Date.now() - startedAt > ttl) {
293
+ clearInterval(timer);
294
+ pollTimer = null;
295
+ handlers.onEnded();
296
+ return;
297
+ }
298
+ const visible = document.visibilityState === "visible" ? "1" : "0";
299
+ const url =
300
+ hb +
301
+ (hb.indexOf("?") < 0 ? "?" : "&") +
302
+ "round=" +
303
+ encodeURIComponent(cfg.round || "") +
304
+ "&visible=" +
305
+ visible;
306
+ doFetch(url, { cache: "no-store" })
307
+ .then(function (r) {
308
+ return r.ok ? r.json() : null;
309
+ })
310
+ .then(function (data) {
311
+ // Act only on a genuine NEWER round of THIS gate: shape-guard the
312
+ // service marker (a foreign app may have taken the port) and require
313
+ // a different nonce (same nonce = this run's own orphan).
314
+ if (!data || data.service !== cfg.service || !data.round) return;
315
+ if (data.round === cfg.round) return;
316
+ if (data.action === "close") {
317
+ clearInterval(timer);
318
+ pollTimer = null;
319
+ handlers.onSuperseded();
320
+ return;
321
+ }
322
+ if (data.action === "wait") return; // grace window deciding — keep polling
323
+ clearInterval(timer);
324
+ pollTimer = null;
325
+ markRoundReadyAndReload();
326
+ })
327
+ .catch(function () {
328
+ /* server down between rounds — keep polling */
329
+ });
330
+ }, cfg.pollMs || 1000);
331
+ pollTimer = timer;
332
+ }
333
+
334
+ return {
335
+ LEDGER_KEY,
336
+ CTX_KEY,
337
+ ROUND_READY_FLAG,
338
+ wire,
339
+ read,
340
+ write,
341
+ saveLedger,
342
+ saveCtx,
343
+ saveCtxSoon,
344
+ queued,
345
+ restore,
346
+ flushBacklog,
347
+ statusLabel,
348
+ notifyPermission,
349
+ requestNotifyPermission,
350
+ markRoundReadyAndReload,
351
+ consumeRoundFlag,
352
+ announceNewRound,
353
+ startNextRoundPolling,
354
+ };
355
+ }