@khanglvm/relay 0.6.0 → 0.8.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.
@@ -167,7 +167,81 @@
167
167
  }
168
168
  .ann-selbtn:hover { background: var(--accent-hover); }
169
169
 
170
- /* ---------- summary list ---------- */
170
+ /* ---------- inline text highlight ---------- */
171
+ /* Commented text in the intro / markdown gets a soft accent highlight that is
172
+ itself clickable (opens the comment popover). */
173
+ .ann-hl {
174
+ background: var(--accent-soft);
175
+ color: inherit;
176
+ border-radius: 3px;
177
+ box-shadow: inset 0 -1px 0 var(--accent);
178
+ padding: 0 1px;
179
+ cursor: pointer;
180
+ transition: background 150ms var(--ease), color 150ms var(--ease);
181
+ }
182
+ .ann-hl:hover { background: var(--accent); color: var(--accent-fg); }
183
+
184
+ /* ---------- comments rail (Outline-style right sidebar) ---------- */
185
+ .ann-rail-toggle {
186
+ position: fixed; z-index: 57; right: 20px; bottom: 20px;
187
+ display: none; align-items: center; gap: 7px;
188
+ background: var(--card); color: var(--fg-2);
189
+ border: 1px solid var(--border-strong); border-radius: 999px;
190
+ padding: 9px 16px; cursor: pointer;
191
+ font: 600 0.82rem var(--sans);
192
+ box-shadow: var(--shadow-lift);
193
+ transition: border-color 150ms var(--ease), color 150ms var(--ease);
194
+ }
195
+ .ann-rail-toggle:hover { border-color: var(--accent); color: var(--accent); }
196
+ .ann-rail-toggle-count {
197
+ display: inline-block; min-width: 18px;
198
+ background: var(--accent); color: var(--accent-fg);
199
+ border-radius: 999px; padding: 0 6px;
200
+ font: 600 0.72rem/18px var(--sans); text-align: center;
201
+ }
202
+
203
+ .ann-rail-scrim {
204
+ position: fixed; inset: 0; z-index: 61;
205
+ background: rgba(28, 27, 25, 0.34);
206
+ display: none;
207
+ }
208
+ .ann-rail-scrim.show { display: block; }
209
+
210
+ .ann-rail {
211
+ position: fixed; z-index: 62; top: 0; right: 0; bottom: 0;
212
+ width: min(340px, 88vw);
213
+ display: flex; flex-direction: column;
214
+ background: var(--card);
215
+ border-left: 1px solid var(--border);
216
+ box-shadow: var(--shadow-lift);
217
+ transform: translateX(100%);
218
+ transition: transform 240ms var(--ease);
219
+ }
220
+ .ann-rail.open { transform: translateX(0); }
221
+ .ann-rail-head {
222
+ flex: none;
223
+ display: flex; align-items: center; gap: 8px;
224
+ padding: 16px 18px;
225
+ border-bottom: 1px solid var(--border);
226
+ }
227
+ .ann-rail-title { font: 500 1.05rem var(--serif); letter-spacing: -0.01em; }
228
+ .ann-rail-headcount {
229
+ color: var(--muted); font-size: 0.8rem;
230
+ background: var(--bg-sunken); border-radius: 999px; padding: 1px 8px;
231
+ }
232
+ .ann-rail-headcount:empty { display: none; }
233
+ .ann-rail-close {
234
+ margin-left: auto;
235
+ background: transparent; color: var(--muted);
236
+ border: none; border-radius: 8px;
237
+ width: 28px; height: 28px;
238
+ font-size: 1.3rem; line-height: 1; cursor: pointer;
239
+ transition: color 150ms var(--ease), background 150ms var(--ease);
240
+ }
241
+ .ann-rail-close:hover { color: var(--fg); background: var(--bg-sunken); }
242
+ .ann-rail-list { flex: 1; overflow-y: auto; padding: 12px 16px 28px; }
243
+
244
+ /* ---------- summary list (rows) ---------- */
171
245
  .ann-sum-head {
172
246
  font: 600 0.95rem var(--sans);
173
247
  margin: 0 0 2px;
@@ -184,6 +258,24 @@
184
258
  .ann-sum-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
185
259
  .ann-sum-replies { color: var(--muted); font-size: 0.75rem; }
186
260
 
261
+ /* Rail rows render as discrete cards rather than a hairline-divided list. */
262
+ .ann-rail-list .ann-sum-row {
263
+ padding: 10px 12px;
264
+ margin-bottom: 8px;
265
+ border: 1px solid var(--border);
266
+ border-radius: 10px;
267
+ background: var(--bg);
268
+ transition: border-color 150ms var(--ease), box-shadow 150ms var(--ease);
269
+ }
270
+ .ann-rail-list .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
271
+ .ann-rail-list .ann-sum-row:hover { border-color: var(--accent); box-shadow: var(--shadow-card); }
272
+
273
+ /* Wide screens: dock the rail beside the content instead of overlaying it. */
274
+ @media (min-width: 1180px) {
275
+ .ann-rail-scrim { display: none !important; }
276
+ body.ann-rail-open { padding-right: 340px; }
277
+ }
278
+
187
279
  /* ---------- jump-to flash ---------- */
188
280
  .ann-flash { animation: ann-flash 1s var(--ease); }
189
281
  @keyframes ann-flash {
@@ -90,13 +90,163 @@
90
90
  let badges = []; // live badge nodes (rebuilt on every refresh)
91
91
  let pendingSelection = null; // {info} captured at mouseup time
92
92
 
93
- let dom = null; // {pin, selBtn, pop}
93
+ // Text-selection comments are anchored back into the document as inline
94
+ // <mark> highlights. textRoots maps a blockId (null = intro/board) to the
95
+ // element whose text can be highlighted; highlightMap maps a target
96
+ // signature to its live <mark> nodes so we can flash or unwrap them.
97
+ const textRoots = new Map();
98
+ const highlightMap = new Map();
99
+
100
+ let dom = null; // {pin, selBtn, pop, rail, railList, railToggle, railScrim, ...}
94
101
  let pinTimer = null;
95
102
  let pinEntry = null;
96
103
  let popOpen = false;
97
104
  let popScrollY = 0;
98
105
  let popSave = null;
99
106
  let badgeTimer = 0;
107
+ let railOpen = false;
108
+
109
+ // Stable signature for a (blockId, target) pair — same key matching() groups by.
110
+ const sigOf = (blockId, target) => JSON.stringify({ b: blockId ?? null, t: target });
111
+
112
+ // Brief highlight pulse used by the rail's jump-to.
113
+ function flashEl(target) {
114
+ if (!target) return;
115
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
116
+ target.classList.remove('ann-flash');
117
+ void target.getBoundingClientRect(); // restart the animation
118
+ target.classList.add('ann-flash');
119
+ setTimeout(() => target.classList.remove('ann-flash'), 1000);
120
+ }
121
+
122
+ // ---------- text-selection highlights ----------
123
+ // Re-locate a saved text quote inside a root using its prefix/suffix anchors,
124
+ // returning a live Range (or null when the text can't be found anymore).
125
+ function findRangeByQuote(root, target) {
126
+ const quote = target && target.quote ? String(target.quote) : '';
127
+ if (!quote) return null;
128
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
129
+ const map = []; // [{ node, start }] start = offset in `full`
130
+ let full = '';
131
+ let node;
132
+ while ((node = walker.nextNode())) {
133
+ map.push({ node, start: full.length });
134
+ full += node.nodeValue;
135
+ }
136
+ if (!map.length) return null;
137
+ const prefix = target.prefix || '';
138
+ const suffix = target.suffix || '';
139
+ const hits = [];
140
+ let from = 0;
141
+ let idx;
142
+ while ((idx = full.indexOf(quote, from)) !== -1) {
143
+ hits.push(idx);
144
+ from = idx + 1;
145
+ }
146
+ if (!hits.length) return null;
147
+ let chosen = hits[0];
148
+ for (const h of hits) {
149
+ const before = full.slice(Math.max(0, h - prefix.length), h);
150
+ const after = full.slice(h + quote.length, h + quote.length + suffix.length);
151
+ if ((!prefix || before.endsWith(prefix)) && (!suffix || after.startsWith(suffix))) {
152
+ chosen = h;
153
+ break;
154
+ }
155
+ }
156
+ const locate = (off) => {
157
+ for (const m of map) {
158
+ if (off <= m.start + m.node.nodeValue.length) return { node: m.node, offset: off - m.start };
159
+ }
160
+ const last = map[map.length - 1];
161
+ return { node: last.node, offset: last.node.nodeValue.length };
162
+ };
163
+ const s = locate(chosen);
164
+ const e = locate(chosen + quote.length);
165
+ const range = document.createRange();
166
+ try {
167
+ range.setStart(s.node, s.offset);
168
+ range.setEnd(e.node, e.offset);
169
+ } catch {
170
+ return null;
171
+ }
172
+ return range;
173
+ }
174
+
175
+ // Wrap every text-node segment inside `range` in its own <mark>; clicking a
176
+ // mark opens the comment popover for that target. Returns the mark nodes.
177
+ function wrapRange(range, sig, info) {
178
+ const sC = range.startContainer;
179
+ const sO = range.startOffset;
180
+ const eC = range.endContainer;
181
+ const eO = range.endOffset;
182
+ const anc = range.commonAncestorContainer;
183
+ const root = anc.nodeType === 1 ? anc : anc.parentNode;
184
+ if (!root) return [];
185
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
186
+ const all = [];
187
+ let n;
188
+ while ((n = walker.nextNode())) all.push(n);
189
+ // Decide membership BEFORE mutating the DOM (splitText adds new siblings).
190
+ const inRange = all.filter((node) => {
191
+ try {
192
+ return range.intersectsNode(node);
193
+ } catch {
194
+ return false;
195
+ }
196
+ });
197
+ const marks = [];
198
+ for (const orig of inRange) {
199
+ let fromOff = 0;
200
+ let toOff = orig.nodeValue.length;
201
+ if (orig === sC) fromOff = sO;
202
+ if (orig === eC) toOff = eO;
203
+ if (fromOff >= toOff) continue;
204
+ let seg = orig;
205
+ if (fromOff > 0) seg = seg.splitText(fromOff);
206
+ if (toOff - fromOff < seg.nodeValue.length) seg.splitText(toOff - fromOff);
207
+ const mark = el('mark', { class: 'ann-hl', 'data-sig': sig });
208
+ seg.parentNode.insertBefore(mark, seg);
209
+ mark.appendChild(seg);
210
+ mark.addEventListener('click', (ev) => {
211
+ ev.stopPropagation();
212
+ openPopover(info, mark);
213
+ });
214
+ marks.push(mark);
215
+ }
216
+ return marks;
217
+ }
218
+
219
+ function unwrapHighlight(sig) {
220
+ const marks = highlightMap.get(sig);
221
+ if (!marks) return;
222
+ for (const m of marks) {
223
+ if (!m.isConnected) continue;
224
+ const parent = m.parentNode;
225
+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
226
+ parent.removeChild(m);
227
+ parent.normalize();
228
+ }
229
+ highlightMap.delete(sig);
230
+ }
231
+
232
+ // Highlight every saved text annotation whose blockId matches this root and
233
+ // that isn't already wrapped (idempotent — safe to call on every register).
234
+ function applyHighlightsForRoot(root, blockId) {
235
+ const key = blockId ?? null;
236
+ const seen = new Set();
237
+ for (const a of annotations) {
238
+ if (!a.target || a.target.kind !== 'text') continue;
239
+ if ((a.blockId ?? null) !== key) continue;
240
+ const sig = sigOf(a.blockId, a.target);
241
+ if (seen.has(sig) || highlightMap.has(sig)) continue;
242
+ seen.add(sig);
243
+ const range = findRangeByQuote(root, a.target);
244
+ if (!range) continue;
245
+ const info = { blockId: a.blockId ?? null, questionId: a.questionId ?? null, target: a.target };
246
+ const marks = wrapRange(range, sig, info);
247
+ if (marks.length) highlightMap.set(sig, marks);
248
+ }
249
+ }
100
250
 
101
251
  // ---------- singleton DOM ----------
102
252
  function ensureDom() {
@@ -135,8 +285,36 @@
135
285
  }
136
286
  });
137
287
 
138
- document.body.append(pin, selBtn, pop);
139
- dom = { pin, selBtn, pop };
288
+ // Comments rail (Outline-style right sidebar) + its floating toggle and
289
+ // narrow-screen scrim. The list is a summary target, so renderSummaries()
290
+ // keeps it in sync; chrome (count, visibility) is refreshed separately.
291
+ const railToggleCount = el('span', { class: 'ann-rail-toggle-count' }, '0');
292
+ const railToggle = el('button', { class: 'ann-rail-toggle', type: 'button', 'aria-label': 'Comments' });
293
+ railToggle.innerHTML =
294
+ '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">' +
295
+ '<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
296
+ railToggle.append(el('span', { class: 'ann-rail-toggle-label' }, 'Comments'), railToggleCount);
297
+ railToggle.addEventListener('click', toggleRail);
298
+
299
+ const railScrim = el('div', { class: 'ann-rail-scrim' });
300
+ railScrim.addEventListener('click', closeRail);
301
+
302
+ const railClose = el('button', { class: 'ann-rail-close', type: 'button', 'aria-label': 'Close comments', title: 'Close' }, '×');
303
+ railClose.addEventListener('click', closeRail);
304
+ const railHeadCount = el('span', { class: 'ann-rail-headcount' }, '');
305
+ const railList = el('div', { class: 'ann-rail-list' });
306
+ const rail = el('aside', { class: 'ann-rail', 'aria-label': 'Comments' },
307
+ el('div', { class: 'ann-rail-head' },
308
+ el('span', { class: 'ann-rail-title' }, 'Comments'),
309
+ railHeadCount,
310
+ railClose
311
+ ),
312
+ railList
313
+ );
314
+
315
+ document.body.append(pin, selBtn, pop, railToggle, railScrim, rail);
316
+ dom = { pin, selBtn, pop, railToggle, railToggleCount, railScrim, rail, railList, railHeadCount };
317
+ summaryEls.add(railList);
140
318
 
141
319
  document.addEventListener('selectionchange', () => {
142
320
  if (dom.selBtn.style.display === '' || dom.selBtn.style.display === 'none') return;
@@ -163,6 +341,36 @@
163
341
  window.addEventListener('resize', scheduleBadgeRefresh);
164
342
  }
165
343
 
344
+ // ---------- comments rail ----------
345
+ function openRail() {
346
+ if (!dom || torndown || !annotations.length) return;
347
+ railOpen = true;
348
+ dom.rail.classList.add('open');
349
+ dom.railScrim.classList.add('show');
350
+ document.body.classList.add('ann-rail-open');
351
+ }
352
+ function closeRail() {
353
+ railOpen = false;
354
+ if (!dom) return;
355
+ dom.rail.classList.remove('open');
356
+ dom.railScrim.classList.remove('show');
357
+ document.body.classList.remove('ann-rail-open');
358
+ }
359
+ function toggleRail() {
360
+ if (railOpen) closeRail();
361
+ else openRail();
362
+ }
363
+ // Sync the toggle badge + rail count with the live list; hide the whole rail
364
+ // affordance when there are no comments.
365
+ function refreshRailChrome() {
366
+ if (!dom) return;
367
+ const n = annotations.length;
368
+ dom.railToggleCount.textContent = String(n);
369
+ dom.railHeadCount.textContent = n ? `${n}` : '';
370
+ dom.railToggle.style.display = n ? 'inline-flex' : 'none';
371
+ if (!n) closeRail();
372
+ }
373
+
166
374
  // ---------- hover pin ----------
167
375
  function showPin(entry) {
168
376
  clearTimeout(pinTimer);
@@ -345,7 +553,21 @@
345
553
  author: 'user',
346
554
  replies: [],
347
555
  });
556
+ // Highlight the selected text in place (re-located via its quote anchors,
557
+ // since the live selection is already gone by save time).
558
+ if (info.target && info.target.kind === 'text') {
559
+ const sig = sigOf(info.blockId, info.target);
560
+ const root = textRoots.get(info.blockId ?? null);
561
+ if (root && !highlightMap.has(sig)) {
562
+ const range = findRangeByQuote(root, info.target);
563
+ if (range) {
564
+ const marks = wrapRange(range, sig, { blockId: info.blockId ?? null, questionId: info.questionId ?? null, target: info.target });
565
+ if (marks.length) highlightMap.set(sig, marks);
566
+ }
567
+ }
568
+ }
348
569
  changed();
570
+ openRail(); // surface the new comment in the sidebar, Outline-style
349
571
  }
350
572
 
351
573
  // Append a user reply to a top-level annotation's thread (cap 50, like the
@@ -366,13 +588,20 @@
366
588
  function removeAnnotation(id) {
367
589
  const i = annotations.findIndex((a) => a.id === id);
368
590
  if (i < 0) return;
591
+ const removed = annotations[i];
369
592
  annotations.splice(i, 1);
593
+ // Drop the inline highlight once the last comment on that text is gone.
594
+ if (removed.target && removed.target.kind === 'text') {
595
+ const sig = sigOf(removed.blockId, removed.target);
596
+ if (!annotations.some((a) => sigOf(a.blockId, a.target) === sig)) unwrapHighlight(sig);
597
+ }
370
598
  changed();
371
599
  }
372
600
 
373
601
  function changed() {
374
602
  scheduleBadgeRefresh();
375
603
  renderSummaries();
604
+ refreshRailChrome();
376
605
  if (onChange) onChange(annotations.slice());
377
606
  }
378
607
 
@@ -434,6 +663,7 @@
434
663
  }
435
664
 
436
665
  function renderSummaryInto(target) {
666
+ const isRail = target.classList.contains('ann-rail-list');
437
667
  target.classList.add('ann-summary');
438
668
  target.replaceChildren();
439
669
  if (!annotations.length) {
@@ -441,7 +671,8 @@
441
671
  return;
442
672
  }
443
673
  target.style.display = '';
444
- target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
674
+ // The rail has its own header chrome; standalone summaries get a heading.
675
+ if (!isRail) target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
445
676
  for (const a of annotations) {
446
677
  const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }, '×');
447
678
  del.addEventListener('click', (e) => {
@@ -463,17 +694,22 @@
463
694
  ),
464
695
  del
465
696
  );
466
- // Clicking a row jumps to (and flashes) the matching registered element.
697
+ // Clicking a row jumps to (and flashes) the matching element: a
698
+ // registered block element, or the inline highlight for a text comment.
467
699
  row.addEventListener('click', () => {
468
700
  const entry = registered.find(
469
701
  (r) => r.el.isConnected && sameBlock(r.info.blockId, a.blockId) && sameTarget(r.info.target, a.target)
470
702
  );
471
- if (!entry) return;
472
- entry.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
473
- entry.el.classList.remove('ann-flash');
474
- void entry.el.getBoundingClientRect(); // restart the animation
475
- entry.el.classList.add('ann-flash');
476
- setTimeout(() => entry.el.classList.remove('ann-flash'), 1000);
703
+ let jump = entry ? entry.el : null;
704
+ if (!jump && a.target && a.target.kind === 'text') {
705
+ const marks = highlightMap.get(sigOf(a.blockId, a.target));
706
+ jump = marks && marks.find((m) => m.isConnected);
707
+ }
708
+ if (!jump) return;
709
+ // On narrow screens the rail overlays the page — close it so the
710
+ // flashed target is actually visible.
711
+ if (isRail && window.matchMedia('(max-width: 1179px)').matches) closeRail();
712
+ flashEl(jump);
477
713
  });
478
714
  target.append(row);
479
715
  }
@@ -506,6 +742,7 @@
506
742
  }
507
743
  scheduleBadgeRefresh();
508
744
  renderSummaries();
745
+ refreshRailChrome();
509
746
  }
510
747
 
511
748
  // Remove every floating element (pin, badges, popover, selection button)
@@ -521,13 +758,20 @@
521
758
  }
522
759
  hidePin();
523
760
  hideSelBtn();
761
+ closeRail();
524
762
  for (const b of badges) b.remove();
525
763
  badges = [];
526
764
  registered = [];
765
+ textRoots.clear();
766
+ highlightMap.clear();
527
767
  if (dom) {
528
768
  dom.pin.remove();
529
769
  dom.selBtn.remove();
530
770
  dom.pop.remove();
771
+ dom.railToggle.remove();
772
+ dom.railScrim.remove();
773
+ dom.rail.remove();
774
+ summaryEls.delete(dom.railList);
531
775
  dom = null;
532
776
  }
533
777
  }
@@ -546,11 +790,15 @@
546
790
  function enableTextSelection(rootEl, baseInfo) {
547
791
  if (torndown) return;
548
792
  ensureDom();
793
+ const blockId = (baseInfo && baseInfo.blockId != null) ? baseInfo.blockId : null;
794
+ textRoots.set(blockId, rootEl);
549
795
  rootEl.addEventListener('mouseup', () => {
550
796
  if (torndown) return;
551
797
  // defer: the selection settles after mouseup
552
798
  setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
553
799
  });
800
+ // Re-anchor any saved text comments for this root (reload / prefill path).
801
+ applyHighlightsForRoot(rootEl, blockId);
554
802
  }
555
803
 
556
804
  function openExternal(info, anchorEl) {
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.
@@ -551,7 +567,7 @@
551
567
  else control.append(controlText(q, q.type === 'textarea'));
552
568
  card.append(control);
553
569
  if (q.note) {
554
- 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…' });
555
571
  noteInput.value = typeof state.notes[q.id] === 'string' ? state.notes[q.id] : '';
556
572
  noteInput.addEventListener('input', () => {
557
573
  state.notes[q.id] = noteInput.value;
@@ -578,12 +594,8 @@
578
594
  ));
579
595
  }
580
596
 
581
- // Annotations summary (editable list) sits directly above the submit bar.
582
- if (Annotate) {
583
- const summary = el('div', { class: 'ann-summary-wrap' });
584
- app.append(summary);
585
- Annotate.renderSummary(summary);
586
- }
597
+ // Element-level comments live in the Outline-style right rail (created and
598
+ // managed by RelayAnnotate); no inline summary list here anymore.
587
599
 
588
600
  const submitBtn = el('button', { class: 'submit', type: 'button' }, spec.submitLabel);
589
601
  saveEl = el('span', { class: 'savestate' }, '');
@@ -616,11 +628,19 @@
616
628
  // best effort
617
629
  }
618
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.';
619
639
  app.replaceChildren(
620
640
  el('div', { class: 'done' },
621
641
  el('div', { class: 'mark' }, '✓'),
622
642
  el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
623
- 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)
624
644
  )
625
645
  );
626
646
  }
@@ -638,8 +658,11 @@
638
658
  });
639
659
  if (!res.ok) throw new Error('submit rejected');
640
660
  submitted = true;
641
- showDone(spec.autoClose);
642
- 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) {
643
666
  setTimeout(() => {
644
667
  window.close();
645
668
  // window.close() is best-effort (browsers may block it for
@@ -651,11 +674,15 @@
651
674
  }, 700);
652
675
  }
653
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.
654
680
  submitBtn.disabled = false;
655
681
  submitBtn.textContent = spec.submitLabel;
656
- banner.textContent = 'Submit failed — the board server may have stopped.';
657
- banner.style.display = 'block';
658
- 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
+ );
659
686
  }
660
687
  });
661
688
 
@@ -751,6 +778,17 @@
751
778
  // the live draft — answers for now-removed question ids are ignored),
752
779
  // then reload to render the new spec. Guarded so it fires once.
753
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
+ }
754
792
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
755
793
  reloading = true;
756
794
  stopHeartbeat();
@@ -768,10 +806,16 @@
768
806
  location.reload();
769
807
  }
770
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.
771
813
  if (++misses >= 2 && !submitted) {
772
- banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
773
- banner.style.display = 'block';
774
- 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
+ );
775
819
  stopHeartbeat();
776
820
  }
777
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 {