@khanglvm/relay 0.5.4 → 0.7.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 +16 -0
- package/docs/AGENT.md +53 -17
- package/package.json +1 -1
- package/skills/relay/SKILL.md +10 -3
- package/src/cli.js +119 -2
- package/src/server.js +111 -14
- package/src/spec.js +7 -4
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +125 -39
- package/src/ui/blocks.css +25 -7
- package/src/ui/blocks.js +54 -1
- package/src/ui/kit.js +335 -27
- package/src/ui/style.css +8 -2
package/src/ui/app.js
CHANGED
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
setTimeout(() => toast.remove(), 4000);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// True once the board has soft-timed-out (the agent stopped waiting) or the
|
|
38
|
+
// live connection dropped — used to tailor the post-submit message. The board
|
|
39
|
+
// stays fully usable either way; we never disable Submit.
|
|
40
|
+
let handedBack = false;
|
|
41
|
+
// Calm, persistent status note (reuses the #banner bar). tone 'info' is the
|
|
42
|
+
// default neutral style; 'warn' is a softer amber, NOT the old red error.
|
|
43
|
+
// Shown at most once per message so the heartbeat can call it every tick.
|
|
44
|
+
const notesShown = new Set();
|
|
45
|
+
function showNotice(message, tone) {
|
|
46
|
+
if (notesShown.has(message)) return;
|
|
47
|
+
notesShown.add(message);
|
|
48
|
+
banner.textContent = message;
|
|
49
|
+
banner.className = 'banner ' + (tone === 'warn' ? 'warn' : 'info');
|
|
50
|
+
banner.style.display = 'block';
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
// ---------- theme (auto -> light -> dark) ----------
|
|
38
54
|
// Server-side pref wins: boards run on random ports, so localStorage alone
|
|
39
55
|
// can't carry the choice across boards. The server persists it globally.
|
|
@@ -232,6 +248,9 @@
|
|
|
232
248
|
onChange: (list) => {
|
|
233
249
|
state.annotations = list;
|
|
234
250
|
scheduleSave();
|
|
251
|
+
// Refresh per-element comment badges inside custom-HTML iframes (bridge
|
|
252
|
+
// sets this once the iframe annotate plumbing is wired below).
|
|
253
|
+
if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
|
|
235
254
|
},
|
|
236
255
|
});
|
|
237
256
|
}
|
|
@@ -516,7 +535,13 @@
|
|
|
516
535
|
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
517
536
|
}
|
|
518
537
|
|
|
519
|
-
|
|
538
|
+
const titleEl = el('h1', {}, spec.title);
|
|
539
|
+
app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
|
|
540
|
+
// The board title is commentable too: hovering it shows the pin, like the
|
|
541
|
+
// intro and every other content element. (themeBtn stays out of it.)
|
|
542
|
+
if (spec.title) {
|
|
543
|
+
Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
|
|
544
|
+
}
|
|
520
545
|
if (spec.intro) {
|
|
521
546
|
const intro = el('p', { class: 'intro' }, spec.intro);
|
|
522
547
|
app.append(intro);
|
|
@@ -542,7 +567,7 @@
|
|
|
542
567
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
543
568
|
card.append(control);
|
|
544
569
|
if (q.note) {
|
|
545
|
-
const noteInput = el('
|
|
570
|
+
const noteInput = el('textarea', { class: 'qnote', rows: 2, placeholder: 'optional note about this answer…' });
|
|
546
571
|
noteInput.value = typeof state.notes[q.id] === 'string' ? state.notes[q.id] : '';
|
|
547
572
|
noteInput.addEventListener('input', () => {
|
|
548
573
|
state.notes[q.id] = noteInput.value;
|
|
@@ -569,19 +594,15 @@
|
|
|
569
594
|
));
|
|
570
595
|
}
|
|
571
596
|
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
const summary = el('div', { class: 'ann-summary-wrap' });
|
|
575
|
-
app.append(summary);
|
|
576
|
-
Annotate.renderSummary(summary);
|
|
577
|
-
}
|
|
597
|
+
// Element-level comments live in the Outline-style right rail (created and
|
|
598
|
+
// managed by RelayAnnotate); no inline summary list here anymore.
|
|
578
599
|
|
|
579
600
|
const submitBtn = el('button', { class: 'submit', type: 'button' }, spec.submitLabel);
|
|
580
601
|
saveEl = el('span', { class: 'savestate' }, '');
|
|
581
602
|
const hint = el('span', { class: 'hint' },
|
|
582
603
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
583
604
|
app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
|
|
584
|
-
app.append(el('footer', { class: 'qb-footer' }, `
|
|
605
|
+
app.append(el('footer', { class: 'qb-footer' }, `relay · ${boot.boardId}`));
|
|
585
606
|
|
|
586
607
|
// ---------- validation & submit ----------
|
|
587
608
|
function validate() {
|
|
@@ -607,11 +628,19 @@
|
|
|
607
628
|
// best effort
|
|
608
629
|
}
|
|
609
630
|
}
|
|
631
|
+
// If the agent already stopped waiting (soft timeout / dropped connection),
|
|
632
|
+
// the submission won't be picked up automatically — tell the user to nudge
|
|
633
|
+
// the agent. Otherwise the normal hand-back copy applies.
|
|
634
|
+
const note = handedBack
|
|
635
|
+
? 'Saved. Your agent had stopped waiting — send it a message so it picks up your answers.'
|
|
636
|
+
: closing
|
|
637
|
+
? 'Handing back to your agent — this tab will close itself…'
|
|
638
|
+
: 'Handed back to your agent. You can close this tab.';
|
|
610
639
|
app.replaceChildren(
|
|
611
640
|
el('div', { class: 'done' },
|
|
612
641
|
el('div', { class: 'mark' }, '✓'),
|
|
613
642
|
el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
614
|
-
el('p', { id: 'done-note' },
|
|
643
|
+
el('p', { id: 'done-note' }, note)
|
|
615
644
|
)
|
|
616
645
|
);
|
|
617
646
|
}
|
|
@@ -629,8 +658,11 @@
|
|
|
629
658
|
});
|
|
630
659
|
if (!res.ok) throw new Error('submit rejected');
|
|
631
660
|
submitted = true;
|
|
632
|
-
|
|
633
|
-
|
|
661
|
+
// Don't auto-close when the agent had stopped waiting — the user needs to
|
|
662
|
+
// read the "send your agent a message" note and act on it.
|
|
663
|
+
const autoClose = spec.autoClose && !handedBack;
|
|
664
|
+
showDone(autoClose);
|
|
665
|
+
if (autoClose) {
|
|
634
666
|
setTimeout(() => {
|
|
635
667
|
window.close();
|
|
636
668
|
// window.close() is best-effort (browsers may block it for
|
|
@@ -642,11 +674,15 @@
|
|
|
642
674
|
}, 700);
|
|
643
675
|
}
|
|
644
676
|
} catch {
|
|
677
|
+
// The server is gone, so this submit couldn't be delivered. Keep the
|
|
678
|
+
// button live and tell the user how to get their input to the agent —
|
|
679
|
+
// their draft was autosaved up to the last edit.
|
|
645
680
|
submitBtn.disabled = false;
|
|
646
681
|
submitBtn.textContent = spec.submitLabel;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
682
|
+
showNotice(
|
|
683
|
+
'Couldn’t reach the agent to submit just now — your draft is saved. Prompt the agent to reopen this board so your input isn’t lost.',
|
|
684
|
+
'warn'
|
|
685
|
+
);
|
|
650
686
|
}
|
|
651
687
|
});
|
|
652
688
|
|
|
@@ -664,33 +700,66 @@
|
|
|
664
700
|
}
|
|
665
701
|
|
|
666
702
|
// ---------- iframe annotate bridge ----------
|
|
667
|
-
// Custom-HTML iframes (via /kit.js relayKit.
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
703
|
+
// Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
|
|
704
|
+
// server) talk to the parent over postMessage:
|
|
705
|
+
// iframe → {relay:'annotate-ready'} we send it counts
|
|
706
|
+
// iframe → {relay:'annotate-request', ref, label, detail?, rect}
|
|
707
|
+
// parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
|
|
708
|
+
// We own the annotation state, popover, and the submitted result; the iframe
|
|
709
|
+
// owns hover/pin/badges over its own (cross-origin) DOM.
|
|
671
710
|
if (Annotate) {
|
|
672
|
-
|
|
673
|
-
const msg = e.data;
|
|
674
|
-
if (!msg || typeof msg !== 'object' || msg.relay !== 'annotate-request') return;
|
|
675
|
-
let frame = null;
|
|
711
|
+
const frameOf = (source) => {
|
|
676
712
|
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
677
|
-
if (f.contentWindow ===
|
|
713
|
+
if (f.contentWindow === source) return f;
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
};
|
|
717
|
+
// Per-element comment counts for one iframe, keyed by target.ref.
|
|
718
|
+
const postCountsTo = (frame) => {
|
|
719
|
+
if (!frame || !frame.contentWindow) return;
|
|
720
|
+
const blockId = frame.getAttribute('data-block-id') || null;
|
|
721
|
+
const counts = {};
|
|
722
|
+
for (const a of Annotate.list()) {
|
|
723
|
+
if ((a.blockId ?? null) !== (blockId ?? null)) continue;
|
|
724
|
+
const t = a.target || {};
|
|
725
|
+
if (t.kind !== 'html-element' || !t.ref) continue;
|
|
726
|
+
counts[t.ref] = (counts[t.ref] || 0) + 1;
|
|
678
727
|
}
|
|
728
|
+
try { frame.contentWindow.postMessage({ relay: 'annotate-counts', counts }, '*'); } catch {}
|
|
729
|
+
};
|
|
730
|
+
// Refresh badges in every html iframe (called whenever annotations change).
|
|
731
|
+
window.__relayBroadcastCounts = () => {
|
|
732
|
+
for (const f of document.querySelectorAll('iframe.viz')) postCountsTo(f);
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
window.addEventListener('message', (e) => {
|
|
736
|
+
const msg = e.data;
|
|
737
|
+
if (!msg || typeof msg !== 'object' || typeof msg.relay !== 'string') return;
|
|
738
|
+
const frame = frameOf(e.source);
|
|
679
739
|
if (!frame) return;
|
|
740
|
+
if (msg.relay === 'annotate-ready') { postCountsTo(frame); return; }
|
|
741
|
+
if (msg.relay !== 'annotate-request') return;
|
|
742
|
+
|
|
680
743
|
const blockId = frame.getAttribute('data-block-id') || null;
|
|
681
744
|
const questionId = frame.getAttribute('data-question-id') || null;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
745
|
+
const target = { kind: 'html-element', label: typeof msg.label === 'string' ? msg.label : 'Element' };
|
|
746
|
+
if (typeof msg.ref === 'string') target.ref = msg.ref;
|
|
747
|
+
if (typeof msg.detail === 'string') target.detail = msg.detail;
|
|
748
|
+
|
|
749
|
+
// Anchor the popover to the actual element: translate the iframe-local
|
|
750
|
+
// viewport rect the iframe sent into page coords. Fall back to the iframe.
|
|
751
|
+
let anchor = frame;
|
|
752
|
+
if (msg.rect && typeof msg.rect === 'object') {
|
|
753
|
+
const fr = frame.getBoundingClientRect();
|
|
754
|
+
const r = msg.rect;
|
|
755
|
+
const left = fr.left + (r.left || 0);
|
|
756
|
+
const top = fr.top + (r.top || 0);
|
|
757
|
+
const width = r.width || 0;
|
|
758
|
+
const height = r.height || 0;
|
|
759
|
+
const pageRect = { left, top, width, height, right: left + width, bottom: top + height };
|
|
760
|
+
anchor = { getBoundingClientRect: () => pageRect };
|
|
761
|
+
}
|
|
762
|
+
Annotate.openExternal({ blockId, questionId: questionId || null, target }, anchor);
|
|
694
763
|
});
|
|
695
764
|
}
|
|
696
765
|
|
|
@@ -709,6 +778,17 @@
|
|
|
709
778
|
// the live draft — answers for now-removed question ids are ignored),
|
|
710
779
|
// then reload to render the new spec. Guarded so it fires once.
|
|
711
780
|
const body = await r.json().catch(() => null);
|
|
781
|
+
// Soft timeout: the board ran past its deadline so the agent was handed a
|
|
782
|
+
// result and stopped waiting — but the server is still up and the board
|
|
783
|
+
// stays fully usable. Surface a calm note (never disable Submit) so the
|
|
784
|
+
// user knows to prompt the agent after submitting.
|
|
785
|
+
if (body && body.softTimedOut && !submitted) {
|
|
786
|
+
handedBack = true;
|
|
787
|
+
showNotice(
|
|
788
|
+
'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.',
|
|
789
|
+
'info'
|
|
790
|
+
);
|
|
791
|
+
}
|
|
712
792
|
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
713
793
|
reloading = true;
|
|
714
794
|
stopHeartbeat();
|
|
@@ -726,10 +806,16 @@
|
|
|
726
806
|
location.reload();
|
|
727
807
|
}
|
|
728
808
|
} catch {
|
|
809
|
+
// Lost the live connection (the session ended, or the machine slept).
|
|
810
|
+
// Keep the board usable — do NOT disable Submit — and show a calm note
|
|
811
|
+
// rather than the old red "closed" banner. A submit attempt that can't
|
|
812
|
+
// reach the server falls back to the same guidance below.
|
|
729
813
|
if (++misses >= 2 && !submitted) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
814
|
+
handedBack = true;
|
|
815
|
+
showNotice(
|
|
816
|
+
'Lost the live connection to your agent’s session — your latest edits were saved. You can still try Submit; if it doesn’t go through, prompt the agent to reopen this board.',
|
|
817
|
+
'warn'
|
|
818
|
+
);
|
|
733
819
|
stopHeartbeat();
|
|
734
820
|
}
|
|
735
821
|
}
|
package/src/ui/blocks.css
CHANGED
|
@@ -74,6 +74,10 @@
|
|
|
74
74
|
font-size: 0.85rem;
|
|
75
75
|
line-height: 1.5;
|
|
76
76
|
}
|
|
77
|
+
/* GFM pipe tables inside markdown reuse .blk-table styling; the wrapper lets a
|
|
78
|
+
wide table scroll horizontally instead of overflowing the column. */
|
|
79
|
+
.blk-markdown .md .md-tablewrap { overflow-x: auto; margin: 12px 0; }
|
|
80
|
+
.blk-markdown .md .md-table { margin: 0; }
|
|
77
81
|
|
|
78
82
|
/* ---------- code block ---------- */
|
|
79
83
|
.blk-pre {
|
|
@@ -307,14 +311,28 @@
|
|
|
307
311
|
.blk-tools .tool-pct { color: var(--muted); font-size: 0.72rem; min-width: 30px; text-align: center; }
|
|
308
312
|
|
|
309
313
|
/* full-screen = fixed overlay (NOT native fullscreen: annotation pins/badges/
|
|
310
|
-
popover live on <body> and must stay visible above the expanded block)
|
|
314
|
+
popover live on <body> and must stay visible above the expanded block).
|
|
315
|
+
Edge-to-edge (no modal gap); the toolbar pins to the top as a fixed bar so
|
|
316
|
+
the close button stays on screen while the content scrolls. */
|
|
311
317
|
.blk-full {
|
|
312
|
-
position: fixed !important; inset:
|
|
313
|
-
background: var(--card); border:
|
|
314
|
-
border-radius: 12px; box-shadow: var(--shadow-lift);
|
|
318
|
+
position: fixed !important; inset: 0; z-index: 49;
|
|
319
|
+
background: var(--card); border: 0; border-radius: 0; box-shadow: none;
|
|
315
320
|
overflow: auto; max-height: none !important; margin: 0 !important;
|
|
316
|
-
padding:
|
|
321
|
+
padding: 44px 0 0;
|
|
317
322
|
}
|
|
318
323
|
body.blk-full-open { overflow: hidden; }
|
|
319
|
-
|
|
320
|
-
.blk-
|
|
324
|
+
/* detach the toolbar from the scrolling content and fix it to the viewport top */
|
|
325
|
+
.blk-full > .blk-tools {
|
|
326
|
+
position: fixed; top: 0; left: 0; right: 0; height: 44px;
|
|
327
|
+
margin: 0; padding: 0 12px;
|
|
328
|
+
background: var(--card); border-bottom: 1px solid var(--border);
|
|
329
|
+
z-index: 51;
|
|
330
|
+
}
|
|
331
|
+
.blk-chart.blk-full {
|
|
332
|
+
height: 100vh !important; width: auto !important;
|
|
333
|
+
padding: 44px 16px 16px;
|
|
334
|
+
}
|
|
335
|
+
.blk-htmlwrap.blk-full iframe.viz {
|
|
336
|
+
height: calc(100vh - 44px) !important; width: 100%;
|
|
337
|
+
margin: 0; border: 0; border-radius: 0;
|
|
338
|
+
}
|
package/src/ui/blocks.js
CHANGED
|
@@ -103,6 +103,55 @@
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// GFM pipe tables — a header row of "| a | b |" cells immediately followed
|
|
107
|
+
// by a separator row of dashes (with optional ":" alignment markers).
|
|
108
|
+
// Rendered as a real .blk-table so it matches the dedicated table block.
|
|
109
|
+
function splitRow(row) {
|
|
110
|
+
const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
111
|
+
return trimmed.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim());
|
|
112
|
+
}
|
|
113
|
+
function isSepRow(row) {
|
|
114
|
+
if (!row || row.indexOf('|') === -1) return false;
|
|
115
|
+
const cells = splitRow(row);
|
|
116
|
+
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c));
|
|
117
|
+
}
|
|
118
|
+
function isTableStart(idx) {
|
|
119
|
+
const head = lines[idx];
|
|
120
|
+
return (
|
|
121
|
+
idx + 1 < lines.length &&
|
|
122
|
+
head.indexOf('|') !== -1 &&
|
|
123
|
+
!isSepRow(head) &&
|
|
124
|
+
isSepRow(lines[idx + 1])
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function consumeTable() {
|
|
128
|
+
const headers = splitRow(lines[i]);
|
|
129
|
+
const aligns = splitRow(lines[i + 1]).map((c) => {
|
|
130
|
+
const left = c.startsWith(':');
|
|
131
|
+
const right = c.endsWith(':');
|
|
132
|
+
return left && right ? 'center' : right ? 'right' : left ? 'left' : '';
|
|
133
|
+
});
|
|
134
|
+
i += 2;
|
|
135
|
+
const body = [];
|
|
136
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && lines[i].indexOf('|') !== -1 && !isSepRow(lines[i])) {
|
|
137
|
+
body.push(splitRow(lines[i]));
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
const ncols = headers.length;
|
|
141
|
+
const alignAttr = (ci) => (aligns[ci] ? ` style="text-align:${aligns[ci]}"` : '');
|
|
142
|
+
const cell = (tag, ci, text) => `<${tag}${alignAttr(ci)}>${mdInline(esc(text == null ? '' : text))}</${tag}>`;
|
|
143
|
+
let out = '<div class="md-tablewrap"><table class="blk-table md-table"><thead><tr>';
|
|
144
|
+
for (let c = 0; c < ncols; c++) out += cell('th', c, headers[c]);
|
|
145
|
+
out += '</tr></thead><tbody>';
|
|
146
|
+
for (const r of body) {
|
|
147
|
+
out += '<tr>';
|
|
148
|
+
for (let c = 0; c < ncols; c++) out += cell('td', c, r[c]);
|
|
149
|
+
out += '</tr>';
|
|
150
|
+
}
|
|
151
|
+
out += '</tbody></table></div>';
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
106
155
|
while (i < lines.length) {
|
|
107
156
|
const line = lines[i];
|
|
108
157
|
|
|
@@ -140,6 +189,9 @@
|
|
|
140
189
|
continue;
|
|
141
190
|
}
|
|
142
191
|
|
|
192
|
+
// GFM pipe table (header row + dash separator)
|
|
193
|
+
if (isTableStart(i)) { html += consumeTable(); continue; }
|
|
194
|
+
|
|
143
195
|
// lists
|
|
144
196
|
if (/^(\s*)([-*]|\d+\.)\s+/.test(line)) { html += consumeList(); continue; }
|
|
145
197
|
|
|
@@ -155,7 +207,8 @@
|
|
|
155
207
|
!/^\s*---+\s*$/.test(lines[i]) &&
|
|
156
208
|
!/^#{1,3}\s+/.test(lines[i]) &&
|
|
157
209
|
!/^\s*>\s?/.test(lines[i]) &&
|
|
158
|
-
!/^(\s*)([-*]|\d+\.)\s+/.test(lines[i])
|
|
210
|
+
!/^(\s*)([-*]|\d+\.)\s+/.test(lines[i]) &&
|
|
211
|
+
!isTableStart(i)
|
|
159
212
|
) {
|
|
160
213
|
buf.push(lines[i]);
|
|
161
214
|
i++;
|