@khanglvm/relay 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AGENT.md +20 -4
- package/package.json +1 -1
- package/skills/relay/SKILL.md +2 -0
- package/src/cli.js +119 -2
- package/src/server.js +89 -11
- package/src/spec.js +1 -1
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +60 -16
- package/src/ui/blocks.css +4 -0
- package/src/ui/blocks.js +54 -1
- package/src/ui/style.css +8 -2
package/docs/AGENT.md
CHANGED
|
@@ -189,7 +189,9 @@ Rules of thumb:
|
|
|
189
189
|
### All block shapes
|
|
190
190
|
|
|
191
191
|
```jsonc
|
|
192
|
-
// Markdown — built-in mini renderer, no library
|
|
192
|
+
// Markdown — built-in mini renderer, no library. Headings, lists, code, quotes,
|
|
193
|
+
// links, and GFM pipe tables all render. For real tabular DATA use a `table`
|
|
194
|
+
// block instead (sortable + per-cell comments); markdown tables are display-only.
|
|
193
195
|
{ "type": "markdown", "md": "## Heading\nAny **CommonMark** prose." }
|
|
194
196
|
|
|
195
197
|
// Mermaid diagram — vendored, lazy-loaded; natural height, max 1200 px + scroll
|
|
@@ -229,7 +231,9 @@ Rules of thumb:
|
|
|
229
231
|
"height": 280
|
|
230
232
|
}
|
|
231
233
|
|
|
232
|
-
// Table — sortable, annotatable cells
|
|
234
|
+
// Table — sortable, with individually annotatable cells. Prefer this over a
|
|
235
|
+
// markdown pipe table whenever you're showing structured data (option matrices,
|
|
236
|
+
// comparisons, workstream/effort grids): users can sort it and comment per cell.
|
|
233
237
|
{
|
|
234
238
|
"type": "table",
|
|
235
239
|
"columns": [
|
|
@@ -266,8 +270,8 @@ Rules of thumb:
|
|
|
266
270
|
| `graphviz` | precise dependency graphs, call graphs, state machines when Mermaid's auto-layout falls short; individually annotatable nodes and edges |
|
|
267
271
|
| `plantuml` | UML diagrams (sequence, class, component) via server rendering; great for detailed interface contracts |
|
|
268
272
|
| `chart` | numbers, trends, comparisons, metrics |
|
|
269
|
-
| `table` | structured comparisons, option matrices, data grids |
|
|
270
|
-
| `markdown` | prose context, background, instructions, section headings |
|
|
273
|
+
| `table` | structured comparisons, option matrices, data grids — **use this for any tabular data**: it's sortable and every cell is commentable, unlike a markdown pipe table |
|
|
274
|
+
| `markdown` | prose context, background, instructions, section headings (renders GFM pipe tables too, but reach for a `table` block for real data) |
|
|
271
275
|
| `code` | code snippets, config examples, command output |
|
|
272
276
|
| `image` | screenshots, mockup exports, photos — local files embed and work offline |
|
|
273
277
|
| `html` | anything else — pixel-perfect mockups, custom widgets, embeds |
|
|
@@ -410,6 +414,18 @@ default 180); once they go idle it returns the normal `wait-timeout` JSON,
|
|
|
410
414
|
with `presence` attached so you can decide what to do next. Prefer this over
|
|
411
415
|
raising `--timeout`.
|
|
412
416
|
|
|
417
|
+
### A `timeout` on a detached board is NOT the end
|
|
418
|
+
|
|
419
|
+
For a **detached** board, the `timeout` deadline is *soft*: it hands you a
|
|
420
|
+
`timeout` result (with the autosaved draft) so you regain control, but the
|
|
421
|
+
server stays up and the board stays fully usable — the user can keep
|
|
422
|
+
commenting and still hit Submit. The page tells them you stopped waiting and
|
|
423
|
+
to prompt you afterward. So if you got a `timeout` and the user might still be
|
|
424
|
+
working: re-check later with `rly result <id>` (its status flips to
|
|
425
|
+
`submitted` once they finish), or pass `--on-result` so a late submit
|
|
426
|
+
push-wakes you. A blocking `rly ask` (no `--detach`) still ends hard on
|
|
427
|
+
timeout, since there's no separate waiter to hand back to.
|
|
428
|
+
|
|
413
429
|
## Push-wake — get notified instead of polling
|
|
414
430
|
|
|
415
431
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
@@ -117,6 +117,8 @@ single/multi question.
|
|
|
117
117
|
"labels": ["Jan","Feb"], "series": [{"label":"x","data":[1,2]}], "height": 320 }
|
|
118
118
|
{ "type": "chart", "config": { /* full Chart.js v4 config */ }, "height": 300 }
|
|
119
119
|
{ "type": "table", "columns": ["A","B"], "rows": [["x","y"]], "sortable": true }
|
|
120
|
+
// ^ use a `table` block for tabular data — sortable + per-cell comments.
|
|
121
|
+
// (markdown blocks render GFM pipe tables too, but those are display-only.)
|
|
120
122
|
{ "type": "code", "lang": "js", "code": "const x = 1;" }
|
|
121
123
|
{ "type": "html", "html": "<p>hi</p>", "height": 360 }
|
|
122
124
|
{ "type": "html", "htmlFile": "viz.html", "height": 400 }
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { CliError, sleep, pollFor } from './util.js';
|
|
7
7
|
import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
@@ -23,7 +23,9 @@ import { openUrl } from './open.js';
|
|
|
23
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
25
25
|
const BIN = path.join(PKG_ROOT, 'bin', 'rly.js');
|
|
26
|
-
const
|
|
26
|
+
const PKG_JSON = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
27
|
+
const VERSION = PKG_JSON.version;
|
|
28
|
+
const PKG_NAME = PKG_JSON.name; // e.g. "@khanglvm/relay" — the global package to upgrade
|
|
27
29
|
|
|
28
30
|
const VALUED_FLAGS = new Set([
|
|
29
31
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
@@ -798,6 +800,113 @@ function cmdAgent() {
|
|
|
798
800
|
return 0;
|
|
799
801
|
}
|
|
800
802
|
|
|
803
|
+
// `rly upgrade` — install the latest CLI globally AND refresh the bundled skill
|
|
804
|
+
// in one shot. (`update` is taken by the live-mutate command, so this is
|
|
805
|
+
// `upgrade` / `self-update`.) Running boards are surfaced and handled: a global
|
|
806
|
+
// reinstall overwrites relay's files, but live detached servers snapshot their
|
|
807
|
+
// UI at first request and serve from memory, so they keep working on their own
|
|
808
|
+
// version. Flags: --stop (stop running boards first), --force (upgrade while
|
|
809
|
+
// they keep running), --cli-only / --skill-only (scope).
|
|
810
|
+
async function cmdUpgrade(args) {
|
|
811
|
+
const force = args.force === true;
|
|
812
|
+
const doStop = args.stop === true;
|
|
813
|
+
const wantCli = args.skillOnly !== true;
|
|
814
|
+
const wantSkill = args.cliOnly !== true;
|
|
815
|
+
|
|
816
|
+
const running = listRunning();
|
|
817
|
+
|
|
818
|
+
// --dry-run: report the plan (incl. how running boards would be handled)
|
|
819
|
+
// without installing anything or stopping anything.
|
|
820
|
+
if (args.dryRun === true) {
|
|
821
|
+
printJson({
|
|
822
|
+
dryRun: true,
|
|
823
|
+
wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'rly skill install'].filter(Boolean),
|
|
824
|
+
runningBoards: running.map((r) => r.id),
|
|
825
|
+
runningHandling: running.length
|
|
826
|
+
? doStop
|
|
827
|
+
? 'stop them first'
|
|
828
|
+
: force
|
|
829
|
+
? 'leave them running (snapshotted)'
|
|
830
|
+
: 'BLOCKED — pass --stop or --force'
|
|
831
|
+
: 'none running',
|
|
832
|
+
});
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (running.length) {
|
|
837
|
+
const ids = running.map((r) => r.id).join(', ');
|
|
838
|
+
if (doStop) {
|
|
839
|
+
process.stderr.write(`Stopping ${running.length} running board(s) before upgrading: ${ids}\n`);
|
|
840
|
+
for (const r of running) {
|
|
841
|
+
try { process.kill(r.pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
842
|
+
}
|
|
843
|
+
for (const r of running) await pollFor(() => (loadRunning(r.id) ? null : true), 5000);
|
|
844
|
+
} else if (!force) {
|
|
845
|
+
// Default: don't silently overwrite under live boards — explain + let the
|
|
846
|
+
// user choose. The boards themselves are safe; this is about clarity.
|
|
847
|
+
process.stderr.write(
|
|
848
|
+
`${running.length} board(s) still running: ${ids}\n` +
|
|
849
|
+
'They keep serving their current version safely (each snapshots its UI in memory),\n' +
|
|
850
|
+
'and stay readable via `rly result <id>` / `rly wait <id>`. Pick one:\n' +
|
|
851
|
+
' • rly stop --all then re-run `rly upgrade` (cleanest)\n' +
|
|
852
|
+
' • rly upgrade --stop stop them as part of this upgrade\n' +
|
|
853
|
+
' • rly upgrade --force upgrade now; leave them running on their snapshot\n'
|
|
854
|
+
);
|
|
855
|
+
printJson({ upgraded: false, reason: 'running-boards', running: running.map((r) => r.id) });
|
|
856
|
+
return 0;
|
|
857
|
+
} else {
|
|
858
|
+
process.stderr.write(`--force: leaving ${running.length} board(s) running on their snapshotted version: ${ids}\n`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const did = {};
|
|
863
|
+
|
|
864
|
+
if (wantCli) {
|
|
865
|
+
process.stderr.write(`\nUpgrading ${PKG_NAME} → latest (npm install -g ${PKG_NAME}@latest)\n`);
|
|
866
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
|
|
867
|
+
if (r.error || r.status !== 0) {
|
|
868
|
+
throw new CliError(
|
|
869
|
+
`npm install failed${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`}. ` +
|
|
870
|
+
`Update manually: npm install -g ${PKG_NAME}@latest`,
|
|
871
|
+
1
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
did.cli = `${PKG_NAME}@latest`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (wantSkill) {
|
|
878
|
+
// Spawn the freshly installed binary (on PATH) so the NEW bundled skill is
|
|
879
|
+
// what lands — this process still holds the previous bundle in memory.
|
|
880
|
+
process.stderr.write('\nRefreshing the bundled skill (rly skill install)\n');
|
|
881
|
+
const r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
|
|
882
|
+
if (r.error || r.status !== 0) {
|
|
883
|
+
process.stderr.write(
|
|
884
|
+
`Skill refresh did not complete${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`} — ` +
|
|
885
|
+
'run `rly skill install` yourself (or `npx skills add khanglvm/relay --skill relay --all`).\n'
|
|
886
|
+
);
|
|
887
|
+
} else {
|
|
888
|
+
did.skill = 'installed';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Report the now-current global version (best-effort).
|
|
893
|
+
let nowVersion = null;
|
|
894
|
+
try {
|
|
895
|
+
const v = spawnSync('rly', ['--version'], { encoding: 'utf8', shell: true });
|
|
896
|
+
if (v.status === 0 && v.stdout) nowVersion = String(v.stdout).trim();
|
|
897
|
+
} catch {
|
|
898
|
+
// best effort
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
printJson({
|
|
902
|
+
upgraded: true,
|
|
903
|
+
...did,
|
|
904
|
+
version: nowVersion,
|
|
905
|
+
note: 'most agents pick the new skill up immediately; if not, re-list skills or restart the session',
|
|
906
|
+
});
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
801
910
|
async function cmdServeInternal(args) {
|
|
802
911
|
const id = args.id;
|
|
803
912
|
if (!id) throw new CliError('__serve: missing --id');
|
|
@@ -807,6 +916,9 @@ async function cmdServeInternal(args) {
|
|
|
807
916
|
open: args.open !== false,
|
|
808
917
|
timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
|
|
809
918
|
quiet: true,
|
|
919
|
+
// Detached board: timeout hands back to the agent but keeps serving so the
|
|
920
|
+
// user can keep commenting and still submit (seamless past the deadline).
|
|
921
|
+
keepAliveOnTimeout: true,
|
|
810
922
|
});
|
|
811
923
|
await done;
|
|
812
924
|
return 0;
|
|
@@ -842,6 +954,8 @@ USAGE
|
|
|
842
954
|
rly agent FULL GUIDE for AI agents (spec format, blocks, sizing, patterns)
|
|
843
955
|
rly skill [install|rules|path] bundled universal agent skill (Claude Code, Codex, …)
|
|
844
956
|
\`rly skill rules >> CLAUDE.md\` adds always-read usage rules
|
|
957
|
+
rly upgrade install the latest CLI globally + refresh the skill in one step
|
|
958
|
+
--stop/--force handle running boards · --dry-run · --cli-only/--skill-only
|
|
845
959
|
|
|
846
960
|
COMMON FLAGS
|
|
847
961
|
--title <s> --intro <s> --html-file <f> --height <px> --submit-label <s>
|
|
@@ -904,6 +1018,9 @@ export async function main(argv) {
|
|
|
904
1018
|
return cmdRm(parseArgs(rest));
|
|
905
1019
|
case 'skill':
|
|
906
1020
|
return cmdSkill(rest);
|
|
1021
|
+
case 'upgrade':
|
|
1022
|
+
case 'self-update':
|
|
1023
|
+
return await cmdUpgrade(parseArgs(rest));
|
|
907
1024
|
case 'agent':
|
|
908
1025
|
return cmdAgent();
|
|
909
1026
|
case 'schema':
|
package/src/server.js
CHANGED
|
@@ -16,12 +16,25 @@ const escapeHtml = (s) =>
|
|
|
16
16
|
|
|
17
17
|
// Concurrently authored UI assets (blocks/annotate) may not exist yet at this
|
|
18
18
|
// phase's runtime — read-guard so the server still boots with empty fallbacks.
|
|
19
|
+
//
|
|
20
|
+
// Successful reads are cached for the lifetime of the process. A running board
|
|
21
|
+
// thus serves one consistent UI snapshot taken at first request: if the relay
|
|
22
|
+
// package is updated on disk (e.g. `rly upgrade`) while this server is live, it
|
|
23
|
+
// keeps serving its own version instead of mixing new assets with old in-memory
|
|
24
|
+
// server logic. Empty/failed reads are NOT cached, preserving the boot-time
|
|
25
|
+
// fallback for assets authored concurrently during a build.
|
|
26
|
+
const _uiCache = new Map();
|
|
19
27
|
function readUi(name) {
|
|
28
|
+
const hit = _uiCache.get(name);
|
|
29
|
+
if (hit) return hit;
|
|
30
|
+
let content = '';
|
|
20
31
|
try {
|
|
21
|
-
|
|
32
|
+
content = fs.readFileSync(path.join(UI_DIR, name), 'utf8');
|
|
22
33
|
} catch {
|
|
23
34
|
return '';
|
|
24
35
|
}
|
|
36
|
+
if (content) _uiCache.set(name, content);
|
|
37
|
+
return content;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
// Strips block bodies for the client payload: html blocks ship only metadata
|
|
@@ -324,7 +337,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
|
|
|
324
337
|
// (submitted / acknowledged / timeout / cancelled). The result is also
|
|
325
338
|
// persisted into the board record so `rly wait` / `rly result` can read it
|
|
326
339
|
// from another process.
|
|
327
|
-
export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false }) {
|
|
340
|
+
export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false, keepAliveOnTimeout = false }) {
|
|
328
341
|
const record = loadBoard(id);
|
|
329
342
|
if (!record) throw new Error(`board ${id} not found`);
|
|
330
343
|
const spec = record.spec;
|
|
@@ -355,6 +368,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
355
368
|
let rev = 1;
|
|
356
369
|
let status = 'open';
|
|
357
370
|
let finished = false;
|
|
371
|
+
// Soft timeout: the board's time is up and a `timeout` result was handed back
|
|
372
|
+
// to the waiting agent, but the server stays live so the user can keep
|
|
373
|
+
// working and still submit. Surfaced via /api/status so the page can show a
|
|
374
|
+
// calm "agent stopped waiting" note instead of disconnecting.
|
|
375
|
+
let softTimedOut = false;
|
|
358
376
|
// Latest client presence ping (null until the first ping arrives).
|
|
359
377
|
let presence = null;
|
|
360
378
|
let resolveDone;
|
|
@@ -372,7 +390,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
372
390
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
373
391
|
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
374
392
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
375
|
-
sendJson(res, 200, { status, rev });
|
|
393
|
+
sendJson(res, 200, { status, rev, softTimedOut });
|
|
376
394
|
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
377
395
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
378
396
|
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
@@ -498,15 +516,25 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
498
516
|
});
|
|
499
517
|
|
|
500
518
|
let timer = null;
|
|
501
|
-
|
|
519
|
+
let idleTimer = null;
|
|
520
|
+
// The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
|
|
521
|
+
// we hand a `timeout` result back to the waiting agent (so `rly wait` returns
|
|
522
|
+
// with the autosaved draft) but keep the server listening, so the user can
|
|
523
|
+
// keep working and still submit. A late submit overwrites the result with
|
|
524
|
+
// `submitted` and re-fires the push-wake; the board only truly closes on
|
|
525
|
+
// submit, an explicit stop, or once the user has clearly left (idle
|
|
526
|
+
// watchdog). A BLOCKING `rly ask` has no separate waiter to hand back to, so
|
|
527
|
+
// its timeout stays hard (close + resolve, exit 2).
|
|
528
|
+
if (timeoutSec > 0) {
|
|
529
|
+
timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
|
|
530
|
+
}
|
|
502
531
|
const onSignal = () => finish({ status: 'cancelled' });
|
|
503
532
|
process.on('SIGINT', onSignal);
|
|
504
533
|
process.on('SIGTERM', onSignal);
|
|
505
534
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
status = partial.status;
|
|
535
|
+
// Build + persist a result object onto the record (read cross-process by
|
|
536
|
+
// `rly wait` / `rly result`). Does NOT touch the server lifecycle.
|
|
537
|
+
function persistResult(partial) {
|
|
510
538
|
// blockEdits: from this submit, else fall back to the autosaved draft
|
|
511
539
|
// (timeout/cancel). null when there are none.
|
|
512
540
|
const editsRaw = partial.blockEdits ?? (record.draft?.blockEdits || {});
|
|
@@ -533,13 +561,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
533
561
|
}
|
|
534
562
|
record.result = result;
|
|
535
563
|
saveBoard(record);
|
|
536
|
-
|
|
537
|
-
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Tear the HTTP server down after a short grace (so the success page renders
|
|
568
|
+
// and auto-closes first). The result is assumed already persisted.
|
|
569
|
+
function closeServer(result) {
|
|
538
570
|
removeRunning(record.id);
|
|
539
571
|
if (timer) clearTimeout(timer);
|
|
572
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
540
573
|
process.removeListener('SIGINT', onSignal);
|
|
541
574
|
process.removeListener('SIGTERM', onSignal);
|
|
542
|
-
// Grace period so the success page renders (and auto-closes) first.
|
|
543
575
|
setTimeout(() => {
|
|
544
576
|
try {
|
|
545
577
|
server.closeAllConnections?.();
|
|
@@ -551,6 +583,52 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
551
583
|
}, 600);
|
|
552
584
|
}
|
|
553
585
|
|
|
586
|
+
// Terminal finish (submit / acknowledge / explicit cancel): persist the
|
|
587
|
+
// result, push-wake the agent for EVERY terminal status, and close the
|
|
588
|
+
// server. A late submit after a soft timeout lands here and overwrites the
|
|
589
|
+
// earlier `timeout` result with `submitted` (re-firing the push-wake).
|
|
590
|
+
function finish(partial) {
|
|
591
|
+
if (finished) return;
|
|
592
|
+
finished = true;
|
|
593
|
+
status = partial.status;
|
|
594
|
+
const result = persistResult(partial);
|
|
595
|
+
runOnResult(record.onResult, result, { quiet });
|
|
596
|
+
closeServer(result);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// SOFT timeout — hand a `timeout` result to the agent but keep the board live
|
|
600
|
+
// and submittable. The agent's `rly wait` returns now; the user can carry on.
|
|
601
|
+
function softTimeout() {
|
|
602
|
+
if (finished || softTimedOut) return;
|
|
603
|
+
softTimedOut = true;
|
|
604
|
+
const result = persistResult({ status: 'timeout' });
|
|
605
|
+
runOnResult(record.onResult, result, { quiet });
|
|
606
|
+
startIdleWatchdog();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// After a soft timeout, close the board for real once the user has clearly
|
|
610
|
+
// left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
|
|
611
|
+
// an abandoned board doesn't keep a server alive forever. Re-persists the
|
|
612
|
+
// latest draft as the final `timeout` result; no double push-wake.
|
|
613
|
+
function startIdleWatchdog() {
|
|
614
|
+
const IDLE_CLOSE_MS = 15 * 60 * 1000;
|
|
615
|
+
const HARD_CAP_MS = 6 * 60 * 60 * 1000;
|
|
616
|
+
const softAt = Date.now();
|
|
617
|
+
idleTimer = setInterval(() => {
|
|
618
|
+
if (finished) {
|
|
619
|
+
clearInterval(idleTimer);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const lastSeen = presence ? presence.atMs : softAt;
|
|
623
|
+
if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
|
|
624
|
+
finished = true;
|
|
625
|
+
clearInterval(idleTimer);
|
|
626
|
+
closeServer(persistResult({ status: 'timeout' }));
|
|
627
|
+
}
|
|
628
|
+
}, 60 * 1000);
|
|
629
|
+
idleTimer.unref?.();
|
|
630
|
+
}
|
|
631
|
+
|
|
554
632
|
if (open) openUrl(url);
|
|
555
633
|
if (!quiet) {
|
|
556
634
|
process.stderr.write(
|
package/src/spec.js
CHANGED
|
@@ -411,7 +411,7 @@ const BLOCK_SCHEMA = {
|
|
|
411
411
|
required: ['type'],
|
|
412
412
|
properties: {
|
|
413
413
|
type: { type: 'string', enum: BLOCK_TYPES },
|
|
414
|
-
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library). Text selections are commentable.' },
|
|
414
|
+
md: { type: 'string', description: 'markdown: built-in mini renderer (no external library) — headings, lists, code, quotes, links, and GFM pipe tables. Text selections are commentable. For real tabular data prefer a "table" block (sortable + per-cell comments).' },
|
|
415
415
|
code: { type: 'string', description: 'mermaid: diagram source (e.g. "graph TD; A-->B"); plantuml: the @startuml…@enduml source; code: the source to display.' },
|
|
416
416
|
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>].' },
|
|
417
417
|
dot: { type: 'string', description: 'graphviz: DOT source (e.g. "digraph { a -> b }"). Rendered offline via vendored Viz.js; nodes and edges are individually commentable.' },
|
package/src/ui/annotate.css
CHANGED
|
@@ -167,7 +167,81 @@
|
|
|
167
167
|
}
|
|
168
168
|
.ann-selbtn:hover { background: var(--accent-hover); }
|
|
169
169
|
|
|
170
|
-
/* ----------
|
|
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 {
|
package/src/ui/annotate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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('
|
|
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
|
-
//
|
|
582
|
-
|
|
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' },
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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 {
|
package/src/ui/blocks.js
CHANGED
|
@@ -103,6 +103,55 @@
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// GFM pipe tables — a header row of "| a | b |" cells immediately followed
|
|
107
|
+
// by a separator row of dashes (with optional ":" alignment markers).
|
|
108
|
+
// Rendered as a real .blk-table so it matches the dedicated table block.
|
|
109
|
+
function splitRow(row) {
|
|
110
|
+
const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
111
|
+
return trimmed.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim());
|
|
112
|
+
}
|
|
113
|
+
function isSepRow(row) {
|
|
114
|
+
if (!row || row.indexOf('|') === -1) return false;
|
|
115
|
+
const cells = splitRow(row);
|
|
116
|
+
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c));
|
|
117
|
+
}
|
|
118
|
+
function isTableStart(idx) {
|
|
119
|
+
const head = lines[idx];
|
|
120
|
+
return (
|
|
121
|
+
idx + 1 < lines.length &&
|
|
122
|
+
head.indexOf('|') !== -1 &&
|
|
123
|
+
!isSepRow(head) &&
|
|
124
|
+
isSepRow(lines[idx + 1])
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function consumeTable() {
|
|
128
|
+
const headers = splitRow(lines[i]);
|
|
129
|
+
const aligns = splitRow(lines[i + 1]).map((c) => {
|
|
130
|
+
const left = c.startsWith(':');
|
|
131
|
+
const right = c.endsWith(':');
|
|
132
|
+
return left && right ? 'center' : right ? 'right' : left ? 'left' : '';
|
|
133
|
+
});
|
|
134
|
+
i += 2;
|
|
135
|
+
const body = [];
|
|
136
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && lines[i].indexOf('|') !== -1 && !isSepRow(lines[i])) {
|
|
137
|
+
body.push(splitRow(lines[i]));
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
const ncols = headers.length;
|
|
141
|
+
const alignAttr = (ci) => (aligns[ci] ? ` style="text-align:${aligns[ci]}"` : '');
|
|
142
|
+
const cell = (tag, ci, text) => `<${tag}${alignAttr(ci)}>${mdInline(esc(text == null ? '' : text))}</${tag}>`;
|
|
143
|
+
let out = '<div class="md-tablewrap"><table class="blk-table md-table"><thead><tr>';
|
|
144
|
+
for (let c = 0; c < ncols; c++) out += cell('th', c, headers[c]);
|
|
145
|
+
out += '</tr></thead><tbody>';
|
|
146
|
+
for (const r of body) {
|
|
147
|
+
out += '<tr>';
|
|
148
|
+
for (let c = 0; c < ncols; c++) out += cell('td', c, r[c]);
|
|
149
|
+
out += '</tr>';
|
|
150
|
+
}
|
|
151
|
+
out += '</tbody></table></div>';
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
106
155
|
while (i < lines.length) {
|
|
107
156
|
const line = lines[i];
|
|
108
157
|
|
|
@@ -140,6 +189,9 @@
|
|
|
140
189
|
continue;
|
|
141
190
|
}
|
|
142
191
|
|
|
192
|
+
// GFM pipe table (header row + dash separator)
|
|
193
|
+
if (isTableStart(i)) { html += consumeTable(); continue; }
|
|
194
|
+
|
|
143
195
|
// lists
|
|
144
196
|
if (/^(\s*)([-*]|\d+\.)\s+/.test(line)) { html += consumeList(); continue; }
|
|
145
197
|
|
|
@@ -155,7 +207,8 @@
|
|
|
155
207
|
!/^\s*---+\s*$/.test(lines[i]) &&
|
|
156
208
|
!/^#{1,3}\s+/.test(lines[i]) &&
|
|
157
209
|
!/^\s*>\s?/.test(lines[i]) &&
|
|
158
|
-
!/^(\s*)([-*]|\d+\.)\s+/.test(lines[i])
|
|
210
|
+
!/^(\s*)([-*]|\d+\.)\s+/.test(lines[i]) &&
|
|
211
|
+
!isTableStart(i)
|
|
159
212
|
) {
|
|
160
213
|
buf.push(lines[i]);
|
|
161
214
|
i++;
|
package/src/ui/style.css
CHANGED
|
@@ -73,7 +73,7 @@ body {
|
|
|
73
73
|
color: var(--fg);
|
|
74
74
|
font: 16px/1.6 var(--sans);
|
|
75
75
|
-webkit-font-smoothing: antialiased;
|
|
76
|
-
transition: background 200ms var(--ease), color 200ms var(--ease);
|
|
76
|
+
transition: background 200ms var(--ease), color 200ms var(--ease), padding 240ms var(--ease);
|
|
77
77
|
}
|
|
78
78
|
.wrap { max-width: 860px; margin: 0 auto; padding: 32px 20px 28px; }
|
|
79
79
|
|
|
@@ -193,7 +193,7 @@ input[type="text"]:focus, textarea:focus {
|
|
|
193
193
|
textarea { min-height: 90px; resize: vertical; }
|
|
194
194
|
|
|
195
195
|
.qnotewrap { margin-top: 10px; }
|
|
196
|
-
.qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; }
|
|
196
|
+
.qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; min-height: 52px; resize: vertical; }
|
|
197
197
|
|
|
198
198
|
.errmsg { color: var(--danger); font-size: 0.85rem; margin: 8px 0 0; display: none; }
|
|
199
199
|
.card.error .errmsg { display: block; }
|
|
@@ -220,8 +220,14 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
220
220
|
position: sticky; top: 0;
|
|
221
221
|
background: var(--danger); color: var(--danger-fg);
|
|
222
222
|
text-align: center; padding: 9px 16px; font-size: 0.9rem;
|
|
223
|
+
line-height: 1.45;
|
|
223
224
|
z-index: 10; display: none;
|
|
225
|
+
border-bottom: 1px solid transparent;
|
|
224
226
|
}
|
|
227
|
+
/* Calm status notes (timeout hand-back / lost connection). Never the red error
|
|
228
|
+
state — the board stays usable, so these only inform. */
|
|
229
|
+
.banner.info { background: var(--accent-soft); color: var(--accent); }
|
|
230
|
+
.banner.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
|
|
225
231
|
|
|
226
232
|
/* Live-update toast: flagged after a `rly update` reload (see app.js).
|
|
227
233
|
Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
|