@khanglvm/relay 0.3.0 → 0.4.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 +11 -0
- package/docs/AGENT.md +47 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +22 -1
- package/skills/relay/examples/diagram-coedit.json +15 -0
- package/src/cli.js +172 -13
- package/src/server.js +98 -0
- package/src/spec.js +9 -1
- package/src/ui/annotate.js +30 -1
- package/src/ui/app.js +73 -2
- package/src/ui/blocks.css +63 -0
- package/src/ui/blocks.js +145 -19
package/README.md
CHANGED
|
@@ -356,6 +356,17 @@ Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
|
|
|
356
356
|
|
|
357
357
|
Storage: `~/.relay` (override with `RLY_HOME`). Boards bind to `127.0.0.1` only.
|
|
358
358
|
|
|
359
|
+
## Presence, push-wake & diagram co-editing (v0.4)
|
|
360
|
+
|
|
361
|
+
While a board is open the page reports user activity; `rly result <id>`
|
|
362
|
+
includes `presence` ({visible, focused, secondsSinceActivity}) and
|
|
363
|
+
`rly wait <id> --while-active --idle-grace 180` keeps waiting while the user
|
|
364
|
+
is demonstrably engaged instead of dying on a fixed timer. For pushes,
|
|
365
|
+
`--on-result '<cmd>'` (on ask/show/reopen/reuse) and `rly wait --notify-cmd`
|
|
366
|
+
execute your command with the result JSON on stdin the moment the board
|
|
367
|
+
finishes. Mermaid blocks with `"editable": true` let the user edit the diagram
|
|
368
|
+
source with live preview — their version returns as `result.blockEdits`.
|
|
369
|
+
|
|
359
370
|
## Agent skill (Claude Code, Codex, …)
|
|
360
371
|
|
|
361
372
|
A universal [SKILL.md](skills/relay/SKILL.md) is bundled:
|
package/docs/AGENT.md
CHANGED
|
@@ -321,6 +321,53 @@ annotation IDs cause a `CliError` (exit 4) listing valid IDs.
|
|
|
321
321
|
The UI shows agent and user replies as a thread under each comment — agent replies
|
|
322
322
|
use an accent chip, user replies use a muted chip.
|
|
323
323
|
|
|
324
|
+
## Presence — is the user still there?
|
|
325
|
+
|
|
326
|
+
While a board is open, the page reports activity (visibility, focus, idle
|
|
327
|
+
time). Use it instead of guessing timeouts:
|
|
328
|
+
|
|
329
|
+
```sh
|
|
330
|
+
rly result b-xxxxx # open board → includes "presence":
|
|
331
|
+
# {open, seen, visible, focused, secondsSinceActivity, secondsSincePing}
|
|
332
|
+
rly wait b-xxxxx --timeout 550 --while-active --idle-grace 180
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`--while-active` keeps extending the wait as long as the user is demonstrably
|
|
336
|
+
active (page visible/focused and interaction within `--idle-grace` seconds,
|
|
337
|
+
default 180); once they go idle it returns the normal `wait-timeout` JSON,
|
|
338
|
+
with `presence` attached so you can decide what to do next. Prefer this over
|
|
339
|
+
raising `--timeout`.
|
|
340
|
+
|
|
341
|
+
## Push-wake — get notified instead of polling
|
|
342
|
+
|
|
343
|
+
```sh
|
|
344
|
+
rly ask --file spec.json --detach --on-result 'curl -s -X POST localhost:9999/wake -d @-'
|
|
345
|
+
rly wait b-xxxxx --notify-cmd 'touch /tmp/board-done'
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`--on-result` (on ask/show/reopen/reuse) runs your shell command the moment
|
|
349
|
+
the board reaches a terminal status — submitted, acknowledged, timeout, or
|
|
350
|
+
cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
|
|
351
|
+
`RLY_STATUS`, `RLY_URL` in the environment. `--notify-cmd` does the same from
|
|
352
|
+
a `wait` that obtains a terminal result. Write a file your harness watches,
|
|
353
|
+
hit a webhook — whatever wakes you.
|
|
354
|
+
|
|
355
|
+
## Editable diagrams — let the user redraw your mermaid
|
|
356
|
+
|
|
357
|
+
Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
|
|
358
|
+
button with live-preview source editing (syntax errors shown inline without
|
|
359
|
+
destroying the last good render; Reset restores your original). Their edited
|
|
360
|
+
source returns in the result:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
"blockEdits": { "b2": "graph TD; A-->B; B-->C[their new step]" }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Diff it against your original to see exactly what the user changed. Recipe:
|
|
367
|
+
propose an architecture as an editable mermaid block + a `yesno` "Does this
|
|
368
|
+
match your mental model?" + a `textarea` for anything the diagram can't say.
|
|
369
|
+
Edits autosave with the draft, so they survive reloads and timeouts too.
|
|
370
|
+
|
|
324
371
|
## Managing boards
|
|
325
372
|
|
|
326
373
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: relay
|
|
3
|
-
description:
|
|
3
|
+
description: Show the user anything visual in an interactive browser board - repo/file structures, architecture diagrams (mermaid, graphviz, plantuml, uml), charts, sortable data tables, code, prototypes - and/or ask structured questions (single/multi choice, yes-no, free text, scale), then wait for Submit and read JSON answers plus element-level comments and user-edited diagrams. PROACTIVELY use whenever you would otherwise (a) draw an ASCII tree/table/diagram in the terminal or describe a structure/design/plan in prose - render it as interactive blocks instead, (b) call a native ask-user/question tool with 2+ questions or options that need explanation, or (c) hand-roll an HTML file or local server to demo an idea - relay replaces all three. Triggers - show me the structure, repo/project/folder structure, file tree, directory layout, codebase map, architecture overview, dependency graph, sequence/class diagram, uml, visualize, diagram, chart, data table, metrics review, mockup or prototype review, plan approval, design/UX feedback, compare alternatives, let the user edit the diagram, clarify requirements before ambiguous work, survey, "ask the user", "show the user", "which do you prefer", "get feedback". Skip only for a single trivial yes/no confirmation.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# relay (`rly`)
|
|
@@ -45,6 +45,19 @@ rly wait b-xxxxx --timeout 550 # blocks until submit, prints result JSON
|
|
|
45
45
|
rly result b-xxxxx # non-blocking peek (includes live draft)
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
For long waits prefer presence-aware waiting over a huge --timeout:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
rly wait b-xxxxx --timeout 550 --while-active --idle-grace 180
|
|
52
|
+
# keeps extending while the user is demonstrably viewing/typing on the board;
|
|
53
|
+
# returns wait-timeout promptly once they are idle/gone (presence included)
|
|
54
|
+
rly result b-xxxxx # while open also shows presence {visible, focused, secondsSinceActivity}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
|
|
58
|
+
(or --notify-cmd on wait) - the command runs the moment the board finishes,
|
|
59
|
+
with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
|
|
60
|
+
|
|
48
61
|
Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
|
|
49
62
|
ONLY when your shell tool has no execution time limit.
|
|
50
63
|
|
|
@@ -168,6 +181,14 @@ numbers, followed by a `table` block for the raw data; at least one question
|
|
|
168
181
|
asking what to act on. In the intro, tell the user they can click chart points
|
|
169
182
|
and table cells to comment on specific values.
|
|
170
183
|
|
|
184
|
+
## Diagram co-editing (user edits your diagram)
|
|
185
|
+
|
|
186
|
+
Add "editable": true to a mermaid block: the user gets an Edit button with
|
|
187
|
+
live-preview source editing. Their version comes back as
|
|
188
|
+
result.blockEdits["<blockId>"] - diff it against your original to see what
|
|
189
|
+
they changed. Recipe: propose an architecture as an editable mermaid block +
|
|
190
|
+
one yesno "Does this match your mental model?" + a textarea for notes.
|
|
191
|
+
|
|
171
192
|
## Reuse & management
|
|
172
193
|
|
|
173
194
|
`rly history` (saved boards) · `rly spec <id>` (print spec to modify) ·
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Architecture review — redraw it if I got it wrong",
|
|
3
|
+
"intro": "Below is my proposed architecture. If it doesn't match your mental model, click 'Edit diagram' and change it directly — I'll read your version.",
|
|
4
|
+
"blocks": [
|
|
5
|
+
{
|
|
6
|
+
"type": "mermaid",
|
|
7
|
+
"editable": true,
|
|
8
|
+
"code": "graph TD; client[Web Client] --> api[API Gateway]; api --> auth[Auth Service]; api --> core[Core Service]; core --> db[(Postgres)]; core --> queue[[Job Queue]]"
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"questions": [
|
|
12
|
+
{ "id": "match", "type": "yesno", "label": "Does this match your mental model (after your edits, if any)?", "required": true },
|
|
13
|
+
{ "id": "notes", "type": "textarea", "label": "Anything the diagram can't express?", "placeholder": "constraints, scaling concerns, naming…" }
|
|
14
|
+
]
|
|
15
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'),
|
|
|
28
28
|
const VALUED_FLAGS = new Set([
|
|
29
29
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
30
30
|
'submit-label', 'height', 'limit', 'target', 'id', 'replies',
|
|
31
|
+
'on-result', 'notify-cmd', 'idle-grace',
|
|
31
32
|
]);
|
|
32
33
|
|
|
33
34
|
function camel(key) {
|
|
@@ -71,6 +72,44 @@ function printJson(obj) {
|
|
|
71
72
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Push-wake for `rly wait --notify-cmd`: run the agent's local shell command
|
|
76
|
+
// once a TERMINAL result lands. Result JSON goes to the command's stdin;
|
|
77
|
+
// RLY_BOARD_ID / RLY_STATUS / RLY_URL are exported. Same shape as the server's
|
|
78
|
+
// --on-result hook. Failures are swallowed; a 30s kill timer caps a hung cmd.
|
|
79
|
+
function runNotifyCmd(cmd, result) {
|
|
80
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return;
|
|
81
|
+
try {
|
|
82
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
RLY_BOARD_ID: result.boardId || '',
|
|
86
|
+
RLY_STATUS: result.status || '',
|
|
87
|
+
RLY_URL: result.url || '',
|
|
88
|
+
},
|
|
89
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
90
|
+
});
|
|
91
|
+
child.on('error', () => {});
|
|
92
|
+
try {
|
|
93
|
+
child.stdin.write(JSON.stringify(result));
|
|
94
|
+
child.stdin.end();
|
|
95
|
+
} catch {
|
|
96
|
+
// best effort
|
|
97
|
+
}
|
|
98
|
+
const killTimer = setTimeout(() => {
|
|
99
|
+
try {
|
|
100
|
+
child.kill('SIGKILL');
|
|
101
|
+
} catch {
|
|
102
|
+
// already gone
|
|
103
|
+
}
|
|
104
|
+
}, 30000);
|
|
105
|
+
killTimer.unref();
|
|
106
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
107
|
+
child.unref();
|
|
108
|
+
} catch {
|
|
109
|
+
// swallow — push-wake is best effort
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
function exitCodeFor(status) {
|
|
75
114
|
return { submitted: 0, acknowledged: 0, open: 0, timeout: 2, cancelled: 3 }[status] ?? 1;
|
|
76
115
|
}
|
|
@@ -146,6 +185,15 @@ async function runOrDetach(record, args) {
|
|
|
146
185
|
const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
|
|
147
186
|
const open = args.open !== false;
|
|
148
187
|
|
|
188
|
+
// Push-wake: persist the agent's --on-result command on the record so BOTH
|
|
189
|
+
// the inline runBoard path and the detached __serve path pick it up (the
|
|
190
|
+
// detached server reads record.onResult from disk). Runtime concern only —
|
|
191
|
+
// not part of the spec.
|
|
192
|
+
if (typeof args.onResult === 'string' && args.onResult.trim()) {
|
|
193
|
+
record.onResult = args.onResult;
|
|
194
|
+
saveBoard(record);
|
|
195
|
+
}
|
|
196
|
+
|
|
149
197
|
if (args.detach) {
|
|
150
198
|
const child = spawn(
|
|
151
199
|
process.execPath,
|
|
@@ -313,25 +361,52 @@ async function cmdUpdate(args) {
|
|
|
313
361
|
return 0;
|
|
314
362
|
}
|
|
315
363
|
|
|
364
|
+
// Best-effort fetch of /api/presence for a running board. Returns the parsed
|
|
365
|
+
// presence object, or null on any failure / timeout (500ms cap via
|
|
366
|
+
// AbortController). Never throws.
|
|
367
|
+
async function fetchPresence(url) {
|
|
368
|
+
if (!url) return null;
|
|
369
|
+
const controller = new AbortController();
|
|
370
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
371
|
+
try {
|
|
372
|
+
const res = await fetch(new URL('/api/presence', url), { signal: controller.signal });
|
|
373
|
+
if (!res.ok) return null;
|
|
374
|
+
return await res.json();
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
} finally {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
316
382
|
async function cmdWait(args) {
|
|
317
383
|
const id = args._[0];
|
|
318
|
-
if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>]');
|
|
384
|
+
if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>] [--while-active] [--idle-grace <sec>] [--notify-cmd <cmd>]');
|
|
319
385
|
const timeoutSec = args.timeout !== undefined ? Math.max(1, Number.parseInt(args.timeout, 10) || 1) : 3600;
|
|
320
|
-
const
|
|
386
|
+
const whileActive = args.whileActive === true;
|
|
387
|
+
const idleGrace = args.idleGrace !== undefined ? Math.max(0, Number.parseInt(args.idleGrace, 10) || 0) : 180;
|
|
388
|
+
const notifyCmd = typeof args.notifyCmd === 'string' && args.notifyCmd.trim() ? args.notifyCmd : null;
|
|
389
|
+
let deadline = Date.now() + timeoutSec * 1000;
|
|
321
390
|
mustLoad(id);
|
|
391
|
+
|
|
392
|
+
// Push-wake: run the agent's --notify-cmd after a TERMINAL result, then print.
|
|
393
|
+
const finishResult = (result) => {
|
|
394
|
+
if (notifyCmd) runNotifyCmd(notifyCmd, result);
|
|
395
|
+
printJson(result);
|
|
396
|
+
return exitCodeFor(result.status);
|
|
397
|
+
};
|
|
398
|
+
|
|
322
399
|
while (Date.now() < deadline) {
|
|
323
400
|
const record = mustLoad(id);
|
|
324
401
|
if (record.result && record.result.finishedAt) {
|
|
325
|
-
|
|
326
|
-
return exitCodeFor(record.result.status);
|
|
402
|
+
return finishResult(record.result);
|
|
327
403
|
}
|
|
328
404
|
const running = loadRunning(id);
|
|
329
405
|
if (!running || !isAlive(running.pid)) {
|
|
330
406
|
await sleep(700); // the result write may be racing the process exit
|
|
331
407
|
const again = loadBoard(id);
|
|
332
408
|
if (again?.result?.finishedAt) {
|
|
333
|
-
|
|
334
|
-
return exitCodeFor(again.result.status);
|
|
409
|
+
return finishResult(again.result);
|
|
335
410
|
}
|
|
336
411
|
printJson({
|
|
337
412
|
status: 'lost',
|
|
@@ -341,17 +416,93 @@ async function cmdWait(args) {
|
|
|
341
416
|
});
|
|
342
417
|
return 5;
|
|
343
418
|
}
|
|
419
|
+
if (Date.now() >= deadline) break;
|
|
344
420
|
await sleep(400);
|
|
345
421
|
}
|
|
346
|
-
|
|
422
|
+
|
|
423
|
+
// Deadline hit while the board is still OPEN. Fetch presence (cheaply).
|
|
424
|
+
const running = loadRunning(id);
|
|
425
|
+
const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
|
|
426
|
+
|
|
427
|
+
// --while-active: if the user is present + recently active, EXTEND and keep
|
|
428
|
+
// waiting (repeat indefinitely while they stay active).
|
|
429
|
+
if (
|
|
430
|
+
whileActive &&
|
|
431
|
+
presence &&
|
|
432
|
+
presence.seen &&
|
|
433
|
+
(presence.visible || presence.focused) &&
|
|
434
|
+
presence.secondsSinceActivity < idleGrace
|
|
435
|
+
) {
|
|
436
|
+
deadline = Date.now() + Math.min(idleGrace, 120) * 1000;
|
|
437
|
+
return cmdWaitLoop(id, deadline, { whileActive, idleGrace, notifyCmd });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const out = {
|
|
347
441
|
status: 'wait-timeout',
|
|
348
442
|
boardId: id,
|
|
349
443
|
hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
|
|
350
|
-
}
|
|
444
|
+
};
|
|
445
|
+
if (presence) out.presence = presence;
|
|
446
|
+
printJson(out);
|
|
447
|
+
return 2;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Continuation loop for `rly wait --while-active` after a deadline extension.
|
|
451
|
+
// Identical waiting logic to cmdWait's main loop, then re-evaluates presence;
|
|
452
|
+
// extends again while the user stays active, otherwise emits wait-timeout.
|
|
453
|
+
async function cmdWaitLoop(id, deadline, opts) {
|
|
454
|
+
const { whileActive, idleGrace, notifyCmd } = opts;
|
|
455
|
+
const finishResult = (result) => {
|
|
456
|
+
if (notifyCmd) runNotifyCmd(notifyCmd, result);
|
|
457
|
+
printJson(result);
|
|
458
|
+
return exitCodeFor(result.status);
|
|
459
|
+
};
|
|
460
|
+
while (Date.now() < deadline) {
|
|
461
|
+
const record = mustLoad(id);
|
|
462
|
+
if (record.result && record.result.finishedAt) {
|
|
463
|
+
return finishResult(record.result);
|
|
464
|
+
}
|
|
465
|
+
const running = loadRunning(id);
|
|
466
|
+
if (!running || !isAlive(running.pid)) {
|
|
467
|
+
await sleep(700);
|
|
468
|
+
const again = loadBoard(id);
|
|
469
|
+
if (again?.result?.finishedAt) {
|
|
470
|
+
return finishResult(again.result);
|
|
471
|
+
}
|
|
472
|
+
printJson({
|
|
473
|
+
status: 'lost',
|
|
474
|
+
boardId: id,
|
|
475
|
+
draft: again?.draft ?? null,
|
|
476
|
+
error: 'board server exited without writing a result',
|
|
477
|
+
});
|
|
478
|
+
return 5;
|
|
479
|
+
}
|
|
480
|
+
if (Date.now() >= deadline) break;
|
|
481
|
+
await sleep(400);
|
|
482
|
+
}
|
|
483
|
+
const running = loadRunning(id);
|
|
484
|
+
const presence = running && isAlive(running.pid) ? await fetchPresence(running.url) : null;
|
|
485
|
+
if (
|
|
486
|
+
whileActive &&
|
|
487
|
+
presence &&
|
|
488
|
+
presence.seen &&
|
|
489
|
+
(presence.visible || presence.focused) &&
|
|
490
|
+
presence.secondsSinceActivity < idleGrace
|
|
491
|
+
) {
|
|
492
|
+
const next = Date.now() + Math.min(idleGrace, 120) * 1000;
|
|
493
|
+
return cmdWaitLoop(id, next, opts);
|
|
494
|
+
}
|
|
495
|
+
const out = {
|
|
496
|
+
status: 'wait-timeout',
|
|
497
|
+
boardId: id,
|
|
498
|
+
hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
|
|
499
|
+
};
|
|
500
|
+
if (presence) out.presence = presence;
|
|
501
|
+
printJson(out);
|
|
351
502
|
return 2;
|
|
352
503
|
}
|
|
353
504
|
|
|
354
|
-
function cmdResult(args) {
|
|
505
|
+
async function cmdResult(args) {
|
|
355
506
|
const record = mustLoad(args._[0]);
|
|
356
507
|
if (record.result && record.result.finishedAt) {
|
|
357
508
|
printJson(record.result);
|
|
@@ -359,8 +510,12 @@ function cmdResult(args) {
|
|
|
359
510
|
}
|
|
360
511
|
const running = loadRunning(record.id);
|
|
361
512
|
if (running && isAlive(running.pid)) {
|
|
362
|
-
// While open, expose the real-time autosaved draft so agents can peek
|
|
363
|
-
|
|
513
|
+
// While open, expose the real-time autosaved draft so agents can peek, plus
|
|
514
|
+
// best-effort presence (whether the user is still viewing/focused/active).
|
|
515
|
+
const out = { status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null };
|
|
516
|
+
const presence = await fetchPresence(running.url);
|
|
517
|
+
if (presence) out.presence = presence;
|
|
518
|
+
printJson(out);
|
|
364
519
|
return 0;
|
|
365
520
|
}
|
|
366
521
|
printJson({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
|
|
@@ -643,9 +798,13 @@ USAGE
|
|
|
643
798
|
rly ask -q "Deploy?::yesno" -q "!Env::single::dev,staging,prod"
|
|
644
799
|
quick inline questions ("!" = required, label::type::options)
|
|
645
800
|
rly ask ... --detach no blocking: prints {boardId,url} now; collect via \`rly wait <id>\`
|
|
801
|
+
rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
|
|
646
802
|
rly show --html-file viz.html visualization-only board (submit button = acknowledge)
|
|
647
803
|
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
|
648
|
-
|
|
804
|
+
--while-active [--idle-grace 180]: keep waiting past the deadline
|
|
805
|
+
while the user is still viewing/focused & recently active
|
|
806
|
+
--notify-cmd "<cmd>": run <cmd> on a terminal result (JSON on stdin)
|
|
807
|
+
rly result <id> result/status now (includes live autosaved draft + presence while open)
|
|
649
808
|
rly list [--json] running boards
|
|
650
809
|
rly open [id] re-open the browser tab of a running board
|
|
651
810
|
rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
|
|
@@ -706,7 +865,7 @@ export async function main(argv) {
|
|
|
706
865
|
case 'wait':
|
|
707
866
|
return await cmdWait(parseArgs(rest));
|
|
708
867
|
case 'result':
|
|
709
|
-
return cmdResult(parseArgs(rest));
|
|
868
|
+
return await cmdResult(parseArgs(rest));
|
|
710
869
|
case 'list':
|
|
711
870
|
return cmdList(parseArgs(rest));
|
|
712
871
|
case 'open':
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
|
|
7
8
|
import { openUrl } from './open.js';
|
|
@@ -80,6 +81,7 @@ function buildPage(record, rev) {
|
|
|
80
81
|
comment: record.draft.comment || '',
|
|
81
82
|
notes: record.draft.notes || {},
|
|
82
83
|
annotations: record.draft.annotations || [],
|
|
84
|
+
blockEdits: record.draft.blockEdits || {},
|
|
83
85
|
}
|
|
84
86
|
: null;
|
|
85
87
|
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
|
|
@@ -130,6 +132,24 @@ function sanitizeAnnotations(value) {
|
|
|
130
132
|
return out;
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
// Validates + sanitizes an incoming blockEdits map (from draft/submit).
|
|
136
|
+
// Keeps only string-keyed entries with string values <= 20000 chars; caps at
|
|
137
|
+
// 50 entries; drops invalid entries. Returns {} when nothing valid is present.
|
|
138
|
+
function sanitizeBlockEdits(value) {
|
|
139
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
140
|
+
const out = {};
|
|
141
|
+
let n = 0;
|
|
142
|
+
for (const key of Object.keys(value)) {
|
|
143
|
+
if (n >= 50) break;
|
|
144
|
+
if (typeof key !== 'string') continue;
|
|
145
|
+
const v = value[key];
|
|
146
|
+
if (typeof v !== 'string' || v.length > 20000) continue;
|
|
147
|
+
out[key] = v;
|
|
148
|
+
n++;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
// Resolves an html block body by id from the board or any question scope.
|
|
134
154
|
function findHtmlBlock(spec, blockId) {
|
|
135
155
|
const scan = (blocks) => (Array.isArray(blocks) ? blocks.find((b) => b && b.id === blockId && b.type === 'html') : undefined);
|
|
@@ -214,6 +234,47 @@ function readBody(req, limit = 5 * 1024 * 1024) {
|
|
|
214
234
|
});
|
|
215
235
|
}
|
|
216
236
|
|
|
237
|
+
// Push-wake: run the agent's own local shell command after a board finishes.
|
|
238
|
+
// The full result JSON is written to the command's stdin; RLY_BOARD_ID /
|
|
239
|
+
// RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
|
|
240
|
+
// only a stderr note when not quiet. A 30s kill timer prevents a hung command
|
|
241
|
+
// from keeping the process alive.
|
|
242
|
+
function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
243
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return;
|
|
244
|
+
try {
|
|
245
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
246
|
+
env: {
|
|
247
|
+
...process.env,
|
|
248
|
+
RLY_BOARD_ID: result.boardId || '',
|
|
249
|
+
RLY_STATUS: result.status || '',
|
|
250
|
+
RLY_URL: result.url || '',
|
|
251
|
+
},
|
|
252
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
253
|
+
});
|
|
254
|
+
child.on('error', () => {
|
|
255
|
+
if (!quiet) process.stderr.write(`[relay] --on-result command failed to spawn\n`);
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
child.stdin.write(JSON.stringify(result));
|
|
259
|
+
child.stdin.end();
|
|
260
|
+
} catch {
|
|
261
|
+
// best effort — stdin may already be gone
|
|
262
|
+
}
|
|
263
|
+
const killTimer = setTimeout(() => {
|
|
264
|
+
try {
|
|
265
|
+
child.kill('SIGKILL');
|
|
266
|
+
} catch {
|
|
267
|
+
// already gone
|
|
268
|
+
}
|
|
269
|
+
}, 30000);
|
|
270
|
+
killTimer.unref();
|
|
271
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
272
|
+
child.unref();
|
|
273
|
+
} catch {
|
|
274
|
+
if (!quiet) process.stderr.write(`[relay] --on-result command failed to run\n`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
217
278
|
// Serves one board on 127.0.0.1 and resolves `done` when it finishes
|
|
218
279
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
219
280
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
@@ -233,6 +294,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
233
294
|
comment: record.result.comment || '',
|
|
234
295
|
notes: record.result.notes || {},
|
|
235
296
|
annotations: record.result.annotations || [],
|
|
297
|
+
blockEdits: record.result.blockEdits || {},
|
|
236
298
|
updatedAt: new Date().toISOString(),
|
|
237
299
|
};
|
|
238
300
|
}
|
|
@@ -248,6 +310,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
248
310
|
let rev = 1;
|
|
249
311
|
let status = 'open';
|
|
250
312
|
let finished = false;
|
|
313
|
+
// Latest client presence ping (null until the first ping arrives).
|
|
314
|
+
let presence = null;
|
|
251
315
|
let resolveDone;
|
|
252
316
|
const done = new Promise((r) => {
|
|
253
317
|
resolveDone = r;
|
|
@@ -264,6 +328,31 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
264
328
|
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
265
329
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
266
330
|
sendJson(res, 200, { status, rev });
|
|
331
|
+
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
332
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
333
|
+
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
334
|
+
if (
|
|
335
|
+
typeof body.visible === 'boolean' &&
|
|
336
|
+
typeof body.focused === 'boolean' &&
|
|
337
|
+
Number.isFinite(body.idleMs) &&
|
|
338
|
+
body.idleMs >= 0
|
|
339
|
+
) {
|
|
340
|
+
presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
|
|
341
|
+
}
|
|
342
|
+
sendJson(res, 200, { ok: true });
|
|
343
|
+
} else if (req.method === 'GET' && pathname === '/api/presence') {
|
|
344
|
+
if (!presence) {
|
|
345
|
+
sendJson(res, 200, { open: true, seen: false });
|
|
346
|
+
} else {
|
|
347
|
+
sendJson(res, 200, {
|
|
348
|
+
open: true,
|
|
349
|
+
seen: true,
|
|
350
|
+
visible: presence.visible,
|
|
351
|
+
focused: presence.focused,
|
|
352
|
+
secondsSinceActivity: Math.round((Date.now() - presence.atMs + presence.idleMs) / 1000),
|
|
353
|
+
secondsSincePing: Math.round((Date.now() - presence.atMs) / 1000),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
267
356
|
} else if (req.method === 'POST' && pathname === '/api/update') {
|
|
268
357
|
if (req.headers['x-relay-token'] !== token) return sendJson(res, 403, { error: 'forbidden' });
|
|
269
358
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
@@ -311,6 +400,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
311
400
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
312
401
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
313
402
|
annotations: sanitizeAnnotations(body.annotations),
|
|
403
|
+
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
314
404
|
updatedAt: new Date().toISOString(),
|
|
315
405
|
};
|
|
316
406
|
saveBoard(record);
|
|
@@ -328,6 +418,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
328
418
|
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
329
419
|
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
330
420
|
annotations: sanitizeAnnotations(body.annotations),
|
|
421
|
+
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
331
422
|
});
|
|
332
423
|
} else {
|
|
333
424
|
sendJson(res, 404, { error: 'not found' });
|
|
@@ -363,6 +454,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
363
454
|
if (finished) return;
|
|
364
455
|
finished = true;
|
|
365
456
|
status = partial.status;
|
|
457
|
+
// blockEdits: from this submit, else fall back to the autosaved draft
|
|
458
|
+
// (timeout/cancel). null when there are none.
|
|
459
|
+
const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
|
|
460
|
+
const blockEdits = editsRaw && Object.keys(editsRaw).length ? editsRaw : null;
|
|
366
461
|
const result = {
|
|
367
462
|
status: partial.status,
|
|
368
463
|
boardId: record.id,
|
|
@@ -373,6 +468,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
373
468
|
comment: partial.comment ?? '',
|
|
374
469
|
notes: partial.notes ?? null,
|
|
375
470
|
annotations: partial.annotations ?? (record.draft?.annotations || []),
|
|
471
|
+
blockEdits,
|
|
376
472
|
createdAt: record.createdAt,
|
|
377
473
|
finishedAt: new Date().toISOString(),
|
|
378
474
|
durationMs: Date.now() - startedAt,
|
|
@@ -384,6 +480,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
384
480
|
}
|
|
385
481
|
record.result = result;
|
|
386
482
|
saveBoard(record);
|
|
483
|
+
// Push-wake: run the agent's local command for EVERY terminal status.
|
|
484
|
+
runOnResult(record.onResult, result, { quiet });
|
|
387
485
|
removeRunning(record.id);
|
|
388
486
|
if (timer) clearTimeout(timer);
|
|
389
487
|
process.removeListener('SIGINT', onSignal);
|
package/src/spec.js
CHANGED
|
@@ -89,6 +89,7 @@ function normalizeBlock(rawBlock, id, cwd, where) {
|
|
|
89
89
|
const code = asStr(rawBlock.code);
|
|
90
90
|
if (!code.trim()) throw new CliError(`${where}: mermaid block needs a non-empty "code" string.`);
|
|
91
91
|
const block = { id, type: 'mermaid', code };
|
|
92
|
+
if (rawBlock.editable === true) block.editable = true;
|
|
92
93
|
if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
|
|
93
94
|
return block;
|
|
94
95
|
}
|
|
@@ -367,6 +368,7 @@ const BLOCK_SCHEMA = {
|
|
|
367
368
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
368
369
|
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
|
|
369
370
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
371
|
+
editable: { type: 'boolean', description: 'mermaid: when true, render an "Edit diagram" toggle so the user can edit the diagram source live. The edited source is returned in result.blockEdits[<blockId>].' },
|
|
370
372
|
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
|
371
373
|
server: { type: 'string', description: 'plantuml: PlantUML server base URL (http(s)). Defaults to https://www.plantuml.com/plantuml. Diagrams render via this server (needs network).' },
|
|
372
374
|
lang: { type: 'string', description: 'code block: language hint for display.' },
|
|
@@ -398,7 +400,7 @@ export const SPEC_SCHEMA = {
|
|
|
398
400
|
title: 'relay board spec',
|
|
399
401
|
type: 'object',
|
|
400
402
|
description:
|
|
401
|
-
'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes",
|
|
403
|
+
'A relay board. Renders an optional intro, board-level blocks, then questions (each with optional per-question blocks), then a submit button. The result JSON contains "answers", "comment", per-question "notes", "annotations" (element-level comments the user attached to any block — see the annotation shape below), and "blockEdits" (a map blockId→edited source for any editable mermaid block the user changed, null when none).',
|
|
402
404
|
properties: {
|
|
403
405
|
title: { type: 'string', description: 'Board title (default "Relay")' },
|
|
404
406
|
intro: { type: 'string', description: 'Intro text shown under the title. Newlines preserved.' },
|
|
@@ -455,6 +457,12 @@ export const SPEC_SCHEMA = {
|
|
|
455
457
|
description:
|
|
456
458
|
'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
|
|
457
459
|
},
|
|
460
|
+
blockEdits: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
readOnly: true,
|
|
463
|
+
description:
|
|
464
|
+
'Returned in the result (not part of the input spec). A map blockId→edited mermaid source for any editable mermaid block the user changed. null when the user made no edits. Read result.blockEdits[<blockId>] for the user\'s edited diagram source.',
|
|
465
|
+
},
|
|
458
466
|
},
|
|
459
467
|
anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
|
|
460
468
|
};
|
package/src/ui/annotate.js
CHANGED
|
@@ -200,6 +200,7 @@
|
|
|
200
200
|
function refreshBadges() {
|
|
201
201
|
for (const b of badges) b.remove();
|
|
202
202
|
badges = [];
|
|
203
|
+
if (torndown) return;
|
|
203
204
|
registered = registered.filter((r) => r.el.isConnected);
|
|
204
205
|
for (const entry of registered) {
|
|
205
206
|
const count = matching(entry.info).length;
|
|
@@ -500,7 +501,32 @@
|
|
|
500
501
|
renderSummaries();
|
|
501
502
|
}
|
|
502
503
|
|
|
504
|
+
// Remove every floating element (pin, badges, popover, selection button)
|
|
505
|
+
// and stop reacting — called when the board reaches its submitted screen so
|
|
506
|
+
// nothing leaks over it (they live on <body> with elevated z-index).
|
|
507
|
+
let torndown = false;
|
|
508
|
+
function teardown() {
|
|
509
|
+
torndown = true;
|
|
510
|
+
try {
|
|
511
|
+
closePopover();
|
|
512
|
+
} catch {
|
|
513
|
+
// popover may not be open
|
|
514
|
+
}
|
|
515
|
+
hidePin();
|
|
516
|
+
hideSelBtn();
|
|
517
|
+
for (const b of badges) b.remove();
|
|
518
|
+
badges = [];
|
|
519
|
+
registered = [];
|
|
520
|
+
if (dom) {
|
|
521
|
+
dom.pin.remove();
|
|
522
|
+
dom.selBtn.remove();
|
|
523
|
+
dom.pop.remove();
|
|
524
|
+
dom = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
503
528
|
function register(targetEl, info) {
|
|
529
|
+
if (torndown) return;
|
|
504
530
|
ensureDom();
|
|
505
531
|
targetEl.classList.add('ann-target');
|
|
506
532
|
const entry = { el: targetEl, info };
|
|
@@ -511,14 +537,17 @@
|
|
|
511
537
|
}
|
|
512
538
|
|
|
513
539
|
function enableTextSelection(rootEl, baseInfo) {
|
|
540
|
+
if (torndown) return;
|
|
514
541
|
ensureDom();
|
|
515
542
|
rootEl.addEventListener('mouseup', () => {
|
|
543
|
+
if (torndown) return;
|
|
516
544
|
// defer: the selection settles after mouseup
|
|
517
545
|
setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
|
|
518
546
|
});
|
|
519
547
|
}
|
|
520
548
|
|
|
521
549
|
function openExternal(info, anchorEl) {
|
|
550
|
+
if (torndown) return;
|
|
522
551
|
openPopover(info, anchorEl);
|
|
523
552
|
}
|
|
524
553
|
|
|
@@ -532,5 +561,5 @@
|
|
|
532
561
|
renderSummaryInto(target);
|
|
533
562
|
}
|
|
534
563
|
|
|
535
|
-
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary };
|
|
564
|
+
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, teardown };
|
|
536
565
|
})();
|
package/src/ui/app.js
CHANGED
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
notes: {},
|
|
83
83
|
comment: '',
|
|
84
84
|
annotations: (boot.prefill && boot.prefill.annotations) || [],
|
|
85
|
+
// Editable-mermaid edits: blockId -> edited source. Seeded from the live
|
|
86
|
+
// draft so a reload/reopen restores the user's edited diagram. Mutated via
|
|
87
|
+
// the blocks ctx.onBlockEdit callback below; returned in payload().
|
|
88
|
+
blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
|
|
85
89
|
};
|
|
86
90
|
let submitted = false;
|
|
87
91
|
|
|
@@ -147,7 +151,13 @@
|
|
|
147
151
|
const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
|
|
148
152
|
if (n) notes[q.id] = n;
|
|
149
153
|
}
|
|
150
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
answers,
|
|
156
|
+
comment: (state.comment || '').trim(),
|
|
157
|
+
notes,
|
|
158
|
+
annotations: state.annotations,
|
|
159
|
+
blockEdits: state.blockEdits,
|
|
160
|
+
};
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
// ---------- real-time autosave ----------
|
|
@@ -174,6 +184,44 @@
|
|
|
174
184
|
}
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
// ---------- presence / awareness ----------
|
|
188
|
+
// Track the last time the user interacted with the page so the agent (via
|
|
189
|
+
// /api/presence) can tell whether someone is still actively viewing the board
|
|
190
|
+
// and keep waiting instead of timing out. Every existing 3s heartbeat tick
|
|
191
|
+
// also POSTs /api/ping {visible, focused, idleMs}; pinging stops after submit.
|
|
192
|
+
let lastInteractionAt = Date.now();
|
|
193
|
+
let lastMoveAt = 0;
|
|
194
|
+
function noteInteraction() {
|
|
195
|
+
lastInteractionAt = Date.now();
|
|
196
|
+
}
|
|
197
|
+
window.addEventListener('pointerdown', noteInteraction, { passive: true });
|
|
198
|
+
window.addEventListener('keydown', noteInteraction, { passive: true });
|
|
199
|
+
window.addEventListener('scroll', noteInteraction, { passive: true });
|
|
200
|
+
window.addEventListener('touchstart', noteInteraction, { passive: true });
|
|
201
|
+
// pointermove fires continuously — throttle to at most once per second.
|
|
202
|
+
window.addEventListener('pointermove', () => {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
if (now - lastMoveAt >= 1000) {
|
|
205
|
+
lastMoveAt = now;
|
|
206
|
+
lastInteractionAt = now;
|
|
207
|
+
}
|
|
208
|
+
}, { passive: true });
|
|
209
|
+
|
|
210
|
+
function pingPresence() {
|
|
211
|
+
if (submitted) return;
|
|
212
|
+
fetch('/api/ping', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
visible: !document.hidden,
|
|
217
|
+
focused: document.hasFocus(),
|
|
218
|
+
idleMs: Date.now() - lastInteractionAt,
|
|
219
|
+
}),
|
|
220
|
+
}).catch(() => {
|
|
221
|
+
// presence is best-effort — a failed ping must never surface to the user
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
177
225
|
// ---------- annotations ----------
|
|
178
226
|
// RelayAnnotate owns the live annotation list; mirror it into state on every
|
|
179
227
|
// change so payload()/autosave/submit carry it exactly like answers.
|
|
@@ -188,13 +236,25 @@
|
|
|
188
236
|
});
|
|
189
237
|
}
|
|
190
238
|
|
|
191
|
-
//
|
|
239
|
+
// Editable-mermaid: record (or clear) the user's edit for a block, then
|
|
240
|
+
// autosave. A null/empty code clears the entry (block matches the original
|
|
241
|
+
// again — e.g. after Reset), so payload()/draft carry only real divergences.
|
|
242
|
+
function onBlockEdit(blockId, codeOrNull) {
|
|
243
|
+
if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
|
|
244
|
+
else state.blockEdits[blockId] = codeOrNull;
|
|
245
|
+
scheduleSave();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
|
|
249
|
+
// the editable-mermaid plumbing (edits map + onBlockEdit callback).
|
|
192
250
|
function blockCtx(questionId) {
|
|
193
251
|
return {
|
|
194
252
|
theme: effectiveTheme,
|
|
195
253
|
htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
|
|
196
254
|
questionId: questionId == null ? null : questionId,
|
|
197
255
|
annotate: Annotate,
|
|
256
|
+
edits: state.blockEdits,
|
|
257
|
+
onBlockEdit,
|
|
198
258
|
};
|
|
199
259
|
}
|
|
200
260
|
|
|
@@ -517,6 +577,15 @@
|
|
|
517
577
|
|
|
518
578
|
function showDone(closing) {
|
|
519
579
|
stopHeartbeat();
|
|
580
|
+
// Annotation pins/badges/popover float on <body> with elevated z-index —
|
|
581
|
+
// remove them so they don't leak over the submitted screen.
|
|
582
|
+
if (window.RelayAnnotate && typeof RelayAnnotate.teardown === 'function') {
|
|
583
|
+
try {
|
|
584
|
+
RelayAnnotate.teardown();
|
|
585
|
+
} catch {
|
|
586
|
+
// best effort
|
|
587
|
+
}
|
|
588
|
+
}
|
|
520
589
|
app.replaceChildren(
|
|
521
590
|
el('div', { class: 'done' },
|
|
522
591
|
el('div', { class: 'mark' }, '✓'),
|
|
@@ -608,6 +677,8 @@
|
|
|
608
677
|
let misses = 0;
|
|
609
678
|
let reloading = false;
|
|
610
679
|
let hb = setInterval(async () => {
|
|
680
|
+
// Piggyback presence on the heartbeat (best-effort; no-ops after submit).
|
|
681
|
+
pingPresence();
|
|
611
682
|
try {
|
|
612
683
|
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
613
684
|
if (!r.ok) throw new Error('bad status');
|
package/src/ui/blocks.css
CHANGED
|
@@ -169,6 +169,69 @@
|
|
|
169
169
|
.blk-mermaid .node,
|
|
170
170
|
.blk-mermaid .edgeLabel { cursor: default; }
|
|
171
171
|
|
|
172
|
+
/* ---------- editable mermaid (diagram editor) ---------- */
|
|
173
|
+
.blk-mermaid-edit { margin-top: 8px; }
|
|
174
|
+
.blk-edit-btn {
|
|
175
|
+
appearance: none;
|
|
176
|
+
background: transparent;
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
border-radius: 7px;
|
|
179
|
+
color: var(--muted);
|
|
180
|
+
font-family: var(--sans);
|
|
181
|
+
font-size: 0.78rem;
|
|
182
|
+
padding: 4px 10px;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
185
|
+
}
|
|
186
|
+
.blk-edit-btn:hover,
|
|
187
|
+
.blk-edit-btn.is-open { color: var(--accent); border-color: var(--accent); }
|
|
188
|
+
.blk-editor { margin-top: 8px; }
|
|
189
|
+
.blk-editor[hidden] { display: none; }
|
|
190
|
+
.blk-editor-ta {
|
|
191
|
+
display: block;
|
|
192
|
+
width: 100%;
|
|
193
|
+
box-sizing: border-box;
|
|
194
|
+
min-height: 120px;
|
|
195
|
+
resize: vertical;
|
|
196
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
line-height: 1.5;
|
|
199
|
+
color: var(--fg);
|
|
200
|
+
background: var(--bg-sunken);
|
|
201
|
+
border: 1px solid var(--border);
|
|
202
|
+
border-radius: 8px;
|
|
203
|
+
padding: 10px 12px;
|
|
204
|
+
}
|
|
205
|
+
.blk-editor-ta:focus {
|
|
206
|
+
outline: none;
|
|
207
|
+
border-color: var(--accent);
|
|
208
|
+
}
|
|
209
|
+
.blk-editor-row {
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 12px;
|
|
213
|
+
margin-top: 8px;
|
|
214
|
+
}
|
|
215
|
+
.blk-editor-reset {
|
|
216
|
+
appearance: none;
|
|
217
|
+
background: transparent;
|
|
218
|
+
border: 1px solid var(--border);
|
|
219
|
+
border-radius: 7px;
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
font-family: var(--sans);
|
|
222
|
+
font-size: 0.78rem;
|
|
223
|
+
padding: 4px 10px;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
226
|
+
}
|
|
227
|
+
.blk-editor-reset:hover { color: var(--accent); border-color: var(--accent); }
|
|
228
|
+
.blk-editor-status {
|
|
229
|
+
font-family: var(--sans);
|
|
230
|
+
font-size: 0.78rem;
|
|
231
|
+
color: var(--muted);
|
|
232
|
+
}
|
|
233
|
+
.blk-editor-status.has-error { color: var(--muted); }
|
|
234
|
+
|
|
172
235
|
/* ---------- graphviz (offline, vendored Viz.js) ---------- */
|
|
173
236
|
.blk-graphviz {
|
|
174
237
|
overflow: auto;
|
package/src/ui/blocks.js
CHANGED
|
@@ -554,16 +554,133 @@
|
|
|
554
554
|
const mermaidRegistry = [];
|
|
555
555
|
const chartRegistry = [];
|
|
556
556
|
|
|
557
|
+
// Effective code = the user's edit if present, else the authored block.code.
|
|
558
|
+
// Used by the initial render, the editor's live preview, and the theme
|
|
559
|
+
// re-render registry path so an edit survives a theme toggle.
|
|
560
|
+
function effectiveMermaidCode(entry) {
|
|
561
|
+
const { block, ctx, blockId } = entry;
|
|
562
|
+
const edited = ctx.edits ? ctx.edits[blockId] : undefined;
|
|
563
|
+
if (edited !== undefined && edited !== null) return edited;
|
|
564
|
+
// The live editor keeps entry.code as the source of truth; if the host's
|
|
565
|
+
// ctx.edits hasn't been updated yet but the user has diverged from the
|
|
566
|
+
// original, preserve that divergence (don't snap back to block.code).
|
|
567
|
+
if (entry.code !== undefined && entry.code !== (block.code || '')) return entry.code;
|
|
568
|
+
return block.code || '';
|
|
569
|
+
}
|
|
570
|
+
|
|
557
571
|
function renderMermaid(block, ctx, blockId) {
|
|
558
572
|
const container = el('div', { class: 'blk-mermaid' });
|
|
559
573
|
const entry = { container, block, ctx, blockId };
|
|
574
|
+
entry.code = effectiveMermaidCode(entry);
|
|
560
575
|
mermaidRegistry.push(entry);
|
|
576
|
+
|
|
577
|
+
if (!block.editable) {
|
|
578
|
+
drawMermaid(entry);
|
|
579
|
+
return container;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Editable: wrap the diagram + an editor below it. The wrapper is what the
|
|
583
|
+
// dispatcher appends; the registry still tracks `container` (the diagram).
|
|
584
|
+
const wrap = el('div', { class: 'blk-mermaid-wrap' });
|
|
585
|
+
wrap.append(container);
|
|
586
|
+
wrap.append(buildMermaidEditor(entry));
|
|
561
587
|
drawMermaid(entry);
|
|
562
|
-
return
|
|
588
|
+
return wrap;
|
|
563
589
|
}
|
|
564
590
|
|
|
565
|
-
|
|
566
|
-
|
|
591
|
+
// Editor: toggle button + collapsible panel (textarea + Reset + status line).
|
|
592
|
+
// Live re-render is debounced 600ms; errors show inline WITHOUT destroying the
|
|
593
|
+
// last good diagram. Each accepted change calls ctx.onBlockEdit(blockId, code)
|
|
594
|
+
// (null when the value matches the original block.code again).
|
|
595
|
+
function buildMermaidEditor(entry) {
|
|
596
|
+
const { block, ctx, blockId, container } = entry;
|
|
597
|
+
const original = block.code || '';
|
|
598
|
+
|
|
599
|
+
const toggleBtn = el('button', { type: 'button', class: 'blk-edit-btn' }, 'Edit diagram');
|
|
600
|
+
const panel = el('div', { class: 'blk-editor', hidden: '' });
|
|
601
|
+
|
|
602
|
+
const ta = el('textarea', { class: 'blk-editor-ta', spellcheck: 'false' });
|
|
603
|
+
ta.value = entry.code;
|
|
604
|
+
|
|
605
|
+
const status = el('div', { class: 'blk-editor-status' }, '');
|
|
606
|
+
const resetBtn = el('button', { type: 'button', class: 'blk-editor-reset' }, 'Reset');
|
|
607
|
+
const row = el('div', { class: 'blk-editor-row' }, resetBtn, status);
|
|
608
|
+
panel.append(ta, row);
|
|
609
|
+
|
|
610
|
+
let open = false;
|
|
611
|
+
toggleBtn.addEventListener('click', () => {
|
|
612
|
+
open = !open;
|
|
613
|
+
panel.hidden = !open;
|
|
614
|
+
toggleBtn.classList.toggle('is-open', open);
|
|
615
|
+
if (open) ta.focus();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
let timer = null;
|
|
619
|
+
function reportEdit(code) {
|
|
620
|
+
// Only report a real divergence; equal-to-original clears the edit (null).
|
|
621
|
+
try {
|
|
622
|
+
if (code === original) ctx.onBlockEdit(blockId, null);
|
|
623
|
+
else ctx.onBlockEdit(blockId, code);
|
|
624
|
+
} catch {
|
|
625
|
+
// host callback failures must not break editing
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Try to render `code`; on success swap the diagram + re-register annotation
|
|
630
|
+
// targets (via drawMermaid) and report the edit; on failure keep the last
|
|
631
|
+
// good diagram and show the error inline.
|
|
632
|
+
function applyCode(code) {
|
|
633
|
+
previewMermaid(entry, code)
|
|
634
|
+
.then(() => {
|
|
635
|
+
status.textContent = '';
|
|
636
|
+
status.classList.remove('has-error');
|
|
637
|
+
entry.code = code;
|
|
638
|
+
reportEdit(code);
|
|
639
|
+
})
|
|
640
|
+
.catch((err) => {
|
|
641
|
+
status.textContent = 'diagram error: ' + (err && err.message ? err.message : String(err));
|
|
642
|
+
status.classList.add('has-error');
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
ta.addEventListener('input', () => {
|
|
647
|
+
if (timer) clearTimeout(timer);
|
|
648
|
+
const code = ta.value;
|
|
649
|
+
timer = setTimeout(() => applyCode(code), 600);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
resetBtn.addEventListener('click', () => {
|
|
653
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
654
|
+
ta.value = original;
|
|
655
|
+
applyCode(original);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
return el('div', { class: 'blk-mermaid-edit' }, toggleBtn, panel);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Render `code` for the editor preview WITHOUT mutating entry.code on failure.
|
|
662
|
+
// Resolves once the diagram is swapped into the container (annotation targets
|
|
663
|
+
// re-registered by drawMermaid); rejects with the render error so the caller
|
|
664
|
+
// can show it inline and keep the last good diagram.
|
|
665
|
+
function previewMermaid(entry, code) {
|
|
666
|
+
const prev = entry.code;
|
|
667
|
+
entry.code = code;
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
drawMermaid(entry, { onDone: resolve, onError: reject });
|
|
670
|
+
}).catch((err) => {
|
|
671
|
+
entry.code = prev;
|
|
672
|
+
throw err;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function drawMermaid(entry, hooks) {
|
|
677
|
+
const { container, ctx, blockId } = entry;
|
|
678
|
+
const onError = hooks && hooks.onError ? hooks.onError : null;
|
|
679
|
+
const onDone = hooks && hooks.onDone ? hooks.onDone : null;
|
|
680
|
+
const fail = (err) => {
|
|
681
|
+
if (onError) onError(err);
|
|
682
|
+
else showMermaidErr(container, err);
|
|
683
|
+
};
|
|
567
684
|
loadMermaid().then((mermaid) => {
|
|
568
685
|
try {
|
|
569
686
|
mermaid.initialize({
|
|
@@ -575,7 +692,7 @@
|
|
|
575
692
|
// ignore re-init issues
|
|
576
693
|
}
|
|
577
694
|
const id = 'rly-mmd-' + (++mermaidSeq);
|
|
578
|
-
const code =
|
|
695
|
+
const code = entry.code !== undefined ? entry.code : effectiveMermaidCode(entry);
|
|
579
696
|
const onSvg = (svg) => {
|
|
580
697
|
container.innerHTML = svg;
|
|
581
698
|
const svgEl = container.querySelector('svg');
|
|
@@ -593,24 +710,26 @@
|
|
|
593
710
|
svgEl.style.maxWidth = '100%';
|
|
594
711
|
}
|
|
595
712
|
}
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
713
|
+
if (ctx.annotate) {
|
|
714
|
+
const nodes = container.querySelectorAll('.node, .edgeLabel');
|
|
715
|
+
nodes.forEach((g) => {
|
|
716
|
+
ctx.annotate.register(g, {
|
|
717
|
+
blockId,
|
|
718
|
+
questionId: ctx.questionId,
|
|
719
|
+
target: {
|
|
720
|
+
kind: 'mermaid-node',
|
|
721
|
+
nodeId: g.id || '',
|
|
722
|
+
text: (g.textContent || '').trim().slice(0, 120),
|
|
723
|
+
},
|
|
724
|
+
});
|
|
607
725
|
});
|
|
608
|
-
}
|
|
726
|
+
}
|
|
727
|
+
if (onDone) onDone();
|
|
609
728
|
};
|
|
610
729
|
try {
|
|
611
730
|
const ret = mermaid.render(id, code);
|
|
612
731
|
if (ret && typeof ret.then === 'function') {
|
|
613
|
-
ret.then((r) => onSvg(r.svg)).catch((err) =>
|
|
732
|
+
ret.then((r) => onSvg(r.svg)).catch((err) => fail(err));
|
|
614
733
|
} else if (ret && ret.svg) {
|
|
615
734
|
onSvg(ret.svg);
|
|
616
735
|
} else if (typeof ret === 'string') {
|
|
@@ -620,9 +739,9 @@
|
|
|
620
739
|
mermaid.render(id, code, (svg) => onSvg(svg));
|
|
621
740
|
}
|
|
622
741
|
} catch (err) {
|
|
623
|
-
|
|
742
|
+
fail(err);
|
|
624
743
|
}
|
|
625
|
-
}).catch((err) =>
|
|
744
|
+
}).catch((err) => fail(err));
|
|
626
745
|
}
|
|
627
746
|
|
|
628
747
|
// Live theme toggle: re-render mermaid diagrams with the new mermaid theme
|
|
@@ -630,6 +749,8 @@
|
|
|
630
749
|
function onThemeChange() {
|
|
631
750
|
for (const entry of mermaidRegistry) {
|
|
632
751
|
try {
|
|
752
|
+
// re-render with the effective code so an edit survives the toggle
|
|
753
|
+
entry.code = effectiveMermaidCode(entry);
|
|
633
754
|
drawMermaid(entry);
|
|
634
755
|
} catch {
|
|
635
756
|
// keep the previous svg on failure
|
|
@@ -844,6 +965,11 @@
|
|
|
844
965
|
htmlSrc: ctx && ctx.htmlSrc ? ctx.htmlSrc : (id) => '/html/b/' + id,
|
|
845
966
|
questionId: ctx && ctx.questionId !== undefined ? ctx.questionId : null,
|
|
846
967
|
annotate: ctx && ctx.annotate ? ctx.annotate : null,
|
|
968
|
+
// editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
|
|
969
|
+
// reports an accepted change (or null to clear back to the original).
|
|
970
|
+
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
971
|
+
onBlockEdit:
|
|
972
|
+
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|
|
847
973
|
};
|
|
848
974
|
for (const block of list) {
|
|
849
975
|
if (!block || !block.type) continue;
|