@khanglvm/relay 0.9.1 → 0.10.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.
package/src/spec.js CHANGED
@@ -25,9 +25,22 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
25
25
 
26
26
  // Block heights clamp to the same window; defaults vary per block type.
27
27
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
28
- export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'html', 'image'];
28
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image'];
29
29
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
30
30
 
31
+ // code/diff blocks may load their text from a local file (like htmlFile). Caps
32
+ // keep a runaway file from bloating the board payload.
33
+ const TEXT_FILE_MAX_BYTES = 512 * 1024;
34
+
35
+ // video blocks: a YouTube/Vimeo link embeds via iframe; an http(s) media URL or
36
+ // a local file plays in a <video> element (local files stream from the server,
37
+ // never embedded, so large clips don't bloat the board).
38
+ const VIDEO_MIMES = {
39
+ mp4: 'video/mp4', m4v: 'video/x-m4v', webm: 'video/webm', ogv: 'video/ogg',
40
+ ogg: 'video/ogg', mov: 'video/quicktime', mkv: 'video/x-matroska',
41
+ };
42
+ const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
43
+
31
44
  // image blocks: local files are embedded as data URIs at spec time (the page
32
45
  // then loads them via /img/b/<id>), so boards stay self-contained offline.
33
46
  const IMAGE_MIMES = {
@@ -71,6 +84,45 @@ function readBlockHtml(block, cwd, where) {
71
84
  return '';
72
85
  }
73
86
 
87
+ // Reads a code/diff block body from an inline string field or a local file.
88
+ // `inlineKey` is the inline field (e.g. "code"/"diff"); `fileKey` its file
89
+ // twin (e.g. "codeFile"/"diffFile"). Returns '' when neither is present.
90
+ function readTextSource(block, inlineKey, fileKey, cwd, where) {
91
+ if (typeof block[inlineKey] === 'string' && block[inlineKey] !== '') return block[inlineKey];
92
+ if (typeof block[fileKey] === 'string' && block[fileKey].trim()) {
93
+ const p = path.resolve(cwd, block[fileKey]);
94
+ let buf;
95
+ try {
96
+ buf = fs.readFileSync(p);
97
+ } catch {
98
+ throw new CliError(`${where}: cannot read ${fileKey} "${block[fileKey]}" (resolved: ${p})`);
99
+ }
100
+ if (buf.length > TEXT_FILE_MAX_BYTES) {
101
+ throw new CliError(`${where}: ${fileKey} "${block[fileKey]}" is ${(buf.length / 1024).toFixed(0)}KB — max ${TEXT_FILE_MAX_BYTES / 1024}KB.`);
102
+ }
103
+ return buf.toString('utf8');
104
+ }
105
+ return '';
106
+ }
107
+
108
+ // Recognizes a YouTube / Vimeo URL (or a bare YouTube id) and returns
109
+ // {provider, videoId, start} for an iframe embed, else null. Cross-platform —
110
+ // pure string parsing, no URL host assumptions beyond the known providers.
111
+ function parseVideoEmbed(src) {
112
+ const s = String(src).trim();
113
+ // bare 11-char YouTube id
114
+ if (/^[\w-]{11}$/.test(s)) return { provider: 'youtube', videoId: s, start: 0 };
115
+ let m;
116
+ if ((m = s.match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([\w-]{11})/i))) {
117
+ const t = s.match(/[?&](?:t|start)=(\d+)/);
118
+ return { provider: 'youtube', videoId: m[1], start: t ? Number(t[1]) : 0 };
119
+ }
120
+ if ((m = s.match(/vimeo\.com\/(?:video\/)?(\d+)/i))) {
121
+ return { provider: 'vimeo', videoId: m[1], start: 0 };
122
+ }
123
+ return null;
124
+ }
125
+
74
126
  // Normalizes one block object. `id` is the already-assigned block id.
75
127
  // Returns the normalized block (with a guaranteed string `type` + `id`).
76
128
  function normalizeBlock(rawBlock, id, cwd, where) {
@@ -126,14 +178,75 @@ function normalizeBlock(rawBlock, id, cwd, where) {
126
178
  }
127
179
 
128
180
  if (type === 'code') {
129
- const code = asStr(rawBlock.code);
130
- if (!code) throw new CliError(`${where}: code block needs a "code" string.`);
181
+ const code = readTextSource(rawBlock, 'code', 'codeFile', cwd, where);
182
+ if (!code) throw new CliError(`${where}: code block needs a "code" string or a readable "codeFile".`);
131
183
  const block = { id, type: 'code', code };
132
184
  if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
185
+ // lang defaults from the codeFile extension when not given explicitly.
186
+ else if (typeof rawBlock.codeFile === 'string' && rawBlock.codeFile.trim()) {
187
+ const ext = path.extname(rawBlock.codeFile).slice(1).toLowerCase();
188
+ if (ext) block.lang = ext;
189
+ }
190
+ if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
133
191
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
134
192
  return block;
135
193
  }
136
194
 
195
+ if (type === 'diff') {
196
+ const diff = readTextSource(rawBlock, 'diff', 'diffFile', cwd, where);
197
+ if (!diff.trim()) throw new CliError(`${where}: diff block needs a non-empty "diff" string (unified diff) or a readable "diffFile".`);
198
+ const block = { id, type: 'diff', diff };
199
+ if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
200
+ if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
201
+ const view = asStr(rawBlock.view).trim().toLowerCase();
202
+ if (view === 'split' || view === 'unified') block.view = view;
203
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
204
+ return block;
205
+ }
206
+
207
+ if (type === 'video') {
208
+ const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
209
+ if (!src) throw new CliError(`${where}: video block needs a "src" (YouTube/Vimeo URL, http(s) media URL, or local file path).`);
210
+ const block = { id, type: 'video' };
211
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
212
+ if (rawBlock.alt !== undefined) block.title = asStr(rawBlock.alt);
213
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
214
+ const embed = parseVideoEmbed(src);
215
+ if (embed) {
216
+ block.provider = embed.provider;
217
+ block.videoId = embed.videoId;
218
+ if (embed.start) block.start = embed.start;
219
+ return block;
220
+ }
221
+ if (/^https?:/i.test(src)) {
222
+ block.src = src;
223
+ const ext = path.extname(src.split(/[?#]/)[0]).slice(1).toLowerCase();
224
+ if (VIDEO_MIMES[ext]) block.mime = VIDEO_MIMES[ext];
225
+ return block;
226
+ }
227
+ // local file — kept as an absolute path the server streams (never embedded
228
+ // in the payload). The client only learns a flag + mime via /video/b/<id>.
229
+ const p = path.resolve(cwd, src);
230
+ const ext = path.extname(p).slice(1).toLowerCase();
231
+ const mime = VIDEO_MIMES[ext];
232
+ if (!mime) {
233
+ throw new CliError(`${where}: unsupported video extension ".${ext}" — use ${Object.keys(VIDEO_MIMES).join('/')}, a YouTube/Vimeo URL, or an http(s) media URL.`);
234
+ }
235
+ let stat;
236
+ try {
237
+ stat = fs.statSync(p);
238
+ } catch {
239
+ throw new CliError(`${where}: cannot read video "${src}" (resolved: ${p})`);
240
+ }
241
+ if (stat.size > VIDEO_MAX_BYTES) {
242
+ throw new CliError(`${where}: video "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${VIDEO_MAX_BYTES / 1024 / 1024}MB.`);
243
+ }
244
+ block.file = p;
245
+ block.mime = mime;
246
+ if (!block.title) block.title = path.basename(p);
247
+ return block;
248
+ }
249
+
137
250
  if (type === 'chart') {
138
251
  const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
139
252
  const hasShorthand =
@@ -412,11 +525,16 @@ const BLOCK_SCHEMA = {
412
525
  properties: {
413
526
  type: { type: 'string', enum: BLOCK_TYPES },
414
527
  md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
415
- code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
528
+ code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display (syntax-highlighted with line numbers).' },
529
+ codeFile: { type: 'string', description: 'code: path to a local source file to load + display instead of inline "code". Resolved against the CWD; lang defaults from the file extension.' },
530
+ filename: { type: 'string', description: 'code/diff: optional file name/path shown as a header label above the block.' },
416
531
  editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
417
532
  dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
418
533
  server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
419
- lang: { type: 'string', description: 'code block: language hint for display.' },
534
+ lang: { type: 'string', description: 'code/diff block: language hint for syntax highlighting (js, ts, py, go, rust, java, c, cpp, csharp, ruby, php, swift, kotlin, sql, yaml, json, sh, css, html, …).' },
535
+ diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
536
+ diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
537
+ view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
420
538
  config: { type: 'object', description: 'chart: a full Chart.js config object.' },
421
539
  kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
422
540
  labels: { type: 'array', description: 'chart shorthand: x-axis / category labels.' },
@@ -425,7 +543,7 @@ const BLOCK_SCHEMA = {
425
543
  description: 'chart shorthand: [{label, data:[...], color?}]. Chart data points are individually commentable.',
426
544
  items: { type: 'object', properties: { label: { type: 'string' }, data: { type: 'array' }, color: { type: 'string' } } },
427
545
  },
428
- title: { type: 'string', description: 'chart shorthand: chart title.' },
546
+ title: { type: 'string', description: 'chart shorthand: chart title. video: title/caption shown under the player.' },
429
547
  columns: {
430
548
  type: 'array',
431
549
  description: 'table: strings, or {key, label, align?}. Cells are commentable.',
@@ -435,8 +553,8 @@ const BLOCK_SCHEMA = {
435
553
  sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
436
554
  html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
437
555
  htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
438
- src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline).' },
439
- alt: { type: 'string', description: 'image: alt text / annotation label.' },
556
+ src: { type: 'string', description: 'image: http(s)/data URL, or a local file path (png/jpg/gif/webp/svg/avif/bmp — embedded at spec time, served offline). video: a YouTube/Vimeo URL (embeds an iframe player), an http(s) media URL, or a local video file (mp4/webm/ogv/mov/mkv/m4v — streamed from the server, never embedded).' },
557
+ alt: { type: 'string', description: 'image: alt text / annotation label. video: accessible title for the player.' },
440
558
  height: { type: 'integer', minimum: BLOCK_HEIGHT.min, maximum: BLOCK_HEIGHT.max, description: 'Block height in px. Defaults: chart 320, html 360; markdown/table/code flow naturally; mermaid/graphviz/plantuml/image natural (max 1200, scrolls).' },
441
559
  },
442
560
  },
package/src/store.js CHANGED
@@ -28,6 +28,10 @@ export function createBoard(spec) {
28
28
  id: newId(),
29
29
  createdAt: new Date().toISOString(),
30
30
  title: spec.title,
31
+ // The directory the board was authored in. Relative file paths an agent
32
+ // writes into the spec (markdown links, ~/… paths) resolve against this, so
33
+ // the live server can open them in the user's default app (POST /api/open).
34
+ cwd: process.cwd(),
31
35
  spec,
32
36
  draft: null,
33
37
  result: null,
package/src/ui/app.js CHANGED
@@ -88,6 +88,68 @@
88
88
  }
89
89
  let themeBtn = null;
90
90
 
91
+ // ---------- localStorage draft mirror ----------
92
+ // Every autosave is ALSO written to localStorage, keyed by board id. This is
93
+ // the durability layer the server file alone can't provide: if the connection
94
+ // drops and the user keeps typing, the in-memory state is mirrored locally, so
95
+ // even a tab reload / browser restart / a freshly opened tab on the same board
96
+ // prefills the LATEST input instead of a blank board or a stale server save.
97
+ // Guards (per design): newest-of-(local,server) wins; the mirror is discarded
98
+ // if the board's spec rev changed (agent edited it); cleared on submit.
99
+ const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown');
100
+ function writeLocalDraft(p, updatedAt) {
101
+ try {
102
+ localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
103
+ v: 1,
104
+ boardId: boot.boardId,
105
+ rev: bootRev,
106
+ updatedAt: updatedAt || new Date().toISOString(),
107
+ payload: p,
108
+ }));
109
+ } catch {
110
+ // localStorage may be full or unavailable (privacy mode) — non-fatal; the
111
+ // server file remains the primary persistence path.
112
+ }
113
+ }
114
+ function clearLocalDraft() {
115
+ try { localStorage.removeItem(LOCAL_DRAFT_KEY); } catch { /* non-fatal */ }
116
+ }
117
+ // Returns the saved local mirror only if it's valid for THIS board+rev,
118
+ // otherwise null (and clears a now-stale entry). rev mismatch ⇒ the agent
119
+ // re-published the board, so old local answers may not map — discard them.
120
+ function loadLocalDraft() {
121
+ let raw;
122
+ try { raw = localStorage.getItem(LOCAL_DRAFT_KEY); } catch { return null; }
123
+ if (!raw) return null;
124
+ let obj;
125
+ try { obj = JSON.parse(raw); } catch { clearLocalDraft(); return null; }
126
+ if (!obj || obj.boardId !== boot.boardId || !obj.payload || typeof obj.payload !== 'object') {
127
+ clearLocalDraft();
128
+ return null;
129
+ }
130
+ // rev-guard: a changed spec rev means the local answers may reference a
131
+ // different question set — don't resurrect them.
132
+ if (bootRev !== null && obj.rev !== undefined && obj.rev !== null && obj.rev !== bootRev) {
133
+ clearLocalDraft();
134
+ return null;
135
+ }
136
+ return obj;
137
+ }
138
+
139
+ // Choose the prefill source: the NEWER of the server draft (boot.prefill) and
140
+ // the local mirror. The server draft shape mirrors a payload() plus updatedAt.
141
+ function chooseInitialPrefill() {
142
+ const server = boot.prefill || null;
143
+ const local = loadLocalDraft();
144
+ if (!local) return server;
145
+ if (!server) return { ...local.payload, __from: 'local' };
146
+ const sT = Date.parse(server.updatedAt || '') || 0;
147
+ const lT = Date.parse(local.updatedAt || '') || 0;
148
+ // Newest wins; ties favor local (the tab that was last typing into).
149
+ return lT >= sT ? { ...local.payload, __from: 'local' } : server;
150
+ }
151
+ const initialPrefill = chooseInitialPrefill();
152
+
91
153
  // ---------- state ----------
92
154
  // state.answers holds raw control state; state.other holds the "Other"
93
155
  // free-text per question; getValue() derives the final answer value.
@@ -97,11 +159,11 @@
97
159
  other: {},
98
160
  notes: {},
99
161
  comment: '',
100
- annotations: (boot.prefill && boot.prefill.annotations) || [],
162
+ annotations: (initialPrefill && initialPrefill.annotations) || [],
101
163
  // Editable-mermaid edits: blockId -> edited source. Seeded from the live
102
164
  // draft so a reload/reopen restores the user's edited diagram. Mutated via
103
165
  // the blocks ctx.onBlockEdit callback below; returned in payload().
104
- blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
166
+ blockEdits: (initialPrefill && initialPrefill.blockEdits) || {},
105
167
  };
106
168
  let submitted = false;
107
169
 
@@ -125,8 +187,11 @@
125
187
  }
126
188
  }
127
189
  }
128
- if (boot.prefill) seedFromPrefill(boot.prefill);
190
+ if (initialPrefill) seedFromPrefill(initialPrefill);
129
191
  else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
192
+ // If the local mirror was newer than the server (or the server had nothing),
193
+ // the in-memory state now holds input the server hasn't seen — flush it once
194
+ // the rest of the app is wired (see the post-init flush near the heartbeat).
130
195
 
131
196
  function getValue(q) {
132
197
  const v = state.answers[q.id];
@@ -176,27 +241,149 @@
176
241
  };
177
242
  }
178
243
 
244
+ // ---------- persistence-lost block ----------
245
+ // The dead-end this guards against: the client can lose its connection to the
246
+ // board's local HTTP server (server gone, port taken over, socket dropped,
247
+ // machine slept). Autosaves then fail silently and the user keeps typing
248
+ // answers/comments that are never persisted, then Submit fails too — all of it
249
+ // thrown away. When persistence is CONFIRMED lost we hard-block: disable every
250
+ // control and overlay an unmissable scrim with a Retry. Local `state` is never
251
+ // touched, so the moment the connection recovers we flush it and unblock —
252
+ // nothing the user typed during the outage is lost.
253
+ let persistenceLost = false;
254
+ let probing = false;
255
+ let lostOverlay = null;
256
+ let lostRetryBtn = null;
257
+
258
+ // A direct, side-effect-free reachability probe. Resolves true when the local
259
+ // server answers /api/status, false on any network/HTTP failure. Used to
260
+ // CONFIRM loss before blocking (so a single dropped request never blocks) and
261
+ // to detect recovery from the Retry button / heartbeat.
262
+ async function probeServer() {
263
+ try {
264
+ const r = await fetch('/api/status', { cache: 'no-store' });
265
+ return r.ok;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ function buildLostOverlay() {
272
+ if (lostOverlay) return lostOverlay;
273
+ lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Retry connection');
274
+ lostRetryBtn.addEventListener('click', retryConnection);
275
+ lostOverlay = el('div', { class: 'lost-overlay', role: 'alertdialog', 'aria-modal': 'true', 'aria-label': 'Connection lost' },
276
+ el('div', { class: 'lost-card' },
277
+ el('div', { class: 'lost-mark' }, '⚠'),
278
+ el('h2', {}, 'Connection lost — input isn’t being saved'),
279
+ el('p', {}, 'This board can no longer reach your agent’s session, so anything you type now won’t be saved. Editing is paused to keep you from losing work.'),
280
+ el('p', { class: 'lost-sub' }, 'Your input up to this point is kept in this tab. Click Retry once the agent’s session is back, or prompt the agent to reopen this board — your draft and unsaved edits will be restored.'),
281
+ lostRetryBtn
282
+ )
283
+ );
284
+ return lostOverlay;
285
+ }
286
+
287
+ // Enter the blocked state: disable controls, mount the overlay. Idempotent.
288
+ function blockForLostPersistence() {
289
+ if (persistenceLost || submitted) return;
290
+ persistenceLost = true;
291
+ document.documentElement.classList.add('relay-blocked');
292
+ // Disable every interactive control inside the form (inputs the user could
293
+ // otherwise keep typing into) plus Submit.
294
+ for (const node of app.querySelectorAll('input, textarea, button, select')) {
295
+ node.disabled = true;
296
+ }
297
+ document.body.append(buildLostOverlay());
298
+ if (saveEl) saveEl.textContent = 'connection lost — not saving';
299
+ }
300
+
301
+ // Leave the blocked state: re-enable controls, remove the overlay, and flush
302
+ // whatever the user typed during the outage so it's persisted right away.
303
+ function unblockAfterRecovery() {
304
+ if (!persistenceLost) return;
305
+ persistenceLost = false;
306
+ document.documentElement.classList.remove('relay-blocked');
307
+ for (const node of app.querySelectorAll('input, textarea, button, select')) {
308
+ node.disabled = false;
309
+ }
310
+ if (lostOverlay) lostOverlay.remove();
311
+ // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
312
+ // everything typed during the outage. saveDraft() updates the save label.
313
+ startHeartbeat();
314
+ misses = 0;
315
+ saveFailures = 0;
316
+ saveDraft();
317
+ }
318
+
319
+ // Retry button: probe once; recover on success, otherwise tell the user it's
320
+ // still down (without un-blocking).
321
+ async function retryConnection() {
322
+ if (probing) return;
323
+ probing = true;
324
+ if (lostRetryBtn) { lostRetryBtn.disabled = true; lostRetryBtn.textContent = 'Checking…'; }
325
+ const ok = await probeServer();
326
+ probing = false;
327
+ if (lostRetryBtn) { lostRetryBtn.disabled = false; lostRetryBtn.textContent = 'Retry connection'; }
328
+ if (ok) unblockAfterRecovery();
329
+ else if (lostRetryBtn) {
330
+ lostRetryBtn.textContent = 'Still unreachable — try again';
331
+ setTimeout(() => { if (lostRetryBtn && persistenceLost) lostRetryBtn.textContent = 'Retry connection'; }, 2500);
332
+ }
333
+ }
334
+
335
+ // Called when a save/heartbeat fails. Confirms loss with a probe (so one
336
+ // dropped request never blocks) before hard-blocking. `force` skips the probe
337
+ // for callers (the heartbeat) that already represent repeated failures.
338
+ async function considerPersistenceLost(force) {
339
+ if (persistenceLost || submitted || probing) return;
340
+ if (!force) {
341
+ probing = true;
342
+ const ok = await probeServer();
343
+ probing = false;
344
+ if (ok || persistenceLost || submitted) return; // recovered or already handled
345
+ }
346
+ blockForLostPersistence();
347
+ }
348
+
179
349
  // ---------- real-time autosave ----------
180
350
  let saveTimer = null;
181
351
  let saveSeq = 0;
182
352
  let saveEl = null;
353
+ // Consecutive failed /api/draft saves. Two in a row triggers a confirming
354
+ // probe → block. Any success resets it.
355
+ let saveFailures = 0;
183
356
  function scheduleSave() {
184
357
  if (submitted) return;
358
+ // Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
359
+ // of) the network save. This is what survives a tab reload / crash / a new
360
+ // tab during a connection outage — it must happen even while blocked.
361
+ writeLocalDraft(payload());
362
+ if (persistenceLost) return; // network save is futile while disconnected
185
363
  if (saveEl) saveEl.textContent = 'saving…';
186
364
  clearTimeout(saveTimer);
187
365
  saveTimer = setTimeout(saveDraft, 450);
188
366
  }
189
367
  async function saveDraft() {
190
368
  const seq = ++saveSeq;
369
+ // Keep the local mirror current on every flush too (covers programmatic
370
+ // saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
371
+ writeLocalDraft(payload());
191
372
  try {
192
- await fetch('/api/draft', {
373
+ const r = await fetch('/api/draft', {
193
374
  method: 'POST',
194
375
  headers: { 'content-type': 'application/json' },
195
376
  body: JSON.stringify(payload()),
196
377
  });
197
- if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft saved ✓';
378
+ if (!r.ok) throw new Error('draft rejected');
379
+ saveFailures = 0;
380
+ if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
198
381
  } catch {
199
- if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft save failed';
382
+ if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft save failed';
383
+ // A save couldn't be persisted to the SERVER — the core data-loss signal.
384
+ // (The local mirror above still captured it.) After two in a row, confirm
385
+ // with a probe and block so the user stops typing into the void.
386
+ if (++saveFailures >= 2) considerPersistenceLost(false);
200
387
  }
201
388
  }
202
389
 
@@ -656,14 +843,26 @@
656
843
  submitBtn.disabled = true;
657
844
  submitBtn.textContent = 'Submitting…';
658
845
  clearTimeout(saveTimer);
846
+ let reached = true;
659
847
  try {
660
- const res = await fetch('/api/submit', {
661
- method: 'POST',
662
- headers: { 'content-type': 'application/json' },
663
- body: JSON.stringify(payload()),
664
- });
848
+ let res;
849
+ try {
850
+ res = await fetch('/api/submit', {
851
+ method: 'POST',
852
+ headers: { 'content-type': 'application/json' },
853
+ body: JSON.stringify(payload()),
854
+ });
855
+ } catch (netErr) {
856
+ // A thrown fetch (vs. an HTTP error response) means we never reached the
857
+ // server — the connection is gone, so this submit was never delivered.
858
+ reached = false;
859
+ throw netErr;
860
+ }
665
861
  if (!res.ok) throw new Error('submit rejected');
666
862
  submitted = true;
863
+ // Submitted successfully → the local mirror is no longer needed and would
864
+ // otherwise resurrect stale answers on a future reopen. Clear it.
865
+ clearLocalDraft();
667
866
  // Don't auto-close when the agent had stopped waiting — the user needs to
668
867
  // read the "send your agent a message" note and act on it.
669
868
  const autoClose = spec.autoClose && !handedBack;
@@ -680,22 +879,33 @@
680
879
  }, 700);
681
880
  }
682
881
  } catch {
683
- // The server is gone, so this submit couldn't be delivered. Keep the
684
- // button live and tell the user how to get their input to the agent —
685
- // their draft was autosaved up to the last edit.
882
+ // Restore the button so the user can retry.
686
883
  submitBtn.disabled = false;
687
884
  submitBtn.textContent = spec.submitLabel;
688
- showNotice(
689
- 'Couldn’t reach the agent to submit just now your draft is saved. Prompt the agent to reopen this board so your input isn’t lost.',
690
- 'warn'
691
- );
885
+ if (!reached) {
886
+ // The connection is gone — the submit (and any further input) can't be
887
+ // persisted. Block hard so the user stops adding feedback that would be
888
+ // lost; the block's probe loop / heartbeat lifts it on recovery, and the
889
+ // user can then submit. force=true: the failed submit already confirms
890
+ // the server is unreachable.
891
+ stopHeartbeat();
892
+ considerPersistenceLost(true);
893
+ startBlockedProbeLoop();
894
+ } else {
895
+ // The server answered with an error (e.g. 409 board already finished) —
896
+ // it's reachable, so don't show the scary block; just guide the user.
897
+ showNotice(
898
+ 'Couldn’t submit — the board may have already closed. Prompt the agent to reopen this board so your input isn’t lost.',
899
+ 'warn'
900
+ );
901
+ }
692
902
  }
693
903
  });
694
904
 
695
905
  // ---------- prefilled load: jump past what's already answered ----------
696
906
  // On reload/reopen with saved answers, scroll to the first unanswered
697
907
  // question so the user doesn't re-scan questions they already did.
698
- if (boot.prefill && QS.length) {
908
+ if (initialPrefill && QS.length) {
699
909
  const answered = QS.filter((q) => getValue(q) !== undefined).length;
700
910
  const firstOpen = QS.find((q) => getValue(q) === undefined);
701
911
  if (answered > 0 && firstOpen) {
@@ -705,6 +915,14 @@
705
915
  }
706
916
  }
707
917
 
918
+ // If the chosen prefill came from the local mirror (newer than the server, or
919
+ // the server had nothing — e.g. a freshly reopened board the user had typed
920
+ // into in another tab during an outage), the server doesn't yet have this
921
+ // input. Flush it once so a brand-new tab's view is also the server's truth.
922
+ if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
923
+ saveDraft();
924
+ }
925
+
708
926
  // ---------- iframe annotate bridge ----------
709
927
  // Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
710
928
  // server) talk to the parent over postMessage:
@@ -772,13 +990,20 @@
772
990
  // ---------- heartbeat ----------
773
991
  let misses = 0;
774
992
  let reloading = false;
775
- let hb = setInterval(async () => {
993
+ let hb = null;
994
+ async function heartbeatTick() {
776
995
  // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
777
996
  pingPresence();
778
997
  try {
779
998
  const r = await fetch('/api/status', { cache: 'no-store' });
780
999
  if (!r.ok) throw new Error('bad status');
781
1000
  misses = 0;
1001
+ // The heartbeat reaching the server is itself proof persistence is back —
1002
+ // if we were blocked (saves had been failing), recover now and flush.
1003
+ if (persistenceLost) {
1004
+ unblockAfterRecovery();
1005
+ return;
1006
+ }
782
1007
  // Live update: the agent ran `rly update`, advancing the server rev.
783
1008
  // Flush whatever the user has typed so far (the reload re-prefills from
784
1009
  // the live draft — answers for now-removed question ids are ignored),
@@ -812,22 +1037,55 @@
812
1037
  location.reload();
813
1038
  }
814
1039
  } catch {
815
- // Lost the live connection (the session ended, or the machine slept).
816
- // Keep the board usable do NOT disable Submit and show a calm note
817
- // rather than the old red "closed" banner. A submit attempt that can't
818
- // reach the server falls back to the same guidance below.
819
- if (++misses >= 2 && !submitted) {
1040
+ // Lost the live connection (the session ended, or the machine slept). Two
1041
+ // consecutive misses means the local server is unreachableso input can
1042
+ // no longer be persisted. Hard-block: disable editing and overlay the
1043
+ // unmissable "connection lost" scrim, so the user can't keep typing
1044
+ // feedback that would be silently discarded. The block re-arms its own
1045
+ // probe loop and the heartbeat recovers it once the server answers again.
1046
+ if (++misses >= 2 && !submitted && !persistenceLost) {
820
1047
  handedBack = true;
821
- showNotice(
822
- 'Lost the live connection to your agent’s session — your latest edits were saved. You can still try Submit; if it doesn’t go through, prompt the agent to reopen this board.',
823
- 'warn'
824
- );
825
1048
  stopHeartbeat();
1049
+ // force=true: two heartbeat misses already confirm the server is gone,
1050
+ // so block immediately without a redundant probe.
1051
+ considerPersistenceLost(true);
1052
+ // While blocked, keep probing so an automatic recovery (server back,
1053
+ // machine woke) lifts the block even if the user never clicks Retry.
1054
+ startBlockedProbeLoop();
826
1055
  }
827
1056
  }
828
- }, 3000);
1057
+ }
1058
+ function startHeartbeat() {
1059
+ if (hb || submitted) return;
1060
+ hb = setInterval(heartbeatTick, 3000);
1061
+ }
829
1062
  function stopHeartbeat() {
830
1063
  if (hb) clearInterval(hb);
831
1064
  hb = null;
832
1065
  }
1066
+
1067
+ // While blocked, the heartbeat is stopped — so run a lightweight probe loop
1068
+ // that lifts the block automatically the moment the server is reachable again
1069
+ // (no Retry click needed). Stops itself on recovery or after submit.
1070
+ let blockedProbe = null;
1071
+ function startBlockedProbeLoop() {
1072
+ if (blockedProbe) return;
1073
+ blockedProbe = setInterval(async () => {
1074
+ if (!persistenceLost || submitted) {
1075
+ clearInterval(blockedProbe);
1076
+ blockedProbe = null;
1077
+ return;
1078
+ }
1079
+ if (probing) return;
1080
+ probing = true;
1081
+ const ok = await probeServer();
1082
+ probing = false;
1083
+ if (ok) {
1084
+ clearInterval(blockedProbe);
1085
+ blockedProbe = null;
1086
+ unblockAfterRecovery();
1087
+ }
1088
+ }, 3000);
1089
+ }
1090
+ startHeartbeat();
833
1091
  })();