@khanglvm/relay 0.14.2 → 0.16.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/README.md +18 -3
- package/docs/AGENT.md +66 -23
- package/package.json +1 -1
- package/skills/relay/SKILL.md +48 -12
- package/src/cli.js +42 -21
- package/src/mcp-ui/board.js +46 -8
- package/src/mcp.js +9 -3
- package/src/server.js +310 -95
- package/src/spec.js +5 -0
- package/src/store.js +15 -0
- package/src/ui/annotate.js +73 -26
- package/src/ui/app.js +100 -36
- package/src/ui/blocks.css +63 -11
- package/src/ui/blocks.js +353 -48
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;
|
package/src/ui/annotate.js
CHANGED
|
@@ -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,10 @@
|
|
|
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 popAnchorEl = null;
|
|
119
|
+
let popPositionFrame = 0;
|
|
120
|
+
const commentDrafts = new Map();
|
|
114
121
|
// Frozen by the host (e.g. relay's connection-lost block): suppress every way
|
|
115
122
|
// to START a comment, so the user can't type feedback that won't be saved.
|
|
116
123
|
let disabled = false;
|
|
@@ -341,16 +348,21 @@
|
|
|
341
348
|
});
|
|
342
349
|
|
|
343
350
|
// Capture-phase: also fires for nested scroll containers (mermaid pane…).
|
|
351
|
+
// Keep an open composer re-anchored instead of closing it: scrolling is a
|
|
352
|
+
// normal review action, and DOM-only draft text must never disappear merely
|
|
353
|
+
// because the user looked elsewhere on the board.
|
|
344
354
|
window.addEventListener('scroll', (e) => {
|
|
345
355
|
hidePin();
|
|
346
356
|
hideSelBtn();
|
|
347
357
|
if (!popOpen) return;
|
|
348
358
|
if (dom.pop.contains(e.target)) return; // textarea scrolling inside
|
|
349
|
-
|
|
350
|
-
if (!isPage || Math.abs(window.scrollY - popScrollY) > 80) closePopover();
|
|
359
|
+
schedulePositionPopover();
|
|
351
360
|
}, true);
|
|
352
361
|
|
|
353
|
-
window.addEventListener('resize',
|
|
362
|
+
window.addEventListener('resize', () => {
|
|
363
|
+
scheduleBadgeRefresh();
|
|
364
|
+
schedulePositionPopover();
|
|
365
|
+
});
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
// ---------- comments rail ----------
|
|
@@ -393,7 +405,7 @@
|
|
|
393
405
|
|
|
394
406
|
// ---------- hover pin ----------
|
|
395
407
|
function showPin(entry) {
|
|
396
|
-
if (disabled) return;
|
|
408
|
+
if (disabled || !permissions.add) return;
|
|
397
409
|
clearTimeout(pinTimer);
|
|
398
410
|
pinEntry = entry;
|
|
399
411
|
const rect = entry.el.getBoundingClientRect();
|
|
@@ -491,10 +503,33 @@
|
|
|
491
503
|
}
|
|
492
504
|
|
|
493
505
|
// ---------- popover ----------
|
|
506
|
+
function positionPopover() {
|
|
507
|
+
popPositionFrame = 0;
|
|
508
|
+
if (!popOpen || !dom || !popAnchorEl || !popAnchorEl.isConnected) return;
|
|
509
|
+
const pop = dom.pop;
|
|
510
|
+
const rect = popAnchorEl.getBoundingClientRect();
|
|
511
|
+
const pw = pop.offsetWidth;
|
|
512
|
+
const ph = pop.offsetHeight;
|
|
513
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
|
|
514
|
+
let top = rect.bottom + 8;
|
|
515
|
+
if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
|
|
516
|
+
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
|
|
517
|
+
pop.style.left = left + 'px';
|
|
518
|
+
pop.style.top = top + 'px';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function schedulePositionPopover() {
|
|
522
|
+
if (!popOpen || popPositionFrame) return;
|
|
523
|
+
popPositionFrame = requestAnimationFrame(positionPopover);
|
|
524
|
+
}
|
|
525
|
+
|
|
494
526
|
function closePopover() {
|
|
495
527
|
if (!popOpen) return;
|
|
496
528
|
popOpen = false;
|
|
497
529
|
popSave = null;
|
|
530
|
+
popAnchorEl = null;
|
|
531
|
+
if (popPositionFrame) cancelAnimationFrame(popPositionFrame);
|
|
532
|
+
popPositionFrame = 0;
|
|
498
533
|
dom.pop.style.display = 'none';
|
|
499
534
|
dom.pop.replaceChildren();
|
|
500
535
|
}
|
|
@@ -547,6 +582,12 @@
|
|
|
547
582
|
function openPopover(info, anchorEl) {
|
|
548
583
|
if (disabled) return;
|
|
549
584
|
ensureDom();
|
|
585
|
+
const hasExisting = matching(info).length > 0;
|
|
586
|
+
const draftKey = sigOf(info.blockId, info.target);
|
|
587
|
+
// Read-only viewers can open existing threads from their badges, but a
|
|
588
|
+
// target with no feedback has nothing to show and must not expose an empty
|
|
589
|
+
// add-comment dialog.
|
|
590
|
+
if (!permissions.add && !hasExisting) return;
|
|
550
591
|
closePopover();
|
|
551
592
|
hidePin();
|
|
552
593
|
const pop = dom.pop;
|
|
@@ -586,7 +627,10 @@
|
|
|
586
627
|
ta.focus();
|
|
587
628
|
});
|
|
588
629
|
// agent replies aren't editable here — only the top-level user comment
|
|
589
|
-
const actions = el('div', { class: 'ann-thread-actions' },
|
|
630
|
+
const actions = el('div', { class: 'ann-thread-actions' },
|
|
631
|
+
permissions.edit && a.author === 'user' ? edit : null,
|
|
632
|
+
permissions.delete ? del : null
|
|
633
|
+
);
|
|
590
634
|
const thread = el('div', { class: 'ann-thread' },
|
|
591
635
|
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), actions),
|
|
592
636
|
textEl
|
|
@@ -616,7 +660,7 @@
|
|
|
616
660
|
submitReply();
|
|
617
661
|
}
|
|
618
662
|
});
|
|
619
|
-
thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
663
|
+
if (permissions.reply) thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
620
664
|
existingWrap.append(thread);
|
|
621
665
|
}
|
|
622
666
|
};
|
|
@@ -624,35 +668,37 @@
|
|
|
624
668
|
pop.append(existingWrap);
|
|
625
669
|
|
|
626
670
|
const ta = el('textarea', { class: 'ann-ta', placeholder: 'Add a comment…', rows: '3' });
|
|
671
|
+
ta.value = commentDrafts.get(draftKey) || '';
|
|
672
|
+
ta.addEventListener('input', () => {
|
|
673
|
+
if (ta.value) commentDrafts.set(draftKey, ta.value);
|
|
674
|
+
else commentDrafts.delete(draftKey);
|
|
675
|
+
});
|
|
627
676
|
const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
|
|
628
677
|
const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
|
|
629
678
|
popSave = () => {
|
|
630
679
|
const text = ta.value.trim();
|
|
631
680
|
if (text) addAnnotation(info, text);
|
|
681
|
+
commentDrafts.delete(draftKey);
|
|
632
682
|
closePopover();
|
|
633
683
|
};
|
|
634
684
|
save.addEventListener('click', () => popSave && popSave());
|
|
635
|
-
cancel.addEventListener('click',
|
|
636
|
-
|
|
685
|
+
cancel.addEventListener('click', () => {
|
|
686
|
+
commentDrafts.delete(draftKey);
|
|
687
|
+
closePopover();
|
|
688
|
+
});
|
|
689
|
+
if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
|
|
637
690
|
|
|
638
|
-
// Position: prefer below the anchor, flip above when out of room,
|
|
639
|
-
//
|
|
640
|
-
//
|
|
691
|
+
// Position: prefer below the anchor, flip above when out of room, and clamp
|
|
692
|
+
// to the viewport. Scroll/resize re-run this through one animation frame so
|
|
693
|
+
// the composer tracks browser scrolling smoothly without losing its draft.
|
|
641
694
|
pop.style.display = 'block';
|
|
642
695
|
pop.style.visibility = 'hidden';
|
|
643
|
-
const rect = anchorEl.getBoundingClientRect();
|
|
644
|
-
const pw = pop.offsetWidth;
|
|
645
|
-
const ph = pop.offsetHeight;
|
|
646
|
-
const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
|
|
647
|
-
let top = rect.bottom + 8;
|
|
648
|
-
if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
|
|
649
|
-
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
|
|
650
|
-
pop.style.left = left + 'px';
|
|
651
|
-
pop.style.top = top + 'px';
|
|
652
|
-
pop.style.visibility = '';
|
|
653
696
|
popOpen = true;
|
|
654
|
-
|
|
655
|
-
|
|
697
|
+
popAnchorEl = anchorEl;
|
|
698
|
+
positionPopover();
|
|
699
|
+
pop.style.visibility = '';
|
|
700
|
+
if (permissions.add) ta.focus();
|
|
701
|
+
else popClose.focus();
|
|
656
702
|
}
|
|
657
703
|
|
|
658
704
|
// ---------- mutations ----------
|
|
@@ -742,7 +788,7 @@
|
|
|
742
788
|
}
|
|
743
789
|
|
|
744
790
|
function maybeShowSelBtn(rootEl, baseInfo) {
|
|
745
|
-
if (disabled) return hideSelBtn();
|
|
791
|
+
if (disabled || !permissions.add) return hideSelBtn();
|
|
746
792
|
const sel = window.getSelection();
|
|
747
793
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
|
|
748
794
|
if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
|
|
@@ -821,7 +867,7 @@
|
|
|
821
867
|
el('div', { class: 'ann-sum-text' }, a.text),
|
|
822
868
|
meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
|
|
823
869
|
),
|
|
824
|
-
del
|
|
870
|
+
permissions.delete ? del : null
|
|
825
871
|
);
|
|
826
872
|
// Clicking a row jumps to (and flashes) the matching element: a
|
|
827
873
|
// registered block element, or the inline highlight for a text comment.
|
|
@@ -912,6 +958,7 @@
|
|
|
912
958
|
registered = [];
|
|
913
959
|
textRoots.clear();
|
|
914
960
|
highlightMap.clear();
|
|
961
|
+
commentDrafts.clear();
|
|
915
962
|
if (dom) {
|
|
916
963
|
dom.pin.remove();
|
|
917
964
|
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,
|
|
@@ -11,6 +12,8 @@
|
|
|
11
12
|
canComment: true,
|
|
12
13
|
canEditComments: true,
|
|
13
14
|
canDeleteComments: true,
|
|
15
|
+
canEditBlocks: true,
|
|
16
|
+
canFinalize: true,
|
|
14
17
|
};
|
|
15
18
|
if (access.role) document.documentElement.dataset.accessRole = access.role;
|
|
16
19
|
const QS = spec.questions || [];
|
|
@@ -23,8 +26,11 @@
|
|
|
23
26
|
let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
|
|
24
27
|
|
|
25
28
|
function authHeaders(extra = {}) {
|
|
26
|
-
|
|
29
|
+
const headers = access.token ? { ...extra, 'x-relay-share-token': access.token } : { ...extra };
|
|
30
|
+
if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
|
|
31
|
+
return headers;
|
|
27
32
|
}
|
|
33
|
+
const canPersistFeedback = responseRequired && Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
|
|
28
34
|
|
|
29
35
|
// ---------- helpers ----------
|
|
30
36
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -133,14 +139,17 @@
|
|
|
133
139
|
applyFontScale();
|
|
134
140
|
|
|
135
141
|
// ---------- localStorage draft mirror ----------
|
|
136
|
-
// Every autosave is ALSO written to localStorage, keyed by board id
|
|
142
|
+
// Every autosave is ALSO written to localStorage, keyed by board id + access
|
|
143
|
+
// role/session. This keeps a reviewer side draft from leaking into a
|
|
144
|
+
// collaborator/read-only tab on the same LAN origin.
|
|
137
145
|
// the durability layer the server file alone can't provide: if the connection
|
|
138
146
|
// drops and the user keeps typing, the in-memory state is mirrored locally, so
|
|
139
147
|
// even a tab reload / browser restart / a freshly opened tab on the same board
|
|
140
148
|
// prefills the LATEST input instead of a blank board or a stale server save.
|
|
141
149
|
// Guards (per design): newest-of-(local,server) wins; the mirror is discarded
|
|
142
150
|
// if the board's spec rev changed (agent edited it); cleared on submit.
|
|
143
|
-
const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown')
|
|
151
|
+
const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown') + '-' +
|
|
152
|
+
(access.role || 'owner') + (access.reviewSessionId ? '-' + access.reviewSessionId : '');
|
|
144
153
|
function writeLocalDraft(p, updatedAt) {
|
|
145
154
|
try {
|
|
146
155
|
localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
|
|
@@ -352,7 +361,7 @@
|
|
|
352
361
|
// to detect recovery from the Retry button / heartbeat.
|
|
353
362
|
async function probeServer() {
|
|
354
363
|
try {
|
|
355
|
-
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
364
|
+
const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
|
|
356
365
|
return r.ok;
|
|
357
366
|
} catch {
|
|
358
367
|
return false;
|
|
@@ -407,6 +416,7 @@
|
|
|
407
416
|
for (const node of app.querySelectorAll('input, textarea, button, select')) {
|
|
408
417
|
node.disabled = false;
|
|
409
418
|
}
|
|
419
|
+
applyAccessRestrictions();
|
|
410
420
|
if (lostNote) lostNote.remove();
|
|
411
421
|
// Re-arm the heartbeat (it stops itself when it confirms loss) and persist
|
|
412
422
|
// everything typed during the outage. saveDraft() updates the save label.
|
|
@@ -454,7 +464,7 @@
|
|
|
454
464
|
// probe → block. Any success resets it.
|
|
455
465
|
let saveFailures = 0;
|
|
456
466
|
function scheduleSave() {
|
|
457
|
-
if (submitted) return;
|
|
467
|
+
if (submitted || !canPersistFeedback) return;
|
|
458
468
|
// Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
|
|
459
469
|
// of) the network save. This is what survives a tab reload / crash / a new
|
|
460
470
|
// tab during a connection outage — it must happen even while blocked.
|
|
@@ -465,6 +475,7 @@
|
|
|
465
475
|
saveTimer = setTimeout(saveDraft, 450);
|
|
466
476
|
}
|
|
467
477
|
async function saveDraft() {
|
|
478
|
+
if (!canPersistFeedback) return;
|
|
468
479
|
const seq = ++saveSeq;
|
|
469
480
|
// Keep the local mirror current on every flush too (covers programmatic
|
|
470
481
|
// saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
|
|
@@ -516,7 +527,7 @@
|
|
|
516
527
|
if (submitted) return;
|
|
517
528
|
fetch('/api/ping', {
|
|
518
529
|
method: 'POST',
|
|
519
|
-
headers: { 'content-type': 'application/json' },
|
|
530
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
520
531
|
body: JSON.stringify({
|
|
521
532
|
visible: !document.hidden,
|
|
522
533
|
focused: document.hasFocus(),
|
|
@@ -531,7 +542,7 @@
|
|
|
531
542
|
// RelayAnnotate owns the live annotation list; mirror it into state on every
|
|
532
543
|
// change so payload()/autosave/submit carry it exactly like answers.
|
|
533
544
|
const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
|
|
534
|
-
if (Annotate) {
|
|
545
|
+
if (Annotate && responseRequired) {
|
|
535
546
|
Annotate.init({
|
|
536
547
|
initial: state.annotations,
|
|
537
548
|
permissions: {
|
|
@@ -548,6 +559,8 @@
|
|
|
548
559
|
if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
|
|
549
560
|
},
|
|
550
561
|
});
|
|
562
|
+
} else if (Annotate && !responseRequired && typeof Annotate.teardown === 'function') {
|
|
563
|
+
Annotate.teardown();
|
|
551
564
|
}
|
|
552
565
|
|
|
553
566
|
// Editable-mermaid: record (or clear) the user's edit for a block, then
|
|
@@ -565,16 +578,34 @@
|
|
|
565
578
|
onBlockEdit(d.blockId, d.value);
|
|
566
579
|
});
|
|
567
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
|
+
|
|
568
592
|
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
|
|
569
593
|
// the editable-mermaid plumbing (edits map + onBlockEdit callback).
|
|
570
594
|
function blockCtx(questionId) {
|
|
571
595
|
return {
|
|
572
596
|
theme: effectiveTheme,
|
|
573
|
-
htmlSrc: (blockId) =>
|
|
597
|
+
htmlSrc: (blockId) => {
|
|
598
|
+
const params = new URLSearchParams({ theme: effectiveTheme() });
|
|
599
|
+
if (access.token) params.set('token', access.token);
|
|
600
|
+
return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
|
|
601
|
+
},
|
|
574
602
|
questionId: questionId == null ? null : questionId,
|
|
575
|
-
annotate: Annotate,
|
|
603
|
+
annotate: responseRequired ? Annotate : null,
|
|
604
|
+
canComment: responseRequired && access.canComment !== false,
|
|
605
|
+
saveArtifact: responseRequired && access.canComment !== false ? saveRegionArtifact : null,
|
|
576
606
|
edits: state.blockEdits,
|
|
577
607
|
onBlockEdit,
|
|
608
|
+
canEditBlocks: access.canEditBlocks !== false,
|
|
578
609
|
};
|
|
579
610
|
}
|
|
580
611
|
|
|
@@ -807,6 +838,7 @@
|
|
|
807
838
|
const byVal = new Map(q.options.map((o) => [o.value, o]));
|
|
808
839
|
let dragFrom = null;
|
|
809
840
|
function move(from, to) {
|
|
841
|
+
if (!access.canEditAnswers) return;
|
|
810
842
|
const arr = state.answers[q.id];
|
|
811
843
|
if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
|
|
812
844
|
const [x] = arr.splice(from, 1);
|
|
@@ -827,7 +859,7 @@
|
|
|
827
859
|
down.disabled = i === order.length - 1;
|
|
828
860
|
up.addEventListener('click', () => move(i, i - 1));
|
|
829
861
|
down.addEventListener('click', () => move(i, i + 1));
|
|
830
|
-
const item = el('div', { class: 'rank-item', draggable: 'true' },
|
|
862
|
+
const item = el('div', { class: 'rank-item', draggable: access.canEditAnswers ? 'true' : 'false' },
|
|
831
863
|
el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
|
|
832
864
|
el('div', { class: 'rank-body' },
|
|
833
865
|
el('div', { class: 'ol' }, o.label),
|
|
@@ -835,6 +867,7 @@
|
|
|
835
867
|
el('div', { class: 'rank-ctrls' }, up, down)
|
|
836
868
|
);
|
|
837
869
|
item.addEventListener('dragstart', (e) => {
|
|
870
|
+
if (!access.canEditAnswers) { e.preventDefault(); return; }
|
|
838
871
|
dragFrom = i; item.classList.add('dragging');
|
|
839
872
|
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
|
|
840
873
|
});
|
|
@@ -1127,7 +1160,7 @@
|
|
|
1127
1160
|
app.append(card);
|
|
1128
1161
|
});
|
|
1129
1162
|
|
|
1130
|
-
if (spec.note) {
|
|
1163
|
+
if (spec.note && responseRequired) {
|
|
1131
1164
|
const note = el('textarea', { placeholder: 'optional note back to the agent…' });
|
|
1132
1165
|
note.value = state.comment || '';
|
|
1133
1166
|
note.addEventListener('input', () => {
|
|
@@ -1149,6 +1182,10 @@
|
|
|
1149
1182
|
const hint = el('span', { class: 'hint' },
|
|
1150
1183
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
1151
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
|
+
}
|
|
1152
1189
|
app.append(submitbar);
|
|
1153
1190
|
|
|
1154
1191
|
function copyText(text, btn) {
|
|
@@ -1182,7 +1219,11 @@
|
|
|
1182
1219
|
),
|
|
1183
1220
|
el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
|
|
1184
1221
|
el('span', { class: 'share-choice-title' }, 'Reviewer'),
|
|
1185
|
-
el('span', { class: 'share-choice-sub' }, 'Can
|
|
1222
|
+
el('span', { class: 'share-choice-sub' }, 'Can answer, comment, and submit a reference-only side review. Does not finalize the board.')
|
|
1223
|
+
),
|
|
1224
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'read' },
|
|
1225
|
+
el('span', { class: 'share-choice-title' }, 'Read only'),
|
|
1226
|
+
el('span', { class: 'share-choice-sub' }, 'Can view the board and existing feedback. Cannot answer, comment, edit, or submit.')
|
|
1186
1227
|
),
|
|
1187
1228
|
el('div', { class: 'share-result' })
|
|
1188
1229
|
);
|
|
@@ -1207,7 +1248,7 @@
|
|
|
1207
1248
|
const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
|
|
1208
1249
|
copy.addEventListener('click', () => copyText(body.url, copy));
|
|
1209
1250
|
result.replaceChildren(
|
|
1210
|
-
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
|
|
1251
|
+
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : role === 'review' ? 'Reviewer link active' : 'Read-only link active'),
|
|
1211
1252
|
el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
|
|
1212
1253
|
copy
|
|
1213
1254
|
);
|
|
@@ -1226,18 +1267,33 @@
|
|
|
1226
1267
|
return footer;
|
|
1227
1268
|
}
|
|
1228
1269
|
|
|
1229
|
-
|
|
1230
|
-
submitbar.style.display = 'none';
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1270
|
+
function applyAccessRestrictions() {
|
|
1271
|
+
if (!access.canSubmit) submitbar.style.display = 'none';
|
|
1272
|
+
if (!access.canEditAnswers) {
|
|
1273
|
+
for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
|
|
1274
|
+
// Viewer controls inside question/option blocks remain useful in a
|
|
1275
|
+
// read-only board (zoom, fit, full-screen, filtering, diff view). Block
|
|
1276
|
+
// mutation controls are independently gated by canEditBlocks.
|
|
1277
|
+
if (node.closest('.blocks')) continue;
|
|
1278
|
+
node.disabled = true;
|
|
1279
|
+
}
|
|
1280
|
+
for (const item of app.querySelectorAll('.rank-item')) {
|
|
1281
|
+
item.draggable = false;
|
|
1282
|
+
item.setAttribute('aria-disabled', 'true');
|
|
1283
|
+
}
|
|
1239
1284
|
}
|
|
1285
|
+
document.documentElement.classList.toggle('relay-review', access.canEditComments === false || access.canDeleteComments === false);
|
|
1286
|
+
}
|
|
1287
|
+
if (access.role === 'review') {
|
|
1288
|
+
submitBtn.textContent = 'Submit side review';
|
|
1289
|
+
hint.textContent = 'Reference only — the owner still submits the final answer.';
|
|
1290
|
+
showNotice('Reviewer mode: your answers and comments are saved separately as a reference-only side review. Submitting here does not finalize the board or notify the waiting agent.', 'info');
|
|
1291
|
+
} else if (access.role === 'read') {
|
|
1292
|
+
showNotice('Read-only mode: you can view the board and existing feedback, but answers, comments, edits, and submission are disabled.', 'info');
|
|
1293
|
+
} else if (access.role === 'collab') {
|
|
1294
|
+
showNotice('Editor mode: answers, comments, and final submission are enabled for this shared board.', 'info');
|
|
1240
1295
|
}
|
|
1296
|
+
applyAccessRestrictions();
|
|
1241
1297
|
app.append(buildFooter());
|
|
1242
1298
|
|
|
1243
1299
|
// ---------- validation & submit ----------
|
|
@@ -1253,7 +1309,7 @@
|
|
|
1253
1309
|
return !firstBad;
|
|
1254
1310
|
}
|
|
1255
1311
|
|
|
1256
|
-
function showDone(closing) {
|
|
1312
|
+
function showDone(closing, sideReview = false) {
|
|
1257
1313
|
stopHeartbeat();
|
|
1258
1314
|
// Annotation pins/badges/popover float on <body> with elevated z-index —
|
|
1259
1315
|
// remove them so they don't leak over the submitted screen.
|
|
@@ -1267,7 +1323,9 @@
|
|
|
1267
1323
|
// If the agent already stopped waiting (soft timeout / dropped connection),
|
|
1268
1324
|
// the submission won't be picked up automatically — tell the user to nudge
|
|
1269
1325
|
// the agent. Otherwise the normal hand-back copy applies.
|
|
1270
|
-
const note =
|
|
1326
|
+
const note = sideReview
|
|
1327
|
+
? 'Saved for reference. This did not finalize the board or notify the waiting agent; the owner still needs to submit the final answer.'
|
|
1328
|
+
: handedBack
|
|
1271
1329
|
? 'Saved. Your agent had stopped waiting — send it a message so it picks up your answers.'
|
|
1272
1330
|
: closing
|
|
1273
1331
|
? 'Handing back to your agent — this tab will close itself…'
|
|
@@ -1275,7 +1333,7 @@
|
|
|
1275
1333
|
app.replaceChildren(
|
|
1276
1334
|
el('div', { class: 'done' },
|
|
1277
1335
|
el('div', { class: 'mark' }, '✓'),
|
|
1278
|
-
el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
1336
|
+
el('h2', {}, sideReview ? 'Side review saved' : QS.length ? 'Submitted' : 'Acknowledged'),
|
|
1279
1337
|
el('p', { id: 'done-note' }, note)
|
|
1280
1338
|
)
|
|
1281
1339
|
);
|
|
@@ -1302,14 +1360,16 @@
|
|
|
1302
1360
|
throw netErr;
|
|
1303
1361
|
}
|
|
1304
1362
|
if (!res.ok) throw new Error('submit rejected');
|
|
1363
|
+
const submitResult = await res.json().catch(() => null);
|
|
1364
|
+
const sideReview = Boolean(submitResult && submitResult.sideReview === true && submitResult.final === false);
|
|
1305
1365
|
submitted = true;
|
|
1306
1366
|
// Submitted successfully → the local mirror is no longer needed and would
|
|
1307
1367
|
// otherwise resurrect stale answers on a future reopen. Clear it.
|
|
1308
1368
|
clearLocalDraft();
|
|
1309
1369
|
// Don't auto-close when the agent had stopped waiting — the user needs to
|
|
1310
1370
|
// read the "send your agent a message" note and act on it.
|
|
1311
|
-
const autoClose = spec.autoClose && !handedBack;
|
|
1312
|
-
showDone(autoClose);
|
|
1371
|
+
const autoClose = !sideReview && spec.autoClose && !handedBack;
|
|
1372
|
+
showDone(autoClose, sideReview);
|
|
1313
1373
|
if (autoClose) {
|
|
1314
1374
|
setTimeout(() => {
|
|
1315
1375
|
window.close();
|
|
@@ -1324,7 +1384,7 @@
|
|
|
1324
1384
|
} catch {
|
|
1325
1385
|
// Restore the button so the user can retry.
|
|
1326
1386
|
submitBtn.disabled = false;
|
|
1327
|
-
submitBtn.textContent = spec.submitLabel;
|
|
1387
|
+
submitBtn.textContent = access.role === 'review' ? 'Submit side review' : spec.submitLabel;
|
|
1328
1388
|
if (!reached) {
|
|
1329
1389
|
// The connection is gone — the submit (and any further input) can't be
|
|
1330
1390
|
// persisted. Block hard so the user stops adding feedback that would be
|
|
@@ -1362,7 +1422,7 @@
|
|
|
1362
1422
|
// the server had nothing — e.g. a freshly reopened board the user had typed
|
|
1363
1423
|
// into in another tab during an outage), the server doesn't yet have this
|
|
1364
1424
|
// input. Flush it once so a brand-new tab's view is also the server's truth.
|
|
1365
|
-
if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
|
|
1425
|
+
if (initialPrefill && initialPrefill.__from === 'local' && !submitted && canPersistFeedback) {
|
|
1366
1426
|
saveDraft();
|
|
1367
1427
|
}
|
|
1368
1428
|
|
|
@@ -1374,7 +1434,7 @@
|
|
|
1374
1434
|
// parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
|
|
1375
1435
|
// We own the annotation state, popover, and the submitted result; the iframe
|
|
1376
1436
|
// owns hover/pin/badges over its own (cross-origin) DOM.
|
|
1377
|
-
if (Annotate) {
|
|
1437
|
+
if (Annotate && responseRequired) {
|
|
1378
1438
|
const frameOf = (source) => {
|
|
1379
1439
|
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
1380
1440
|
if (f.contentWindow === source) return f;
|
|
@@ -1456,7 +1516,7 @@
|
|
|
1456
1516
|
// Piggyback presence on the heartbeat (best-effort; no-ops after submit).
|
|
1457
1517
|
pingPresence();
|
|
1458
1518
|
try {
|
|
1459
|
-
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
1519
|
+
const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
|
|
1460
1520
|
if (!r.ok) throw new Error('bad status');
|
|
1461
1521
|
misses = 0;
|
|
1462
1522
|
// The heartbeat reaching the server is itself proof persistence is back —
|
|
@@ -1476,10 +1536,14 @@
|
|
|
1476
1536
|
// user knows to prompt the agent after submitting.
|
|
1477
1537
|
if (body && body.softTimedOut && !submitted) {
|
|
1478
1538
|
handedBack = true;
|
|
1479
|
-
|
|
1480
|
-
'
|
|
1481
|
-
|
|
1482
|
-
|
|
1539
|
+
if (access.role === 'review') {
|
|
1540
|
+
showNotice('This remains a reference-only side review. Submit when ready; the owner still needs to provide the final answer.', 'info');
|
|
1541
|
+
} else if (access.role !== 'read') {
|
|
1542
|
+
showNotice(
|
|
1543
|
+
'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
|
|
1544
|
+
'info'
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1483
1547
|
}
|
|
1484
1548
|
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
1485
1549
|
// Don't yank the board out from under someone mid-comment: an open
|