@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/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
- app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
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('input', { type: 'text', class: 'qnote', placeholder: 'optional note about this answer…' });
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
- // Annotations summary (editable list) sits directly above the submit bar.
573
- if (Annotate) {
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' }, `quest-board · ${boot.boardId}`));
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' }, closing ? 'Handing back to your agent — this tab will close itself…' : 'Handed back to your agent. You can close this tab.')
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
- showDone(spec.autoClose);
633
- if (spec.autoClose) {
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
- banner.textContent = 'Submit failed — the board server may have stopped.';
648
- banner.style.display = 'block';
649
- setTimeout(() => { if (!submitted) banner.style.display = 'none'; }, 4000);
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.commentable) post
668
- // {relay:'annotate-request', label, detail?}. Match the source to the
669
- // iframe, read its block/question ids, and open the annotate popover anchored
670
- // to that iframe.
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
- window.addEventListener('message', (e) => {
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 === e.source) { frame = f; break; }
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
- Annotate.openExternal(
683
- {
684
- blockId,
685
- questionId: questionId || null,
686
- target: {
687
- kind: 'html-element',
688
- label: typeof msg.label === 'string' ? msg.label : 'Element',
689
- detail: typeof msg.detail === 'string' ? msg.detail : undefined,
690
- },
691
- },
692
- frame
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
- banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
731
- banner.style.display = 'block';
732
- submitBtn.disabled = true;
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: 12px; z-index: 49;
313
- background: var(--card); border: 1px solid var(--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: 48px 16px 16px;
321
+ padding: 44px 0 0;
317
322
  }
318
323
  body.blk-full-open { overflow: hidden; }
319
- .blk-chart.blk-full { height: calc(100vh - 24px) !important; width: auto !important; }
320
- .blk-htmlwrap.blk-full iframe.viz { height: calc(100vh - 90px) !important; width: 100%; }
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++;