@khanglvm/relay 0.15.0 → 0.16.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/server.js CHANGED
@@ -5,13 +5,28 @@ import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
8
+ import {
9
+ loadBoard,
10
+ saveBoard,
11
+ saveRunning,
12
+ removeRunning,
13
+ loadPref,
14
+ savePref,
15
+ saveBoardArtifact,
16
+ artifactDirPath,
17
+ } from './store.js';
9
18
  import { openUrl } from './open.js';
10
19
  import { assertSpecReady } from './spec.js';
11
20
 
12
21
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
13
22
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
23
  const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
24
+ const ARTIFACT_MAX_BYTES = 4 * 1024 * 1024;
25
+ const ARTIFACT_MIMES = {
26
+ 'image/png': 'png',
27
+ 'image/jpeg': 'jpg',
28
+ 'image/webp': 'webp',
29
+ };
15
30
 
16
31
  const escapeHtml = (s) =>
17
32
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -262,7 +277,51 @@ function sanitizeReplies(value) {
262
277
  // Drops anything that isn't a well-formed annotation object; caps at 500.
263
278
  // Each annotation may carry an optional author ('user'|'agent', default
264
279
  // 'user') and a threaded replies array (validated + capped at 50).
265
- function sanitizeAnnotations(value) {
280
+ function sanitizeAnnotationCrop(value, boardId) {
281
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !boardId) return null;
282
+ const rawPath = typeof value.path === 'string' ? value.path : '';
283
+ if (!rawPath) return null;
284
+ const base = path.resolve(artifactDirPath(boardId)) + path.sep;
285
+ const resolved = path.resolve(rawPath);
286
+ if (!resolved.startsWith(base)) return null;
287
+ let stat;
288
+ try { stat = fs.statSync(resolved); } catch { return null; }
289
+ if (!stat.isFile() || stat.size < 1 || stat.size > ARTIFACT_MAX_BYTES) return null;
290
+ const mime = ARTIFACT_MIMES[value.mime] ? value.mime : 'image/png';
291
+ return {
292
+ path: resolved,
293
+ name: path.basename(resolved),
294
+ mime,
295
+ bytes: stat.size,
296
+ width: Math.max(1, Math.min(10000, Number.parseInt(value.width, 10) || 1)),
297
+ height: Math.max(1, Math.min(10000, Number.parseInt(value.height, 10) || 1)),
298
+ };
299
+ }
300
+
301
+ function sanitizeAnnotationTarget(value, boardId) {
302
+ const clean = { ...value };
303
+ delete clean.cropDataUrl;
304
+ if (clean.kind !== 'image-region') {
305
+ delete clean.crop;
306
+ return clean;
307
+ }
308
+ const unit = (v) => Math.max(0, Math.min(1, Number(v) || 0));
309
+ clean.x = unit(clean.x);
310
+ clean.y = unit(clean.y);
311
+ clean.w = unit(clean.w);
312
+ clean.h = unit(clean.h);
313
+ if (clean.x + clean.w > 1) clean.w = 1 - clean.x;
314
+ if (clean.y + clean.h > 1) clean.h = 1 - clean.y;
315
+ clean.label = limitString(clean.label, 200);
316
+ if (clean.side === 'before' || clean.side === 'after') clean.side = clean.side;
317
+ else delete clean.side;
318
+ const crop = sanitizeAnnotationCrop(clean.crop, boardId);
319
+ if (crop) clean.crop = crop;
320
+ else delete clean.crop;
321
+ return clean;
322
+ }
323
+
324
+ function sanitizeAnnotations(value, boardId) {
266
325
  if (!Array.isArray(value)) return [];
267
326
  const out = [];
268
327
  for (const a of value) {
@@ -270,7 +329,11 @@ function sanitizeAnnotations(value) {
270
329
  if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
271
330
  if (typeof a.text !== 'string' || a.text.length > 5000) continue;
272
331
  if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
273
- const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
332
+ const clean = {
333
+ ...a,
334
+ target: sanitizeAnnotationTarget(a.target, boardId),
335
+ author: a.author === 'agent' ? 'agent' : 'user',
336
+ };
274
337
  if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
275
338
  out.push(clean);
276
339
  }
@@ -628,6 +691,14 @@ function readBody(req, limit = 5 * 1024 * 1024) {
628
691
  });
629
692
  }
630
693
 
694
+ function artifactBytesMatch(mime, bytes) {
695
+ if (!Buffer.isBuffer(bytes) || bytes.length < 4) return false;
696
+ if (mime === 'image/png') return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
697
+ if (mime === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9;
698
+ if (mime === 'image/webp') return bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
699
+ return false;
700
+ }
701
+
631
702
  // Push-wake: run the agent's own local shell command after a board finishes.
632
703
  // The full result JSON is written to the command's stdin; RLY_BOARD_ID /
633
704
  // RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
@@ -673,7 +744,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
673
744
  // (submitted / acknowledged / timeout / cancelled). The result is also
674
745
  // persisted into the board record so `rly wait` / `rly result` can read it
675
746
  // from another process.
676
- export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false, keepAliveOnTimeout = false }) {
747
+ export async function runBoard({ id, port = 0, open = true, timeoutSec = 86400, quiet = false, keepAliveOnTimeout = false }) {
677
748
  const record = loadBoard(id);
678
749
  if (!record) throw new Error(`board ${id} not found`);
679
750
  const spec = record.spec;
@@ -933,7 +1004,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
933
1004
  const block = findHtmlBlock(record.spec, blockId);
934
1005
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
935
1006
  const access = accessFor(req, reqUrl);
936
- sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
1007
+ sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true && record.spec.responseRequired !== false));
937
1008
  } else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
938
1009
  // Embedded image bytes (image blocks authored from local files).
939
1010
  const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
@@ -942,6 +1013,32 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
942
1013
  if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
943
1014
  res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
944
1015
  res.end(Buffer.from(m[2], 'base64'));
1016
+ } else if (req.method === 'POST' && pathname === '/api/artifact') {
1017
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
1018
+ if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin artifact upload rejected' });
1019
+ const access = accessFor(req, reqUrl);
1020
+ if (!access.canComment) return sendJson(res, 403, { error: 'this share cannot add comments' });
1021
+ const body = JSON.parse((await readBody(req, 6 * 1024 * 1024)) || '{}');
1022
+ const blockId = typeof body.blockId === 'string' ? body.blockId : '';
1023
+ const block = findBlock(record.spec, blockId, 'image') || findBlock(record.spec, blockId, 'compare');
1024
+ if (!block) return sendJson(res, 404, { error: `no image or comparison block "${blockId}"` });
1025
+ const match = typeof body.dataUrl === 'string'
1026
+ ? body.dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/)
1027
+ : null;
1028
+ if (!match || !ARTIFACT_MIMES[match[1]]) return sendJson(res, 400, { error: 'artifact must be a PNG, JPEG, or WebP data URL' });
1029
+ const bytes = Buffer.from(match[2], 'base64');
1030
+ if (bytes.length < 1 || bytes.length > ARTIFACT_MAX_BYTES) return sendJson(res, 400, { error: 'artifact image is empty or larger than 4 MB' });
1031
+ if (!artifactBytesMatch(match[1], bytes)) return sendJson(res, 400, { error: 'artifact bytes do not match the declared image type' });
1032
+ const target = saveBoardArtifact(record.id, bytes, ARTIFACT_MIMES[match[1]]);
1033
+ sendJson(res, 200, {
1034
+ ok: true,
1035
+ path: target,
1036
+ name: path.basename(target),
1037
+ mime: match[1],
1038
+ bytes: bytes.length,
1039
+ width: Math.max(1, Math.min(10000, Number.parseInt(body.width, 10) || 1)),
1040
+ height: Math.max(1, Math.min(10000, Number.parseInt(body.height, 10) || 1)),
1041
+ });
945
1042
  } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
946
1043
  // Local video bytes, Range-streamed so the <video> element can seek.
947
1044
  const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
@@ -958,14 +1055,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
958
1055
  // Legacy alias → the board's first html block.
959
1056
  const block = firstBoardHtml(record.spec);
960
1057
  const access = accessFor(req, reqUrl);
961
- sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
1058
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
962
1059
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
963
1060
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
964
1061
  const q = record.spec.questions.find((q) => q.id === qid);
965
1062
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
966
1063
  const block = firstQuestionHtml(q);
967
1064
  const access = accessFor(req, reqUrl);
968
- sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
1065
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
969
1066
  } else if (req.method === 'POST' && pathname === '/api/pref') {
970
1067
  const body = JSON.parse((await readBody(req)) || '{}');
971
1068
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
@@ -998,6 +1095,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
998
1095
  if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
999
1096
  sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
1000
1097
  } else if (req.method === 'POST' && pathname === '/api/draft') {
1098
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
1001
1099
  const access = accessFor(req, reqUrl);
1002
1100
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1003
1101
  if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
@@ -1011,7 +1109,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1011
1109
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1012
1110
  comment: typeof body.comment === 'string' ? body.comment : '',
1013
1111
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1014
- annotations: sanitizeAnnotations(body.annotations),
1112
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1015
1113
  blockEdits: {},
1016
1114
  draftRev: priorRev + 1,
1017
1115
  updatedAt: new Date().toISOString(),
@@ -1022,7 +1120,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1022
1120
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1023
1121
  comment: typeof body.comment === 'string' ? body.comment : '',
1024
1122
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1025
- annotations: sanitizeAnnotations(body.annotations),
1123
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1026
1124
  blockEdits: sanitizeBlockEdits(body.blockEdits),
1027
1125
  updatedAt: new Date().toISOString(),
1028
1126
  };
@@ -1034,6 +1132,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1034
1132
  : draftRev;
1035
1133
  sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
1036
1134
  } else if (req.method === 'POST' && pathname === '/api/submit') {
1135
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not accept submissions' });
1037
1136
  const access = accessFor(req, reqUrl);
1038
1137
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1039
1138
  if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
@@ -1052,7 +1151,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1052
1151
  skipped,
1053
1152
  comment: typeof body.comment === 'string' ? body.comment : '',
1054
1153
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1055
- annotations: sanitizeAnnotations(body.annotations),
1154
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1056
1155
  blockEdits: {},
1057
1156
  submittedAt: new Date().toISOString(),
1058
1157
  };
@@ -1076,7 +1175,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1076
1175
  skipped,
1077
1176
  comment: typeof body.comment === 'string' ? body.comment : '',
1078
1177
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1079
- annotations: sanitizeAnnotations(body.annotations),
1178
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1080
1179
  blockEdits: sanitizeBlockEdits(body.blockEdits),
1081
1180
  });
1082
1181
  } else if (req.method === 'GET' && pathname === '/api/share') {
package/src/spec.js CHANGED
@@ -853,6 +853,7 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
853
853
  allowPartial: raw.allowPartial !== false,
854
854
  note: raw.note !== false,
855
855
  autoClose: raw.autoClose !== false,
856
+ responseRequired: raw.responseRequired !== false,
856
857
  questions: [],
857
858
  submitLabel: '',
858
859
  };
@@ -997,6 +998,9 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
997
998
  if (!spec.questions.length && !spec.blocks.length) {
998
999
  throw new CliError('Spec needs "questions" and/or "blocks"/"html" — nothing to show.');
999
1000
  }
1001
+ if (!spec.responseRequired && spec.questions.length) {
1002
+ throw new CliError('responseRequired:false is display-only and cannot contain questions. Use responseRequired:true when answers are needed.');
1003
+ }
1000
1004
  spec.submitLabel = asStr(raw.submitLabel).trim() || (spec.questions.length ? 'Submit' : 'Acknowledge');
1001
1005
  return spec;
1002
1006
  }
@@ -1126,6 +1130,7 @@ export const SPEC_SCHEMA = {
1126
1130
  allowPartial: { type: 'boolean', default: true, description: 'When true, users may submit with unanswered questions (returned in "skipped").' },
1127
1131
  note: { type: 'boolean', default: true, description: 'Show an optional free-text note box ("Anything else?") returned as "comment".' },
1128
1132
  autoClose: { type: 'boolean', default: true, description: 'Try to close the browser tab automatically after submit.' },
1133
+ responseRequired: { type: 'boolean', default: true, description: 'Set false for a display-only board: no questions, note box, comments, or Submit/Acknowledge action are shown, and the presenting agent should continue immediately.' },
1129
1134
  submitLabel: { type: 'string', description: 'Submit button label. Defaults: "Submit", or "Acknowledge" when there are no questions.' },
1130
1135
  questions: {
1131
1136
  type: 'array',
package/src/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import crypto from 'node:crypto';
4
5
 
5
6
  // All state lives under one dir so multiple boards/instances never collide.
6
7
  // RLY_HOME override exists for tests and sandboxed agents.
@@ -21,6 +22,19 @@ export function newId() {
21
22
 
22
23
  const boardPath = (id) => path.join(BOARDS_DIR, `${id}.json`);
23
24
  const runningPath = (id) => path.join(RUNNING_DIR, `${id}.json`);
25
+ export const artifactDirPath = (id) => path.join(BOARDS_DIR, `${id}.artifacts`);
26
+
27
+ export function saveBoardArtifact(id, bytes, ext = 'png') {
28
+ ensureDirs();
29
+ if (!/^b-[a-z0-9]+$/i.test(String(id))) throw new Error('invalid board id');
30
+ const cleanExt = /^(?:png|jpe?g|webp)$/i.test(String(ext)) ? String(ext).toLowerCase() : 'png';
31
+ const dir = artifactDirPath(id);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const file = `region-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}.${cleanExt}`;
34
+ const target = path.join(dir, file);
35
+ fs.writeFileSync(target, bytes);
36
+ return target;
37
+ }
24
38
 
25
39
  export function createBoard(spec) {
26
40
  ensureDirs();
@@ -72,6 +86,7 @@ export function deleteBoard(id) {
72
86
  try {
73
87
  fs.unlinkSync(boardPath(id));
74
88
  try { fs.unlinkSync(path.join(BOARDS_DIR, `${id}.result.json`)); } catch { /* no sidecar */ }
89
+ try { fs.rmSync(artifactDirPath(id), { recursive: true, force: true }); } catch { /* no artifacts */ }
75
90
  return true;
76
91
  } catch {
77
92
  return false;
@@ -48,6 +48,11 @@
48
48
  return truncate(t.label || 'Image', 50);
49
49
  case 'image-point':
50
50
  return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + 'pin ' + Math.round((t.x || 0) * 100) + '%, ' + Math.round((t.y || 0) * 100) + '%';
51
+ case 'image-region': {
52
+ const source = t.side ? t.side.charAt(0).toUpperCase() + t.side.slice(1) + ' · ' : '';
53
+ const area = `${Math.round((t.w || 0) * 100)}×${Math.round((t.h || 0) * 100)}% area`;
54
+ return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + source + area;
55
+ }
51
56
  case 'table-cell': {
52
57
  let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
53
58
  if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
@@ -109,8 +114,11 @@
109
114
  let pinTimer = null;
110
115
  let pinEntry = null;
111
116
  let popOpen = false;
112
- let popScrollY = 0;
113
117
  let popSave = null;
118
+ let popCloseHook = null;
119
+ let popAnchorEl = null;
120
+ let popPositionFrame = 0;
121
+ const commentDrafts = new Map();
114
122
  // Frozen by the host (e.g. relay's connection-lost block): suppress every way
115
123
  // to START a comment, so the user can't type feedback that won't be saved.
116
124
  let disabled = false;
@@ -341,16 +349,21 @@
341
349
  });
342
350
 
343
351
  // Capture-phase: also fires for nested scroll containers (mermaid pane…).
352
+ // Keep an open composer re-anchored instead of closing it: scrolling is a
353
+ // normal review action, and DOM-only draft text must never disappear merely
354
+ // because the user looked elsewhere on the board.
344
355
  window.addEventListener('scroll', (e) => {
345
356
  hidePin();
346
357
  hideSelBtn();
347
358
  if (!popOpen) return;
348
359
  if (dom.pop.contains(e.target)) return; // textarea scrolling inside
349
- const isPage = e.target === document || e.target === document.documentElement || e.target === document.body;
350
- if (!isPage || Math.abs(window.scrollY - popScrollY) > 80) closePopover();
360
+ schedulePositionPopover();
351
361
  }, true);
352
362
 
353
- window.addEventListener('resize', scheduleBadgeRefresh);
363
+ window.addEventListener('resize', () => {
364
+ scheduleBadgeRefresh();
365
+ schedulePositionPopover();
366
+ });
354
367
  }
355
368
 
356
369
  // ---------- comments rail ----------
@@ -491,12 +504,40 @@
491
504
  }
492
505
 
493
506
  // ---------- popover ----------
494
- function closePopover() {
507
+ function positionPopover() {
508
+ popPositionFrame = 0;
509
+ if (!popOpen || !dom || !popAnchorEl || !popAnchorEl.isConnected) return;
510
+ const pop = dom.pop;
511
+ const rect = popAnchorEl.getBoundingClientRect();
512
+ const pw = pop.offsetWidth;
513
+ const ph = pop.offsetHeight;
514
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
515
+ let top = rect.bottom + 8;
516
+ if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
517
+ top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
518
+ pop.style.left = left + 'px';
519
+ pop.style.top = top + 'px';
520
+ }
521
+
522
+ function schedulePositionPopover() {
523
+ if (!popOpen || popPositionFrame) return;
524
+ popPositionFrame = requestAnimationFrame(positionPopover);
525
+ }
526
+
527
+ function closePopover(reason = 'dismissed') {
495
528
  if (!popOpen) return;
529
+ const closeHook = popCloseHook;
496
530
  popOpen = false;
497
531
  popSave = null;
532
+ popCloseHook = null;
533
+ popAnchorEl = null;
534
+ if (popPositionFrame) cancelAnimationFrame(popPositionFrame);
535
+ popPositionFrame = 0;
498
536
  dom.pop.style.display = 'none';
499
537
  dom.pop.replaceChildren();
538
+ if (closeHook) {
539
+ try { closeHook(reason); } catch (_) { /* lifecycle hooks are best-effort */ }
540
+ }
500
541
  }
501
542
 
502
543
  // ---------- delete a comment (with confirm) ----------
@@ -548,16 +589,18 @@
548
589
  if (disabled) return;
549
590
  ensureDom();
550
591
  const hasExisting = matching(info).length > 0;
592
+ const draftKey = sigOf(info.blockId, info.target);
551
593
  // Read-only viewers can open existing threads from their badges, but a
552
594
  // target with no feedback has nothing to show and must not expose an empty
553
595
  // add-comment dialog.
554
596
  if (!permissions.add && !hasExisting) return;
555
597
  closePopover();
598
+ popCloseHook = typeof info.onClose === 'function' ? info.onClose : null;
556
599
  hidePin();
557
600
  const pop = dom.pop;
558
601
  // Header: target label + an explicit CLOSE button, so × always means "close".
559
602
  const popClose = el('button', { class: 'ann-pop-close', type: 'button', title: 'Close', 'aria-label': 'Close' }, '×');
560
- popClose.addEventListener('click', closePopover);
603
+ popClose.addEventListener('click', () => closePopover('dismissed'));
561
604
  pop.replaceChildren(el('div', { class: 'ann-pop-head' }, el('div', { class: 'ann-pop-label' }, humanize(info.target)), popClose));
562
605
 
563
606
  // Existing comments on this exact target, rendered as threads: author
@@ -632,34 +675,36 @@
632
675
  pop.append(existingWrap);
633
676
 
634
677
  const ta = el('textarea', { class: 'ann-ta', placeholder: 'Add a comment…', rows: '3' });
678
+ ta.value = commentDrafts.get(draftKey) || '';
679
+ ta.addEventListener('input', () => {
680
+ if (ta.value) commentDrafts.set(draftKey, ta.value);
681
+ else commentDrafts.delete(draftKey);
682
+ });
635
683
  const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
636
684
  const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
637
685
  popSave = () => {
638
686
  const text = ta.value.trim();
639
687
  if (text) addAnnotation(info, text);
640
- closePopover();
688
+ commentDrafts.delete(draftKey);
689
+ if (text) closePopover('saved');
690
+ else closePopover('empty');
641
691
  };
642
692
  save.addEventListener('click', () => popSave && popSave());
643
- cancel.addEventListener('click', closePopover);
693
+ cancel.addEventListener('click', () => {
694
+ commentDrafts.delete(draftKey);
695
+ closePopover('cancelled');
696
+ });
644
697
  if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
645
698
 
646
- // Position: prefer below the anchor, flip above when out of room,
647
- // clamp to the viewport with an 8px margin. Fixed positioning, so we
648
- // recompute on open and simply close on big scrolls.
699
+ // Position: prefer below the anchor, flip above when out of room, and clamp
700
+ // to the viewport. Scroll/resize re-run this through one animation frame so
701
+ // the composer tracks browser scrolling smoothly without losing its draft.
649
702
  pop.style.display = 'block';
650
703
  pop.style.visibility = 'hidden';
651
- const rect = anchorEl.getBoundingClientRect();
652
- const pw = pop.offsetWidth;
653
- const ph = pop.offsetHeight;
654
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
655
- let top = rect.bottom + 8;
656
- if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
657
- top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
658
- pop.style.left = left + 'px';
659
- pop.style.top = top + 'px';
660
- pop.style.visibility = '';
661
704
  popOpen = true;
662
- popScrollY = window.scrollY;
705
+ popAnchorEl = anchorEl;
706
+ positionPopover();
707
+ pop.style.visibility = '';
663
708
  if (permissions.add) ta.focus();
664
709
  else popClose.focus();
665
710
  }
@@ -921,6 +966,7 @@
921
966
  registered = [];
922
967
  textRoots.clear();
923
968
  highlightMap.clear();
969
+ commentDrafts.clear();
924
970
  if (dom) {
925
971
  dom.pin.remove();
926
972
  dom.selBtn.remove();
package/src/ui/app.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const boot = JSON.parse(document.getElementById('boot').textContent);
5
5
  const spec = boot.spec;
6
+ const responseRequired = spec.responseRequired !== false;
6
7
  const access = boot.access || {
7
8
  role: 'owner',
8
9
  canShare: true,
@@ -29,7 +30,7 @@
29
30
  if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
30
31
  return headers;
31
32
  }
32
- const canPersistFeedback = Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
33
+ const canPersistFeedback = responseRequired && Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
33
34
 
34
35
  // ---------- helpers ----------
35
36
  function el(tag, attrs = {}, ...children) {
@@ -541,7 +542,7 @@
541
542
  // RelayAnnotate owns the live annotation list; mirror it into state on every
542
543
  // change so payload()/autosave/submit carry it exactly like answers.
543
544
  const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
544
- if (Annotate) {
545
+ if (Annotate && responseRequired) {
545
546
  Annotate.init({
546
547
  initial: state.annotations,
547
548
  permissions: {
@@ -558,6 +559,8 @@
558
559
  if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
559
560
  },
560
561
  });
562
+ } else if (Annotate && !responseRequired && typeof Annotate.teardown === 'function') {
563
+ Annotate.teardown();
561
564
  }
562
565
 
563
566
  // Editable-mermaid: record (or clear) the user's edit for a block, then
@@ -575,6 +578,17 @@
575
578
  onBlockEdit(d.blockId, d.value);
576
579
  });
577
580
 
581
+ async function saveRegionArtifact(artifact) {
582
+ const res = await fetch('/api/artifact', {
583
+ method: 'POST',
584
+ headers: authHeaders({ 'content-type': 'application/json' }),
585
+ body: JSON.stringify(artifact),
586
+ });
587
+ const body = await res.json().catch(() => null);
588
+ if (!res.ok || !body || !body.path) throw new Error(body && body.error ? body.error : 'could not save image crop');
589
+ return body;
590
+ }
591
+
578
592
  // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
579
593
  // the editable-mermaid plumbing (edits map + onBlockEdit callback).
580
594
  function blockCtx(questionId) {
@@ -586,8 +600,9 @@
586
600
  return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
587
601
  },
588
602
  questionId: questionId == null ? null : questionId,
589
- annotate: Annotate,
590
- canComment: access.canComment !== false,
603
+ annotate: responseRequired ? Annotate : null,
604
+ canComment: responseRequired && access.canComment !== false,
605
+ saveArtifact: responseRequired && access.canComment !== false ? saveRegionArtifact : null,
591
606
  edits: state.blockEdits,
592
607
  onBlockEdit,
593
608
  canEditBlocks: access.canEditBlocks !== false,
@@ -1145,7 +1160,7 @@
1145
1160
  app.append(card);
1146
1161
  });
1147
1162
 
1148
- if (spec.note) {
1163
+ if (spec.note && responseRequired) {
1149
1164
  const note = el('textarea', { placeholder: 'optional note back to the agent…' });
1150
1165
  note.value = state.comment || '';
1151
1166
  note.addEventListener('input', () => {
@@ -1167,6 +1182,10 @@
1167
1182
  const hint = el('span', { class: 'hint' },
1168
1183
  QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
1169
1184
  const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
1185
+ if (!responseRequired) {
1186
+ submitbar.replaceChildren(el('span', { class: 'hint' }, 'Display only · no response requested'));
1187
+ submitbar.classList.add('display-only');
1188
+ }
1170
1189
  app.append(submitbar);
1171
1190
 
1172
1191
  function copyText(text, btn) {
@@ -1415,7 +1434,7 @@
1415
1434
  // parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
1416
1435
  // We own the annotation state, popover, and the submitted result; the iframe
1417
1436
  // owns hover/pin/badges over its own (cross-origin) DOM.
1418
- if (Annotate) {
1437
+ if (Annotate && responseRequired) {
1419
1438
  const frameOf = (source) => {
1420
1439
  for (const f of document.querySelectorAll('iframe.viz')) {
1421
1440
  if (f.contentWindow === source) return f;