@khanglvm/relay 0.2.0 → 0.4.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,8 @@
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';
5
+ import { spawn } from 'node:child_process';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
6
8
  import { openUrl } from './open.js';
@@ -46,7 +48,7 @@ function vendorPresent(file) {
46
48
  }
47
49
  }
48
50
 
49
- function buildPage(record) {
51
+ function buildPage(record, rev) {
50
52
  const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
51
53
  const css = readUi('style.css');
52
54
  const blocksCss = readUi('blocks.css');
@@ -69,6 +71,7 @@ function buildPage(record) {
69
71
  const vendor = {
70
72
  chart: specNeeds(spec, 'chart') && vendorPresent('chart.umd.js'),
71
73
  mermaid: specNeeds(spec, 'mermaid') && vendorPresent('mermaid.min.js'),
74
+ viz: specNeeds(spec, 'graphviz') && vendorPresent('viz-standalone.js'),
72
75
  };
73
76
  // Prefill from the LIVE draft at request time (drafts autosave in real time),
74
77
  // so a mid-fill page reload restores everything the user already entered.
@@ -78,9 +81,10 @@ function buildPage(record) {
78
81
  comment: record.draft.comment || '',
79
82
  notes: record.draft.notes || {},
80
83
  annotations: record.draft.annotations || [],
84
+ blockEdits: record.draft.blockEdits || {},
81
85
  }
82
86
  : null;
83
- const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor };
87
+ const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
84
88
  const json = JSON.stringify(boot).replace(/</g, '\\u003c');
85
89
  return html
86
90
  .split('__TITLE__').join(escapeHtml(spec.title))
@@ -93,8 +97,26 @@ function buildPage(record) {
93
97
  .split('__BOOT_JSON__').join(json);
94
98
  }
95
99
 
100
+ // Validates + sanitizes one annotation's threaded replies. Keeps only
101
+ // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
102
+ function sanitizeReplies(value) {
103
+ if (!Array.isArray(value)) return [];
104
+ const out = [];
105
+ for (const r of value) {
106
+ if (out.length >= 50) break;
107
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) continue;
108
+ if (typeof r.text !== 'string' || r.text.length > 5000) continue;
109
+ const author = r.author === 'agent' ? 'agent' : 'user';
110
+ const createdAt = typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString();
111
+ out.push({ author, text: r.text, createdAt });
112
+ }
113
+ return out;
114
+ }
115
+
96
116
  // Validates + sanitizes an incoming annotations array (from draft/submit).
97
117
  // Drops anything that isn't a well-formed annotation object; caps at 500.
118
+ // Each annotation may carry an optional author ('user'|'agent', default
119
+ // 'user') and a threaded replies array (validated + capped at 50).
98
120
  function sanitizeAnnotations(value) {
99
121
  if (!Array.isArray(value)) return [];
100
122
  const out = [];
@@ -103,7 +125,27 @@ function sanitizeAnnotations(value) {
103
125
  if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
104
126
  if (typeof a.text !== 'string' || a.text.length > 5000) continue;
105
127
  if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
106
- out.push(a);
128
+ const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
129
+ if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
130
+ out.push(clean);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // Validates + sanitizes an incoming blockEdits map (from draft/submit).
136
+ // Keeps only string-keyed entries with string values <= 20000 chars; caps at
137
+ // 50 entries; drops invalid entries. Returns {} when nothing valid is present.
138
+ function sanitizeBlockEdits(value) {
139
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
140
+ const out = {};
141
+ let n = 0;
142
+ for (const key of Object.keys(value)) {
143
+ if (n >= 50) break;
144
+ if (typeof key !== 'string') continue;
145
+ const v = value[key];
146
+ if (typeof v !== 'string' || v.length > 20000) continue;
147
+ out[key] = v;
148
+ n++;
107
149
  }
108
150
  return out;
109
151
  }
@@ -192,6 +234,47 @@ function readBody(req, limit = 5 * 1024 * 1024) {
192
234
  });
193
235
  }
194
236
 
237
+ // Push-wake: run the agent's own local shell command after a board finishes.
238
+ // The full result JSON is written to the command's stdin; RLY_BOARD_ID /
239
+ // RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
240
+ // only a stderr note when not quiet. A 30s kill timer prevents a hung command
241
+ // from keeping the process alive.
242
+ function runOnResult(cmd, result, { quiet = false } = {}) {
243
+ if (typeof cmd !== 'string' || !cmd.trim()) return;
244
+ try {
245
+ const child = spawn('/bin/sh', ['-c', cmd], {
246
+ env: {
247
+ ...process.env,
248
+ RLY_BOARD_ID: result.boardId || '',
249
+ RLY_STATUS: result.status || '',
250
+ RLY_URL: result.url || '',
251
+ },
252
+ stdio: ['pipe', 'ignore', 'ignore'],
253
+ });
254
+ child.on('error', () => {
255
+ if (!quiet) process.stderr.write(`[relay] --on-result command failed to spawn\n`);
256
+ });
257
+ try {
258
+ child.stdin.write(JSON.stringify(result));
259
+ child.stdin.end();
260
+ } catch {
261
+ // best effort — stdin may already be gone
262
+ }
263
+ const killTimer = setTimeout(() => {
264
+ try {
265
+ child.kill('SIGKILL');
266
+ } catch {
267
+ // already gone
268
+ }
269
+ }, 30000);
270
+ killTimer.unref();
271
+ child.on('close', () => clearTimeout(killTimer));
272
+ child.unref();
273
+ } catch {
274
+ if (!quiet) process.stderr.write(`[relay] --on-result command failed to run\n`);
275
+ }
276
+ }
277
+
195
278
  // Serves one board on 127.0.0.1 and resolves `done` when it finishes
196
279
  // (submitted / acknowledged / timeout / cancelled). The result is also
197
280
  // persisted into the board record so `rly wait` / `rly result` can read it
@@ -211,6 +294,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
211
294
  comment: record.result.comment || '',
212
295
  notes: record.result.notes || {},
213
296
  annotations: record.result.annotations || [],
297
+ blockEdits: record.result.blockEdits || {},
214
298
  updatedAt: new Date().toISOString(),
215
299
  };
216
300
  }
@@ -220,8 +304,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
220
304
  }
221
305
 
222
306
  const startedAt = Date.now();
307
+ // Mutation token: only `rly update` (which reads the running-file record)
308
+ // can authenticate to POST /api/update. Never embedded in the page/boot.
309
+ const token = crypto.randomBytes(16).toString('hex');
310
+ let rev = 1;
223
311
  let status = 'open';
224
312
  let finished = false;
313
+ // Latest client presence ping (null until the first ping arrives).
314
+ let presence = null;
225
315
  let resolveDone;
226
316
  const done = new Promise((r) => {
227
317
  resolveDone = r;
@@ -233,11 +323,48 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
233
323
  const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
234
324
  try {
235
325
  if (req.method === 'GET' && pathname === '/') {
236
- sendHtml(res, buildPage(record));
326
+ sendHtml(res, buildPage(record, rev));
237
327
  } else if (req.method === 'GET' && pathname === '/api/board') {
238
- sendJson(res, 200, { id: record.id, spec, draft: record.draft, result: record.result });
328
+ sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
239
329
  } else if (req.method === 'GET' && pathname === '/api/status') {
240
- sendJson(res, 200, { status });
330
+ sendJson(res, 200, { status, rev });
331
+ } else if (req.method === 'POST' && pathname === '/api/ping') {
332
+ const body = JSON.parse((await readBody(req)) || '{}');
333
+ // Validate body shape: visible/focused booleans, idleMs finite >= 0.
334
+ if (
335
+ typeof body.visible === 'boolean' &&
336
+ typeof body.focused === 'boolean' &&
337
+ Number.isFinite(body.idleMs) &&
338
+ body.idleMs >= 0
339
+ ) {
340
+ presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
341
+ }
342
+ sendJson(res, 200, { ok: true });
343
+ } else if (req.method === 'GET' && pathname === '/api/presence') {
344
+ if (!presence) {
345
+ sendJson(res, 200, { open: true, seen: false });
346
+ } else {
347
+ sendJson(res, 200, {
348
+ open: true,
349
+ seen: true,
350
+ visible: presence.visible,
351
+ focused: presence.focused,
352
+ secondsSinceActivity: Math.round((Date.now() - presence.atMs + presence.idleMs) / 1000),
353
+ secondsSincePing: Math.round((Date.now() - presence.atMs) / 1000),
354
+ });
355
+ }
356
+ } else if (req.method === 'POST' && pathname === '/api/update') {
357
+ if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
358
+ const body = JSON.parse((await readBody(req)) || '{}');
359
+ const next = body.spec;
360
+ if (next === null || typeof next !== 'object' || Array.isArray(next) ||
361
+ !Array.isArray(next.questions) || !Array.isArray(next.blocks)) {
362
+ return sendJson(res, 400, { error: 'spec must be an object with questions[] and blocks[] arrays' });
363
+ }
364
+ record.spec = next;
365
+ rev++;
366
+ saveBoard(record);
367
+ sendJson(res, 200, { ok: true, rev });
241
368
  } else if (req.method === 'GET' && pathname === '/kit.js') {
242
369
  if (!sendFromDir(res, UI_DIR, 'kit.js', 'application/javascript; charset=utf-8')) {
243
370
  sendJs(res, ''); // kit.js authored concurrently — empty fallback keeps iframes working
@@ -249,16 +376,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
249
376
  }
250
377
  } else if (req.method === 'GET' && pathname.startsWith('/html/b/')) {
251
378
  const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
252
- const block = findHtmlBlock(spec, blockId);
379
+ const block = findHtmlBlock(record.spec, blockId);
253
380
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
254
381
  sendHtml(res, wrapFragment(block.html || '', theme));
255
382
  } else if (req.method === 'GET' && pathname === '/html/board') {
256
383
  // Legacy alias → the board's first html block.
257
- const block = firstBoardHtml(spec);
384
+ const block = firstBoardHtml(record.spec);
258
385
  sendHtml(res, wrapFragment((block && block.html) || '', theme));
259
386
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
260
387
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
261
- const q = spec.questions.find((q) => q.id === qid);
388
+ const q = record.spec.questions.find((q) => q.id === qid);
262
389
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
263
390
  const block = firstQuestionHtml(q);
264
391
  sendHtml(res, wrapFragment((block && block.html) || '', theme));
@@ -273,6 +400,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
273
400
  comment: typeof body.comment === 'string' ? body.comment : '',
274
401
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
275
402
  annotations: sanitizeAnnotations(body.annotations),
403
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
276
404
  updatedAt: new Date().toISOString(),
277
405
  };
278
406
  saveBoard(record);
@@ -281,15 +409,16 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
281
409
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
282
410
  const body = JSON.parse((await readBody(req)) || '{}');
283
411
  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);
412
+ const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
285
413
  sendJson(res, 200, { ok: true });
286
414
  finish({
287
- status: spec.questions.length ? 'submitted' : 'acknowledged',
415
+ status: record.spec.questions.length ? 'submitted' : 'acknowledged',
288
416
  answers,
289
417
  skipped,
290
418
  comment: typeof body.comment === 'string' ? body.comment : '',
291
419
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
292
420
  annotations: sanitizeAnnotations(body.annotations),
421
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
293
422
  });
294
423
  } else {
295
424
  sendJson(res, 404, { error: 'not found' });
@@ -311,6 +440,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
311
440
  port: actualPort,
312
441
  url,
313
442
  title: spec.title,
443
+ token,
314
444
  startedAt: new Date().toISOString(),
315
445
  });
316
446
 
@@ -324,6 +454,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
324
454
  if (finished) return;
325
455
  finished = true;
326
456
  status = partial.status;
457
+ // blockEdits: from this submit, else fall back to the autosaved draft
458
+ // (timeout/cancel). null when there are none.
459
+ const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
460
+ const blockEdits = editsRaw && Object.keys(editsRaw).length ? editsRaw : null;
327
461
  const result = {
328
462
  status: partial.status,
329
463
  boardId: record.id,
@@ -334,6 +468,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
334
468
  comment: partial.comment ?? '',
335
469
  notes: partial.notes ?? null,
336
470
  annotations: partial.annotations ?? (record.draft?.annotations || []),
471
+ blockEdits,
337
472
  createdAt: record.createdAt,
338
473
  finishedAt: new Date().toISOString(),
339
474
  durationMs: Date.now() - startedAt,
@@ -345,6 +480,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
345
480
  }
346
481
  record.result = result;
347
482
  saveBoard(record);
483
+ // Push-wake: run the agent's local command for EVERY terminal status.
484
+ runOnResult(record.onResult, result, { quiet });
348
485
  removeRunning(record.id);
349
486
  if (timer) clearTimeout(timer);
350
487
  process.removeListener('SIGINT', onSignal);
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));
@@ -89,6 +89,30 @@ function normalizeBlock(rawBlock, id, cwd, where) {
89
89
  const code = asStr(rawBlock.code);
90
90
  if (!code.trim()) throw new CliError(`${where}: mermaid block needs a non-empty "code" string.`);
91
91
  const block = { id, type: 'mermaid', code };
92
+ if (rawBlock.editable === true) block.editable = true;
93
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
94
+ return block;
95
+ }
96
+
97
+ if (type === 'graphviz') {
98
+ const dot = asStr(rawBlock.dot);
99
+ if (!dot.trim()) throw new CliError(`${where}: graphviz block needs a non-empty "dot" string.`);
100
+ const block = { id, type: 'graphviz', dot };
101
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
102
+ return block;
103
+ }
104
+
105
+ if (type === 'plantuml') {
106
+ const code = asStr(rawBlock.code);
107
+ if (!code.trim()) throw new CliError(`${where}: plantuml block needs a non-empty "code" string.`);
108
+ const block = { id, type: 'plantuml', code };
109
+ if (rawBlock.server !== undefined && rawBlock.server !== null && rawBlock.server !== '') {
110
+ const server = asStr(rawBlock.server).trim();
111
+ if (!/^https?:\/\/\S+$/i.test(server)) {
112
+ throw new CliError(`${where}: plantuml "server" must be an http(s) URL string.`);
113
+ }
114
+ block.server = server;
115
+ }
92
116
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
93
117
  return block;
94
118
  }
@@ -343,7 +367,10 @@ const BLOCK_SCHEMA = {
343
367
  properties: {
344
368
  type: { type: 'string', enum: BLOCK_TYPES },
345
369
  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.' },
370
+ code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
371
+ 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>].' },
372
+ dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
373
+ 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
374
  lang: { type: 'string', description: 'code block: language hint for display.' },
348
375
  config: { type: 'object', description: 'chart: a full Chart.js config object.' },
349
376
  kind: { type: 'string', enum: CHART_KINDS, description: 'chart shorthand: chart kind (alternative to "config").' },
@@ -363,7 +390,7 @@ const BLOCK_SCHEMA = {
363
390
  sortable: { type: 'boolean', description: 'table: enable click-to-sort headers.' },
364
391
  html: { type: 'string', description: 'html: custom markup rendered in a sandboxed iframe.' },
365
392
  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).' },
393
+ 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
394
  },
368
395
  },
369
396
  };
@@ -373,7 +400,7 @@ export const SPEC_SCHEMA = {
373
400
  title: 'relay board spec',
374
401
  type: 'object',
375
402
  description:
376
- 'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", and "annotations" (element-level comments the user attached to any block — see the annotation shape below).',
403
+ 'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", "annotations" (element-level comments the user attached to any block — see the annotation shape below), and "blockEdits" (a map blockId→edited source for any editable mermaid block the user changed, null when none).',
377
404
  properties: {
378
405
  title: { type: 'string', description: 'Board title (default "Relay")' },
379
406
  intro: { type: 'string', description: 'Intro text shown under the title. Newlines preserved.' },
@@ -430,6 +457,12 @@ export const SPEC_SCHEMA = {
430
457
  description:
431
458
  'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
432
459
  },
460
+ blockEdits: {
461
+ type: 'object',
462
+ readOnly: true,
463
+ description:
464
+ 'Returned in the result (not part of the input spec). A map blockId→edited mermaid source for any editable mermaid block the user changed. null when the user made no edits. Read result.blockEdits[<blockId>] for the user\'s edited diagram source.',
465
+ },
433
466
  },
434
467
  anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
435
468
  };
@@ -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"
@@ -177,6 +200,7 @@
177
200
  function refreshBadges() {
178
201
  for (const b of badges) b.remove();
179
202
  badges = [];
203
+ if (torndown) return;
180
204
  registered = registered.filter((r) => r.el.isConnected);
181
205
  for (const entry of registered) {
182
206
  const count = matching(entry.info).length;
@@ -221,7 +245,10 @@
221
245
  const pop = dom.pop;
222
246
  pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
223
247
 
224
- // Existing comments on this exact target, each with a delete button.
248
+ // Existing comments on this exact target, rendered as threads: author
249
+ // chip + time + delete, the comment text, its replies indented below,
250
+ // and a compact reply input per thread. Deleting a comment deletes its
251
+ // whole thread.
225
252
  const existingWrap = el('div', { class: 'ann-pop-existing' });
226
253
  const renderExisting = () => {
227
254
  existingWrap.replaceChildren();
@@ -231,7 +258,37 @@
231
258
  removeAnnotation(a.id);
232
259
  renderExisting();
233
260
  });
234
- existingWrap.append(el('div', { class: 'ann-pop-item' }, el('span', { class: 'ann-pop-text' }, a.text), del));
261
+ const thread = el('div', { class: 'ann-thread' },
262
+ el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), del),
263
+ el('div', { class: 'ann-pop-text' }, a.text)
264
+ );
265
+ if (Array.isArray(a.replies) && a.replies.length) {
266
+ const list = el('div', { class: 'ann-replies' });
267
+ for (const r of a.replies) {
268
+ list.append(el('div', { class: 'ann-reply' },
269
+ el('div', { class: 'ann-reply-head' }, chip(r.author), timeEl(r.createdAt)),
270
+ el('div', { class: 'ann-reply-text' }, r.text)
271
+ ));
272
+ }
273
+ thread.append(list);
274
+ }
275
+ const input = el('input', { class: 'ann-reply-input', type: 'text', placeholder: 'Reply…', 'aria-label': 'Reply' });
276
+ const btn = el('button', { class: 'ann-reply-btn', type: 'button' }, 'Reply');
277
+ const submitReply = () => {
278
+ const text = input.value.trim();
279
+ if (!text) return;
280
+ addReply(a.id, text);
281
+ renderExisting();
282
+ };
283
+ btn.addEventListener('click', submitReply);
284
+ input.addEventListener('keydown', (e) => {
285
+ if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
286
+ e.preventDefault();
287
+ submitReply();
288
+ }
289
+ });
290
+ thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
291
+ existingWrap.append(thread);
235
292
  }
236
293
  };
237
294
  renderExisting();
@@ -278,6 +335,23 @@
278
335
  target: info.target,
279
336
  text,
280
337
  createdAt: new Date().toISOString(),
338
+ author: 'user',
339
+ replies: [],
340
+ });
341
+ changed();
342
+ }
343
+
344
+ // Append a user reply to a top-level annotation's thread (cap 50, like the
345
+ // server). Empty text is ignored by callers.
346
+ function addReply(id, text) {
347
+ const a = annotations.find((x) => x.id === id);
348
+ if (!a) return;
349
+ if (!Array.isArray(a.replies)) a.replies = [];
350
+ if (a.replies.length >= 50) return;
351
+ a.replies.push({
352
+ author: 'user',
353
+ text: String(text).slice(0, 5000),
354
+ createdAt: new Date().toISOString(),
281
355
  });
282
356
  changed();
283
357
  }
@@ -367,10 +441,18 @@
367
441
  e.stopPropagation();
368
442
  removeAnnotation(a.id);
369
443
  });
444
+ // Thread meta: reply count, plus an "agent" chip when the latest entry
445
+ // in the thread (last reply, or the comment itself) is agent-authored.
446
+ const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
447
+ const latest = replyCount ? a.replies[replyCount - 1] : a;
448
+ const meta = [];
449
+ if (replyCount) meta.push(el('span', { class: 'ann-sum-replies' }, `${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`));
450
+ if (latest.author === 'agent') meta.push(chip('agent'));
370
451
  const row = el('div', { class: 'ann-sum-row' },
371
452
  el('div', { class: 'ann-sum-main' },
372
453
  el('div', { class: 'ann-sum-target' }, humanize(a.target)),
373
- el('div', { class: 'ann-sum-text' }, a.text)
454
+ el('div', { class: 'ann-sum-text' }, a.text),
455
+ meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
374
456
  ),
375
457
  del
376
458
  );
@@ -393,7 +475,22 @@
393
475
  // ---------- public API ----------
394
476
  function init(opts = {}) {
395
477
  ensureDom();
396
- annotations = Array.isArray(opts.initial) ? opts.initial.map((a) => ({ ...a })) : [];
478
+ // Normalize threads: missing author -> 'user', missing replies -> [].
479
+ annotations = Array.isArray(opts.initial)
480
+ ? opts.initial.map((a) => ({
481
+ ...a,
482
+ author: a.author === 'agent' ? 'agent' : 'user',
483
+ replies: Array.isArray(a.replies)
484
+ ? a.replies
485
+ .filter((r) => r && typeof r === 'object' && typeof r.text === 'string')
486
+ .map((r) => ({
487
+ author: r.author === 'agent' ? 'agent' : 'user',
488
+ text: r.text,
489
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
490
+ }))
491
+ : [],
492
+ }))
493
+ : [];
397
494
  onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
398
495
  idCounter = 0;
399
496
  for (const a of annotations) {
@@ -404,7 +501,32 @@
404
501
  renderSummaries();
405
502
  }
406
503
 
504
+ // Remove every floating element (pin, badges, popover, selection button)
505
+ // and stop reacting — called when the board reaches its submitted screen so
506
+ // nothing leaks over it (they live on <body> with elevated z-index).
507
+ let torndown = false;
508
+ function teardown() {
509
+ torndown = true;
510
+ try {
511
+ closePopover();
512
+ } catch {
513
+ // popover may not be open
514
+ }
515
+ hidePin();
516
+ hideSelBtn();
517
+ for (const b of badges) b.remove();
518
+ badges = [];
519
+ registered = [];
520
+ if (dom) {
521
+ dom.pin.remove();
522
+ dom.selBtn.remove();
523
+ dom.pop.remove();
524
+ dom = null;
525
+ }
526
+ }
527
+
407
528
  function register(targetEl, info) {
529
+ if (torndown) return;
408
530
  ensureDom();
409
531
  targetEl.classList.add('ann-target');
410
532
  const entry = { el: targetEl, info };
@@ -415,14 +537,17 @@
415
537
  }
416
538
 
417
539
  function enableTextSelection(rootEl, baseInfo) {
540
+ if (torndown) return;
418
541
  ensureDom();
419
542
  rootEl.addEventListener('mouseup', () => {
543
+ if (torndown) return;
420
544
  // defer: the selection settles after mouseup
421
545
  setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
422
546
  });
423
547
  }
424
548
 
425
549
  function openExternal(info, anchorEl) {
550
+ if (torndown) return;
426
551
  openPopover(info, anchorEl);
427
552
  }
428
553
 
@@ -436,5 +561,5 @@
436
561
  renderSummaryInto(target);
437
562
  }
438
563
 
439
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary };
564
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, teardown };
440
565
  })();