@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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.js +68 -0
- package/bin/install-service.sh +107 -0
- package/bin/self-update.sh +43 -0
- package/bin/uninstall-service.sh +22 -0
- package/lib/answer.js +64 -0
- package/lib/auth.js +81 -0
- package/lib/config.js +118 -0
- package/lib/push.js +153 -0
- package/lib/resources.js +137 -0
- package/lib/sessions.js +529 -0
- package/lib/terminal.js +278 -0
- package/lib/tmux.js +462 -0
- package/lib/transcript.js +451 -0
- package/lib/tui.js +50 -0
- package/lib/uploads.js +42 -0
- package/lib/version.js +73 -0
- package/package.json +49 -0
- package/public/app.js +756 -0
- package/public/index.html +120 -0
- package/public/styles.css +848 -0
- package/server.js +910 -0
- package/web/README.md +66 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/bash-I8pq0VWm.js +1 -0
- package/web/dist/assets/core-BYJcZW10.js +3 -0
- package/web/dist/assets/css-DazXZka4.js +1 -0
- package/web/dist/assets/diff-DiTmLxSS.js +1 -0
- package/web/dist/assets/index-Bb7gXgl-.css +1 -0
- package/web/dist/assets/index-wrjqfzbL.js +77 -0
- package/web/dist/assets/javascript-BKRaQes9.js +1 -0
- package/web/dist/assets/json-DIYVocXf.js +1 -0
- package/web/dist/assets/markdown-BrP960CR.js +1 -0
- package/web/dist/assets/python-sE43i1Pi.js +1 -0
- package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
- package/web/dist/assets/xml-BXBhIUeX.js +1 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +25 -0
- 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);
|