@khanglvm/relay 0.9.0 → 0.10.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 +29 -0
- package/docs/AGENT.md +35 -3
- package/package.json +2 -2
- package/src/cli.js +41 -0
- package/src/open.js +12 -0
- package/src/server.js +193 -0
- package/src/spec.js +126 -8
- package/src/store.js +4 -0
- package/src/ui/app.js +295 -31
- package/src/ui/blocks.css +144 -2
- package/src/ui/blocks.js +407 -60
- package/src/ui/style.css +47 -1
package/src/ui/app.js
CHANGED
|
@@ -88,6 +88,68 @@
|
|
|
88
88
|
}
|
|
89
89
|
let themeBtn = null;
|
|
90
90
|
|
|
91
|
+
// ---------- localStorage draft mirror ----------
|
|
92
|
+
// Every autosave is ALSO written to localStorage, keyed by board id. This is
|
|
93
|
+
// the durability layer the server file alone can't provide: if the connection
|
|
94
|
+
// drops and the user keeps typing, the in-memory state is mirrored locally, so
|
|
95
|
+
// even a tab reload / browser restart / a freshly opened tab on the same board
|
|
96
|
+
// prefills the LATEST input instead of a blank board or a stale server save.
|
|
97
|
+
// Guards (per design): newest-of-(local,server) wins; the mirror is discarded
|
|
98
|
+
// if the board's spec rev changed (agent edited it); cleared on submit.
|
|
99
|
+
const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown');
|
|
100
|
+
function writeLocalDraft(p, updatedAt) {
|
|
101
|
+
try {
|
|
102
|
+
localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
|
|
103
|
+
v: 1,
|
|
104
|
+
boardId: boot.boardId,
|
|
105
|
+
rev: bootRev,
|
|
106
|
+
updatedAt: updatedAt || new Date().toISOString(),
|
|
107
|
+
payload: p,
|
|
108
|
+
}));
|
|
109
|
+
} catch {
|
|
110
|
+
// localStorage may be full or unavailable (privacy mode) — non-fatal; the
|
|
111
|
+
// server file remains the primary persistence path.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function clearLocalDraft() {
|
|
115
|
+
try { localStorage.removeItem(LOCAL_DRAFT_KEY); } catch { /* non-fatal */ }
|
|
116
|
+
}
|
|
117
|
+
// Returns the saved local mirror only if it's valid for THIS board+rev,
|
|
118
|
+
// otherwise null (and clears a now-stale entry). rev mismatch ⇒ the agent
|
|
119
|
+
// re-published the board, so old local answers may not map — discard them.
|
|
120
|
+
function loadLocalDraft() {
|
|
121
|
+
let raw;
|
|
122
|
+
try { raw = localStorage.getItem(LOCAL_DRAFT_KEY); } catch { return null; }
|
|
123
|
+
if (!raw) return null;
|
|
124
|
+
let obj;
|
|
125
|
+
try { obj = JSON.parse(raw); } catch { clearLocalDraft(); return null; }
|
|
126
|
+
if (!obj || obj.boardId !== boot.boardId || !obj.payload || typeof obj.payload !== 'object') {
|
|
127
|
+
clearLocalDraft();
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// rev-guard: a changed spec rev means the local answers may reference a
|
|
131
|
+
// different question set — don't resurrect them.
|
|
132
|
+
if (bootRev !== null && obj.rev !== undefined && obj.rev !== null && obj.rev !== bootRev) {
|
|
133
|
+
clearLocalDraft();
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return obj;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Choose the prefill source: the NEWER of the server draft (boot.prefill) and
|
|
140
|
+
// the local mirror. The server draft shape mirrors a payload() plus updatedAt.
|
|
141
|
+
function chooseInitialPrefill() {
|
|
142
|
+
const server = boot.prefill || null;
|
|
143
|
+
const local = loadLocalDraft();
|
|
144
|
+
if (!local) return server;
|
|
145
|
+
if (!server) return { ...local.payload, __from: 'local' };
|
|
146
|
+
const sT = Date.parse(server.updatedAt || '') || 0;
|
|
147
|
+
const lT = Date.parse(local.updatedAt || '') || 0;
|
|
148
|
+
// Newest wins; ties favor local (the tab that was last typing into).
|
|
149
|
+
return lT >= sT ? { ...local.payload, __from: 'local' } : server;
|
|
150
|
+
}
|
|
151
|
+
const initialPrefill = chooseInitialPrefill();
|
|
152
|
+
|
|
91
153
|
// ---------- state ----------
|
|
92
154
|
// state.answers holds raw control state; state.other holds the "Other"
|
|
93
155
|
// free-text per question; getValue() derives the final answer value.
|
|
@@ -97,11 +159,11 @@
|
|
|
97
159
|
other: {},
|
|
98
160
|
notes: {},
|
|
99
161
|
comment: '',
|
|
100
|
-
annotations: (
|
|
162
|
+
annotations: (initialPrefill && initialPrefill.annotations) || [],
|
|
101
163
|
// Editable-mermaid edits: blockId -> edited source. Seeded from the live
|
|
102
164
|
// draft so a reload/reopen restores the user's edited diagram. Mutated via
|
|
103
165
|
// the blocks ctx.onBlockEdit callback below; returned in payload().
|
|
104
|
-
blockEdits: (
|
|
166
|
+
blockEdits: (initialPrefill && initialPrefill.blockEdits) || {},
|
|
105
167
|
};
|
|
106
168
|
let submitted = false;
|
|
107
169
|
|
|
@@ -125,8 +187,11 @@
|
|
|
125
187
|
}
|
|
126
188
|
}
|
|
127
189
|
}
|
|
128
|
-
if (
|
|
190
|
+
if (initialPrefill) seedFromPrefill(initialPrefill);
|
|
129
191
|
else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
192
|
+
// If the local mirror was newer than the server (or the server had nothing),
|
|
193
|
+
// the in-memory state now holds input the server hasn't seen — flush it once
|
|
194
|
+
// the rest of the app is wired (see the post-init flush near the heartbeat).
|
|
130
195
|
|
|
131
196
|
function getValue(q) {
|
|
132
197
|
const v = state.answers[q.id];
|
|
@@ -176,27 +241,149 @@
|
|
|
176
241
|
};
|
|
177
242
|
}
|
|
178
243
|
|
|
244
|
+
// ---------- persistence-lost block ----------
|
|
245
|
+
// The dead-end this guards against: the client can lose its connection to the
|
|
246
|
+
// board's local HTTP server (server gone, port taken over, socket dropped,
|
|
247
|
+
// machine slept). Autosaves then fail silently and the user keeps typing
|
|
248
|
+
// answers/comments that are never persisted, then Submit fails too — all of it
|
|
249
|
+
// thrown away. When persistence is CONFIRMED lost we hard-block: disable every
|
|
250
|
+
// control and overlay an unmissable scrim with a Retry. Local `state` is never
|
|
251
|
+
// touched, so the moment the connection recovers we flush it and unblock —
|
|
252
|
+
// nothing the user typed during the outage is lost.
|
|
253
|
+
let persistenceLost = false;
|
|
254
|
+
let probing = false;
|
|
255
|
+
let lostOverlay = null;
|
|
256
|
+
let lostRetryBtn = null;
|
|
257
|
+
|
|
258
|
+
// A direct, side-effect-free reachability probe. Resolves true when the local
|
|
259
|
+
// server answers /api/status, false on any network/HTTP failure. Used to
|
|
260
|
+
// CONFIRM loss before blocking (so a single dropped request never blocks) and
|
|
261
|
+
// to detect recovery from the Retry button / heartbeat.
|
|
262
|
+
async function probeServer() {
|
|
263
|
+
try {
|
|
264
|
+
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
265
|
+
return r.ok;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildLostOverlay() {
|
|
272
|
+
if (lostOverlay) return lostOverlay;
|
|
273
|
+
lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Retry connection');
|
|
274
|
+
lostRetryBtn.addEventListener('click', retryConnection);
|
|
275
|
+
lostOverlay = el('div', { class: 'lost-overlay', role: 'alertdialog', 'aria-modal': 'true', 'aria-label': 'Connection lost' },
|
|
276
|
+
el('div', { class: 'lost-card' },
|
|
277
|
+
el('div', { class: 'lost-mark' }, '⚠'),
|
|
278
|
+
el('h2', {}, 'Connection lost — input isn’t being saved'),
|
|
279
|
+
el('p', {}, 'This board can no longer reach your agent’s session, so anything you type now won’t be saved. Editing is paused to keep you from losing work.'),
|
|
280
|
+
el('p', { class: 'lost-sub' }, 'Your input up to this point is kept in this tab. Click Retry once the agent’s session is back, or prompt the agent to reopen this board — your draft and unsaved edits will be restored.'),
|
|
281
|
+
lostRetryBtn
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
return lostOverlay;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Enter the blocked state: disable controls, mount the overlay. Idempotent.
|
|
288
|
+
function blockForLostPersistence() {
|
|
289
|
+
if (persistenceLost || submitted) return;
|
|
290
|
+
persistenceLost = true;
|
|
291
|
+
document.documentElement.classList.add('relay-blocked');
|
|
292
|
+
// Disable every interactive control inside the form (inputs the user could
|
|
293
|
+
// otherwise keep typing into) plus Submit.
|
|
294
|
+
for (const node of app.querySelectorAll('input, textarea, button, select')) {
|
|
295
|
+
node.disabled = true;
|
|
296
|
+
}
|
|
297
|
+
document.body.append(buildLostOverlay());
|
|
298
|
+
if (saveEl) saveEl.textContent = 'connection lost — not saving';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Leave the blocked state: re-enable controls, remove the overlay, and flush
|
|
302
|
+
// whatever the user typed during the outage so it's persisted right away.
|
|
303
|
+
function unblockAfterRecovery() {
|
|
304
|
+
if (!persistenceLost) return;
|
|
305
|
+
persistenceLost = false;
|
|
306
|
+
document.documentElement.classList.remove('relay-blocked');
|
|
307
|
+
for (const node of app.querySelectorAll('input, textarea, button, select')) {
|
|
308
|
+
node.disabled = false;
|
|
309
|
+
}
|
|
310
|
+
if (lostOverlay) lostOverlay.remove();
|
|
311
|
+
// Re-arm the heartbeat (it stops itself when it confirms loss) and persist
|
|
312
|
+
// everything typed during the outage. saveDraft() updates the save label.
|
|
313
|
+
startHeartbeat();
|
|
314
|
+
misses = 0;
|
|
315
|
+
saveFailures = 0;
|
|
316
|
+
saveDraft();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Retry button: probe once; recover on success, otherwise tell the user it's
|
|
320
|
+
// still down (without un-blocking).
|
|
321
|
+
async function retryConnection() {
|
|
322
|
+
if (probing) return;
|
|
323
|
+
probing = true;
|
|
324
|
+
if (lostRetryBtn) { lostRetryBtn.disabled = true; lostRetryBtn.textContent = 'Checking…'; }
|
|
325
|
+
const ok = await probeServer();
|
|
326
|
+
probing = false;
|
|
327
|
+
if (lostRetryBtn) { lostRetryBtn.disabled = false; lostRetryBtn.textContent = 'Retry connection'; }
|
|
328
|
+
if (ok) unblockAfterRecovery();
|
|
329
|
+
else if (lostRetryBtn) {
|
|
330
|
+
lostRetryBtn.textContent = 'Still unreachable — try again';
|
|
331
|
+
setTimeout(() => { if (lostRetryBtn && persistenceLost) lostRetryBtn.textContent = 'Retry connection'; }, 2500);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Called when a save/heartbeat fails. Confirms loss with a probe (so one
|
|
336
|
+
// dropped request never blocks) before hard-blocking. `force` skips the probe
|
|
337
|
+
// for callers (the heartbeat) that already represent repeated failures.
|
|
338
|
+
async function considerPersistenceLost(force) {
|
|
339
|
+
if (persistenceLost || submitted || probing) return;
|
|
340
|
+
if (!force) {
|
|
341
|
+
probing = true;
|
|
342
|
+
const ok = await probeServer();
|
|
343
|
+
probing = false;
|
|
344
|
+
if (ok || persistenceLost || submitted) return; // recovered or already handled
|
|
345
|
+
}
|
|
346
|
+
blockForLostPersistence();
|
|
347
|
+
}
|
|
348
|
+
|
|
179
349
|
// ---------- real-time autosave ----------
|
|
180
350
|
let saveTimer = null;
|
|
181
351
|
let saveSeq = 0;
|
|
182
352
|
let saveEl = null;
|
|
353
|
+
// Consecutive failed /api/draft saves. Two in a row triggers a confirming
|
|
354
|
+
// probe → block. Any success resets it.
|
|
355
|
+
let saveFailures = 0;
|
|
183
356
|
function scheduleSave() {
|
|
184
357
|
if (submitted) return;
|
|
358
|
+
// Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
|
|
359
|
+
// of) the network save. This is what survives a tab reload / crash / a new
|
|
360
|
+
// tab during a connection outage — it must happen even while blocked.
|
|
361
|
+
writeLocalDraft(payload());
|
|
362
|
+
if (persistenceLost) return; // network save is futile while disconnected
|
|
185
363
|
if (saveEl) saveEl.textContent = 'saving…';
|
|
186
364
|
clearTimeout(saveTimer);
|
|
187
365
|
saveTimer = setTimeout(saveDraft, 450);
|
|
188
366
|
}
|
|
189
367
|
async function saveDraft() {
|
|
190
368
|
const seq = ++saveSeq;
|
|
369
|
+
// Keep the local mirror current on every flush too (covers programmatic
|
|
370
|
+
// saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
|
|
371
|
+
writeLocalDraft(payload());
|
|
191
372
|
try {
|
|
192
|
-
await fetch('/api/draft', {
|
|
373
|
+
const r = await fetch('/api/draft', {
|
|
193
374
|
method: 'POST',
|
|
194
375
|
headers: { 'content-type': 'application/json' },
|
|
195
376
|
body: JSON.stringify(payload()),
|
|
196
377
|
});
|
|
197
|
-
if (
|
|
378
|
+
if (!r.ok) throw new Error('draft rejected');
|
|
379
|
+
saveFailures = 0;
|
|
380
|
+
if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
|
|
198
381
|
} catch {
|
|
199
|
-
if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft save failed';
|
|
382
|
+
if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft save failed';
|
|
383
|
+
// A save couldn't be persisted to the SERVER — the core data-loss signal.
|
|
384
|
+
// (The local mirror above still captured it.) After two in a row, confirm
|
|
385
|
+
// with a probe and block so the user stops typing into the void.
|
|
386
|
+
if (++saveFailures >= 2) considerPersistenceLost(false);
|
|
200
387
|
}
|
|
201
388
|
}
|
|
202
389
|
|
|
@@ -543,7 +730,13 @@
|
|
|
543
730
|
Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
|
|
544
731
|
}
|
|
545
732
|
if (spec.intro) {
|
|
546
|
-
|
|
733
|
+
// Render the intro as markdown (bold/italic/code/links/lists) — agents write
|
|
734
|
+
// markdown here by default. Falls back to plain text if blocks.js is absent.
|
|
735
|
+
// The .blk-markdown class scopes the shared markdown typography to it.
|
|
736
|
+
const md = typeof window.RelayBlocks !== 'undefined' && window.RelayBlocks.renderMarkdown;
|
|
737
|
+
const intro = md
|
|
738
|
+
? el('div', { class: 'intro blk-markdown' }, window.RelayBlocks.renderMarkdown(spec.intro))
|
|
739
|
+
: el('p', { class: 'intro' }, spec.intro);
|
|
547
740
|
app.append(intro);
|
|
548
741
|
Annotate?.enableTextSelection(intro, { blockId: null, questionId: null });
|
|
549
742
|
}
|
|
@@ -650,14 +843,26 @@
|
|
|
650
843
|
submitBtn.disabled = true;
|
|
651
844
|
submitBtn.textContent = 'Submitting…';
|
|
652
845
|
clearTimeout(saveTimer);
|
|
846
|
+
let reached = true;
|
|
653
847
|
try {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
848
|
+
let res;
|
|
849
|
+
try {
|
|
850
|
+
res = await fetch('/api/submit', {
|
|
851
|
+
method: 'POST',
|
|
852
|
+
headers: { 'content-type': 'application/json' },
|
|
853
|
+
body: JSON.stringify(payload()),
|
|
854
|
+
});
|
|
855
|
+
} catch (netErr) {
|
|
856
|
+
// A thrown fetch (vs. an HTTP error response) means we never reached the
|
|
857
|
+
// server — the connection is gone, so this submit was never delivered.
|
|
858
|
+
reached = false;
|
|
859
|
+
throw netErr;
|
|
860
|
+
}
|
|
659
861
|
if (!res.ok) throw new Error('submit rejected');
|
|
660
862
|
submitted = true;
|
|
863
|
+
// Submitted successfully → the local mirror is no longer needed and would
|
|
864
|
+
// otherwise resurrect stale answers on a future reopen. Clear it.
|
|
865
|
+
clearLocalDraft();
|
|
661
866
|
// Don't auto-close when the agent had stopped waiting — the user needs to
|
|
662
867
|
// read the "send your agent a message" note and act on it.
|
|
663
868
|
const autoClose = spec.autoClose && !handedBack;
|
|
@@ -674,22 +879,33 @@
|
|
|
674
879
|
}, 700);
|
|
675
880
|
}
|
|
676
881
|
} catch {
|
|
677
|
-
//
|
|
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.
|
|
882
|
+
// Restore the button so the user can retry.
|
|
680
883
|
submitBtn.disabled = false;
|
|
681
884
|
submitBtn.textContent = spec.submitLabel;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
885
|
+
if (!reached) {
|
|
886
|
+
// The connection is gone — the submit (and any further input) can't be
|
|
887
|
+
// persisted. Block hard so the user stops adding feedback that would be
|
|
888
|
+
// lost; the block's probe loop / heartbeat lifts it on recovery, and the
|
|
889
|
+
// user can then submit. force=true: the failed submit already confirms
|
|
890
|
+
// the server is unreachable.
|
|
891
|
+
stopHeartbeat();
|
|
892
|
+
considerPersistenceLost(true);
|
|
893
|
+
startBlockedProbeLoop();
|
|
894
|
+
} else {
|
|
895
|
+
// The server answered with an error (e.g. 409 board already finished) —
|
|
896
|
+
// it's reachable, so don't show the scary block; just guide the user.
|
|
897
|
+
showNotice(
|
|
898
|
+
'Couldn’t submit — the board may have already closed. Prompt the agent to reopen this board so your input isn’t lost.',
|
|
899
|
+
'warn'
|
|
900
|
+
);
|
|
901
|
+
}
|
|
686
902
|
}
|
|
687
903
|
});
|
|
688
904
|
|
|
689
905
|
// ---------- prefilled load: jump past what's already answered ----------
|
|
690
906
|
// On reload/reopen with saved answers, scroll to the first unanswered
|
|
691
907
|
// question so the user doesn't re-scan questions they already did.
|
|
692
|
-
if (
|
|
908
|
+
if (initialPrefill && QS.length) {
|
|
693
909
|
const answered = QS.filter((q) => getValue(q) !== undefined).length;
|
|
694
910
|
const firstOpen = QS.find((q) => getValue(q) === undefined);
|
|
695
911
|
if (answered > 0 && firstOpen) {
|
|
@@ -699,6 +915,14 @@
|
|
|
699
915
|
}
|
|
700
916
|
}
|
|
701
917
|
|
|
918
|
+
// If the chosen prefill came from the local mirror (newer than the server, or
|
|
919
|
+
// the server had nothing — e.g. a freshly reopened board the user had typed
|
|
920
|
+
// into in another tab during an outage), the server doesn't yet have this
|
|
921
|
+
// input. Flush it once so a brand-new tab's view is also the server's truth.
|
|
922
|
+
if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
|
|
923
|
+
saveDraft();
|
|
924
|
+
}
|
|
925
|
+
|
|
702
926
|
// ---------- iframe annotate bridge ----------
|
|
703
927
|
// Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
|
|
704
928
|
// server) talk to the parent over postMessage:
|
|
@@ -766,13 +990,20 @@
|
|
|
766
990
|
// ---------- heartbeat ----------
|
|
767
991
|
let misses = 0;
|
|
768
992
|
let reloading = false;
|
|
769
|
-
let hb =
|
|
993
|
+
let hb = null;
|
|
994
|
+
async function heartbeatTick() {
|
|
770
995
|
// Piggyback presence on the heartbeat (best-effort; no-ops after submit).
|
|
771
996
|
pingPresence();
|
|
772
997
|
try {
|
|
773
998
|
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
774
999
|
if (!r.ok) throw new Error('bad status');
|
|
775
1000
|
misses = 0;
|
|
1001
|
+
// The heartbeat reaching the server is itself proof persistence is back —
|
|
1002
|
+
// if we were blocked (saves had been failing), recover now and flush.
|
|
1003
|
+
if (persistenceLost) {
|
|
1004
|
+
unblockAfterRecovery();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
776
1007
|
// Live update: the agent ran `rly update`, advancing the server rev.
|
|
777
1008
|
// Flush whatever the user has typed so far (the reload re-prefills from
|
|
778
1009
|
// the live draft — answers for now-removed question ids are ignored),
|
|
@@ -806,22 +1037,55 @@
|
|
|
806
1037
|
location.reload();
|
|
807
1038
|
}
|
|
808
1039
|
} catch {
|
|
809
|
-
// Lost the live connection (the session ended, or the machine slept).
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
1040
|
+
// Lost the live connection (the session ended, or the machine slept). Two
|
|
1041
|
+
// consecutive misses means the local server is unreachable — so input can
|
|
1042
|
+
// no longer be persisted. Hard-block: disable editing and overlay the
|
|
1043
|
+
// unmissable "connection lost" scrim, so the user can't keep typing
|
|
1044
|
+
// feedback that would be silently discarded. The block re-arms its own
|
|
1045
|
+
// probe loop and the heartbeat recovers it once the server answers again.
|
|
1046
|
+
if (++misses >= 2 && !submitted && !persistenceLost) {
|
|
814
1047
|
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
|
-
);
|
|
819
1048
|
stopHeartbeat();
|
|
1049
|
+
// force=true: two heartbeat misses already confirm the server is gone,
|
|
1050
|
+
// so block immediately without a redundant probe.
|
|
1051
|
+
considerPersistenceLost(true);
|
|
1052
|
+
// While blocked, keep probing so an automatic recovery (server back,
|
|
1053
|
+
// machine woke) lifts the block even if the user never clicks Retry.
|
|
1054
|
+
startBlockedProbeLoop();
|
|
820
1055
|
}
|
|
821
1056
|
}
|
|
822
|
-
}
|
|
1057
|
+
}
|
|
1058
|
+
function startHeartbeat() {
|
|
1059
|
+
if (hb || submitted) return;
|
|
1060
|
+
hb = setInterval(heartbeatTick, 3000);
|
|
1061
|
+
}
|
|
823
1062
|
function stopHeartbeat() {
|
|
824
1063
|
if (hb) clearInterval(hb);
|
|
825
1064
|
hb = null;
|
|
826
1065
|
}
|
|
1066
|
+
|
|
1067
|
+
// While blocked, the heartbeat is stopped — so run a lightweight probe loop
|
|
1068
|
+
// that lifts the block automatically the moment the server is reachable again
|
|
1069
|
+
// (no Retry click needed). Stops itself on recovery or after submit.
|
|
1070
|
+
let blockedProbe = null;
|
|
1071
|
+
function startBlockedProbeLoop() {
|
|
1072
|
+
if (blockedProbe) return;
|
|
1073
|
+
blockedProbe = setInterval(async () => {
|
|
1074
|
+
if (!persistenceLost || submitted) {
|
|
1075
|
+
clearInterval(blockedProbe);
|
|
1076
|
+
blockedProbe = null;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (probing) return;
|
|
1080
|
+
probing = true;
|
|
1081
|
+
const ok = await probeServer();
|
|
1082
|
+
probing = false;
|
|
1083
|
+
if (ok) {
|
|
1084
|
+
clearInterval(blockedProbe);
|
|
1085
|
+
blockedProbe = null;
|
|
1086
|
+
unblockAfterRecovery();
|
|
1087
|
+
}
|
|
1088
|
+
}, 3000);
|
|
1089
|
+
}
|
|
1090
|
+
startHeartbeat();
|
|
827
1091
|
})();
|
package/src/ui/blocks.css
CHANGED
|
@@ -79,6 +79,42 @@
|
|
|
79
79
|
.blk-markdown .md .md-tablewrap { overflow-x: auto; margin: 12px 0; }
|
|
80
80
|
.blk-markdown .md .md-table { margin: 0; }
|
|
81
81
|
|
|
82
|
+
/* Click-to-open local file links. Rendered wherever markdown runs (intro, md
|
|
83
|
+
blocks, table cells). A small file glyph + accent text signals "openable",
|
|
84
|
+
distinct from a plain web link. The whole chip is the click target. */
|
|
85
|
+
.rly-filelink {
|
|
86
|
+
display: inline-flex; align-items: baseline; gap: 0.3em;
|
|
87
|
+
color: var(--accent); cursor: pointer; text-decoration: none;
|
|
88
|
+
border-bottom: 1px solid transparent;
|
|
89
|
+
border-radius: 4px;
|
|
90
|
+
transition: border-color 150ms var(--ease), background 150ms var(--ease);
|
|
91
|
+
}
|
|
92
|
+
.rly-filelink:hover { border-bottom-color: var(--accent); }
|
|
93
|
+
.rly-filelink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
94
|
+
.rly-filelink .rly-filelink-ico {
|
|
95
|
+
flex: none; align-self: center;
|
|
96
|
+
width: 0.92em; height: 0.92em;
|
|
97
|
+
background: currentColor;
|
|
98
|
+
/* a document glyph drawn via mask, so it inherits the accent color */
|
|
99
|
+
-webkit-mask: var(--rly-file-mask) center / contain no-repeat;
|
|
100
|
+
mask: var(--rly-file-mask) center / contain no-repeat;
|
|
101
|
+
opacity: 0.85;
|
|
102
|
+
}
|
|
103
|
+
:root {
|
|
104
|
+
--rly-file-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 1.5V8h4.5L14 3.5z'/%3E%3C/svg%3E");
|
|
105
|
+
}
|
|
106
|
+
.rly-filelink.rly-filelink-code .rly-filelink-txt {
|
|
107
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
108
|
+
font-size: 0.86em;
|
|
109
|
+
background: var(--bg-sunken);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
border-radius: 5px;
|
|
112
|
+
padding: 1px 5px;
|
|
113
|
+
}
|
|
114
|
+
.rly-filelink.is-opening { opacity: 0.55; pointer-events: none; }
|
|
115
|
+
/* Error-tone toast for a failed open (success reuses the plain .toast). */
|
|
116
|
+
.toast.toast-err { background: var(--danger); color: var(--danger-fg); }
|
|
117
|
+
|
|
82
118
|
/* ---------- code block ---------- */
|
|
83
119
|
.blk-pre {
|
|
84
120
|
background: var(--bg-sunken);
|
|
@@ -87,24 +123,130 @@
|
|
|
87
123
|
padding: 14px 16px;
|
|
88
124
|
overflow-x: auto;
|
|
89
125
|
margin: 0;
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
/* Pin font metrics on the <pre> itself: its line-box strut uses the pre's
|
|
127
|
+
own font-size, so leaving it inherited (≈16px) makes lines taller than the
|
|
128
|
+
0.85rem gutter and the numbers drift. Match them exactly. */
|
|
92
129
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
93
130
|
font-size: 0.85rem;
|
|
94
131
|
line-height: 1.55;
|
|
132
|
+
}
|
|
133
|
+
.blk-pre code {
|
|
134
|
+
font-family: inherit;
|
|
135
|
+
font-size: inherit;
|
|
136
|
+
line-height: inherit;
|
|
95
137
|
color: var(--fg);
|
|
96
138
|
white-space: pre;
|
|
97
139
|
}
|
|
98
140
|
/* subtle tinter colors via theme vars only */
|
|
99
141
|
.blk-pre .tok-kw,
|
|
142
|
+
.blk-difftable .tok-kw,
|
|
100
143
|
.blk-markdown .md .tok-kw { color: var(--accent); }
|
|
101
144
|
.blk-pre .tok-str,
|
|
145
|
+
.blk-difftable .tok-str,
|
|
102
146
|
.blk-markdown .md .tok-str { color: var(--ok); }
|
|
103
147
|
.blk-pre .tok-com,
|
|
148
|
+
.blk-difftable .tok-com,
|
|
104
149
|
.blk-markdown .md .tok-com { color: var(--muted); font-style: italic; }
|
|
105
150
|
.blk-pre .tok-num,
|
|
151
|
+
.blk-difftable .tok-num,
|
|
106
152
|
.blk-markdown .md .tok-num { color: var(--fg-2); }
|
|
107
153
|
|
|
154
|
+
/* code block: file-name / language header above the card */
|
|
155
|
+
.blk-codehead {
|
|
156
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
157
|
+
padding: 6px 12px; margin-bottom: -1px;
|
|
158
|
+
background: var(--bg-sunken);
|
|
159
|
+
border: 1px solid var(--border); border-bottom: 0;
|
|
160
|
+
border-radius: 10px 10px 0 0;
|
|
161
|
+
font-size: 0.74rem; color: var(--muted);
|
|
162
|
+
}
|
|
163
|
+
.blk-codehead .blk-codename {
|
|
164
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
165
|
+
color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
166
|
+
}
|
|
167
|
+
.blk-codehead .blk-codelang { text-transform: uppercase; letter-spacing: 0.05em; flex: none; }
|
|
168
|
+
.blk-codehead:empty,
|
|
169
|
+
.blk-codehead .blk-codename:empty,
|
|
170
|
+
.blk-codehead .blk-codelang:empty { display: none; }
|
|
171
|
+
.blk-codehead:has(.blk-codename:empty) { justify-content: flex-end; }
|
|
172
|
+
|
|
173
|
+
/* code block: line-number gutter + code column share one bordered card */
|
|
174
|
+
.blk-coderow {
|
|
175
|
+
display: flex; align-items: stretch;
|
|
176
|
+
background: var(--bg-sunken);
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
border-radius: 10px;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
}
|
|
181
|
+
.blk-codehead + .blk-coderow { border-radius: 0 0 10px 10px; }
|
|
182
|
+
.blk-gutter {
|
|
183
|
+
flex: none; user-select: none; -webkit-user-select: none;
|
|
184
|
+
padding: 14px 10px 14px 14px; text-align: right;
|
|
185
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
186
|
+
font-size: 0.85rem; line-height: 1.55;
|
|
187
|
+
color: var(--muted); white-space: pre;
|
|
188
|
+
border-right: 1px solid var(--border);
|
|
189
|
+
}
|
|
190
|
+
.blk-coderow .blk-pre {
|
|
191
|
+
flex: 1 1 auto; border: 0; border-radius: 0; background: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ---------- diff (unified / git diff) ---------- */
|
|
195
|
+
.blk-diffscroll { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); }
|
|
196
|
+
.blk-codehead + .blk-diffscroll { border-radius: 0 0 10px 10px; }
|
|
197
|
+
.blk-difftable {
|
|
198
|
+
border-collapse: collapse; width: 100%;
|
|
199
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
200
|
+
font-size: 0.84rem; line-height: 1.5;
|
|
201
|
+
}
|
|
202
|
+
.blk-difftable td { padding: 0 8px; vertical-align: top; }
|
|
203
|
+
.blk-difftable .diff-no {
|
|
204
|
+
width: 1%; white-space: nowrap; text-align: right;
|
|
205
|
+
color: var(--muted); user-select: none; -webkit-user-select: none;
|
|
206
|
+
padding: 0 6px; border-right: 1px solid var(--border);
|
|
207
|
+
}
|
|
208
|
+
.blk-difftable .diff-sign {
|
|
209
|
+
width: 1ch; text-align: center; color: var(--muted);
|
|
210
|
+
user-select: none; -webkit-user-select: none; padding: 0 4px;
|
|
211
|
+
}
|
|
212
|
+
.blk-difftable .diff-code { width: 100%; white-space: pre; }
|
|
213
|
+
.blk-difftable .diff-code code { font: inherit; color: var(--fg); background: none; }
|
|
214
|
+
.blk-difftable .diff-add { background: rgba(77, 138, 102, 0.16); }
|
|
215
|
+
.blk-difftable .diff-add .diff-sign { color: var(--ok); }
|
|
216
|
+
.blk-difftable .diff-del { background: rgba(188, 68, 52, 0.15); }
|
|
217
|
+
.blk-difftable .diff-del .diff-sign { color: var(--danger); }
|
|
218
|
+
.blk-difftable .diff-hunk td { color: var(--accent); background: var(--accent-soft); padding-top: 2px; padding-bottom: 2px; }
|
|
219
|
+
.blk-difftable .diff-meta td { color: var(--muted); font-style: italic; }
|
|
220
|
+
.blk-difftable .diff-hunk .diff-code code,
|
|
221
|
+
.blk-difftable .diff-meta .diff-code code { color: inherit; }
|
|
222
|
+
|
|
223
|
+
/* diff: side-by-side (split) view — old | new. Long lines grow the table and
|
|
224
|
+
the wrapper scrolls horizontally; short code splits ~50/50. */
|
|
225
|
+
.blk-difftable-split .diff-code { width: 50%; }
|
|
226
|
+
.blk-difftable-split .diff-newside { border-left: 2px solid var(--border-strong); }
|
|
227
|
+
.blk-difftable .diff-fill {
|
|
228
|
+
background-color: var(--bg-sunken);
|
|
229
|
+
background-image: repeating-linear-gradient(45deg, transparent 0, transparent 6px, rgba(128, 128, 128, 0.07) 6px, rgba(128, 128, 128, 0.07) 12px);
|
|
230
|
+
}
|
|
231
|
+
/* Unified/Split view toggle in the diff header */
|
|
232
|
+
.blk-difftoggle {
|
|
233
|
+
flex: none; cursor: pointer; font: inherit; font-size: 0.72rem;
|
|
234
|
+
color: var(--fg-2); background: var(--card);
|
|
235
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
|
|
236
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
237
|
+
}
|
|
238
|
+
.blk-difftoggle:hover { color: var(--accent); border-color: var(--accent); }
|
|
239
|
+
|
|
240
|
+
/* ---------- video (iframe embed / local stream / direct URL) ---------- */
|
|
241
|
+
.blk-videowrap { margin: 0; }
|
|
242
|
+
.blk-video, .blk-video-embed {
|
|
243
|
+
display: block; width: 100%; border: 0;
|
|
244
|
+
border-radius: 10px; background: #000;
|
|
245
|
+
}
|
|
246
|
+
.blk-video { max-height: 70vh; }
|
|
247
|
+
.blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
|
|
248
|
+
.blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
|
|
249
|
+
|
|
108
250
|
/* ---------- table ---------- */
|
|
109
251
|
.blk-table {
|
|
110
252
|
width: 100%;
|