@khanglvm/relay 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import crypto from 'node:crypto';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
6
7
  import { openUrl } from './open.js';
@@ -46,7 +47,7 @@ function vendorPresent(file) {
46
47
  }
47
48
  }
48
49
 
49
- function buildPage(record) {
50
+ function buildPage(record, rev) {
50
51
  const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
51
52
  const css = readUi('style.css');
52
53
  const blocksCss = readUi('blocks.css');
@@ -69,6 +70,7 @@ function buildPage(record) {
69
70
  const vendor = {
70
71
  chart: specNeeds(spec, 'chart') && vendorPresent('chart.umd.js'),
71
72
  mermaid: specNeeds(spec, 'mermaid') && vendorPresent('mermaid.min.js'),
73
+ viz: specNeeds(spec, 'graphviz') && vendorPresent('viz-standalone.js'),
72
74
  };
73
75
  // Prefill from the LIVE draft at request time (drafts autosave in real time),
74
76
  // so a mid-fill page reload restores everything the user already entered.
@@ -80,7 +82,7 @@ function buildPage(record) {
80
82
  annotations: record.draft.annotations || [],
81
83
  }
82
84
  : null;
83
- const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor };
85
+ const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
84
86
  const json = JSON.stringify(boot).replace(/</g, '\\u003c');
85
87
  return html
86
88
  .split('__TITLE__').join(escapeHtml(spec.title))
@@ -93,8 +95,26 @@ function buildPage(record) {
93
95
  .split('__BOOT_JSON__').join(json);
94
96
  }
95
97
 
98
+ // Validates + sanitizes one annotation's threaded replies. Keeps only
99
+ // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
100
+ function sanitizeReplies(value) {
101
+ if (!Array.isArray(value)) return [];
102
+ const out = [];
103
+ for (const r of value) {
104
+ if (out.length >= 50) break;
105
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) continue;
106
+ if (typeof r.text !== 'string' || r.text.length > 5000) continue;
107
+ const author = r.author === 'agent' ? 'agent' : 'user';
108
+ const createdAt = typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString();
109
+ out.push({ author, text: r.text, createdAt });
110
+ }
111
+ return out;
112
+ }
113
+
96
114
  // Validates + sanitizes an incoming annotations array (from draft/submit).
97
115
  // Drops anything that isn't a well-formed annotation object; caps at 500.
116
+ // Each annotation may carry an optional author ('user'|'agent', default
117
+ // 'user') and a threaded replies array (validated + capped at 50).
98
118
  function sanitizeAnnotations(value) {
99
119
  if (!Array.isArray(value)) return [];
100
120
  const out = [];
@@ -103,7 +123,9 @@ function sanitizeAnnotations(value) {
103
123
  if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
104
124
  if (typeof a.text !== 'string' || a.text.length > 5000) continue;
105
125
  if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
106
- out.push(a);
126
+ const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
127
+ if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
128
+ out.push(clean);
107
129
  }
108
130
  return out;
109
131
  }
@@ -220,6 +242,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
220
242
  }
221
243
 
222
244
  const startedAt = Date.now();
245
+ // Mutation token: only `rly update` (which reads the running-file record)
246
+ // can authenticate to POST /api/update. Never embedded in the page/boot.
247
+ const token = crypto.randomBytes(16).toString('hex');
248
+ let rev = 1;
223
249
  let status = 'open';
224
250
  let finished = false;
225
251
  let resolveDone;
@@ -233,11 +259,23 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
233
259
  const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
234
260
  try {
235
261
  if (req.method === 'GET' && pathname === '/') {
236
- sendHtml(res, buildPage(record));
262
+ sendHtml(res, buildPage(record, rev));
237
263
  } else if (req.method === 'GET' && pathname === '/api/board') {
238
- sendJson(res, 200, { id: record.id, spec, draft: record.draft, result: record.result });
264
+ sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
239
265
  } else if (req.method === 'GET' && pathname === '/api/status') {
240
- sendJson(res, 200, { status });
266
+ sendJson(res, 200, { status, rev });
267
+ } else if (req.method === 'POST' && pathname === '/api/update') {
268
+ if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
269
+ const body = JSON.parse((await readBody(req)) || '{}');
270
+ const next = body.spec;
271
+ if (next === null || typeof next !== 'object' || Array.isArray(next) ||
272
+ !Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
273
+ return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
274
+ }
275
+ record.spec = next;
276
+ rev++;
277
+ saveBoard(record);
278
+ sendJson(res, 200, { ok: true, rev });
241
279
  } else if (req.method === 'GET' && pathname === '/kit.js') {
242
280
  if (!sendFromDir(res, UI_DIR, 'kit.js', 'application/javascript; charset=utf-8')) {
243
281
  sendJs(res, ''); // kit.js authored concurrently — empty fallback keeps iframes working
@@ -249,16 +287,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
249
287
  }
250
288
  } else if (req.method === 'GET' && pathname.startsWith('/html/b/')) {
251
289
  const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
252
- const block = findHtmlBlock(spec, blockId);
290
+ const block = findHtmlBlock(record.spec, blockId);
253
291
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
254
292
  sendHtml(res, wrapFragment(block.html || '', theme));
255
293
  } else if (req.method === 'GET' && pathname === '/html/board') {
256
294
  // Legacy alias → the board's first html block.
257
- const block = firstBoardHtml(spec);
295
+ const block = firstBoardHtml(record.spec);
258
296
  sendHtml(res, wrapFragment((block && block.html) || '', theme));
259
297
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
260
298
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
261
- const q = spec.questions.find((q) => q.id === qid);
299
+ const q = record.spec.questions.find((q) => q.id === qid);
262
300
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
263
301
  const block = firstQuestionHtml(q);
264
302
  sendHtml(res, wrapFragment((block && block.html) || '', theme));
@@ -281,10 +319,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
281
319
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
282
320
  const body = JSON.parse((await readBody(req)) || '{}');
283
321
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
284
- const skipped = spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
322
+ const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
285
323
  sendJson(res, 200, { ok: true });
286
324
  finish({
287
- status: spec.questions.length ? 'submitted' : 'acknowledged',
325
+ status: record.spec.questions.length ? 'submitted' : 'acknowledged',
288
326
  answers,
289
327
  skipped,
290
328
  comment: typeof body.comment === 'string' ? body.comment : '',
@@ -311,6 +349,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
311
349
  port: actualPort,
312
350
  url,
313
351
  title: spec.title,
352
+ token,
314
353
  startedAt: new Date().toISOString(),
315
354
  });
316
355
 
package/src/spec.js CHANGED
@@ -25,7 +25,7 @@ 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', 'chart', 'table', 'code', 'html'];
28
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'html'];
29
29
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
30
30
 
31
31
  const asStr = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
@@ -93,6 +93,29 @@ function normalizeBlock(rawBlock, id, cwd, where) {
93
93
  return block;
94
94
  }
95
95
 
96
+ if (type === 'graphviz') {
97
+ const dot = asStr(rawBlock.dot);
98
+ if (!dot.trim()) throw new CliError(`${where}: graphviz block needs a non-empty "dot" string.`);
99
+ const block = { id, type: 'graphviz', dot };
100
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
101
+ return block;
102
+ }
103
+
104
+ if (type === 'plantuml') {
105
+ const code = asStr(rawBlock.code);
106
+ if (!code.trim()) throw new CliError(`${where}: plantuml block needs a non-empty "code" string.`);
107
+ const block = { id, type: 'plantuml', code };
108
+ if (rawBlock.server !== undefined && rawBlock.server !== null && rawBlock.server !== '') {
109
+ const server = asStr(rawBlock.server).trim();
110
+ if (!/^https?:\/\/\S+$/i.test(server)) {
111
+ throw new CliError(`${where}: plantuml "server" must be an http(s) URL string.`);
112
+ }
113
+ block.server = server;
114
+ }
115
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
116
+ return block;
117
+ }
118
+
96
119
  if (type === 'code') {
97
120
  const code = asStr(rawBlock.code);
98
121
  if (!code) throw new CliError(`${where}: code block needs a "code" string.`);
@@ -343,7 +366,9 @@ const BLOCK_SCHEMA = {
343
366
  properties: {
344
367
  type: { type: 'string', enum: BLOCK_TYPES },
345
368
  md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
346
- code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); code: the source to display.' },
369
+ code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
370
+ dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
371
+ 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).' },
347
372
  lang: { type: 'string', description: 'code block: language hint for display.' },
348
373
  config: { type: 'object', description: 'chart: a full Chart.js config object.' },
349
374
  kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
@@ -363,7 +388,7 @@ const BLOCK_SCHEMA = {
363
388
  sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
364
389
  html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
365
390
  htmlFile: { type: 'string', description: 'html: path to an HTML file (alternative to "html").' },
366
- 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 natural (max 1200, scrolls).' },
391
+ 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 natural (max 1200, scrolls).' },
367
392
  },
368
393
  },
369
394
  };
@@ -50,14 +50,64 @@
50
50
  margin-bottom: 8px;
51
51
  overflow-wrap: anywhere;
52
52
  }
53
- .ann-pop-existing { margin-bottom: 8px; }
53
+ .ann-pop-existing { margin-bottom: 8px; max-height: min(50vh, 420px); overflow-y: auto; }
54
54
  .ann-pop-existing:empty { display: none; margin: 0; }
55
- .ann-pop-item {
56
- display: flex; align-items: flex-start; gap: 8px;
57
- padding: 6px 0;
55
+
56
+ /* ---------- comment threads (popover) ---------- */
57
+ .ann-thread {
58
+ padding: 7px 0;
58
59
  border-top: 1px solid var(--border);
59
60
  }
60
- .ann-pop-text { flex: 1; font-size: 0.88rem; white-space: pre-wrap; overflow-wrap: anywhere; }
61
+ .ann-thread-head { display: flex; align-items: center; gap: 6px; }
62
+ .ann-thread-head .ann-del { margin-left: auto; }
63
+ .ann-pop-text { font-size: 0.88rem; white-space: pre-wrap; overflow-wrap: anywhere; }
64
+
65
+ /* author chips: "you" muted, "agent" accent */
66
+ .ann-chip {
67
+ flex: none;
68
+ display: inline-block;
69
+ background: var(--bg-sunken); color: var(--muted);
70
+ border-radius: 999px;
71
+ padding: 1px 7px;
72
+ font: 600 0.68rem/1.5 var(--sans);
73
+ }
74
+ .ann-chip-agent { background: var(--accent-soft); color: var(--accent); }
75
+ .ann-time { color: var(--muted); font-size: 0.7rem; }
76
+
77
+ /* replies, indented under their comment */
78
+ .ann-replies {
79
+ margin-top: 5px;
80
+ padding-left: 10px;
81
+ border-left: 2px solid var(--border);
82
+ }
83
+ .ann-reply { padding: 3px 0; }
84
+ .ann-reply-head { display: flex; align-items: center; gap: 6px; }
85
+ .ann-reply-text { font-size: 0.84rem; white-space: pre-wrap; overflow-wrap: anywhere; }
86
+
87
+ /* compact per-thread reply form */
88
+ .ann-reply-form { display: flex; gap: 6px; margin-top: 6px; }
89
+ .ann-reply-input {
90
+ flex: 1; min-width: 0;
91
+ background: var(--bg-sunken); color: var(--fg);
92
+ border: 1px solid var(--border); border-radius: 8px;
93
+ padding: 4px 8px;
94
+ font: 0.8rem/1.4 var(--sans);
95
+ }
96
+ .ann-reply-input:focus {
97
+ outline: none;
98
+ border-color: var(--accent);
99
+ box-shadow: 0 0 0 2px var(--accent-soft);
100
+ }
101
+ .ann-reply-btn {
102
+ flex: none;
103
+ background: transparent; color: var(--accent);
104
+ border: 1px solid var(--border); border-radius: 8px;
105
+ padding: 4px 10px;
106
+ font: 600 0.78rem var(--sans);
107
+ cursor: pointer;
108
+ transition: border-color 150ms var(--ease);
109
+ }
110
+ .ann-reply-btn:hover { border-color: var(--accent); }
61
111
  .ann-pop textarea.ann-ta {
62
112
  width: 100%; min-height: 64px;
63
113
  background: var(--bg-sunken); color: var(--fg);
@@ -131,6 +181,8 @@
131
181
  .ann-sum-main { flex: 1; min-width: 0; }
132
182
  .ann-sum-target { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
133
183
  .ann-sum-text { font-size: 0.92rem; white-space: pre-wrap; overflow-wrap: anywhere; }
184
+ .ann-sum-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
185
+ .ann-sum-replies { color: var(--muted); font-size: 0.75rem; }
134
186
 
135
187
  /* ---------- jump-to flash ---------- */
136
188
  .ann-flash { animation: ann-flash 1s var(--ease); }
@@ -40,6 +40,10 @@
40
40
  }
41
41
  case 'mermaid-node':
42
42
  return 'Diagram · ' + truncate(t.text || t.nodeId || 'node', 50);
43
+ case 'graphviz-node':
44
+ return 'Graph · ' + truncate(t.text || t.nodeId || 'node', 50);
45
+ case 'image':
46
+ return truncate(t.label || 'Image', 50);
43
47
  case 'table-cell': {
44
48
  let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
45
49
  if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
@@ -58,6 +62,25 @@
58
62
  const sameTarget = (a, b) => JSON.stringify(a) === JSON.stringify(b);
59
63
  const sameBlock = (a, b) => (a ?? null) === (b ?? null);
60
64
 
65
+ // Author chip: "you" (muted) for user comments, "agent" (accent) for agent.
66
+ function chip(author) {
67
+ const agent = author === 'agent';
68
+ return el('span', { class: 'ann-chip' + (agent ? ' ann-chip-agent' : '') }, agent ? 'agent' : 'you');
69
+ }
70
+
71
+ // Short HH:MM timestamp (empty string when the date is unparsable).
72
+ function fmtTime(iso) {
73
+ const d = new Date(iso);
74
+ if (isNaN(d.getTime())) return '';
75
+ const p = (n) => String(n).padStart(2, '0');
76
+ return p(d.getHours()) + ':' + p(d.getMinutes());
77
+ }
78
+
79
+ function timeEl(iso) {
80
+ const t = fmtTime(iso);
81
+ return t ? el('span', { class: 'ann-time' }, t) : null;
82
+ }
83
+
61
84
  // ---------- state ----------
62
85
  let annotations = []; // contract annotation objects
63
86
  let idCounter = 0; // continues from max existing "aN"
@@ -221,7 +244,10 @@
221
244
  const pop = dom.pop;
222
245
  pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
223
246
 
224
- // Existing comments on this exact target, each with a delete button.
247
+ // Existing comments on this exact target, rendered as threads: author
248
+ // chip + time + delete, the comment text, its replies indented below,
249
+ // and a compact reply input per thread. Deleting a comment deletes its
250
+ // whole thread.
225
251
  const existingWrap = el('div', { class: 'ann-pop-existing' });
226
252
  const renderExisting = () => {
227
253
  existingWrap.replaceChildren();
@@ -231,7 +257,37 @@
231
257
  removeAnnotation(a.id);
232
258
  renderExisting();
233
259
  });
234
- existingWrap.append(el('div', { class: 'ann-pop-item' }, el('span', { class: 'ann-pop-text' }, a.text), del));
260
+ const thread = el('div', { class: 'ann-thread' },
261
+ el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), del),
262
+ el('div', { class: 'ann-pop-text' }, a.text)
263
+ );
264
+ if (Array.isArray(a.replies) && a.replies.length) {
265
+ const list = el('div', { class: 'ann-replies' });
266
+ for (const r of a.replies) {
267
+ list.append(el('div', { class: 'ann-reply' },
268
+ el('div', { class: 'ann-reply-head' }, chip(r.author), timeEl(r.createdAt)),
269
+ el('div', { class: 'ann-reply-text' }, r.text)
270
+ ));
271
+ }
272
+ thread.append(list);
273
+ }
274
+ const input = el('input', { class: 'ann-reply-input', type: 'text', placeholder: 'Reply…', 'aria-label': 'Reply' });
275
+ const btn = el('button', { class: 'ann-reply-btn', type: 'button' }, 'Reply');
276
+ const submitReply = () => {
277
+ const text = input.value.trim();
278
+ if (!text) return;
279
+ addReply(a.id, text);
280
+ renderExisting();
281
+ };
282
+ btn.addEventListener('click', submitReply);
283
+ input.addEventListener('keydown', (e) => {
284
+ if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
285
+ e.preventDefault();
286
+ submitReply();
287
+ }
288
+ });
289
+ thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
290
+ existingWrap.append(thread);
235
291
  }
236
292
  };
237
293
  renderExisting();
@@ -278,6 +334,23 @@
278
334
  target: info.target,
279
335
  text,
280
336
  createdAt: new Date().toISOString(),
337
+ author: 'user',
338
+ replies: [],
339
+ });
340
+ changed();
341
+ }
342
+
343
+ // Append a user reply to a top-level annotation's thread (cap 50, like the
344
+ // server). Empty text is ignored by callers.
345
+ function addReply(id, text) {
346
+ const a = annotations.find((x) => x.id === id);
347
+ if (!a) return;
348
+ if (!Array.isArray(a.replies)) a.replies = [];
349
+ if (a.replies.length >= 50) return;
350
+ a.replies.push({
351
+ author: 'user',
352
+ text: String(text).slice(0, 5000),
353
+ createdAt: new Date().toISOString(),
281
354
  });
282
355
  changed();
283
356
  }
@@ -367,10 +440,18 @@
367
440
  e.stopPropagation();
368
441
  removeAnnotation(a.id);
369
442
  });
443
+ // Thread meta: reply count, plus an "agent" chip when the latest entry
444
+ // in the thread (last reply, or the comment itself) is agent-authored.
445
+ const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
446
+ const latest = replyCount ? a.replies[replyCount - 1] : a;
447
+ const meta = [];
448
+ if (replyCount) meta.push(el('span', { class: 'ann-sum-replies' }, `${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`));
449
+ if (latest.author === 'agent') meta.push(chip('agent'));
370
450
  const row = el('div', { class: 'ann-sum-row' },
371
451
  el('div', { class: 'ann-sum-main' },
372
452
  el('div', { class: 'ann-sum-target' }, humanize(a.target)),
373
- el('div', { class: 'ann-sum-text' }, a.text)
453
+ el('div', { class: 'ann-sum-text' }, a.text),
454
+ meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
374
455
  ),
375
456
  del
376
457
  );
@@ -393,7 +474,22 @@
393
474
  // ---------- public API ----------
394
475
  function init(opts = {}) {
395
476
  ensureDom();
396
- annotations = Array.isArray(opts.initial) ? opts.initial.map((a) => ({ ...a })) : [];
477
+ // Normalize threads: missing author -> 'user', missing replies -> [].
478
+ annotations = Array.isArray(opts.initial)
479
+ ? opts.initial.map((a) => ({
480
+ ...a,
481
+ author: a.author === 'agent' ? 'agent' : 'user',
482
+ replies: Array.isArray(a.replies)
483
+ ? a.replies
484
+ .filter((r) => r && typeof r === 'object' && typeof r.text === 'string')
485
+ .map((r) => ({
486
+ author: r.author === 'agent' ? 'agent' : 'user',
487
+ text: r.text,
488
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
489
+ }))
490
+ : [],
491
+ }))
492
+ : [];
397
493
  onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
398
494
  idCounter = 0;
399
495
  for (const a of annotations) {
package/src/ui/app.js CHANGED
@@ -6,6 +6,10 @@
6
6
  const QS = spec.questions || [];
7
7
  const app = document.getElementById('app');
8
8
  const banner = document.getElementById('banner');
9
+ // Live-update baseline: the server's rev at page-build time. The heartbeat
10
+ // compares /api/status.rev to this and reloads the board when it advances
11
+ // (an agent ran `rly update`).
12
+ const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
9
13
 
10
14
  // ---------- helpers ----------
11
15
  function el(tag, attrs = {}, ...children) {
@@ -22,6 +26,14 @@
22
26
  return n;
23
27
  }
24
28
 
29
+ // Small, self-dismissing toast pinned to the top-center of the viewport.
30
+ // Used to flag a live `rly update` after the reload (accent-soft bg).
31
+ function showToast(message) {
32
+ const toast = el('div', { class: 'toast' }, message);
33
+ document.body.append(toast);
34
+ setTimeout(() => toast.remove(), 4000);
35
+ }
36
+
25
37
  // ---------- theme (auto -> light -> dark) ----------
26
38
  // Server-side pref wins: boards run on random ports, so localStorage alone
27
39
  // can't carry the choice across boards. The server persists it globally.
@@ -413,6 +425,16 @@
413
425
  });
414
426
  applyTheme();
415
427
 
428
+ // Just reloaded after a live `rly update`? Flag set before reload below.
429
+ try {
430
+ if (sessionStorage.getItem('relay-updated') === '1') {
431
+ sessionStorage.removeItem('relay-updated');
432
+ showToast('Board updated by the agent');
433
+ }
434
+ } catch {
435
+ // sessionStorage may be unavailable (privacy mode) — non-fatal
436
+ }
437
+
416
438
  app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
417
439
  if (spec.intro) {
418
440
  const intro = el('p', { class: 'intro' }, spec.intro);
@@ -584,11 +606,33 @@
584
606
 
585
607
  // ---------- heartbeat ----------
586
608
  let misses = 0;
609
+ let reloading = false;
587
610
  let hb = setInterval(async () => {
588
611
  try {
589
612
  const r = await fetch('/api/status', { cache: 'no-store' });
590
613
  if (!r.ok) throw new Error('bad status');
591
614
  misses = 0;
615
+ // Live update: the agent ran `rly update`, advancing the server rev.
616
+ // Flush whatever the user has typed so far (the reload re-prefills from
617
+ // the live draft — answers for now-removed question ids are ignored),
618
+ // then reload to render the new spec. Guarded so it fires once.
619
+ const body = await r.json().catch(() => null);
620
+ if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
621
+ reloading = true;
622
+ stopHeartbeat();
623
+ clearTimeout(saveTimer);
624
+ try {
625
+ await saveDraft();
626
+ } catch {
627
+ // a failed final save shouldn't block the reload to the new spec
628
+ }
629
+ try {
630
+ sessionStorage.setItem('relay-updated', '1');
631
+ } catch {
632
+ // sessionStorage may be unavailable — the reload still applies the update
633
+ }
634
+ location.reload();
635
+ }
592
636
  } catch {
593
637
  if (++misses >= 2 && !submitted) {
594
638
  banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
package/src/ui/blocks.css CHANGED
@@ -168,3 +168,37 @@
168
168
  }
169
169
  .blk-mermaid .node,
170
170
  .blk-mermaid .edgeLabel { cursor: default; }
171
+
172
+ /* ---------- graphviz (offline, vendored Viz.js) ---------- */
173
+ .blk-graphviz {
174
+ overflow: auto;
175
+ max-height: 1200px;
176
+ text-align: center;
177
+ /* graphviz draws on its own white canvas; frame it as a deliberate card so
178
+ it reads intentional in dark mode instead of a stray white rectangle */
179
+ background: #fff;
180
+ border: 1px solid var(--border);
181
+ border-radius: 10px;
182
+ padding: 10px;
183
+ }
184
+ .blk-graphviz svg {
185
+ max-width: 100%;
186
+ height: auto;
187
+ }
188
+ .blk-graphviz g.node,
189
+ .blk-graphviz g.edge { cursor: default; }
190
+
191
+ /* ---------- plantuml (server-rendered image) ---------- */
192
+ .blk-plantuml {
193
+ overflow: auto;
194
+ max-height: 1200px;
195
+ text-align: center;
196
+ background: #fff;
197
+ border: 1px solid var(--border);
198
+ border-radius: 10px;
199
+ padding: 10px;
200
+ }
201
+ .blk-plantuml .blk-plantuml-img {
202
+ max-width: 100%;
203
+ height: auto;
204
+ }