@idl3/claude-control 0.1.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/bin/cli.js +68 -0
  4. package/bin/install-service.sh +107 -0
  5. package/bin/self-update.sh +43 -0
  6. package/bin/uninstall-service.sh +22 -0
  7. package/lib/answer.js +64 -0
  8. package/lib/auth.js +81 -0
  9. package/lib/config.js +118 -0
  10. package/lib/push.js +153 -0
  11. package/lib/resources.js +137 -0
  12. package/lib/sessions.js +529 -0
  13. package/lib/terminal.js +278 -0
  14. package/lib/tmux.js +462 -0
  15. package/lib/transcript.js +451 -0
  16. package/lib/tui.js +50 -0
  17. package/lib/uploads.js +42 -0
  18. package/lib/version.js +73 -0
  19. package/package.json +49 -0
  20. package/public/app.js +756 -0
  21. package/public/index.html +120 -0
  22. package/public/styles.css +848 -0
  23. package/server.js +910 -0
  24. package/web/README.md +66 -0
  25. package/web/dist/apple-touch-icon.png +0 -0
  26. package/web/dist/assets/bash-I8pq0VWm.js +1 -0
  27. package/web/dist/assets/core-BYJcZW10.js +3 -0
  28. package/web/dist/assets/css-DazXZka4.js +1 -0
  29. package/web/dist/assets/diff-DiTmLxSS.js +1 -0
  30. package/web/dist/assets/index-Bb7gXgl-.css +1 -0
  31. package/web/dist/assets/index-wrjqfzbL.js +77 -0
  32. package/web/dist/assets/javascript-BKRaQes9.js +1 -0
  33. package/web/dist/assets/json-DIYVocXf.js +1 -0
  34. package/web/dist/assets/markdown-BrP960CR.js +1 -0
  35. package/web/dist/assets/python-sE43i1Pi.js +1 -0
  36. package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
  37. package/web/dist/assets/xml-BXBhIUeX.js +1 -0
  38. package/web/dist/icon-192.png +0 -0
  39. package/web/dist/icon-512.png +0 -0
  40. package/web/dist/index.html +25 -0
  41. package/web/dist/manifest.webmanifest +25 -0
  42. package/web/dist/sw.js +57 -0
package/public/app.js ADDED
@@ -0,0 +1,756 @@
1
+ /**
2
+ * claude-cockpit — frontend
3
+ * Vanilla ESM, no framework, no CDN. All text via textContent (XSS-safe).
4
+ */
5
+
6
+ // ── Constants ─────────────────────────────────────────────────────────────
7
+ const WS_RECONNECT_BASE_MS = 1000;
8
+ const WS_RECONNECT_MAX_MS = 30_000;
9
+
10
+ // ── Client state ─────────────────────────────────────────────────────────
11
+ const state = {
12
+ sessions: [], // Session[]
13
+ selectedId: null, // string|null
14
+ messages: new Map(), // id -> NormalizedMessage[]
15
+ pending: new Map(), // id -> Pending|null
16
+ modalOpen: false,
17
+ // per-modal: selections[qIdx] = Set<label>
18
+ modalSelections: [],
19
+ modalPendingRef: null, // the Pending object currently shown
20
+ };
21
+
22
+ // ── WS connection ─────────────────────────────────────────────────────────
23
+ let ws = null;
24
+ let reconnectTimeout = null;
25
+ let reconnectDelay = WS_RECONNECT_BASE_MS;
26
+ let wsConnected = false;
27
+
28
+ function wsUrl() {
29
+ const loc = window.location;
30
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
31
+ const params = new URLSearchParams(loc.search);
32
+ const token = params.get('token');
33
+ const base = `${proto}//${loc.host}`;
34
+ return token ? `${base}?token=${encodeURIComponent(token)}` : base;
35
+ }
36
+
37
+ function connect() {
38
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
39
+ setConnState('connecting');
40
+ ws = new WebSocket(wsUrl());
41
+
42
+ ws.addEventListener('open', () => {
43
+ wsConnected = true;
44
+ reconnectDelay = WS_RECONNECT_BASE_MS;
45
+ setConnState('connected');
46
+ // Server pushes sessions+resources on open. After a drop+reconnect the server
47
+ // has lost our subscription, so re-subscribe to the selected session to resume
48
+ // the transcript stream.
49
+ if (state.selectedId) {
50
+ sendWs({ type: 'subscribe', id: state.selectedId });
51
+ }
52
+ });
53
+
54
+ ws.addEventListener('message', (evt) => {
55
+ let msg;
56
+ try { msg = JSON.parse(evt.data); } catch { return; }
57
+ handleServerMessage(msg);
58
+ });
59
+
60
+ ws.addEventListener('close', () => {
61
+ wsConnected = false;
62
+ ws = null;
63
+ setConnState('disconnected');
64
+ scheduleReconnect();
65
+ });
66
+
67
+ ws.addEventListener('error', () => {
68
+ // 'close' will also fire; let that handle reconnect
69
+ });
70
+ }
71
+
72
+ function scheduleReconnect() {
73
+ clearTimeout(reconnectTimeout);
74
+ reconnectTimeout = setTimeout(() => { connect(); }, reconnectDelay);
75
+ reconnectDelay = Math.min(reconnectDelay * 2, WS_RECONNECT_MAX_MS);
76
+ }
77
+
78
+ function sendWs(obj) {
79
+ if (ws && ws.readyState === WebSocket.OPEN) {
80
+ ws.send(JSON.stringify(obj));
81
+ }
82
+ }
83
+
84
+ // ── Server message handler ────────────────────────────────────────────────
85
+ function handleServerMessage(msg) {
86
+ switch (msg.type) {
87
+ case 'sessions':
88
+ state.sessions = msg.sessions || [];
89
+ renderSessionList();
90
+ break;
91
+
92
+ case 'messages':
93
+ state.messages.set(msg.id, msg.messages || []);
94
+ if (msg.pending !== undefined) {
95
+ state.pending.set(msg.id, msg.pending);
96
+ updateSessionBadge(msg.id, !!msg.pending);
97
+ }
98
+ if (msg.id === state.selectedId) {
99
+ renderTranscript();
100
+ syncModal();
101
+ }
102
+ break;
103
+
104
+ case 'append': {
105
+ const existing = state.messages.get(msg.id) || [];
106
+ state.messages.set(msg.id, [...existing, ...(msg.messages || [])]);
107
+ if (msg.id === state.selectedId) appendMessages(msg.messages || []);
108
+ break;
109
+ }
110
+
111
+ case 'pending':
112
+ state.pending.set(msg.id, msg.pending);
113
+ // update badge on session item
114
+ updateSessionBadge(msg.id, !!msg.pending);
115
+ if (msg.id === state.selectedId) syncModal();
116
+ break;
117
+
118
+ case 'resources':
119
+ updateHud(msg.snapshot, msg.warning);
120
+ break;
121
+
122
+ case 'capture':
123
+ handleCapture(msg.id, msg.text);
124
+ break;
125
+
126
+ case 'ack':
127
+ if (!msg.ok) {
128
+ console.warn('[cockpit] ack error', msg.op, msg.error);
129
+ toast(`${msg.op} failed: ${msg.error || 'error'}`, 'error');
130
+ } else if (msg.op === 'answer') {
131
+ toast('answer sent →', 'ok');
132
+ }
133
+ break;
134
+
135
+ default:
136
+ break;
137
+ }
138
+ }
139
+
140
+ // ── Session rail ──────────────────────────────────────────────────────────
141
+ function renderSessionList() {
142
+ const ul = document.getElementById('session-list');
143
+ const selectedId = state.selectedId;
144
+
145
+ // Diff: preserve existing items if possible (avoid full re-render)
146
+ const existingIds = new Set([...ul.querySelectorAll('[data-id]')].map(el => el.dataset.id));
147
+ const newIds = new Set(state.sessions.map(s => s.id));
148
+
149
+ // Remove stale
150
+ for (const el of [...ul.querySelectorAll('[data-id]')]) {
151
+ if (!newIds.has(el.dataset.id)) el.remove();
152
+ }
153
+
154
+ for (const session of state.sessions) {
155
+ let item = ul.querySelector(`[data-id="${CSS.escape(session.id)}"]`);
156
+ if (!item) {
157
+ item = makeSessionItem(session);
158
+ ul.appendChild(item);
159
+ }
160
+ updateSessionItem(item, session, session.id === selectedId);
161
+ }
162
+ }
163
+
164
+ function makeSessionItem(session) {
165
+ const li = document.createElement('li');
166
+ li.role = 'option';
167
+ li.tabIndex = 0;
168
+ li.dataset.id = session.id;
169
+ li.className = 'session-item';
170
+
171
+ // Top row
172
+ const top = document.createElement('div');
173
+ top.className = 'session-item-top';
174
+
175
+ const dot = document.createElement('span');
176
+ dot.className = 'active-dot';
177
+ dot.setAttribute('aria-hidden', 'true');
178
+
179
+ const name = document.createElement('span');
180
+ name.className = 'session-name';
181
+
182
+ const badge = document.createElement('span');
183
+ badge.className = 'ask-badge';
184
+ badge.textContent = 'ASK';
185
+ badge.setAttribute('aria-label', 'pending question');
186
+
187
+ top.append(dot, name, badge);
188
+
189
+ const cwd = document.createElement('div');
190
+ cwd.className = 'session-cwd';
191
+
192
+ const cmd = document.createElement('div');
193
+ cmd.className = 'session-cmd';
194
+
195
+ li.append(top, cwd, cmd);
196
+
197
+ li.addEventListener('click', () => selectSession(session.id));
198
+ li.addEventListener('keydown', (e) => {
199
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectSession(session.id); }
200
+ });
201
+
202
+ return li;
203
+ }
204
+
205
+ function updateSessionItem(item, session, selected) {
206
+ item.setAttribute('aria-selected', selected ? 'true' : 'false');
207
+ item.dataset.active = session.active ? 'true' : 'false';
208
+ item.dataset.pending = session.pending ? 'true' : 'false';
209
+
210
+ item.querySelector('.session-name').textContent = session.name || session.id;
211
+ item.querySelector('.session-cwd').textContent = shortenPath(session.cwd || '');
212
+ item.querySelector('.session-cmd').textContent = session.cmd || '';
213
+ }
214
+
215
+ function updateSessionBadge(id, pending) {
216
+ const item = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
217
+ if (item) item.dataset.pending = pending ? 'true' : 'false';
218
+ // also update state.sessions
219
+ const s = state.sessions.find(s => s.id === id);
220
+ if (s) s.pending = pending;
221
+ }
222
+
223
+ function shortenPath(p) {
224
+ if (!p) return '';
225
+ // Collapse a home-style prefix (/Users/<u>/… on macOS, /home/<u>/… on Linux)
226
+ // to ~/ without hardcoding the OS.
227
+ const m = p.match(/^\/(Users|home)\/[^/]+\/(.*)$/);
228
+ if (m) return '~/' + m[2];
229
+ const homeRoot = p.match(/^\/(Users|home)\/[^/]+\/?$/);
230
+ if (homeRoot) return '~';
231
+ if (p.length <= 35) return p;
232
+ return '…' + p.slice(-32);
233
+ }
234
+
235
+ // ── Session selection ─────────────────────────────────────────────────────
236
+ function selectSession(id) {
237
+ if (state.selectedId === id) return;
238
+
239
+ // Unsubscribe old
240
+ if (state.selectedId) {
241
+ sendWs({ type: 'unsubscribe', id: state.selectedId });
242
+ }
243
+
244
+ state.selectedId = id;
245
+
246
+ // Update rail highlight
247
+ for (const el of document.querySelectorAll('.session-item')) {
248
+ el.setAttribute('aria-selected', el.dataset.id === id ? 'true' : 'false');
249
+ }
250
+
251
+ // Mobile master-detail: reveal the chat pane (CSS hides the rail when set).
252
+ document.body.classList.add('session-open');
253
+
254
+ // Subscribe
255
+ sendWs({ type: 'subscribe', id });
256
+
257
+ // Update header
258
+ const session = state.sessions.find(s => s.id === id);
259
+ const header = document.getElementById('transcript-header');
260
+ const composer = document.getElementById('composer');
261
+ header.hidden = false;
262
+ composer.hidden = false;
263
+ document.getElementById('header-session-name').textContent = session?.name || id;
264
+ document.getElementById('header-cwd').textContent = session?.cwd || '';
265
+
266
+ // Show existing messages if cached, else show loading placeholder
267
+ const msgs = state.messages.get(id);
268
+ if (msgs) {
269
+ renderTranscript();
270
+ } else {
271
+ clearTranscript();
272
+ showTranscriptPlaceholder('loading…');
273
+ }
274
+
275
+ syncModal();
276
+ }
277
+
278
+ // ── Transcript rendering ──────────────────────────────────────────────────
279
+ function clearTranscript() {
280
+ const pane = document.getElementById('transcript');
281
+ pane.textContent = '';
282
+ }
283
+
284
+ function showTranscriptPlaceholder(text) {
285
+ const pane = document.getElementById('transcript');
286
+ const div = document.createElement('div');
287
+ div.className = 'transcript-empty';
288
+ const p = document.createElement('p');
289
+ p.textContent = text;
290
+ div.appendChild(p);
291
+ pane.appendChild(div);
292
+ }
293
+
294
+ function renderTranscript() {
295
+ clearTranscript();
296
+ const msgs = state.messages.get(state.selectedId) || [];
297
+ const pane = document.getElementById('transcript');
298
+ if (msgs.length === 0) {
299
+ showTranscriptPlaceholder('no messages yet');
300
+ return;
301
+ }
302
+ for (const msg of msgs) {
303
+ pane.appendChild(buildMsgRow(msg));
304
+ }
305
+ scrollTranscriptToBottom();
306
+ }
307
+
308
+ function appendMessages(msgs) {
309
+ if (!msgs || msgs.length === 0) return;
310
+ const pane = document.getElementById('transcript');
311
+ // Remove empty-state placeholder if present
312
+ const empty = pane.querySelector('.transcript-empty');
313
+ if (empty) empty.remove();
314
+ for (const msg of msgs) {
315
+ pane.appendChild(buildMsgRow(msg));
316
+ }
317
+ scrollTranscriptToBottom();
318
+ }
319
+
320
+ function scrollTranscriptToBottom() {
321
+ const pane = document.getElementById('transcript');
322
+ pane.scrollTop = pane.scrollHeight;
323
+ }
324
+
325
+ function buildMsgRow(msg) {
326
+ const row = document.createElement('div');
327
+ row.className = 'msg-row';
328
+ row.dataset.role = msg.role;
329
+ row.dataset.uuid = msg.uuid || '';
330
+
331
+ // Role label
332
+ const roleLabel = document.createElement('div');
333
+ roleLabel.className = 'msg-role';
334
+ roleLabel.textContent = msg.role;
335
+ row.appendChild(roleLabel);
336
+
337
+ // Message body
338
+ const body = document.createElement('div');
339
+ body.className = 'msg-body';
340
+
341
+ for (const block of (msg.blocks || [])) {
342
+ const el = buildBlock(block);
343
+ if (el) body.appendChild(el);
344
+ }
345
+
346
+ row.appendChild(body);
347
+ return row;
348
+ }
349
+
350
+ function buildBlock(block) {
351
+ switch (block.kind) {
352
+ case 'text': {
353
+ const div = document.createElement('div');
354
+ div.className = 'block-text';
355
+ div.textContent = block.text;
356
+ return div;
357
+ }
358
+
359
+ case 'thinking': {
360
+ const details = document.createElement('details');
361
+ details.className = 'block-thinking';
362
+ const summary = document.createElement('summary');
363
+ summary.textContent = '▸ thinking…';
364
+ const pre = document.createElement('div');
365
+ pre.className = 'thinking-text';
366
+ pre.textContent = block.text;
367
+ details.append(summary, pre);
368
+ return details;
369
+ }
370
+
371
+ case 'tool_use': {
372
+ const div = document.createElement('div');
373
+ div.className = 'block-tool-use';
374
+ const arrow = document.createElement('span');
375
+ arrow.className = 'tool-arrow';
376
+ arrow.textContent = '▸';
377
+ const name = document.createElement('span');
378
+ name.className = 'tool-name';
379
+ name.textContent = block.name || '';
380
+ const sep = document.createElement('span');
381
+ sep.className = 'tool-sep';
382
+ sep.textContent = '—';
383
+ const input = document.createElement('span');
384
+ input.className = 'tool-input';
385
+ input.textContent = block.inputSummary || '';
386
+ div.append(arrow, name, sep, input);
387
+ return div;
388
+ }
389
+
390
+ case 'tool_result': {
391
+ const div = document.createElement('div');
392
+ div.className = 'block-tool-result';
393
+ div.dataset.error = block.isError ? 'true' : 'false';
394
+ div.textContent = block.text || '';
395
+ return div;
396
+ }
397
+
398
+ default:
399
+ return null;
400
+ }
401
+ }
402
+
403
+ // ── Resource HUD ──────────────────────────────────────────────────────────
404
+ function updateHud(snapshot, warning) {
405
+ if (!snapshot) return;
406
+
407
+ const hud = document.getElementById('resource-hud');
408
+ const overLimit = snapshot.overLimit || !!warning;
409
+
410
+ hud.classList.toggle('warning', overLimit);
411
+
412
+ const s = snapshot.self || {};
413
+ const sys = snapshot.system || {};
414
+
415
+ setHudVal('hud-cpu', s.cpuPct != null ? `${s.cpuPct.toFixed(1)}%` : '—');
416
+ setHudVal('hud-rss', s.rssMB != null ? `${s.rssMB.toFixed(0)} MB` : '—');
417
+ setHudVal('hud-heap', s.heapMB != null ? `${s.heapMB.toFixed(0)} MB` : '—');
418
+
419
+ const load = sys.loadavg;
420
+ setHudVal('hud-load', load ? `${load[0].toFixed(2)}` : '—');
421
+ setHudVal('hud-mem', sys.memUsedPct != null ? `${sys.memUsedPct.toFixed(0)}%` : '—');
422
+
423
+ const warnEl = document.getElementById('hud-warn');
424
+ warnEl.hidden = !overLimit;
425
+ if (overLimit) {
426
+ const txt = document.getElementById('hud-warn-text');
427
+ txt.textContent = warning || 'over limit';
428
+ }
429
+ }
430
+
431
+ function setHudVal(id, text) {
432
+ const el = document.getElementById(id);
433
+ if (el) el.textContent = text;
434
+ }
435
+
436
+ // ── Reply composer ────────────────────────────────────────────────────────
437
+ function initComposer() {
438
+ const input = document.getElementById('reply-input');
439
+ const sendBtn = document.getElementById('send-btn');
440
+
441
+ input.addEventListener('keydown', (e) => {
442
+ if (e.key === 'Enter' && !e.shiftKey) {
443
+ e.preventDefault();
444
+ sendReply();
445
+ }
446
+ });
447
+ sendBtn.addEventListener('click', () => sendReply());
448
+
449
+ // Attachments: upload raw bytes, inject the saved path into the composer so
450
+ // the Claude session can read it (Claude Code loads image/file paths).
451
+ const attachBtn = document.getElementById('attach-btn');
452
+ const attachInput = document.getElementById('attach-input');
453
+ attachBtn.addEventListener('click', () => attachInput.click());
454
+ attachInput.addEventListener('change', async () => {
455
+ const files = [...attachInput.files];
456
+ attachInput.value = ''; // allow re-selecting the same file later
457
+ for (const f of files) await uploadOne(f);
458
+ });
459
+ }
460
+
461
+ function authQuery() {
462
+ const t = new URLSearchParams(window.location.search).get('token');
463
+ return t ? `&token=${encodeURIComponent(t)}` : '';
464
+ }
465
+
466
+ async function uploadOne(file) {
467
+ if (!state.selectedId) { toast('select a session first', 'error'); return; }
468
+ toast(`uploading ${file.name}…`);
469
+ try {
470
+ const url = `/api/upload?name=${encodeURIComponent(file.name)}${authQuery()}`;
471
+ const res = await fetch(url, { method: 'POST', body: file });
472
+ const json = await res.json().catch(() => ({}));
473
+ if (!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);
474
+ insertIntoComposer(json.path);
475
+ toast(`attached ${json.name}`, 'ok');
476
+ } catch (err) {
477
+ toast(`attach failed: ${err.message}`, 'error');
478
+ }
479
+ }
480
+
481
+ function insertIntoComposer(text) {
482
+ const input = document.getElementById('reply-input');
483
+ const sep = input.value && !input.value.endsWith(' ') ? ' ' : '';
484
+ input.value = `${input.value}${sep}${text} `;
485
+ input.focus();
486
+ }
487
+
488
+ function sendReply() {
489
+ const input = document.getElementById('reply-input');
490
+ const text = input.value;
491
+ if (!text.trim()) return;
492
+ if (!state.selectedId) { toast('select a session first', 'error'); return; }
493
+ if (!wsConnected) { toast('not connected — reconnecting…', 'error'); return; }
494
+ sendWs({ type: 'reply', id: state.selectedId, text });
495
+ input.value = '';
496
+ input.style.height = '';
497
+ toast('sent →', 'ok');
498
+ }
499
+
500
+ // ── Toast (transient feedback) ──────────────────────────────────────────────
501
+ let toastTimer = null;
502
+ function toast(message, kind = '') {
503
+ const el = document.getElementById('toast');
504
+ if (!el) return;
505
+ el.textContent = message;
506
+ el.className = 'toast show' + (kind ? ` toast-${kind}` : '');
507
+ clearTimeout(toastTimer);
508
+ toastTimer = setTimeout(() => { el.className = 'toast' + (kind ? ` toast-${kind}` : ''); }, 2200);
509
+ }
510
+
511
+ // ── Modal (AskUserQuestion) ───────────────────────────────────────────────
512
+ function syncModal() {
513
+ const id = state.selectedId;
514
+ if (!id) { closeModal(); return; }
515
+ const pending = state.pending.get(id);
516
+ if (pending && pending.questions && pending.questions.length > 0) {
517
+ openModal(pending);
518
+ } else {
519
+ closeModal();
520
+ }
521
+ }
522
+
523
+ function openModal(pending) {
524
+ if (state.modalOpen && state.modalPendingRef === pending) return;
525
+ state.modalPendingRef = pending;
526
+ state.modalOpen = true;
527
+ state.modalSelections = pending.questions.map(() => new Set());
528
+
529
+ renderModalQuestions(pending.questions);
530
+
531
+ const modal = document.getElementById('ask-modal');
532
+ modal.removeAttribute('hidden');
533
+
534
+ // Focus trap: focus the first focusable element
535
+ requestAnimationFrame(() => {
536
+ const first = modal.querySelector('button, [tabindex="0"]');
537
+ if (first) first.focus();
538
+ });
539
+
540
+ hideCaptureOutput();
541
+ }
542
+
543
+ function closeModal() {
544
+ if (!state.modalOpen) return;
545
+ state.modalOpen = false;
546
+ state.modalPendingRef = null;
547
+ state.modalSelections = [];
548
+ const modal = document.getElementById('ask-modal');
549
+ modal.setAttribute('hidden', '');
550
+ hideCaptureOutput();
551
+ }
552
+
553
+ function renderModalQuestions(questions) {
554
+ const container = document.getElementById('ask-questions');
555
+ container.textContent = '';
556
+
557
+ questions.forEach((q, qIdx) => {
558
+ const block = document.createElement('div');
559
+ block.className = 'question-block';
560
+
561
+ if (q.header) {
562
+ const header = document.createElement('div');
563
+ header.className = 'question-header';
564
+ header.textContent = q.header;
565
+ block.appendChild(header);
566
+ }
567
+
568
+ const qText = document.createElement('div');
569
+ qText.className = 'question-text';
570
+ qText.textContent = q.question;
571
+ block.appendChild(qText);
572
+
573
+ if (q.multiSelect) {
574
+ const hint = document.createElement('div');
575
+ hint.className = 'question-hint';
576
+ hint.textContent = 'select one or more';
577
+ block.appendChild(hint);
578
+ }
579
+
580
+ if (q.options && q.options.length > 0) {
581
+ const grid = document.createElement('div');
582
+ grid.className = 'options-grid';
583
+
584
+ q.options.forEach((opt) => {
585
+ const btn = document.createElement('button');
586
+ btn.className = 'option-btn';
587
+ btn.setAttribute('aria-pressed', 'false');
588
+ btn.dataset.qIdx = qIdx;
589
+ btn.dataset.label = opt.label;
590
+
591
+ const labelEl = document.createElement('span');
592
+ labelEl.className = 'option-label';
593
+ labelEl.textContent = opt.label;
594
+ btn.appendChild(labelEl);
595
+
596
+ if (opt.description) {
597
+ const desc = document.createElement('span');
598
+ desc.className = 'option-desc';
599
+ desc.textContent = opt.description;
600
+ btn.appendChild(desc);
601
+ }
602
+
603
+ btn.addEventListener('click', () => toggleOption(qIdx, opt.label, q.multiSelect, btn));
604
+ grid.appendChild(btn);
605
+ });
606
+
607
+ block.appendChild(grid);
608
+ }
609
+
610
+ container.appendChild(block);
611
+ });
612
+
613
+ updateAnswerButtonState();
614
+ }
615
+
616
+ // Enable "send answer" only when every question has at least one selection.
617
+ function updateAnswerButtonState() {
618
+ const btn = document.getElementById('ask-send-btn');
619
+ if (!btn) return;
620
+ const ready =
621
+ state.modalSelections.length > 0 &&
622
+ state.modalSelections.every((s) => s.size > 0);
623
+ btn.disabled = !ready;
624
+ }
625
+
626
+ function toggleOption(qIdx, label, multiSelect, clickedBtn) {
627
+ const sel = state.modalSelections[qIdx];
628
+ if (!sel) return;
629
+
630
+ if (multiSelect) {
631
+ if (sel.has(label)) {
632
+ sel.delete(label);
633
+ clickedBtn.setAttribute('aria-pressed', 'false');
634
+ clickedBtn.classList.remove('selected');
635
+ } else {
636
+ sel.add(label);
637
+ clickedBtn.setAttribute('aria-pressed', 'true');
638
+ clickedBtn.classList.add('selected');
639
+ }
640
+ } else {
641
+ // radio behavior: deselect all in same question, select this
642
+ const container = document.getElementById('ask-questions');
643
+ const siblings = container.querySelectorAll(`[data-q-idx="${qIdx}"]`);
644
+ for (const sib of siblings) {
645
+ sib.setAttribute('aria-pressed', 'false');
646
+ sib.classList.remove('selected');
647
+ }
648
+ sel.clear();
649
+ sel.add(label);
650
+ clickedBtn.setAttribute('aria-pressed', 'true');
651
+ clickedBtn.classList.add('selected');
652
+ }
653
+ updateAnswerButtonState();
654
+ }
655
+
656
+ function sendAnswer() {
657
+ const id = state.selectedId;
658
+ const pending = state.modalPendingRef;
659
+ if (!id || !pending) return;
660
+
661
+ // Guard: every question must have at least one selection, else the server's
662
+ // buildAnswerKeys throws and the modal would already be closed (silent fail).
663
+ if (!state.modalSelections.every((s) => s.size > 0)) return;
664
+
665
+ // selections[i] = array of chosen labels for question i
666
+ const selections = state.modalSelections.map(s => [...s]);
667
+
668
+ sendWs({
669
+ type: 'answer',
670
+ id,
671
+ toolUseId: pending.toolUseId,
672
+ selections,
673
+ });
674
+
675
+ closeModal();
676
+ }
677
+
678
+ function handleCapture(id, text) {
679
+ if (id !== state.selectedId) return;
680
+ const output = document.getElementById('ask-capture-output');
681
+ const pre = document.getElementById('ask-capture-pre');
682
+ output.hidden = false;
683
+ pre.textContent = text || '';
684
+ }
685
+
686
+ function hideCaptureOutput() {
687
+ const output = document.getElementById('ask-capture-output');
688
+ const pre = document.getElementById('ask-capture-pre');
689
+ if (output) output.hidden = true;
690
+ if (pre) pre.textContent = '';
691
+ }
692
+
693
+ // ── Focus trap for modal ──────────────────────────────────────────────────
694
+ function initModalFocusTrap() {
695
+ const modal = document.getElementById('ask-modal');
696
+
697
+ modal.addEventListener('keydown', (e) => {
698
+ if (!state.modalOpen) return;
699
+ if (e.key === 'Escape') { e.preventDefault(); closeModal(); return; }
700
+ if (e.key !== 'Tab') return;
701
+
702
+ const focusable = [...modal.querySelectorAll(
703
+ 'button:not([disabled]), [tabindex="0"], textarea, input, select'
704
+ )].filter(el => !el.closest('[hidden]'));
705
+
706
+ if (focusable.length === 0) return;
707
+ const first = focusable[0];
708
+ const last = focusable[focusable.length - 1];
709
+
710
+ if (e.shiftKey) {
711
+ if (document.activeElement === first) { e.preventDefault(); last.focus(); }
712
+ } else {
713
+ if (document.activeElement === last) { e.preventDefault(); first.focus(); }
714
+ }
715
+ });
716
+
717
+ // Click backdrop to close
718
+ modal.addEventListener('click', (e) => {
719
+ if (e.target === modal) closeModal();
720
+ });
721
+ }
722
+
723
+ // ── Connection state visual ───────────────────────────────────────────────
724
+ function setConnState(state) {
725
+ const dot = document.getElementById('connection-dot');
726
+ if (!dot) return;
727
+ dot.className = 'conn-dot conn-' + state;
728
+ dot.title = state;
729
+ }
730
+
731
+ // ── Init ──────────────────────────────────────────────────────────────────
732
+ function init() {
733
+ // Show initial empty state
734
+ showTranscriptPlaceholder('select a session →');
735
+
736
+ // Wire modal buttons
737
+ document.getElementById('ask-modal-close').addEventListener('click', closeModal);
738
+ document.getElementById('ask-cancel-btn').addEventListener('click', closeModal);
739
+ document.getElementById('ask-send-btn').addEventListener('click', sendAnswer);
740
+ document.getElementById('ask-capture-btn').addEventListener('click', () => {
741
+ if (state.selectedId) sendWs({ type: 'capture', id: state.selectedId });
742
+ });
743
+
744
+ // Mobile: back to the session list (detail -> master).
745
+ document.getElementById('mobile-back').addEventListener('click', () => {
746
+ document.body.classList.remove('session-open');
747
+ });
748
+
749
+ initComposer();
750
+ initModalFocusTrap();
751
+
752
+ // Start WS
753
+ connect();
754
+ }
755
+
756
+ document.addEventListener('DOMContentLoaded', init);