@khanglvm/relay 0.10.2 → 0.11.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.
@@ -0,0 +1,854 @@
1
+ // board.js — the relay board as an MCP App (SEP-1865 "io.modelcontextprotocol/ui").
2
+ //
3
+ // This is the same board relay opens in a browser, but rendered INSIDE the
4
+ // host app (Claude desktop/mobile, Codex, …) as a sandboxed inline iframe.
5
+ // There is no local HTTP server here: every exchange with the host travels over
6
+ // JSON-RPC on window.postMessage — the spec arrives as the tool result, the
7
+ // user's answers go back via `ui/update-model-context`, and vendored libraries
8
+ // (Chart.js / Mermaid / Viz.js) are pulled through the host's `resources/read`.
9
+ //
10
+ // Rich blocks are rendered by the SAME window.RelayBlocks as the browser board
11
+ // (markdown, code, diff, table, chart, mermaid, graphviz, image, html), so the
12
+ // two surfaces stay in lockstep. Annotations / autosave / heartbeat are
13
+ // browser-server concepts and intentionally absent; per-question notes and the
14
+ // overall comment carry structured feedback back to the agent.
15
+ (() => {
16
+ 'use strict';
17
+
18
+ const BOOT = (() => {
19
+ try { return JSON.parse(document.getElementById('boot').textContent); } catch { return {}; }
20
+ })();
21
+ const PROTOCOL = '2025-06-18';
22
+
23
+ // ---------- tiny DOM helper (mirrors app.js / blocks.js) ----------
24
+ function el(tag, attrs = {}, ...children) {
25
+ const n = document.createElement(tag);
26
+ for (const [k, v] of Object.entries(attrs)) {
27
+ if (v === undefined || v === null) continue;
28
+ if (k === 'class') n.className = v;
29
+ else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
30
+ else n.setAttribute(k, v);
31
+ }
32
+ for (const c of children.flat()) {
33
+ if (c !== null && c !== undefined) n.append(c.nodeType ? c : document.createTextNode(c));
34
+ }
35
+ return n;
36
+ }
37
+
38
+ // ======================================================================
39
+ // MCP Apps postMessage / JSON-RPC bridge
40
+ // ======================================================================
41
+ const pending = new Map();
42
+ let rpcSeq = 0;
43
+ const notifyHandlers = Object.create(null);
44
+ function onNotify(method, fn) {
45
+ (notifyHandlers[method] || (notifyHandlers[method] = [])).push(fn);
46
+ }
47
+ function post(msg) {
48
+ try {
49
+ (window.parent && window.parent !== window ? window.parent : window).postMessage(msg, '*');
50
+ } catch {
51
+ // host frame gone — nothing we can do
52
+ }
53
+ }
54
+ function request(method, params) {
55
+ const id = 'rly-' + (++rpcSeq);
56
+ return new Promise((resolve, reject) => {
57
+ pending.set(id, { resolve, reject });
58
+ post({ jsonrpc: '2.0', id, method, params: params || {} });
59
+ // Don't hang forever if a host ignores a method — resolve-less reject.
60
+ setTimeout(() => {
61
+ if (pending.has(id)) {
62
+ pending.delete(id);
63
+ reject(new Error('timeout: ' + method));
64
+ }
65
+ }, 15000);
66
+ });
67
+ }
68
+ function notify(method, params) {
69
+ post({ jsonrpc: '2.0', method, params: params || {} });
70
+ }
71
+
72
+ window.addEventListener('message', (e) => {
73
+ const msg = e.data;
74
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
75
+ // A response to one of our requests.
76
+ if (msg.id !== undefined && msg.id !== null && (('result' in msg) || ('error' in msg))) {
77
+ const p = pending.get(msg.id);
78
+ if (!p) return;
79
+ pending.delete(msg.id);
80
+ if (msg.error) p.reject(Object.assign(new Error(msg.error.message || 'rpc error'), { rpc: msg.error }));
81
+ else p.resolve(msg.result);
82
+ return;
83
+ }
84
+ // A notification or request FROM the host.
85
+ if (typeof msg.method === 'string') {
86
+ const hs = notifyHandlers[msg.method] || [];
87
+ for (const h of hs) {
88
+ try { h(msg.params || {}, msg); } catch { /* handler errors never break the bridge */ }
89
+ }
90
+ // Host requests we must acknowledge.
91
+ if (msg.id !== undefined && msg.id !== null) {
92
+ if (msg.method === 'ui/resource-teardown') post({ jsonrpc: '2.0', id: msg.id, result: {} });
93
+ else post({ jsonrpc: '2.0', id: msg.id, result: {} });
94
+ }
95
+ }
96
+ });
97
+
98
+ // ======================================================================
99
+ // theme + size
100
+ // ======================================================================
101
+ let host = {};
102
+ function effectiveTheme() {
103
+ if (host.theme === 'dark' || host.theme === 'light') return host.theme;
104
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
105
+ }
106
+ function applyTheme() {
107
+ const t = host.theme;
108
+ if (t === 'dark' || t === 'light') {
109
+ document.documentElement.dataset.theme = t;
110
+ // Pin color-scheme so the host's light-dark() style variables resolve to
111
+ // the side the host actually picked (not whatever the OS prefers).
112
+ document.documentElement.style.colorScheme = t;
113
+ } else {
114
+ delete document.documentElement.dataset.theme;
115
+ document.documentElement.style.colorScheme = 'light dark';
116
+ }
117
+ if (window.RelayBlocks && typeof RelayBlocks.onThemeChange === 'function') {
118
+ try { RelayBlocks.onThemeChange(effectiveTheme()); } catch { /* not rendered yet */ }
119
+ }
120
+ }
121
+
122
+ // Full color-blend: map the host's standardized style variables (SEP-1865
123
+ // theming) onto relay's own custom properties so the board adopts the app's
124
+ // surfaces, text, borders, primary-action color and fonts — reading as part of
125
+ // Claude/Codex rather than a foreign page. Every mapping is conditional: a
126
+ // token the host omits keeps relay's own default, so it degrades gracefully on
127
+ // leaner hosts (where the warm terracotta identity simply stays).
128
+ const HOST_VAR_MAP = {
129
+ '--bg': '--color-background-primary',
130
+ '--card': '--color-background-secondary',
131
+ '--bg-sunken': '--color-background-tertiary',
132
+ '--fg': '--color-text-primary',
133
+ '--fg-2': '--color-text-secondary',
134
+ '--muted': '--color-text-tertiary',
135
+ '--border': '--color-border-primary',
136
+ '--border-strong': '--color-border-secondary',
137
+ '--accent': '--color-background-inverse',
138
+ '--accent-hover': '--color-background-inverse',
139
+ '--accent-fg': '--color-text-inverse',
140
+ '--accent-soft': '--color-background-tertiary',
141
+ '--danger': '--color-text-danger',
142
+ '--ok': '--color-text-success',
143
+ '--sans': '--font-sans',
144
+ '--mono': '--font-mono',
145
+ };
146
+ let hostFontStyleEl = null;
147
+ function adoptHostStyles() {
148
+ const styles = host && host.styles;
149
+ if (!styles || typeof styles !== 'object') return;
150
+ const v = styles.variables && typeof styles.variables === 'object' ? styles.variables : {};
151
+ const root = document.documentElement.style;
152
+ for (const [ours, theirs] of Object.entries(HOST_VAR_MAP)) {
153
+ if (typeof v[theirs] === 'string' && v[theirs]) root.setProperty(ours, v[theirs]);
154
+ }
155
+ const fonts = styles.css && typeof styles.css.fonts === 'string' ? styles.css.fonts : '';
156
+ if (fonts) {
157
+ if (!hostFontStyleEl) { hostFontStyleEl = document.createElement('style'); document.head.appendChild(hostFontStyleEl); }
158
+ hostFontStyleEl.textContent = fonts;
159
+ }
160
+ }
161
+
162
+ let sizeTimer = null;
163
+ function measureHeight() {
164
+ return Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
165
+ }
166
+ function sendSize() {
167
+ notify('ui/notifications/size-changed', { width: document.documentElement.scrollWidth, height: measureHeight() });
168
+ }
169
+ function reportSize() {
170
+ if (sizeTimer) return;
171
+ sizeTimer = setTimeout(() => { sizeTimer = null; sendSize(); }, 60);
172
+ }
173
+ // Immediate, un-debounced report — used when the board shrinks (e.g. after
174
+ // submit) so the host collapses the iframe right away instead of waiting.
175
+ function reportSizeNow() {
176
+ if (sizeTimer) { clearTimeout(sizeTimer); sizeTimer = null; }
177
+ sendSize();
178
+ }
179
+ window.addEventListener('resize', reportSize);
180
+ if (typeof ResizeObserver !== 'undefined') {
181
+ try { new ResizeObserver(reportSize).observe(document.documentElement); } catch { /* older host */ }
182
+ }
183
+
184
+ // ======================================================================
185
+ // client-side spec normalization (resilience)
186
+ // ----------------------------------------------------------------------
187
+ // The host normally hands us the SERVER-normalized spec via the tool result
188
+ // (structuredContent.spec) — options as {value,label}, blocks with ids, etc.
189
+ // If a host only forwards the raw tool input, this brings it close enough to
190
+ // render: it assigns ids and coerces option/column shapes. File-backed blocks
191
+ // (codeFile/htmlFile/local images) can't be resolved client-side and are left
192
+ // to the server path.
193
+ function clientNormalize(raw) {
194
+ if (!raw || typeof raw !== 'object') return null;
195
+ const spec = {
196
+ title: String(raw.title || 'Relay'),
197
+ intro: typeof raw.intro === 'string' ? raw.intro : '',
198
+ blocks: normBlocks(raw.blocks, ''),
199
+ allowPartial: raw.allowPartial !== false,
200
+ note: raw.note !== false,
201
+ autoClose: raw.autoClose !== false,
202
+ questions: [],
203
+ submitLabel: typeof raw.submitLabel === 'string' ? raw.submitLabel : '',
204
+ };
205
+ const qs = Array.isArray(raw.questions) ? raw.questions : [];
206
+ qs.forEach((rq, i) => {
207
+ if (!rq || typeof rq !== 'object') return;
208
+ const id = String(rq.id || 'q' + (i + 1));
209
+ const type = String(rq.type || 'text');
210
+ const q = {
211
+ id, type,
212
+ label: String(rq.label || rq.question || rq.text || ''),
213
+ description: typeof rq.description === 'string' ? rq.description : '',
214
+ required: rq.required === true,
215
+ note: rq.note === undefined ? type === 'single' : rq.note === true,
216
+ placeholder: typeof rq.placeholder === 'string' ? rq.placeholder : '',
217
+ blocks: normBlocks(rq.blocks, id + '-'),
218
+ };
219
+ if (type === 'single' || type === 'multi') {
220
+ const opts = Array.isArray(rq.options) ? rq.options : [];
221
+ q.options = opts.map((o, j) => {
222
+ if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) };
223
+ if (o && typeof o === 'object') {
224
+ const value = String(o.value != null ? o.value : o.label != null ? o.label : '');
225
+ const out = { value, label: String(o.label != null ? o.label : value) };
226
+ if (o.description) out.description = String(o.description);
227
+ const ob = normBlocks(o.blocks, id + '-o' + (j + 1) + '-');
228
+ if (ob.length) out.blocks = ob;
229
+ return out;
230
+ }
231
+ return { value: String(o), label: String(o) };
232
+ });
233
+ q.other = rq.other === true;
234
+ }
235
+ if (type === 'scale') {
236
+ q.min = Number.isFinite(rq.min) ? rq.min : 1;
237
+ q.max = Number.isFinite(rq.max) ? rq.max : Math.max(5, q.min + 1);
238
+ q.minLabel = typeof rq.minLabel === 'string' ? rq.minLabel : '';
239
+ q.maxLabel = typeof rq.maxLabel === 'string' ? rq.maxLabel : '';
240
+ }
241
+ if (type === 'color' && Array.isArray(rq.presets)) {
242
+ q.presets = rq.presets.map((c) => String(c)).filter(Boolean);
243
+ }
244
+ if (rq.default !== undefined) q.default = rq.default;
245
+ spec.questions.push(q);
246
+ });
247
+ if (!spec.submitLabel) spec.submitLabel = spec.questions.length ? 'Submit' : 'Acknowledge';
248
+ return spec;
249
+ }
250
+ function normBlocks(blocks, prefix) {
251
+ if (!Array.isArray(blocks)) return [];
252
+ let n = 0;
253
+ const out = [];
254
+ for (const b of blocks) {
255
+ if (!b || typeof b !== 'object' || !b.type) continue;
256
+ out.push(b.id ? b : { ...b, id: prefix + 'b' + (++n) });
257
+ if (b.id) n++;
258
+ }
259
+ return out;
260
+ }
261
+
262
+ // ======================================================================
263
+ // vendored libraries pulled through the host bridge (resources/read)
264
+ // ----------------------------------------------------------------------
265
+ // blocks.js lazy-loads Chart.js / Mermaid / Viz.js via <script src="/vendor/…">
266
+ // and short-circuits when the global already exists. There's no server here,
267
+ // so we fetch the vendor source over the bridge and define the global up
268
+ // front; blocks.js then never reaches for the (absent) /vendor route.
269
+ const NEED = { chart: ['chart.umd.js', 'Chart'], mermaid: ['mermaid.min.js', 'mermaid'], graphviz: ['viz-standalone.js', 'Viz'] };
270
+ function blockTypesIn(spec) {
271
+ const types = new Set();
272
+ const scan = (blocks) => {
273
+ for (const b of Array.isArray(blocks) ? blocks : []) {
274
+ if (b && b.type) types.add(b.type);
275
+ if (b && Array.isArray(b.blocks)) scan(b.blocks);
276
+ }
277
+ };
278
+ scan(spec.blocks);
279
+ for (const q of spec.questions || []) {
280
+ scan(q.blocks);
281
+ for (const o of Array.isArray(q.options) ? q.options : []) if (o) scan(o.blocks);
282
+ }
283
+ return types;
284
+ }
285
+ async function ensureVendor(file, globalName) {
286
+ if (window[globalName]) return true;
287
+ try {
288
+ const res = await request('resources/read', { uri: 'ui://relay/vendor/' + file });
289
+ const c = res && Array.isArray(res.contents) ? res.contents[0] : null;
290
+ const code = c && (c.text || (c.blob ? atob(c.blob) : ''));
291
+ if (!code) return false;
292
+ const s = document.createElement('script');
293
+ s.textContent = code;
294
+ document.head.appendChild(s);
295
+ return Boolean(window[globalName]);
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+ async function preloadVendors(spec) {
301
+ const types = blockTypesIn(spec);
302
+ const jobs = [];
303
+ if (types.has('chart')) jobs.push(ensureVendor(...NEED.chart));
304
+ if (types.has('mermaid')) jobs.push(ensureVendor(...NEED.mermaid));
305
+ if (types.has('graphviz')) jobs.push(ensureVendor(...NEED.graphviz));
306
+ if (jobs.length) await Promise.allSettled(jobs);
307
+ }
308
+
309
+ // html blocks: the browser board serves each in its own iframe from
310
+ // /html/b/<id>. Here we wrap the body into a self-contained document and hand
311
+ // blocks.js a blob: URL for the iframe src (same sandbox, no server needed).
312
+ const htmlBodies = new Map();
313
+ function indexHtmlBlocks(spec) {
314
+ const add = (blocks) => {
315
+ for (const b of Array.isArray(blocks) ? blocks : []) {
316
+ if (b && b.type === 'html' && typeof b.html === 'string') htmlBodies.set(b.id, b.html);
317
+ }
318
+ };
319
+ add(spec.blocks);
320
+ for (const q of spec.questions || []) {
321
+ add(q.blocks);
322
+ for (const o of Array.isArray(q.options) ? q.options : []) if (o) add(o.blocks);
323
+ }
324
+ }
325
+ function htmlBlobSrc(blockId) {
326
+ const body = htmlBodies.get(blockId) || '';
327
+ const dark = effectiveTheme() === 'dark';
328
+ let doc;
329
+ if (/<html[\s>]/i.test(body)) {
330
+ doc = body;
331
+ } else {
332
+ const bg = dark ? '#282624' : '#ffffff';
333
+ const fg = dark ? '#edeae4' : '#1c1b19';
334
+ doc =
335
+ '<!doctype html><html><head><meta charset="utf-8">' +
336
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
337
+ '<style>:root{color-scheme:' + (dark ? 'dark' : 'light') + '}' +
338
+ 'body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;' +
339
+ 'background:' + bg + ';color:' + fg + '}</style></head><body>' + body + '</body></html>';
340
+ }
341
+ try {
342
+ return URL.createObjectURL(new Blob([doc], { type: 'text/html' }));
343
+ } catch {
344
+ return 'data:text/html;charset=utf-8,' + encodeURIComponent(doc);
345
+ }
346
+ }
347
+
348
+ function blockCtx(questionId) {
349
+ return {
350
+ theme: effectiveTheme,
351
+ htmlSrc: (blockId) => htmlBlobSrc(blockId),
352
+ questionId: questionId == null ? null : questionId,
353
+ annotate: null,
354
+ edits: state.blockEdits,
355
+ onBlockEdit: (blockId, codeOrNull) => {
356
+ if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
357
+ else state.blockEdits[blockId] = codeOrNull;
358
+ },
359
+ };
360
+ }
361
+ function renderBlocks(container, blocks, questionId) {
362
+ if (!Array.isArray(blocks) || !blocks.length) return;
363
+ const target = el('div', { class: 'blocks' });
364
+ container.append(target);
365
+ if (!window.RelayBlocks) {
366
+ target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
367
+ return;
368
+ }
369
+ Promise.resolve()
370
+ .then(() => window.RelayBlocks.render(target, blocks, blockCtx(questionId)))
371
+ .then(reportSize)
372
+ .catch(() => {
373
+ target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
374
+ });
375
+ }
376
+
377
+ // ======================================================================
378
+ // board state + answer controls (ported from the browser board)
379
+ // ======================================================================
380
+ let spec = null;
381
+ let QS = [];
382
+ const state = { answers: {}, other: {}, notes: {}, comment: '', blockEdits: {} };
383
+ let submitted = false;
384
+ const cards = {};
385
+ const app = document.getElementById('app');
386
+
387
+ // ---------- display mode (host-driven) ----------
388
+ // GUI hosts (Claude, Codex) render their OWN full-screen control, so relay
389
+ // adds no redundant button. We still declare fullscreen support in
390
+ // ui/initialize and react to the host's mode change: in fullscreen the iframe
391
+ // fills the window, so we center the content to a readable column.
392
+ let displayMode = 'inline';
393
+ function applyDisplayMode() {
394
+ document.documentElement.classList.toggle('mcp-fullscreen', displayMode === 'fullscreen');
395
+ reportSize();
396
+ }
397
+
398
+ function seedDefaults() {
399
+ for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
400
+ }
401
+
402
+ function getValue(q) {
403
+ const v = state.answers[q.id];
404
+ const oth = state.other[q.id];
405
+ switch (q.type) {
406
+ case 'single': {
407
+ if (oth && oth.on) { const t = (oth.text || '').trim(); return t || undefined; }
408
+ return typeof v === 'string' && v ? v : undefined;
409
+ }
410
+ case 'multi': {
411
+ const arr = Array.isArray(v) ? [...v] : [];
412
+ if (oth && oth.on) { const t = (oth.text || '').trim(); if (t) arr.push(t); }
413
+ return arr.length ? arr : undefined;
414
+ }
415
+ case 'yesno':
416
+ return v === 'yes' || v === 'no' ? v : undefined;
417
+ case 'scale':
418
+ return typeof v === 'number' ? v : undefined;
419
+ default: {
420
+ const t = typeof v === 'string' ? v.trim() : '';
421
+ return t || undefined;
422
+ }
423
+ }
424
+ }
425
+
426
+ function payload() {
427
+ const answers = {};
428
+ const notes = {};
429
+ for (const q of QS) {
430
+ const v = getValue(q);
431
+ if (v !== undefined) answers[q.id] = v;
432
+ const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
433
+ if (n) notes[q.id] = n;
434
+ }
435
+ const skipped = QS.filter((q) => !(q.id in answers)).map((q) => q.id);
436
+ const blockEdits = Object.keys(state.blockEdits).length ? state.blockEdits : null;
437
+ return { answers, skipped, comment: (state.comment || '').trim(), notes, blockEdits };
438
+ }
439
+
440
+ function clearErr(qid) { if (cards[qid]) cards[qid].classList.remove('error'); }
441
+
442
+ function syncOptSel(group) {
443
+ for (const lab of group.querySelectorAll('label.opt')) {
444
+ const input = lab.querySelector('input');
445
+ lab.classList.toggle('sel', input.checked);
446
+ const wrap = lab.closest('.optwrap');
447
+ if (wrap) wrap.classList.toggle('sel', input.checked);
448
+ }
449
+ }
450
+ function withOptionBlocks(labelEl, o, questionId) {
451
+ if (!Array.isArray(o.blocks) || !o.blocks.length) return labelEl;
452
+ const wrap = el('div', { class: 'optwrap' + (labelEl.classList.contains('sel') ? ' sel' : '') }, labelEl);
453
+ renderBlocks(wrap, o.blocks, questionId);
454
+ return wrap;
455
+ }
456
+
457
+ function controlSingle(q) {
458
+ const group = el('div');
459
+ const entries = [];
460
+ let otherRadio = null;
461
+ const otherOn = () => Boolean(state.other[q.id] && state.other[q.id].on);
462
+ const syncSingle = () => {
463
+ for (const { input, value } of entries) input.checked = state.answers[q.id] === value && !otherOn();
464
+ if (otherRadio) otherRadio.checked = otherOn();
465
+ syncOptSel(group);
466
+ clearErr(q.id);
467
+ };
468
+ for (const o of q.options) {
469
+ const input = el('input', { type: 'radio', name: q.id });
470
+ entries.push({ input, value: o.value });
471
+ input.checked = state.answers[q.id] === o.value && !otherOn();
472
+ input.addEventListener('click', (e) => {
473
+ e.preventDefault();
474
+ if (state.answers[q.id] === o.value && !otherOn()) delete state.answers[q.id];
475
+ else { state.answers[q.id] = o.value; if (state.other[q.id]) state.other[q.id].on = false; }
476
+ setTimeout(syncSingle, 0);
477
+ });
478
+ input.addEventListener('change', () => {
479
+ if (!input.checked) return;
480
+ state.answers[q.id] = o.value;
481
+ if (state.other[q.id]) state.other[q.id].on = false;
482
+ syncSingle();
483
+ });
484
+ group.append(withOptionBlocks(
485
+ el('label', { class: 'opt' + (input.checked ? ' sel' : '') }, input,
486
+ el('div', {}, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null)),
487
+ o, q.id));
488
+ }
489
+ if (q.other) {
490
+ otherRadio = el('input', { type: 'radio', name: q.id });
491
+ const text = el('input', { type: 'text', placeholder: 'your own answer…' });
492
+ text.value = (state.other[q.id] && state.other[q.id].text) || '';
493
+ otherRadio.checked = otherOn();
494
+ const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
495
+ otherRadio.addEventListener('click', (e) => { e.preventDefault(); const oth = ensureOther(); oth.on = !oth.on; if (oth.on) delete state.answers[q.id]; setTimeout(syncSingle, 0); });
496
+ otherRadio.addEventListener('change', () => { if (!otherRadio.checked) return; const oth = ensureOther(); oth.on = true; delete state.answers[q.id]; syncSingle(); });
497
+ text.addEventListener('input', () => { const oth = ensureOther(); oth.text = text.value; if (!oth.on) { oth.on = true; delete state.answers[q.id]; } syncSingle(); });
498
+ group.append(el('label', { class: 'opt' + (otherRadio.checked ? ' sel' : '') }, otherRadio,
499
+ el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))));
500
+ }
501
+ return group;
502
+ }
503
+
504
+ function controlMulti(q) {
505
+ const group = el('div');
506
+ const selected = new Set(Array.isArray(state.answers[q.id]) ? state.answers[q.id] : []);
507
+ const readChecked = () => {
508
+ state.answers[q.id] = [...group.querySelectorAll('input[data-val]')].filter((i) => i.checked).map((i) => i.dataset.val);
509
+ syncOptSel(group);
510
+ clearErr(q.id);
511
+ };
512
+ for (const o of q.options) {
513
+ const input = el('input', { type: 'checkbox', 'data-val': o.value });
514
+ input.checked = selected.has(o.value);
515
+ input.addEventListener('change', readChecked);
516
+ group.append(withOptionBlocks(
517
+ el('label', { class: 'opt' + (input.checked ? ' sel' : '') }, input,
518
+ el('div', {}, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null)),
519
+ o, q.id));
520
+ }
521
+ if (q.other) {
522
+ const oth = state.other[q.id];
523
+ const box = el('input', { type: 'checkbox' });
524
+ const text = el('input', { type: 'text', placeholder: 'your own answer…' });
525
+ box.checked = Boolean(oth && oth.on);
526
+ text.value = (oth && oth.text) || '';
527
+ const sync = () => { state.other[q.id] = { on: box.checked, text: text.value }; syncOptSel(group); clearErr(q.id); };
528
+ box.addEventListener('change', sync);
529
+ text.addEventListener('input', () => { if (!box.checked) box.checked = true; sync(); });
530
+ group.append(el('label', { class: 'opt' + (box.checked ? ' sel' : '') }, box,
531
+ el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))));
532
+ }
533
+ return group;
534
+ }
535
+
536
+ function segButtons(q, values, labels) {
537
+ const seg = el('div', { class: q.type === 'scale' ? 'scale' : 'seg' });
538
+ const buttons = [];
539
+ values.forEach((v, i) => {
540
+ const b = el('button', { type: 'button' }, labels[i]);
541
+ if (state.answers[q.id] === v) b.classList.add('sel');
542
+ b.addEventListener('click', () => {
543
+ if (state.answers[q.id] === v) delete state.answers[q.id];
544
+ else state.answers[q.id] = v;
545
+ for (const x of buttons) x.classList.toggle('sel', state.answers[q.id] === values[buttons.indexOf(x)]);
546
+ clearErr(q.id);
547
+ });
548
+ buttons.push(b);
549
+ seg.append(b);
550
+ });
551
+ return seg;
552
+ }
553
+ function controlScale(q) {
554
+ const values = [];
555
+ for (let i = q.min; i <= q.max; i++) values.push(i);
556
+ const seg = segButtons(q, values, values.map(String));
557
+ const row = el('div', { class: 'scale' });
558
+ if (q.minLabel) row.append(el('span', { class: 'slabel' }, q.minLabel));
559
+ row.append(...seg.children);
560
+ if (q.maxLabel) row.append(el('span', { class: 'slabel' }, q.maxLabel));
561
+ return row;
562
+ }
563
+ function controlText(q, multiline) {
564
+ const input = multiline
565
+ ? el('textarea', { placeholder: q.placeholder || '' })
566
+ : el('input', { type: 'text', placeholder: q.placeholder || '' });
567
+ input.value = typeof state.answers[q.id] === 'string' ? state.answers[q.id] : '';
568
+ input.addEventListener('input', () => { state.answers[q.id] = input.value; clearErr(q.id); });
569
+ return input;
570
+ }
571
+
572
+ function toHex6(c) {
573
+ const s = String(c || '').trim();
574
+ let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
575
+ if (m) return '#' + m[1].toLowerCase();
576
+ m = /^#?([0-9a-fA-F]{3})$/.exec(s);
577
+ if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
578
+ return '#000000';
579
+ }
580
+ function controlColor(q) {
581
+ const wrap = el('div', { class: 'colorpick' });
582
+ const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
583
+ const swatch = el('input', { type: 'color', class: 'colorswatch' });
584
+ const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
585
+ swatch.value = toHex6(init || '#888888');
586
+ if (init) { hex.value = init; state.answers[q.id] = init; }
587
+ const set = (val) => { state.answers[q.id] = val; clearErr(q.id); };
588
+ swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
589
+ hex.addEventListener('input', () => { const v = hex.value.trim(); if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) swatch.value = toHex6(v); set(v); });
590
+ wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
591
+ if (Array.isArray(q.presets) && q.presets.length) {
592
+ const presets = el('div', { class: 'colorpresets' });
593
+ for (const c of q.presets) {
594
+ const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
595
+ b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
596
+ presets.append(b);
597
+ }
598
+ wrap.append(presets);
599
+ }
600
+ return wrap;
601
+ }
602
+
603
+ // ======================================================================
604
+ // render
605
+ // ======================================================================
606
+ function render() {
607
+ app.replaceChildren();
608
+ app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title)));
609
+ if (spec.intro) {
610
+ const md = window.RelayBlocks && RelayBlocks.renderMarkdown;
611
+ app.append(md
612
+ ? el('div', { class: 'intro blk-markdown' }, RelayBlocks.renderMarkdown(spec.intro))
613
+ : el('p', { class: 'intro' }, spec.intro));
614
+ }
615
+ renderBlocks(app, spec.blocks || [], null);
616
+
617
+ QS.forEach((q, idx) => {
618
+ const required = q.required || !spec.allowPartial;
619
+ const card = el('div', { class: 'card' },
620
+ el('div', { class: 'qnum' }, 'Q' + (idx + 1)),
621
+ el('p', { class: 'qlabel' }, q.label, required ? el('span', { class: 'req' }, ' *') : null),
622
+ q.description ? el('p', { class: 'qdesc' }, q.description) : null);
623
+ renderBlocks(card, q.blocks || [], q.id);
624
+ const control = el('div', { class: 'control' });
625
+ if (q.type === 'single') control.append(controlSingle(q));
626
+ else if (q.type === 'multi') control.append(controlMulti(q));
627
+ else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
628
+ else if (q.type === 'scale') control.append(controlScale(q));
629
+ else if (q.type === 'color') control.append(controlColor(q));
630
+ else control.append(controlText(q, q.type === 'textarea'));
631
+ card.append(control);
632
+ if (q.note) {
633
+ const noteInput = el('textarea', { class: 'qnote', rows: 2, placeholder: 'optional note about this answer…' });
634
+ noteInput.value = typeof state.notes[q.id] === 'string' ? state.notes[q.id] : '';
635
+ noteInput.addEventListener('input', () => { state.notes[q.id] = noteInput.value; });
636
+ card.append(el('div', { class: 'qnotewrap' }, noteInput));
637
+ }
638
+ card.append(el('p', { class: 'errmsg' }, 'This question is required.'));
639
+ cards[q.id] = card;
640
+ app.append(card);
641
+ });
642
+
643
+ if (spec.note) {
644
+ const note = el('textarea', { placeholder: 'optional note back to the agent…' });
645
+ note.value = state.comment || '';
646
+ note.addEventListener('input', () => { state.comment = note.value; });
647
+ app.append(el('div', { class: 'card' },
648
+ el('p', { class: 'qlabel' }, 'Anything else?'),
649
+ el('p', { class: 'qdesc' }, 'Free-text note returned to the agent along with your answers.'),
650
+ el('div', { class: 'control' }, note)));
651
+ }
652
+
653
+ if (composing) {
654
+ // still streaming in — show a live "composing" note, no submit yet
655
+ app.append(el('div', { class: 'submitbar' }, el('span', { class: 'mcp-composing' }, 'Composing this board…')));
656
+ reportSize();
657
+ return;
658
+ }
659
+ const submitBtn = el('button', { class: 'submit', type: 'button' }, spec.submitLabel);
660
+ const saveEl = el('span', { class: 'savestate' }, '');
661
+ const hint = el('span', { class: 'hint' }, QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
662
+ submitBtn.addEventListener('click', () => onSubmit(submitBtn, saveEl));
663
+ app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
664
+ reportSize();
665
+ }
666
+
667
+ function validate() {
668
+ let firstBad = null;
669
+ for (const q of QS) {
670
+ const required = q.required || !spec.allowPartial;
671
+ const bad = required && getValue(q) === undefined;
672
+ cards[q.id].classList.toggle('error', bad);
673
+ if (bad && !firstBad) firstBad = cards[q.id];
674
+ }
675
+ if (firstBad) firstBad.scrollIntoView({ behavior: 'smooth', block: 'center' });
676
+ return !firstBad;
677
+ }
678
+
679
+ // A human-readable transcript of the submission — so the agent reads the
680
+ // answers even on a host that doesn't surface structuredContent.
681
+ function summarize(data) {
682
+ const lines = [];
683
+ lines.push('The user submitted the relay board "' + spec.title + '".');
684
+ if (QS.length) {
685
+ lines.push('', 'Answers:');
686
+ for (const q of QS) {
687
+ const v = data.answers[q.id];
688
+ let shown;
689
+ if (v === undefined) shown = '(skipped)';
690
+ else if (Array.isArray(v)) shown = v.join(', ');
691
+ else shown = String(v);
692
+ lines.push('- ' + q.label + ' [' + q.id + ']: ' + shown);
693
+ if (data.notes[q.id]) lines.push(' note: ' + data.notes[q.id]);
694
+ }
695
+ }
696
+ if (data.comment) lines.push('', 'Comment: ' + data.comment);
697
+ if (data.blockEdits) lines.push('', 'Edited diagrams: ' + Object.keys(data.blockEdits).join(', '));
698
+ return lines.join('\n');
699
+ }
700
+
701
+ async function onSubmit(submitBtn, saveEl) {
702
+ if (submitted) return;
703
+ if (!validate()) return;
704
+ submitBtn.disabled = true;
705
+ submitBtn.textContent = 'Submitting…';
706
+ const data = payload();
707
+ const structured = {
708
+ boardId: BOOT.boardId || null,
709
+ status: QS.length ? 'submitted' : 'acknowledged',
710
+ answers: data.answers,
711
+ skipped: data.skipped,
712
+ notes: data.notes,
713
+ comment: data.comment,
714
+ blockEdits: data.blockEdits,
715
+ };
716
+ const text = summarize(data);
717
+ let delivered = false;
718
+ try {
719
+ await request('ui/update-model-context', { content: [{ type: 'text', text }], structuredContent: structured });
720
+ delivered = true;
721
+ } catch {
722
+ // Fallback for hosts without update-model-context: post a chat message.
723
+ try { await request('ui/message', { role: 'user', content: { type: 'text', text } }); delivered = true; } catch { /* give up gracefully */ }
724
+ }
725
+ submitted = true;
726
+ showDone(delivered);
727
+ }
728
+
729
+ function showDone(delivered) {
730
+ // Collapse: leave fullscreen, drop the whole form for a one-line confirmation
731
+ // so the host shrinks the iframe to a small footprint in the transcript.
732
+ document.documentElement.classList.remove('mcp-fullscreen');
733
+ app.replaceChildren(el('div', { class: 'mcp-done' },
734
+ el('span', { class: 'mark' }, '✓'),
735
+ el('span', { class: 'lead' }, QS.length ? 'Submitted' : 'Acknowledged'),
736
+ el('span', { class: 'sub' }, delivered
737
+ ? '· sent back to the agent'
738
+ : '· tell the agent you’ve responded')));
739
+ // Report the small height immediately, then again next frame / after layout
740
+ // settles — beats hosts that only grow on debounced size events.
741
+ reportSizeNow();
742
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reportSizeNow);
743
+ setTimeout(reportSizeNow, 150);
744
+ }
745
+
746
+ // ======================================================================
747
+ // boot
748
+ // ======================================================================
749
+ function setStatus(text) {
750
+ const s = document.getElementById('mcp-status');
751
+ if (s) s.textContent = text;
752
+ }
753
+
754
+ let booted = false;
755
+ async function boot(rawOrNormalized, alreadyNormalized) {
756
+ if (booted || submitted) return;
757
+ const next = alreadyNormalized ? rawOrNormalized : clientNormalize(rawOrNormalized);
758
+ if (!next || (!Array.isArray(next.questions)) ) { return; }
759
+ if (!next.questions.length && !(Array.isArray(next.blocks) && next.blocks.length)) return;
760
+ booted = true;
761
+ composing = false;
762
+ if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
763
+ spec = next;
764
+ QS = spec.questions || [];
765
+ seedDefaults();
766
+ indexHtmlBlocks(spec);
767
+ setStatus('Preparing…');
768
+ try { await preloadVendors(spec); } catch { /* render anyway; blocks degrade individually */ }
769
+ render();
770
+ applyDisplayMode();
771
+ }
772
+
773
+ // Progressive preview: the host MAY stream the tool call's JSON as the agent
774
+ // writes it (ui/notifications/tool-input-partial — unclosed JSON auto-closed
775
+ // into a valid object). We render whatever blocks/questions are already valid
776
+ // so the board appears incrementally instead of only after the whole spec is
777
+ // generated. It's a read-only preview — submit is withheld until the final
778
+ // input/result arrives. Degrades to nothing on hosts that don't stream.
779
+ let composing = false;
780
+ let previewTimer = null;
781
+ let vendorsKicked = false;
782
+ function renderPreview(rawArgs) {
783
+ if (booted || submitted) return;
784
+ const next = clientNormalize(rawArgs);
785
+ if (!next) return;
786
+ const hasContent = (next.questions && next.questions.length) || (Array.isArray(next.blocks) && next.blocks.length);
787
+ if (!hasContent) return;
788
+ spec = next;
789
+ QS = spec.questions || [];
790
+ composing = true;
791
+ indexHtmlBlocks(spec);
792
+ if (!vendorsKicked) { vendorsKicked = true; preloadVendors(spec).catch(() => {}); }
793
+ if (previewTimer) return; // coalesce a burst of partials into one paint
794
+ previewTimer = setTimeout(() => { previewTimer = null; if (!booted && !submitted) render(); }, 120);
795
+ }
796
+
797
+ // The spec can arrive as the tool RESULT (preferred — server-normalized) or,
798
+ // on a leaner host, as the tool INPUT (raw). Prefer the result: when raw input
799
+ // lands first, hold briefly for a result before falling back to the input.
800
+ let rawInputTimer = null;
801
+ onNotify('ui/notifications/tool-result', (p) => {
802
+ if (rawInputTimer) { clearTimeout(rawInputTimer); rawInputTimer = null; }
803
+ const sc = p && p.structuredContent;
804
+ if (sc && sc.spec) return boot(sc.spec, true);
805
+ // Some hosts may echo the spec at the top level of structuredContent.
806
+ if (sc && Array.isArray(sc.questions)) return boot(sc, true);
807
+ });
808
+ onNotify('ui/notifications/tool-input', (p) => {
809
+ const args = p && p.arguments;
810
+ if (!args || typeof args !== 'object' || booted || rawInputTimer) return;
811
+ rawInputTimer = setTimeout(() => { rawInputTimer = null; boot(args, false); }, 250);
812
+ });
813
+ onNotify('ui/notifications/tool-input-partial', (p) => {
814
+ const args = p && p.arguments;
815
+ if (args && typeof args === 'object') renderPreview(args);
816
+ });
817
+ onNotify('ui/notifications/host-context-changed', (p) => {
818
+ if (p && typeof p === 'object') {
819
+ if ('theme' in p) host.theme = p.theme;
820
+ if ('styles' in p) host.styles = p.styles;
821
+ if (p.displayMode === 'inline' || p.displayMode === 'fullscreen' || p.displayMode === 'pip') {
822
+ displayMode = p.displayMode;
823
+ applyDisplayMode();
824
+ }
825
+ adoptHostStyles();
826
+ applyTheme();
827
+ }
828
+ });
829
+
830
+ // Kick off the handshake. Proceed even if the host doesn't answer init.
831
+ // SEP-1865 lifecycle: ui/initialize → (host reply) → ui/notifications/initialized.
832
+ // The host MUST NOT send tool-input / tool-result (i.e. the board spec) until
833
+ // it receives `initialized`, so this notification is mandatory — without it the
834
+ // board never gets its spec and never renders.
835
+ (async () => {
836
+ try {
837
+ const res = await request('ui/initialize', {
838
+ protocolVersion: PROTOCOL,
839
+ capabilities: {},
840
+ clientInfo: { name: 'relay', version: String(BOOT.version || '0') },
841
+ appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
842
+ });
843
+ host = (res && res.hostContext) || {};
844
+ } catch {
845
+ host = {};
846
+ }
847
+ if (host.displayMode === 'fullscreen' || host.displayMode === 'pip') displayMode = host.displayMode;
848
+ notify('ui/notifications/initialized', {});
849
+ adoptHostStyles();
850
+ applyTheme();
851
+ setStatus('Waiting for the board…');
852
+ reportSize();
853
+ })();
854
+ })();