@khanglvm/relay 0.13.9 → 0.14.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 +9 -6
- package/docs/AGENT.md +22 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +25 -2
- package/src/cli.js +51 -1
- package/src/mcp.js +4 -3
- package/src/server.js +229 -20
- package/src/ui/annotate.js +35 -7
- package/src/ui/app.js +152 -4
- package/src/ui/style.css +52 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ step, leaving any boards you have open untouched.
|
|
|
43
43
|
relay also runs as an **MCP App** ([SEP-1865](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp))
|
|
44
44
|
— so the same board renders **inline, right in the conversation**, on Claude
|
|
45
45
|
desktop **and mobile** and in Codex, no browser tab. `rly mcp` is a
|
|
46
|
-
|
|
46
|
+
stdio MCP server with no npm runtime dependencies; register it once and the agent gets two tools,
|
|
47
47
|
`relay_ask` (collect decisions/feedback with real form controls) and
|
|
48
48
|
`relay_show` (present a plan, diagram, diff, table, or prototype):
|
|
49
49
|
|
|
@@ -81,8 +81,9 @@ unchanged; pick whichever surface fits.
|
|
|
81
81
|
| Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
|
|
82
82
|
|
|
83
83
|
Everything autosaves in real time (drafts survive timeouts), multiple boards
|
|
84
|
-
run at once, and the package has **zero runtime dependencies** — plain
|
|
85
|
-
Node ≥ 18
|
|
84
|
+
run at once, and the package has **zero npm runtime dependencies** — plain
|
|
85
|
+
Node ≥ 18. Browser-side Chart.js, Mermaid, and Viz.js assets are vendored in
|
|
86
|
+
the package and lazy-loaded offline when a board actually needs them.
|
|
86
87
|
|
|
87
88
|
## Learn more
|
|
88
89
|
|
|
@@ -91,6 +92,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
91
92
|
| `rly help` | every command at a glance |
|
|
92
93
|
| `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
|
|
93
94
|
| `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
|
|
95
|
+
| `rly share <board-id>` | activate/list/revoke same-Wi-Fi reviewer or collaborator links for a running browser board |
|
|
94
96
|
| `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
|
|
95
97
|
| `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
|
|
96
98
|
| `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
|
|
@@ -102,14 +104,14 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
|
|
|
102
104
|
## Development
|
|
103
105
|
|
|
104
106
|
```sh
|
|
105
|
-
npm test #
|
|
107
|
+
npm test # smoke tests with no external services (spawns real servers, fake-submits)
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
## Changelog
|
|
109
111
|
|
|
110
112
|
### 0.11.0 — render inline inside the Claude & Codex apps (MCP App)
|
|
111
113
|
- **`rly mcp` — relay as an MCP App** ([SEP-1865](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp),
|
|
112
|
-
extension `io.modelcontextprotocol/ui`). A
|
|
114
|
+
extension `io.modelcontextprotocol/ui`). A stdio MCP server with no npm runtime dependencies
|
|
113
115
|
that declares a `ui://relay/board` resource (`text/html;profile=mcp-app`) and
|
|
114
116
|
two tools, **`relay_ask`** and **`relay_show`**, linked to it via
|
|
115
117
|
`_meta.ui.resourceUri` (plus `openai/outputTemplate` for ChatGPT/Codex). The
|
|
@@ -172,7 +174,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
|
|
|
172
174
|
- **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
|
|
173
175
|
board whose connection drops blocks further input instead of losing it, and
|
|
174
176
|
`rly rescue <id>` re-serves on the same port so an open tab reconnects.
|
|
175
|
-
- Still **zero runtime dependencies**, offline, and cross-platform.
|
|
177
|
+
- Still **zero npm runtime dependencies**, offline, and cross-platform. Vendored
|
|
178
|
+
browser libraries are loaded only for boards that need them.
|
|
176
179
|
|
|
177
180
|
### 0.9.1
|
|
178
181
|
- The board **intro renders as markdown** (bold/italic/code/links/lists).
|
package/docs/AGENT.md
CHANGED
|
@@ -71,6 +71,28 @@ Answers **autosave in real time** as the user fills the board — a page reload
|
|
|
71
71
|
restores them, and a draft survives timeouts/cancellation (included in those
|
|
72
72
|
results), so partial input is never lost.
|
|
73
73
|
|
|
74
|
+
## Same-Wi-Fi sharing
|
|
75
|
+
|
|
76
|
+
Browser boards are local-owner only by default. A same-Wi-Fi phone/tablet/other
|
|
77
|
+
laptop gets a locked page until sharing is explicitly activated. The owner can
|
|
78
|
+
click **Share** at the bottom of the board, or you can manage the same links for
|
|
79
|
+
the user:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
rly share b-xxxxx # list active share roles
|
|
83
|
+
rly share b-xxxxx --role review # reviewer: add comments only
|
|
84
|
+
rly share b-xxxxx --role collab # collaborator: edit/comment/submit
|
|
85
|
+
rly share b-xxxxx --role review --revoke # revoke one role
|
|
86
|
+
rly share b-xxxxx --revoke --all # revoke all active links
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use `review` unless the user explicitly wants the other device to submit as the
|
|
90
|
+
owner. Reviewers cannot change answers, submit, edit comments, or delete
|
|
91
|
+
comments; their autosaves merge new comments into the existing draft.
|
|
92
|
+
Collaborators can edit answers, comment, open allowed local file links, and
|
|
93
|
+
submit. Shared viewers refresh from the live draft when another viewer saves,
|
|
94
|
+
deferred while someone is typing/commenting.
|
|
95
|
+
|
|
74
96
|
## Inline mode — relay as an MCP App (Claude & Codex apps)
|
|
75
97
|
|
|
76
98
|
Everything above is the **CLI** surface (you run `rly` in a terminal and read
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -174,6 +174,28 @@ Exit codes: 0 submitted · 2 timeout · 3 cancelled · 5 not found. On
|
|
|
174
174
|
timeout/cancel the result still contains the autosaved `draft` of partial
|
|
175
175
|
answers and any annotations written so far.
|
|
176
176
|
|
|
177
|
+
## Same-Wi-Fi sharing
|
|
178
|
+
|
|
179
|
+
Browser boards are local-owner only by default. A phone/tablet/other laptop on
|
|
180
|
+
the same Wi-Fi gets a locked page until sharing is explicitly activated. The
|
|
181
|
+
owner can click **Share** at the bottom of the board, or an agent can manage the
|
|
182
|
+
same links with `rly share`:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
rly share b-xxxxx # list active share roles
|
|
186
|
+
rly share b-xxxxx --role review # activate a reviewer link
|
|
187
|
+
rly share b-xxxxx --role collab # activate a collaborator link
|
|
188
|
+
rly share b-xxxxx --role review --revoke # revoke one role
|
|
189
|
+
rly share b-xxxxx --revoke --all # revoke all active share links
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Use `review` when the other device should add comments only. Reviewers cannot
|
|
193
|
+
change answers, submit, edit comments, or delete comments; their autosaves merge
|
|
194
|
+
new comments into the existing draft. Use `collab` only when the user explicitly
|
|
195
|
+
wants that device to act as the owner: collaborators can edit answers, comment,
|
|
196
|
+
open allowed local file links, and submit. Shared viewers refresh from the live
|
|
197
|
+
draft when another viewer saves, deferred while someone is typing/commenting.
|
|
198
|
+
|
|
177
199
|
## Minimal spec
|
|
178
200
|
|
|
179
201
|
```json
|
|
@@ -347,8 +369,9 @@ the option (and stays annotatable); the label row selects. Use per-option
|
|
|
347
369
|
blocks whenever a question's choices have visual/example context; skip them
|
|
348
370
|
for plainly textual options.
|
|
349
371
|
|
|
350
|
-
Chart.js, Mermaid, and Graphviz are **vendored and lazy-loaded** — the
|
|
351
|
-
|
|
372
|
+
Chart.js, Mermaid, and Graphviz are **vendored and lazy-loaded** — the package
|
|
373
|
+
has no npm runtime dependencies, but these browser assets are bundled for rich
|
|
374
|
+
blocks. PlantUML uses the public plantuml.com server by default;
|
|
352
375
|
pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
|
|
353
376
|
`"htmlHeight"` on root or questions are still accepted and normalised automatically.
|
|
354
377
|
|
package/src/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ const VALUED_FLAGS = new Set([
|
|
|
33
33
|
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
34
34
|
'submit-label', 'height', 'limit', 'target', 'id', 'replies',
|
|
35
35
|
'on-result', 'notify-cmd', 'idle-grace', 'scope', 'range',
|
|
36
|
-
'host', 'token', 'allow-origin',
|
|
36
|
+
'host', 'token', 'allow-origin', 'role',
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
39
|
function camel(key) {
|
|
@@ -779,6 +779,45 @@ async function cmdResult(args) {
|
|
|
779
779
|
return 5;
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
+
async function cmdShare(args) {
|
|
783
|
+
const record = mustLoad(args._[0]);
|
|
784
|
+
const running = loadRunning(record.id);
|
|
785
|
+
if (!running || !isAlive(running.pid)) {
|
|
786
|
+
throw new CliError(`board "${record.id}" is not running. Use \`rly reopen ${record.id} --detach\` first.`, 5);
|
|
787
|
+
}
|
|
788
|
+
const role = args.all ? 'all' : args.role;
|
|
789
|
+
if (role !== undefined && !['collab', 'review', 'all'].includes(role)) {
|
|
790
|
+
throw new CliError('--role must be collab or review.', 4);
|
|
791
|
+
}
|
|
792
|
+
const shareUrl = new URL('/api/share', running.url);
|
|
793
|
+
let res;
|
|
794
|
+
try {
|
|
795
|
+
if (args.revoke) {
|
|
796
|
+
res = await fetch(shareUrl, {
|
|
797
|
+
method: 'DELETE',
|
|
798
|
+
headers: { 'content-type': 'application/json' },
|
|
799
|
+
body: JSON.stringify({ role: role || 'all' }),
|
|
800
|
+
});
|
|
801
|
+
} else if (role && role !== 'all') {
|
|
802
|
+
res = await fetch(shareUrl, {
|
|
803
|
+
method: 'POST',
|
|
804
|
+
headers: { 'content-type': 'application/json' },
|
|
805
|
+
body: JSON.stringify({ role }),
|
|
806
|
+
});
|
|
807
|
+
} else {
|
|
808
|
+
res = await fetch(shareUrl, { method: 'GET' });
|
|
809
|
+
}
|
|
810
|
+
} catch (err) {
|
|
811
|
+
throw new CliError(`could not reach board "${record.id}" at ${running.url}: ${String((err && err.message) || err)}`, 5);
|
|
812
|
+
}
|
|
813
|
+
const body = await res.json().catch(() => null);
|
|
814
|
+
if (!res.ok) {
|
|
815
|
+
throw new CliError((body && body.error) || `share request failed with HTTP ${res.status}`, res.status === 400 ? 4 : 5);
|
|
816
|
+
}
|
|
817
|
+
printJson({ status: args.revoke ? 'revoked' : role && role !== 'all' ? 'active' : 'shares', boardId: record.id, ...body });
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
|
|
782
821
|
function cmdList(args) {
|
|
783
822
|
const running = listRunning();
|
|
784
823
|
if (args.json) {
|
|
@@ -1056,6 +1095,10 @@ the user should view — put it in relay instead of printing it.**
|
|
|
1056
1095
|
- Point the user at a file with a clickable local path in a markdown block; embed a
|
|
1057
1096
|
screen recording with a \`video\` block; when answer choices are visual, give each
|
|
1058
1097
|
option its own visual (\`options[].blocks\`) so the user picks by looking.
|
|
1098
|
+
- Same-Wi-Fi sharing is locked by default. When the user wants another device to
|
|
1099
|
+
review or co-fill a running board, manage explicit links with \`rly share <id>\`:
|
|
1100
|
+
\`--role review\` creates a comments-only reviewer link, \`--role collab\` creates
|
|
1101
|
+
an edit/comment/submit link, and \`--revoke\` disables active links.
|
|
1059
1102
|
- **There's a purpose-built component for most content — use the MOST SPECIFIC one,
|
|
1060
1103
|
never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
|
|
1061
1104
|
load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
|
|
@@ -1561,6 +1604,11 @@ USAGE
|
|
|
1561
1604
|
NB: also writes the FULL result to "resultFile" (first field) —
|
|
1562
1605
|
read that file if your shell truncates stdout; never pipe to head/tail
|
|
1563
1606
|
rly result <id> result/status now (includes live autosaved draft + presence while open)
|
|
1607
|
+
rly share <id> list active same-Wi-Fi share links for a running board
|
|
1608
|
+
rly share <id> --role review activate a reviewer link (comments only)
|
|
1609
|
+
rly share <id> --role collab activate a collaborator link (edit/comment/submit)
|
|
1610
|
+
rly share <id> --role review --revoke
|
|
1611
|
+
revoke one share role; use --revoke --all to disable all roles
|
|
1564
1612
|
rly list [--json] running boards
|
|
1565
1613
|
rly open [id] re-open the browser tab of a running board
|
|
1566
1614
|
rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
|
|
@@ -1644,6 +1692,8 @@ export async function main(argv) {
|
|
|
1644
1692
|
return await cmdWait(parseArgs(rest));
|
|
1645
1693
|
case 'result':
|
|
1646
1694
|
return await cmdResult(parseArgs(rest));
|
|
1695
|
+
case 'share':
|
|
1696
|
+
return await cmdShare(parseArgs(rest));
|
|
1647
1697
|
case 'list':
|
|
1648
1698
|
return cmdList(parseArgs(rest));
|
|
1649
1699
|
case 'open':
|
package/src/mcp.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// mcp.js — relay as an MCP App (SEP-1865, extension "io.modelcontextprotocol/ui").
|
|
2
2
|
//
|
|
3
|
-
// `rly mcp` starts
|
|
4
|
-
// host that supports MCP Apps (Claude desktop/mobile, Codex,
|
|
5
|
-
// boards render INLINE in the conversation instead of opening a
|
|
3
|
+
// `rly mcp` starts an MCP server over stdio with no npm runtime dependencies.
|
|
4
|
+
// Register it with a host that supports MCP Apps (Claude desktop/mobile, Codex,
|
|
5
|
+
// …) and relay's boards render INLINE in the conversation instead of opening a
|
|
6
|
+
// browser tab:
|
|
6
7
|
//
|
|
7
8
|
// • the server declares a UI resource ui://relay/board (text/html;profile=mcp-app)
|
|
8
9
|
// • the tools `relay_ask` / `relay_show` link to it via _meta.ui.resourceUri
|
package/src/server.js
CHANGED
|
@@ -96,7 +96,46 @@ function vendorPresent(file) {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function
|
|
99
|
+
function cleanShareHost(raw) {
|
|
100
|
+
const s = String(raw || '').trim();
|
|
101
|
+
if (!s) return null;
|
|
102
|
+
let host = s;
|
|
103
|
+
try {
|
|
104
|
+
host = s.includes('://') ? new URL(s).hostname : s;
|
|
105
|
+
} catch {
|
|
106
|
+
host = s;
|
|
107
|
+
}
|
|
108
|
+
host = host.replace(/^https?:\/\//, '').split('/')[0].split(':')[0].trim();
|
|
109
|
+
if (!host || host === '127.0.0.1' || host === 'localhost') return null;
|
|
110
|
+
return host;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isPrivateIpv4(address) {
|
|
114
|
+
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(address)) return false;
|
|
115
|
+
const parts = address.split('.').map((n) => Number(n));
|
|
116
|
+
if (parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
117
|
+
return (
|
|
118
|
+
parts[0] === 10 ||
|
|
119
|
+
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
|
120
|
+
(parts[0] === 192 && parts[1] === 168)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shareHost() {
|
|
125
|
+
const forced = cleanShareHost(process.env.RLY_SHARE_HOST);
|
|
126
|
+
if (forced) return forced;
|
|
127
|
+
const candidates = [];
|
|
128
|
+
const nets = os.networkInterfaces();
|
|
129
|
+
for (const list of Object.values(nets)) {
|
|
130
|
+
for (const net of list || []) {
|
|
131
|
+
if (!net || net.internal || net.family !== 'IPv4') continue;
|
|
132
|
+
candidates.push(net.address);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return candidates.find(isPrivateIpv4) || candidates[0] || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
|
|
100
139
|
const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
|
|
101
140
|
const css = readUi('style.css');
|
|
102
141
|
const blocksCss = readUi('blocks.css');
|
|
@@ -136,7 +175,7 @@ function buildPage(record, rev) {
|
|
|
136
175
|
updatedAt: record.draft.updatedAt || null,
|
|
137
176
|
}
|
|
138
177
|
: null;
|
|
139
|
-
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
|
|
178
|
+
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
|
|
140
179
|
const json = JSON.stringify(boot).replace(/</g, '\\u003c');
|
|
141
180
|
return html
|
|
142
181
|
.split('__TITLE__').join(escapeHtml(spec.title))
|
|
@@ -149,6 +188,14 @@ function buildPage(record, rev) {
|
|
|
149
188
|
.split('__BOOT_JSON__').join(json);
|
|
150
189
|
}
|
|
151
190
|
|
|
191
|
+
function buildLockedPage(title = 'Relay board') {
|
|
192
|
+
return '<!doctype html><html><head><meta charset="utf-8">' +
|
|
193
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
194
|
+
'<title>' + escapeHtml(title) + '</title>' +
|
|
195
|
+
'<style>body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fcfbf9;color:#1c1b19;display:grid;min-height:100vh;place-items:center}.box{max-width:420px;padding:28px;text-align:center}.title{font-size:22px;font-weight:650;margin-bottom:8px}.sub{color:#57534e}</style>' +
|
|
196
|
+
'</head><body><div class="box"><div class="title">Share link not active</div><div class="sub">Ask the board owner to use Share and choose a permission before opening this board from another device.</div></div></body></html>';
|
|
197
|
+
}
|
|
198
|
+
|
|
152
199
|
// Validates + sanitizes one annotation's threaded replies. Keeps only
|
|
153
200
|
// well-formed {author, text, createdAt} entries; caps at 50; coerces author.
|
|
154
201
|
function sanitizeReplies(value) {
|
|
@@ -184,6 +231,50 @@ function sanitizeAnnotations(value) {
|
|
|
184
231
|
return out;
|
|
185
232
|
}
|
|
186
233
|
|
|
234
|
+
function nextAnnotationId(existing, used) {
|
|
235
|
+
let max = 0;
|
|
236
|
+
for (const a of existing) {
|
|
237
|
+
const m = /^a(\d+)$/.exec(String(a && a.id || ''));
|
|
238
|
+
if (m) max = Math.max(max, Number.parseInt(m[1], 10) || 0);
|
|
239
|
+
}
|
|
240
|
+
let id;
|
|
241
|
+
do {
|
|
242
|
+
id = 'a' + (++max);
|
|
243
|
+
} while (used.has(id));
|
|
244
|
+
used.add(id);
|
|
245
|
+
return id;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sameReply(a, b) {
|
|
249
|
+
return a && b && a.author === b.author && a.text === b.text && a.createdAt === b.createdAt;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function mergeReviewAnnotations(existingValue, incomingValue) {
|
|
253
|
+
const existing = sanitizeAnnotations(existingValue);
|
|
254
|
+
const incoming = sanitizeAnnotations(incomingValue);
|
|
255
|
+
const out = existing.map((a) => ({ ...a, replies: Array.isArray(a.replies) ? [...a.replies] : [] }));
|
|
256
|
+
const byId = new Map(out.map((a) => [String(a.id || ''), a]));
|
|
257
|
+
const used = new Set(out.map((a) => String(a.id || '')).filter(Boolean));
|
|
258
|
+
for (const ann of incoming) {
|
|
259
|
+
const id = String(ann.id || '');
|
|
260
|
+
const current = byId.get(id);
|
|
261
|
+
if (!current) {
|
|
262
|
+
const next = { ...ann, id: id && !used.has(id) ? id : nextAnnotationId(out, used) };
|
|
263
|
+
next.replies = Array.isArray(next.replies) ? next.replies : [];
|
|
264
|
+
out.push(next);
|
|
265
|
+
byId.set(next.id, next);
|
|
266
|
+
used.add(next.id);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const replies = Array.isArray(current.replies) ? current.replies : (current.replies = []);
|
|
270
|
+
for (const r of Array.isArray(ann.replies) ? ann.replies : []) {
|
|
271
|
+
if (replies.length >= 50) break;
|
|
272
|
+
if (!replies.some((x) => sameReply(x, r))) replies.push(r);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out.slice(0, 500);
|
|
276
|
+
}
|
|
277
|
+
|
|
187
278
|
function limitString(v, max) {
|
|
188
279
|
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
189
280
|
}
|
|
@@ -390,12 +481,13 @@ function buildOpenAllowlist(spec, baseCwd) {
|
|
|
390
481
|
// True when an Origin header (if present) belongs to this board's own server.
|
|
391
482
|
// Same-origin fetches send no Origin or our own; a foreign Origin is a
|
|
392
483
|
// cross-site POST and must not be allowed to open a local file.
|
|
393
|
-
function sameOrigin(req, port) {
|
|
484
|
+
function sameOrigin(req, port, extraHosts = []) {
|
|
394
485
|
const origin = req.headers.origin;
|
|
395
486
|
if (!origin) return true;
|
|
396
487
|
try {
|
|
397
|
-
const
|
|
398
|
-
|
|
488
|
+
const u = new URL(origin);
|
|
489
|
+
if (u.port !== String(port)) return false;
|
|
490
|
+
return new Set(['127.0.0.1', 'localhost', ...extraHosts.filter(Boolean)]).has(u.hostname);
|
|
399
491
|
} catch {
|
|
400
492
|
return false;
|
|
401
493
|
}
|
|
@@ -606,7 +698,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
606
698
|
// Mutation token: only `rly update` (which reads the running-file record)
|
|
607
699
|
// can authenticate to POST /api/update. Never embedded in the page/boot.
|
|
608
700
|
const token = crypto.randomBytes(16).toString('hex');
|
|
701
|
+
const shareTokens = {
|
|
702
|
+
collab: crypto.randomBytes(16).toString('hex'),
|
|
703
|
+
review: crypto.randomBytes(16).toString('hex'),
|
|
704
|
+
};
|
|
705
|
+
const activeShares = { collab: false, review: false };
|
|
609
706
|
let rev = 1;
|
|
707
|
+
let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
|
|
610
708
|
let status = 'open';
|
|
611
709
|
let finished = false;
|
|
612
710
|
// Soft timeout: the board's time is up and a `timeout` result was handed back
|
|
@@ -616,6 +714,58 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
616
714
|
let softTimedOut = false;
|
|
617
715
|
// Latest client presence ping (null until the first ping arrives).
|
|
618
716
|
let presence = null;
|
|
717
|
+
let actualPort = 0;
|
|
718
|
+
let url = '';
|
|
719
|
+
const advertisedHost = shareHost();
|
|
720
|
+
const accessFor = (req, reqUrl = null) => {
|
|
721
|
+
const host = String(req.headers.host || '').split(':')[0];
|
|
722
|
+
if (host === '127.0.0.1' || host === 'localhost') {
|
|
723
|
+
return {
|
|
724
|
+
role: 'owner',
|
|
725
|
+
canShare: true,
|
|
726
|
+
canSubmit: true,
|
|
727
|
+
canEditAnswers: true,
|
|
728
|
+
canComment: true,
|
|
729
|
+
canEditComments: true,
|
|
730
|
+
canDeleteComments: true,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
|
|
734
|
+
if (activeShares.collab && tokenValue === shareTokens.collab) {
|
|
735
|
+
return {
|
|
736
|
+
role: 'collab',
|
|
737
|
+
token: shareTokens.collab,
|
|
738
|
+
canShare: false,
|
|
739
|
+
canSubmit: true,
|
|
740
|
+
canEditAnswers: true,
|
|
741
|
+
canComment: true,
|
|
742
|
+
canEditComments: true,
|
|
743
|
+
canDeleteComments: true,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (activeShares.review && tokenValue === shareTokens.review) {
|
|
747
|
+
return {
|
|
748
|
+
role: 'review',
|
|
749
|
+
token: shareTokens.review,
|
|
750
|
+
canShare: false,
|
|
751
|
+
canSubmit: false,
|
|
752
|
+
canEditAnswers: false,
|
|
753
|
+
canComment: true,
|
|
754
|
+
canEditComments: false,
|
|
755
|
+
canDeleteComments: false,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
role: 'locked',
|
|
760
|
+
canShare: false,
|
|
761
|
+
canSubmit: false,
|
|
762
|
+
canEditAnswers: false,
|
|
763
|
+
canComment: false,
|
|
764
|
+
canEditComments: false,
|
|
765
|
+
canDeleteComments: false,
|
|
766
|
+
};
|
|
767
|
+
};
|
|
768
|
+
const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
|
|
619
769
|
let resolveDone;
|
|
620
770
|
const done = new Promise((r) => {
|
|
621
771
|
resolveDone = r;
|
|
@@ -627,11 +777,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
627
777
|
const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
|
628
778
|
try {
|
|
629
779
|
if (req.method === 'GET' && pathname === '/') {
|
|
630
|
-
|
|
780
|
+
const access = accessFor(req, reqUrl);
|
|
781
|
+
if (access.role === 'locked') {
|
|
782
|
+
sendHtml(res, buildLockedPage(record.spec.title));
|
|
783
|
+
} else {
|
|
784
|
+
sendHtml(res, buildPage(record, rev, { access, draftRev }));
|
|
785
|
+
}
|
|
631
786
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
787
|
+
if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
632
788
|
sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
|
|
633
789
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
634
|
-
sendJson(res, 200, { status, rev, softTimedOut });
|
|
790
|
+
sendJson(res, 200, { status, rev, draftRev, softTimedOut });
|
|
791
|
+
} else if (req.method === 'GET' && pathname === '/api/draft') {
|
|
792
|
+
if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
793
|
+
sendJson(res, 200, { draft: record.draft || null, draftRev });
|
|
635
794
|
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
636
795
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
637
796
|
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
@@ -728,7 +887,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
728
887
|
} else if (req.method === 'POST' && pathname === '/api/open') {
|
|
729
888
|
// Open a board-referenced local file in the OS default app. Guarded by
|
|
730
889
|
// a same-origin check + an allowlist of paths the board actually links.
|
|
731
|
-
if (!sameOrigin(req, actualPort)) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
|
|
890
|
+
if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
|
|
891
|
+
const access = accessFor(req, reqUrl);
|
|
892
|
+
if (access.role !== 'owner' && access.role !== 'collab') return sendJson(res, 403, { error: 'this share cannot open local files' });
|
|
732
893
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
733
894
|
const raw = typeof body.path === 'string' ? body.path : '';
|
|
734
895
|
if (!raw.trim()) return sendJson(res, 400, { error: 'missing "path"' });
|
|
@@ -748,18 +909,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
748
909
|
if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
|
|
749
910
|
sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
|
|
750
911
|
} else if (req.method === 'POST' && pathname === '/api/draft') {
|
|
912
|
+
const access = accessFor(req, reqUrl);
|
|
913
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
751
914
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
915
|
+
if (access.role === 'review') {
|
|
916
|
+
const current = record.draft && typeof record.draft === 'object' ? record.draft : {};
|
|
917
|
+
record.draft = {
|
|
918
|
+
answers: current.answers && typeof current.answers === 'object' ? current.answers : {},
|
|
919
|
+
comment: typeof current.comment === 'string' ? current.comment : '',
|
|
920
|
+
notes: current.notes && typeof current.notes === 'object' ? current.notes : {},
|
|
921
|
+
annotations: mergeReviewAnnotations(current.annotations, body.annotations),
|
|
922
|
+
blockEdits: current.blockEdits && typeof current.blockEdits === 'object' ? current.blockEdits : {},
|
|
923
|
+
updatedAt: new Date().toISOString(),
|
|
924
|
+
};
|
|
925
|
+
} else {
|
|
926
|
+
record.draft = {
|
|
927
|
+
answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
|
|
928
|
+
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
929
|
+
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
930
|
+
annotations: sanitizeAnnotations(body.annotations),
|
|
931
|
+
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
932
|
+
updatedAt: new Date().toISOString(),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
record.draftRev = ++draftRev;
|
|
760
936
|
saveBoard(record);
|
|
761
|
-
sendJson(res, 200, { ok: true });
|
|
937
|
+
sendJson(res, 200, { ok: true, draftRev });
|
|
762
938
|
} else if (req.method === 'POST' && pathname === '/api/submit') {
|
|
939
|
+
const access = accessFor(req, reqUrl);
|
|
940
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
941
|
+
if (!access.canSubmit) return sendJson(res, 403, { error: 'this share can comment only' });
|
|
763
942
|
if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
|
|
764
943
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
765
944
|
const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
|
|
@@ -774,6 +953,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
774
953
|
annotations: sanitizeAnnotations(body.annotations),
|
|
775
954
|
blockEdits: sanitizeBlockEdits(body.blockEdits),
|
|
776
955
|
});
|
|
956
|
+
} else if (req.method === 'GET' && pathname === '/api/share') {
|
|
957
|
+
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
|
|
958
|
+
const roles = {};
|
|
959
|
+
for (const role of ['collab', 'review']) {
|
|
960
|
+
roles[role] = {
|
|
961
|
+
active: activeShares[role] === true,
|
|
962
|
+
url: activeShares[role] === true ? shareUrlFor(role) : null,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
sendJson(res, 200, { ok: true, roles });
|
|
966
|
+
} else if (req.method === 'POST' && pathname === '/api/share') {
|
|
967
|
+
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
|
|
968
|
+
if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
|
|
969
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
970
|
+
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
|
|
971
|
+
if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "review"' });
|
|
972
|
+
activeShares[role] = true;
|
|
973
|
+
sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
|
|
974
|
+
} else if (req.method === 'DELETE' && pathname === '/api/share') {
|
|
975
|
+
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
|
|
976
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
977
|
+
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'all' ? 'all' : null;
|
|
978
|
+
if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "all"' });
|
|
979
|
+
if (role === 'all') {
|
|
980
|
+
activeShares.collab = false;
|
|
981
|
+
activeShares.review = false;
|
|
982
|
+
} else {
|
|
983
|
+
activeShares[role] = false;
|
|
984
|
+
}
|
|
985
|
+
sendJson(res, 200, { ok: true, role, active: false });
|
|
777
986
|
} else {
|
|
778
987
|
sendJson(res, 404, { error: 'not found' });
|
|
779
988
|
}
|
|
@@ -804,15 +1013,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
804
1013
|
}
|
|
805
1014
|
};
|
|
806
1015
|
server.once('error', onErr);
|
|
807
|
-
server.listen(p, '
|
|
1016
|
+
server.listen(p, '0.0.0.0', () => {
|
|
808
1017
|
server.removeListener('error', onErr);
|
|
809
1018
|
resolve();
|
|
810
1019
|
});
|
|
811
1020
|
};
|
|
812
1021
|
bind(port, true);
|
|
813
1022
|
});
|
|
814
|
-
|
|
815
|
-
|
|
1023
|
+
actualPort = server.address().port;
|
|
1024
|
+
url = `http://127.0.0.1:${actualPort}/`;
|
|
816
1025
|
// Remember the port this board last bound, so `rly rescue <id>` can re-serve
|
|
817
1026
|
// on the SAME port — letting a still-open (but disconnected) browser tab
|
|
818
1027
|
// reconnect to its relative /api/* URLs without the user touching anything.
|
package/src/ui/annotate.js
CHANGED
|
@@ -114,6 +114,7 @@
|
|
|
114
114
|
// Frozen by the host (e.g. relay's connection-lost block): suppress every way
|
|
115
115
|
// to START a comment, so the user can't type feedback that won't be saved.
|
|
116
116
|
let disabled = false;
|
|
117
|
+
let permissions = { add: true, edit: true, delete: true, reply: true };
|
|
117
118
|
let badgeTimer = 0;
|
|
118
119
|
let railOpen = false;
|
|
119
120
|
|
|
@@ -513,6 +514,7 @@
|
|
|
513
514
|
try { delConfirmSkip = localStorage.getItem(DEL_SKIP_KEY) === '1'; } catch { /* sandbox: no storage */ }
|
|
514
515
|
|
|
515
516
|
function requestDelete(id, after) {
|
|
517
|
+
if (!permissions.delete) return;
|
|
516
518
|
const done = () => { removeAnnotation(id); if (after) after(); };
|
|
517
519
|
if (delConfirmSkip) { done(); return; }
|
|
518
520
|
showDeleteConfirm(done);
|
|
@@ -655,6 +657,7 @@
|
|
|
655
657
|
|
|
656
658
|
// ---------- mutations ----------
|
|
657
659
|
function addAnnotation(info, text) {
|
|
660
|
+
if (!permissions.add) return;
|
|
658
661
|
annotations.push({
|
|
659
662
|
id: 'a' + (++idCounter),
|
|
660
663
|
questionId: info.questionId !== undefined ? info.questionId : null,
|
|
@@ -687,6 +690,7 @@
|
|
|
687
690
|
// Append a user reply to a top-level annotation's thread (cap 50, like the
|
|
688
691
|
// server). Empty text is ignored by callers.
|
|
689
692
|
function addReply(id, text) {
|
|
693
|
+
if (!permissions.reply) return;
|
|
690
694
|
const a = annotations.find((x) => x.id === id);
|
|
691
695
|
if (!a) return;
|
|
692
696
|
if (!Array.isArray(a.replies)) a.replies = [];
|
|
@@ -701,6 +705,7 @@
|
|
|
701
705
|
|
|
702
706
|
// Edit a top-level comment's text in place (author edits their own comment).
|
|
703
707
|
function editAnnotation(id, text) {
|
|
708
|
+
if (!permissions.edit) return;
|
|
704
709
|
const a = annotations.find((x) => x.id === id);
|
|
705
710
|
if (!a) return;
|
|
706
711
|
const t = String(text).slice(0, 5000).trim();
|
|
@@ -710,6 +715,7 @@
|
|
|
710
715
|
}
|
|
711
716
|
|
|
712
717
|
function removeAnnotation(id) {
|
|
718
|
+
if (!permissions.delete) return;
|
|
713
719
|
const i = annotations.findIndex((a) => a.id === id);
|
|
714
720
|
if (i < 0) return;
|
|
715
721
|
const removed = annotations[i];
|
|
@@ -844,11 +850,9 @@
|
|
|
844
850
|
}
|
|
845
851
|
|
|
846
852
|
// ---------- public API ----------
|
|
847
|
-
function
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
annotations = Array.isArray(opts.initial)
|
|
851
|
-
? opts.initial.map((a) => ({
|
|
853
|
+
function normalizeList(value) {
|
|
854
|
+
return Array.isArray(value)
|
|
855
|
+
? value.map((a) => ({
|
|
852
856
|
...a,
|
|
853
857
|
author: a.author === 'agent' ? 'agent' : 'user',
|
|
854
858
|
replies: Array.isArray(a.replies)
|
|
@@ -862,12 +866,28 @@
|
|
|
862
866
|
: [],
|
|
863
867
|
}))
|
|
864
868
|
: [];
|
|
865
|
-
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function resetCounter() {
|
|
866
872
|
idCounter = 0;
|
|
867
873
|
for (const a of annotations) {
|
|
868
874
|
const m = /^a(\d+)$/.exec(String(a.id || ''));
|
|
869
875
|
if (m) idCounter = Math.max(idCounter, parseInt(m[1], 10));
|
|
870
876
|
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function init(opts = {}) {
|
|
880
|
+
ensureDom();
|
|
881
|
+
// Normalize threads: missing author -> 'user', missing replies -> [].
|
|
882
|
+
annotations = normalizeList(opts.initial);
|
|
883
|
+
onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
|
|
884
|
+
permissions = {
|
|
885
|
+
add: !(opts.permissions && opts.permissions.add === false),
|
|
886
|
+
edit: !(opts.permissions && opts.permissions.edit === false),
|
|
887
|
+
delete: !(opts.permissions && opts.permissions.delete === false),
|
|
888
|
+
reply: !(opts.permissions && opts.permissions.reply === false),
|
|
889
|
+
};
|
|
890
|
+
resetCounter();
|
|
871
891
|
scheduleBadgeRefresh();
|
|
872
892
|
renderSummaries();
|
|
873
893
|
refreshRailChrome();
|
|
@@ -938,6 +958,14 @@
|
|
|
938
958
|
return annotations.slice();
|
|
939
959
|
}
|
|
940
960
|
|
|
961
|
+
function setList(next) {
|
|
962
|
+
annotations = normalizeList(next);
|
|
963
|
+
resetCounter();
|
|
964
|
+
scheduleBadgeRefresh();
|
|
965
|
+
renderSummaries();
|
|
966
|
+
refreshRailChrome();
|
|
967
|
+
}
|
|
968
|
+
|
|
941
969
|
// True while the user is mid-comment: the popover is open, which hosts both
|
|
942
970
|
// the "Add a comment" box and every thread's reply input. That text lives
|
|
943
971
|
// only in the DOM until Save, so the board must not reload it away.
|
|
@@ -968,5 +996,5 @@
|
|
|
968
996
|
renderSummaryInto(target);
|
|
969
997
|
}
|
|
970
998
|
|
|
971
|
-
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
|
|
999
|
+
window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, setList, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
|
|
972
1000
|
})();
|
package/src/ui/app.js
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
|
|
4
4
|
const boot = JSON.parse(document.getElementById('boot').textContent);
|
|
5
5
|
const spec = boot.spec;
|
|
6
|
+
const access = boot.access || {
|
|
7
|
+
role: 'owner',
|
|
8
|
+
canShare: true,
|
|
9
|
+
canSubmit: true,
|
|
10
|
+
canEditAnswers: true,
|
|
11
|
+
canComment: true,
|
|
12
|
+
canEditComments: true,
|
|
13
|
+
canDeleteComments: true,
|
|
14
|
+
};
|
|
15
|
+
if (access.role) document.documentElement.dataset.accessRole = access.role;
|
|
6
16
|
const QS = spec.questions || [];
|
|
7
17
|
const app = document.getElementById('app');
|
|
8
18
|
const banner = document.getElementById('banner');
|
|
@@ -10,6 +20,11 @@
|
|
|
10
20
|
// compares /api/status.rev to this and reloads the board when it advances
|
|
11
21
|
// (an agent ran `rly update`).
|
|
12
22
|
const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
|
|
23
|
+
let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
|
|
24
|
+
|
|
25
|
+
function authHeaders(extra = {}) {
|
|
26
|
+
return access.token ? { ...extra, 'x-relay-share-token': access.token } : extra;
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
// ---------- helpers ----------
|
|
15
30
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -453,10 +468,12 @@
|
|
|
453
468
|
try {
|
|
454
469
|
const r = await fetch('/api/draft', {
|
|
455
470
|
method: 'POST',
|
|
456
|
-
headers: { 'content-type': 'application/json' },
|
|
471
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
457
472
|
body: JSON.stringify(payload()),
|
|
458
473
|
});
|
|
459
474
|
if (!r.ok) throw new Error('draft rejected');
|
|
475
|
+
const body = await r.json().catch(() => null);
|
|
476
|
+
if (body && typeof body.draftRev === 'number') seenDraftRev = body.draftRev;
|
|
460
477
|
saveFailures = 0;
|
|
461
478
|
if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
|
|
462
479
|
} catch {
|
|
@@ -513,6 +530,12 @@
|
|
|
513
530
|
if (Annotate) {
|
|
514
531
|
Annotate.init({
|
|
515
532
|
initial: state.annotations,
|
|
533
|
+
permissions: {
|
|
534
|
+
add: access.canComment !== false,
|
|
535
|
+
edit: access.canEditComments !== false,
|
|
536
|
+
delete: access.canDeleteComments !== false,
|
|
537
|
+
reply: access.canComment !== false,
|
|
538
|
+
},
|
|
516
539
|
onChange: (list) => {
|
|
517
540
|
state.annotations = list;
|
|
518
541
|
scheduleSave();
|
|
@@ -1019,6 +1042,10 @@
|
|
|
1019
1042
|
sessionStorage.removeItem('relay-updated');
|
|
1020
1043
|
showToast('Board updated by the agent');
|
|
1021
1044
|
}
|
|
1045
|
+
if (sessionStorage.getItem('relay-draft-updated') === '1') {
|
|
1046
|
+
sessionStorage.removeItem('relay-draft-updated');
|
|
1047
|
+
showToast('Board refreshed with shared changes');
|
|
1048
|
+
}
|
|
1022
1049
|
} catch {
|
|
1023
1050
|
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
1024
1051
|
}
|
|
@@ -1099,8 +1126,112 @@
|
|
|
1099
1126
|
saveEl = el('span', { class: 'savestate' }, '');
|
|
1100
1127
|
const hint = el('span', { class: 'hint' },
|
|
1101
1128
|
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
1102
|
-
|
|
1103
|
-
app.append(
|
|
1129
|
+
const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
|
|
1130
|
+
app.append(submitbar);
|
|
1131
|
+
|
|
1132
|
+
function copyText(text, btn) {
|
|
1133
|
+
const done = () => {
|
|
1134
|
+
if (!btn) return;
|
|
1135
|
+
const old = btn.textContent;
|
|
1136
|
+
btn.textContent = 'Copied';
|
|
1137
|
+
setTimeout(() => { btn.textContent = old; }, 1800);
|
|
1138
|
+
};
|
|
1139
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1140
|
+
navigator.clipboard.writeText(text).then(done).catch(() => {});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const ta = document.createElement('textarea');
|
|
1144
|
+
ta.value = text;
|
|
1145
|
+
ta.style.position = 'fixed';
|
|
1146
|
+
ta.style.opacity = '0';
|
|
1147
|
+
document.body.append(ta);
|
|
1148
|
+
ta.select();
|
|
1149
|
+
try { document.execCommand('copy'); done(); } catch {}
|
|
1150
|
+
ta.remove();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function openSharePanel(host) {
|
|
1154
|
+
const panel = el('div', { class: 'share-panel' },
|
|
1155
|
+
el('div', { class: 'share-title' }, 'Share this board'),
|
|
1156
|
+
el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working only after you confirm.'),
|
|
1157
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'collab' },
|
|
1158
|
+
el('span', { class: 'share-choice-title' }, 'Collaborator'),
|
|
1159
|
+
el('span', { class: 'share-choice-sub' }, 'Can edit answers, comment, and submit as owner.')
|
|
1160
|
+
),
|
|
1161
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
|
|
1162
|
+
el('span', { class: 'share-choice-title' }, 'Reviewer'),
|
|
1163
|
+
el('span', { class: 'share-choice-sub' }, 'Can add comments only. No submit, answer edits, edit, or delete.')
|
|
1164
|
+
),
|
|
1165
|
+
el('div', { class: 'share-result' })
|
|
1166
|
+
);
|
|
1167
|
+
const close = () => panel.remove();
|
|
1168
|
+
const closeBtn = el('button', { class: 'share-close', type: 'button', 'aria-label': 'Close share panel' }, '×');
|
|
1169
|
+
closeBtn.addEventListener('click', close);
|
|
1170
|
+
panel.prepend(closeBtn);
|
|
1171
|
+
const result = panel.querySelector('.share-result');
|
|
1172
|
+
for (const btn of panel.querySelectorAll('.share-choice')) {
|
|
1173
|
+
btn.addEventListener('click', async () => {
|
|
1174
|
+
const role = btn.getAttribute('data-role');
|
|
1175
|
+
const label = role === 'collab' ? 'collaborator' : 'reviewer';
|
|
1176
|
+
if (!window.confirm(`Activate a ${label} link for same-Wi-Fi devices?`)) return;
|
|
1177
|
+
btn.disabled = true;
|
|
1178
|
+
result.textContent = 'Activating...';
|
|
1179
|
+
try {
|
|
1180
|
+
const res = await fetch('/api/share', {
|
|
1181
|
+
method: 'POST',
|
|
1182
|
+
headers: { 'content-type': 'application/json' },
|
|
1183
|
+
body: JSON.stringify({ role }),
|
|
1184
|
+
});
|
|
1185
|
+
const body = await res.json().catch(() => null);
|
|
1186
|
+
if (!res.ok || !body || !body.url) throw new Error(body && body.error ? body.error : 'share failed');
|
|
1187
|
+
const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
|
|
1188
|
+
copy.addEventListener('click', () => copyText(body.url, copy));
|
|
1189
|
+
result.replaceChildren(
|
|
1190
|
+
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
|
|
1191
|
+
el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
|
|
1192
|
+
copy
|
|
1193
|
+
);
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
result.textContent = err && err.message ? err.message : 'Could not activate sharing.';
|
|
1196
|
+
} finally {
|
|
1197
|
+
btn.disabled = false;
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
host.append(panel);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function buildFooter() {
|
|
1205
|
+
const footer = el('footer', { class: 'qb-footer' }, el('span', {}, `relay · ${boot.boardId}`));
|
|
1206
|
+
if (access.canShare) {
|
|
1207
|
+
const wrap = el('span', { class: 'share-wrap' });
|
|
1208
|
+
const shareBtn = el('button', { class: 'share-btn', type: 'button' }, 'Share');
|
|
1209
|
+
shareBtn.addEventListener('click', () => {
|
|
1210
|
+
const existing = wrap.querySelector('.share-panel');
|
|
1211
|
+
if (existing) existing.remove();
|
|
1212
|
+
else openSharePanel(wrap);
|
|
1213
|
+
});
|
|
1214
|
+
wrap.append(shareBtn);
|
|
1215
|
+
footer.append(wrap);
|
|
1216
|
+
} else if (access.role === 'review') {
|
|
1217
|
+
footer.append(el('span', { class: 'share-mode' }, 'reviewer · comments only'));
|
|
1218
|
+
} else if (access.role === 'collab') {
|
|
1219
|
+
footer.append(el('span', { class: 'share-mode' }, 'collaborator'));
|
|
1220
|
+
}
|
|
1221
|
+
return footer;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!access.canSubmit) {
|
|
1225
|
+
submitbar.style.display = 'none';
|
|
1226
|
+
app.append(el('div', { class: 'access-note' }, 'Reviewer mode: comments are saved live. Answers and submission are disabled.'));
|
|
1227
|
+
}
|
|
1228
|
+
if (!access.canEditAnswers) {
|
|
1229
|
+
document.documentElement.classList.add('relay-review');
|
|
1230
|
+
for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
|
|
1231
|
+
node.disabled = true;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
app.append(buildFooter());
|
|
1104
1235
|
|
|
1105
1236
|
// ---------- validation & submit ----------
|
|
1106
1237
|
function validate() {
|
|
@@ -1154,7 +1285,7 @@
|
|
|
1154
1285
|
try {
|
|
1155
1286
|
res = await fetch('/api/submit', {
|
|
1156
1287
|
method: 'POST',
|
|
1157
|
-
headers: { 'content-type': 'application/json' },
|
|
1288
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
1158
1289
|
body: JSON.stringify(payload()),
|
|
1159
1290
|
});
|
|
1160
1291
|
} catch (netErr) {
|
|
@@ -1368,6 +1499,23 @@
|
|
|
1368
1499
|
// sessionStorage may be unavailable — the reload still applies the update
|
|
1369
1500
|
}
|
|
1370
1501
|
location.reload();
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (body && typeof body.draftRev === 'number' && body.draftRev > seenDraftRev && !submitted && !reloading) {
|
|
1505
|
+
if (userIsComposing()) {
|
|
1506
|
+
showNotice('Another viewer updated this board — it’ll refresh as soon as you finish typing.', 'info');
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
reloading = true;
|
|
1510
|
+
stopHeartbeat();
|
|
1511
|
+
clearTimeout(saveTimer);
|
|
1512
|
+
clearLocalDraft();
|
|
1513
|
+
try {
|
|
1514
|
+
sessionStorage.setItem('relay-draft-updated', '1');
|
|
1515
|
+
} catch {
|
|
1516
|
+
// sessionStorage may be unavailable — the reload still applies the update
|
|
1517
|
+
}
|
|
1518
|
+
location.reload();
|
|
1371
1519
|
}
|
|
1372
1520
|
} catch {
|
|
1373
1521
|
// Lost the live connection (the session ended, or the machine slept). Two
|
package/src/ui/style.css
CHANGED
|
@@ -341,7 +341,58 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
341
341
|
.done h2 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em; margin: 0 0 8px; }
|
|
342
342
|
.done p { color: var(--muted); margin: 0; }
|
|
343
343
|
|
|
344
|
-
.qb-footer {
|
|
344
|
+
.qb-footer {
|
|
345
|
+
color: var(--muted); font-size: 0.75rem; text-align: center; margin-top: 28px;
|
|
346
|
+
display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
347
|
+
}
|
|
348
|
+
.share-wrap { position: relative; display: inline-flex; }
|
|
349
|
+
.share-btn, .share-copy, .share-choice, .share-close {
|
|
350
|
+
font: inherit;
|
|
351
|
+
}
|
|
352
|
+
.share-btn {
|
|
353
|
+
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
354
|
+
background: var(--card); color: var(--fg-2);
|
|
355
|
+
padding: 5px 12px; cursor: pointer;
|
|
356
|
+
}
|
|
357
|
+
.share-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
358
|
+
.share-panel {
|
|
359
|
+
position: absolute; left: 50%; bottom: calc(100% + 10px); transform: translateX(-50%);
|
|
360
|
+
width: min(360px, calc(100vw - 28px));
|
|
361
|
+
background: var(--card); color: var(--fg);
|
|
362
|
+
border: 1px solid var(--border-strong); border-radius: 12px;
|
|
363
|
+
box-shadow: var(--shadow-lift);
|
|
364
|
+
padding: 16px; text-align: left; z-index: 45;
|
|
365
|
+
}
|
|
366
|
+
.share-close {
|
|
367
|
+
position: absolute; top: 8px; right: 8px;
|
|
368
|
+
border: 0; background: transparent; color: var(--muted);
|
|
369
|
+
cursor: pointer; font-size: 1.1rem; line-height: 1;
|
|
370
|
+
}
|
|
371
|
+
.share-title { font-weight: 650; font-size: 0.95rem; margin-right: 24px; }
|
|
372
|
+
.share-desc { color: var(--muted); font-size: 0.78rem; margin: 4px 20px 12px 0; }
|
|
373
|
+
.share-choice {
|
|
374
|
+
width: 100%; display: flex; flex-direction: column; gap: 2px;
|
|
375
|
+
border: 1px solid var(--border); border-radius: 10px;
|
|
376
|
+
background: var(--bg-sunken); color: var(--fg);
|
|
377
|
+
padding: 10px 12px; margin-top: 8px; cursor: pointer; text-align: left;
|
|
378
|
+
}
|
|
379
|
+
.share-choice:hover { border-color: var(--accent); }
|
|
380
|
+
.share-choice-title { font-weight: 650; }
|
|
381
|
+
.share-choice-sub { color: var(--muted); font-size: 0.76rem; }
|
|
382
|
+
.share-result { margin-top: 12px; color: var(--fg-2); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
383
|
+
.share-ready { color: var(--ok); font-weight: 650; margin-bottom: 4px; }
|
|
384
|
+
.share-url { color: var(--accent); display: block; margin-bottom: 8px; }
|
|
385
|
+
.share-copy {
|
|
386
|
+
border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-fg);
|
|
387
|
+
padding: 7px 12px; cursor: pointer; font-weight: 650;
|
|
388
|
+
}
|
|
389
|
+
.share-mode { color: var(--fg-2); }
|
|
390
|
+
.access-note {
|
|
391
|
+
color: var(--fg-2); background: var(--accent-soft);
|
|
392
|
+
border-radius: 10px; padding: 10px 14px; font-size: 0.86rem;
|
|
393
|
+
}
|
|
394
|
+
.relay-review .ann-del,
|
|
395
|
+
.relay-review .ann-edit { display: none !important; }
|
|
345
396
|
|
|
346
397
|
@media (max-width: 480px) {
|
|
347
398
|
.wrap { padding: 18px 12px 18px; }
|