@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/README.md +31 -1
- package/docs/AGENT.md +42 -3
- package/package.json +2 -2
- package/skills/relay/SKILL.md +32 -2
- package/src/cli.js +89 -11
- package/src/open.js +12 -0
- package/src/server.js +193 -0
- package/src/spec.js +126 -8
- package/src/store.js +4 -0
- package/src/ui/app.js +288 -30
- package/src/ui/blocks.css +144 -2
- package/src/ui/blocks.js +406 -59
- package/src/ui/style.css +41 -0
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 =
|
|
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
|
|
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: (
|
|
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: (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
1040
|
+
// Lost the live connection (the session ended, or the machine slept). Two
|
|
1041
|
+
// consecutive misses means the local server is unreachable — so 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
|
-
}
|
|
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
|
})();
|