@lelouchhe/webagent 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.
@@ -0,0 +1,612 @@
1
+ // WebSocket event handling and history replay
2
+
3
+ import {
4
+ state, dom, setBusy, setConfigValue, getConfigOption, updateConfigOptions,
5
+ updateModeUI, resetSessionUI, requestNewSession, setHashSessionId, updateSessionInfo,
6
+ setConnectionStatus, clearCancelTimer,
7
+ } from './state.mmjqzu9r.js';
8
+ import {
9
+ addMessage, addSystem, finishAssistant, finishThinking, hideWaiting,
10
+ scrollToBottom, renderMd, escHtml, renderPatchDiff, addBashBlock, finishBash, appendMessageElement,
11
+ formatLocalTime,
12
+ } from './render.mmjqzu9r.js';
13
+
14
+ function finishPromptIfIdle() {
15
+ if (!state.pendingPromptDone) return;
16
+ if (state.pendingToolCallIds.size > 0 || state.pendingPermissionRequestIds.size > 0) return;
17
+ hideWaiting();
18
+ finishThinking();
19
+ finishAssistant();
20
+ setBusy(false);
21
+ dom.input.focus();
22
+ state.pendingPromptDone = false;
23
+ }
24
+
25
+ function cancelPendingTurnUI() {
26
+ for (const id of state.pendingToolCallIds) {
27
+ const el = document.getElementById(`tc-${id}`);
28
+ if (!el) continue;
29
+ el.className = 'tool-call failed';
30
+ const iconSpan = el.querySelector('.icon');
31
+ if (iconSpan) iconSpan.textContent = '✗';
32
+ }
33
+ for (const requestId of state.pendingPermissionRequestIds) {
34
+ const permEl = document.querySelector(`.permission[data-request-id="${requestId}"]`);
35
+ if (!permEl) continue;
36
+ const titleEl = permEl.querySelector('.title');
37
+ const title = titleEl?.textContent || '⚿';
38
+ permEl.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — cancelled</span>`;
39
+ }
40
+ state.pendingToolCallIds.clear();
41
+ state.pendingPermissionRequestIds.clear();
42
+ }
43
+
44
+ export async function loadHistory(sid) {
45
+ state.replayInProgress = true;
46
+ state.replayQueue = [];
47
+ try {
48
+ const res = await fetch(`/api/sessions/${sid}/events`);
49
+ if (!res.ok) return false;
50
+ const events = await res.json();
51
+ for (let i = 0; i < events.length; i++) {
52
+ const data = JSON.parse(events[i].data);
53
+ replayEvent(events[i].type, data, events, i);
54
+ }
55
+ if (events.length) {
56
+ state.lastEventSeq = events[events.length - 1].seq;
57
+ }
58
+ setSyncBoundary();
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ } finally {
63
+ state.replayInProgress = false;
64
+ drainReplayQueue();
65
+ }
66
+ }
67
+
68
+ /** Mark the last DOM child as the sync boundary for incremental reconnect. */
69
+ function setSyncBoundary() {
70
+ const prev = dom.messages.querySelector('[data-sync-boundary]');
71
+ if (prev) prev.removeAttribute('data-sync-boundary');
72
+ const last = dom.messages.lastElementChild;
73
+ if (last) last.setAttribute('data-sync-boundary', '');
74
+ }
75
+
76
+ /**
77
+ * Fetch only events added since the last sync point and replay them.
78
+ * Returns true if new events were applied (or none needed), false on error.
79
+ */
80
+ export async function loadNewEvents(sid) {
81
+ state.replayInProgress = true;
82
+ state.replayQueue = [];
83
+ try {
84
+ const url = `/api/sessions/${sid}/events?after_seq=${state.lastEventSeq}`;
85
+ const res = await fetch(url);
86
+ if (!res.ok) return false;
87
+ const events = await res.json();
88
+
89
+ // Always remove DOM elements added after the sync boundary (live-rendered
90
+ // content that may be orphaned or overlap with new DB events), and reset
91
+ // in-progress streaming state. This must run even when the event list is
92
+ // empty so that partially-streamed elements left over from a disconnect
93
+ // don't stay in the DOM.
94
+ const boundary = dom.messages.querySelector('[data-sync-boundary]');
95
+ if (boundary) {
96
+ while (boundary.nextElementSibling) boundary.nextElementSibling.remove();
97
+ }
98
+ state.currentAssistantEl = null;
99
+ state.currentAssistantText = '';
100
+ state.currentThinkingEl = null;
101
+ state.currentThinkingText = '';
102
+ state.currentBashEl = null;
103
+
104
+ if (events.length === 0) return true;
105
+
106
+ for (let i = 0; i < events.length; i++) {
107
+ const data = JSON.parse(events[i].data);
108
+ replayEvent(events[i].type, data, events, i);
109
+ }
110
+ state.lastEventSeq = events[events.length - 1].seq;
111
+ setSyncBoundary();
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ } finally {
116
+ state.replayInProgress = false;
117
+ drainReplayQueue();
118
+ }
119
+ }
120
+
121
+ export function replayEvent(type, data, events, idx) {
122
+ switch (type) {
123
+ case 'user_message': {
124
+ const el = addMessage('user', data.text);
125
+ if (data.images) {
126
+ for (const img of data.images) {
127
+ const imgEl = document.createElement('img');
128
+ imgEl.className = 'user-image';
129
+ imgEl.src = `/data/${img.path}`;
130
+ el.appendChild(imgEl);
131
+ }
132
+ }
133
+ break;
134
+ }
135
+ case 'assistant_message':
136
+ addMessage('assistant', data.text);
137
+ break;
138
+ case 'thinking': {
139
+ const el = document.createElement('details');
140
+ el.className = 'thinking';
141
+ el.innerHTML = `<summary>⠿ thought</summary><div class="thinking-content">${escHtml(data.text)}</div>`;
142
+ appendMessageElement(el);
143
+ break;
144
+ }
145
+ case 'tool_call': {
146
+ const icons = { read: 'cat', edit: 'edit', execute: 'exec', search: 'find', delete: 'rm' };
147
+ const icon = icons[data.kind] || 'run';
148
+ const el = document.createElement('div');
149
+ el.className = 'tool-call';
150
+ el.id = `tc-${data.id}`;
151
+ let label = `<span class="icon">${icon}</span> ${escHtml(data.title)}`;
152
+ const ri = data.rawInput;
153
+ if (ri && ri.command) {
154
+ label += `<span class="tc-detail">$ ${escHtml(ri.command)}</span>`;
155
+ } else if (ri && ri.path) {
156
+ label += `<span class="tc-detail">${escHtml(ri.path)}</span>`;
157
+ }
158
+ el.innerHTML = label;
159
+ const diffHtml = data.kind === 'edit' ? renderPatchDiff(ri) : null;
160
+ if (diffHtml) {
161
+ const details = document.createElement('details');
162
+ details.innerHTML = `<summary>diff</summary><div class="diff-view">${diffHtml}</div>`;
163
+ el.appendChild(details);
164
+ }
165
+ const detail = el.querySelector('.tc-detail');
166
+ if (detail) {
167
+ el.addEventListener('click', (e) => {
168
+ if (e.target.closest('details')) return;
169
+ detail.classList.toggle('expanded');
170
+ });
171
+ }
172
+ appendMessageElement(el);
173
+ break;
174
+ }
175
+ case 'tool_call_update': {
176
+ const el = document.getElementById(`tc-${data.id}`);
177
+ if (el) {
178
+ const statusIcon = data.status === 'completed' ? '✓' : data.status === 'failed' ? '✗' : '…';
179
+ el.className = `tool-call ${data.status}`;
180
+ const iconSpan = el.querySelector('.icon');
181
+ if (iconSpan) iconSpan.textContent = statusIcon;
182
+ }
183
+ // Clear pending state so prompt_done can finish after reconnect replay
184
+ if (data.status === 'completed' || data.status === 'failed') {
185
+ state.pendingToolCallIds.delete(data.id);
186
+ }
187
+ break;
188
+ }
189
+ case 'plan': {
190
+ const el = document.createElement('div');
191
+ el.className = 'plan';
192
+ el.innerHTML = '<div class="plan-title">― plan</div>' +
193
+ (data.entries || []).map(e => {
194
+ const s = { pending: '○', in_progress: '◉', completed: '●' }[e.status] || '?';
195
+ return `<div class="plan-entry">${s} ${escHtml(e.content)}</div>`;
196
+ }).join('');
197
+ appendMessageElement(el);
198
+ break;
199
+ }
200
+ case 'permission_request': {
201
+ const el = document.createElement('div');
202
+ el.className = 'permission';
203
+ el.dataset.requestId = data.requestId;
204
+ el.dataset.title = data.title || '';
205
+ el.innerHTML = `<span class="title" style="opacity:0.5">⚿ ${escHtml(data.title)}</span> `;
206
+ // Check if this permission was already resolved later in history
207
+ const wasResolved = events && events.slice(idx + 1).some(e =>
208
+ e.type === 'permission_response' && JSON.parse(e.data).requestId === data.requestId
209
+ );
210
+ if (!wasResolved && data.options) {
211
+ el.querySelector('.title').style.opacity = '1';
212
+ data.options.forEach(opt => {
213
+ const btn = document.createElement('button');
214
+ const isAllow = (opt.kind || '').includes('allow');
215
+ btn.className = isAllow ? 'allow' : 'deny';
216
+ btn.textContent = opt.name;
217
+ btn.onclick = () => {
218
+ const isDeny = (opt.kind || '').includes('reject') || (opt.kind || '').includes('deny');
219
+ state.ws.send(JSON.stringify({
220
+ type: 'permission_response',
221
+ sessionId: state.sessionId,
222
+ requestId: data.requestId,
223
+ optionId: opt.optionId,
224
+ optionName: opt.name,
225
+ denied: isDeny,
226
+ }));
227
+ el.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(data.title)} — ${escHtml(opt.name)}</span>`;
228
+ };
229
+ el.appendChild(btn);
230
+ });
231
+ }
232
+ appendMessageElement(el);
233
+ break;
234
+ }
235
+ case 'permission_response': {
236
+ const el = document.querySelector(`.permission[data-request-id="${data.requestId}"]`);
237
+ if (el) {
238
+ const title = el.dataset.title ? `⚿ ${el.dataset.title}` : '⚿';
239
+ const action = data.denied ? 'denied' : data.optionName || 'allowed';
240
+ el.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
241
+ }
242
+ break;
243
+ }
244
+ case 'bash_command': {
245
+ const el = addBashBlock(data.command, false);
246
+ el.id = 'bash-replay-pending';
247
+ break;
248
+ }
249
+ case 'bash_result': {
250
+ const el = document.getElementById('bash-replay-pending');
251
+ if (el) {
252
+ el.removeAttribute('id');
253
+ if (data.output) {
254
+ const out = el.querySelector('.bash-output');
255
+ out.textContent = data.output;
256
+ out.classList.add('has-content');
257
+ }
258
+ finishBash(el, data.code, data.signal);
259
+ }
260
+ break;
261
+ }
262
+ case 'prompt_done':
263
+ state.pendingToolCallIds.clear();
264
+ state.pendingPermissionRequestIds.clear();
265
+ state.pendingPromptDone = false;
266
+ setBusy(false);
267
+ break;
268
+ }
269
+ }
270
+
271
+ /** Process queued WS events, skipping any that duplicate content already in the DOM. */
272
+ function drainReplayQueue() {
273
+ const queue = state.replayQueue;
274
+ state.replayQueue = [];
275
+ for (const msg of queue) {
276
+ if (isDuplicateOfReplay(msg)) continue;
277
+ handleEvent(msg);
278
+ }
279
+ }
280
+
281
+ /** Check whether a queued WS event duplicates an element already rendered by replay. */
282
+ function isDuplicateOfReplay(msg) {
283
+ switch (msg.type) {
284
+ case 'tool_call':
285
+ return !!document.getElementById(`tc-${msg.id}`);
286
+ case 'permission_request':
287
+ return !!document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
288
+ default:
289
+ return false;
290
+ }
291
+ }
292
+
293
+ export function handleEvent(msg) {
294
+ // Queue events that arrive while history replay is in progress to avoid duplicates
295
+ if (state.replayInProgress) {
296
+ state.replayQueue.push(msg);
297
+ return;
298
+ }
299
+
300
+ // Ignore events from other sessions (multi-client broadcast)
301
+ if (msg.sessionId && state.sessionId && msg.sessionId !== state.sessionId
302
+ && msg.type !== 'session_created' && msg.type !== 'session_deleted') {
303
+ return;
304
+ }
305
+ switch (msg.type) {
306
+ case 'connected':
307
+ if (msg.cancelTimeout != null) state.cancelTimeout = msg.cancelTimeout;
308
+ break;
309
+
310
+ case 'session_created':
311
+ // Only switch to the new session if this client requested it
312
+ if (!state.awaitingNewSession && state.sessionId && msg.sessionId !== state.sessionId) {
313
+ break;
314
+ }
315
+ state.awaitingNewSession = false;
316
+ state.sessionId = msg.sessionId;
317
+ state.sessionCwd = msg.cwd || state.sessionCwd;
318
+ state.sessionTitle = msg.title || null;
319
+ if (msg.configOptions?.length) updateConfigOptions(msg.configOptions);
320
+ setHashSessionId(state.sessionId);
321
+ updateSessionInfo(state.sessionId, state.sessionTitle);
322
+ setConnectionStatus('connected', 'connected');
323
+ dom.input.disabled = false;
324
+ dom.sendBtn.disabled = false;
325
+ dom.input.placeholder = '';
326
+ setBusy(Boolean(msg.busyKind));
327
+ if (msg.busyKind === 'bash') {
328
+ const pendingBashEl = document.getElementById('bash-replay-pending');
329
+ if (pendingBashEl) {
330
+ pendingBashEl.removeAttribute('id');
331
+ pendingBashEl.querySelector('.bash-cmd')?.classList.add('running');
332
+ state.currentBashEl = pendingBashEl;
333
+ }
334
+ } else {
335
+ state.currentBashEl = null;
336
+ }
337
+ if (dom.messages.children.length === 0) {
338
+ addSystem(`Session created: ${state.sessionTitle || msg.sessionId.slice(0, 8) + '…'}`);
339
+ }
340
+ break;
341
+
342
+ case 'user_message': {
343
+ state.turnEnded = false;
344
+ if (msg.sessionId === state.sessionId) {
345
+ const el = addMessage('user', msg.text);
346
+ if (msg.images) {
347
+ for (const img of msg.images) {
348
+ const imgEl = document.createElement('img');
349
+ imgEl.className = 'user-image';
350
+ imgEl.src = `/data/${img.path}`;
351
+ el.appendChild(imgEl);
352
+ }
353
+ }
354
+ }
355
+ break;
356
+ }
357
+
358
+ case 'message_chunk':
359
+ state.turnEnded = false;
360
+ hideWaiting();
361
+ finishThinking();
362
+ if (!state.currentAssistantEl) {
363
+ state.currentAssistantEl = addMessage('assistant', '');
364
+ state.currentAssistantText = '';
365
+ }
366
+ state.currentAssistantText += msg.text;
367
+ state.currentAssistantEl.innerHTML = renderMd(state.currentAssistantText);
368
+ scrollToBottom();
369
+ break;
370
+
371
+ case 'thought_chunk':
372
+ state.turnEnded = false;
373
+ hideWaiting();
374
+ if (!state.currentThinkingEl) {
375
+ state.currentThinkingEl = document.createElement('details');
376
+ state.currentThinkingEl.className = 'thinking';
377
+ state.currentThinkingEl.innerHTML = '<summary class="active">⠿ thinking...</summary><div class="thinking-content"></div>';
378
+ state.currentThinkingText = '';
379
+ appendMessageElement(state.currentThinkingEl);
380
+ }
381
+ state.currentThinkingText += msg.text;
382
+ state.currentThinkingEl.querySelector('.thinking-content').textContent = state.currentThinkingText;
383
+ scrollToBottom();
384
+ break;
385
+
386
+ case 'tool_call': {
387
+ if (state.turnEnded) break;
388
+ state.pendingToolCallIds.add(msg.id);
389
+ setBusy(true);
390
+ hideWaiting();
391
+ finishThinking();
392
+ finishAssistant();
393
+ const icons = { read: 'cat', edit: 'edit', execute: 'exec', search: 'find', delete: 'rm' };
394
+ const icon = icons[msg.kind] || 'run';
395
+ const el = document.createElement('div');
396
+ el.className = 'tool-call';
397
+ el.id = `tc-${msg.id}`;
398
+ let label = `<span class="icon">${icon}</span> ${escHtml(msg.title)}`;
399
+ const ri = msg.rawInput;
400
+ if (ri && ri.command) {
401
+ label += `<span class="tc-detail">$ ${escHtml(ri.command)}</span>`;
402
+ } else if (ri && ri.path) {
403
+ label += `<span class="tc-detail">${escHtml(ri.path)}</span>`;
404
+ }
405
+ el.innerHTML = label;
406
+ const diffHtml = msg.kind === 'edit' ? renderPatchDiff(ri) : null;
407
+ if (diffHtml) {
408
+ const details = document.createElement('details');
409
+ details.innerHTML = `<summary>diff</summary><div class="diff-view">${diffHtml}</div>`;
410
+ el.appendChild(details);
411
+ }
412
+ const detail = el.querySelector('.tc-detail');
413
+ if (detail) {
414
+ el.addEventListener('click', (e) => {
415
+ // Don't toggle tc-detail when clicking inside a <details> element
416
+ if (e.target.closest('details')) return;
417
+ detail.classList.toggle('expanded');
418
+ });
419
+ }
420
+ appendMessageElement(el);
421
+ break;
422
+ }
423
+
424
+ case 'tool_call_update': {
425
+ const el = document.getElementById(`tc-${msg.id}`);
426
+ if (msg.status === 'completed' || msg.status === 'failed') {
427
+ state.pendingToolCallIds.delete(msg.id);
428
+ }
429
+ if (el) {
430
+ const statusIcon = msg.status === 'completed' ? '✓' : msg.status === 'failed' ? '✗' : '…';
431
+ el.className = `tool-call ${msg.status}`;
432
+ const iconSpan = el.querySelector('.icon');
433
+ if (iconSpan) iconSpan.textContent = statusIcon;
434
+ if (msg.content && msg.content.length && !el.querySelector('details')) {
435
+ const text = msg.content
436
+ .map(c => {
437
+ if (c.type === 'terminal') return `[terminal ${c.terminalId}]`;
438
+ if (c.content?.text) return c.content.text;
439
+ if (Array.isArray(c.content)) return c.content.map(cc => cc.text || '').join('');
440
+ return '';
441
+ })
442
+ .filter(Boolean).join('\n');
443
+ if (text) {
444
+ const details = document.createElement('details');
445
+ details.innerHTML = `<summary>output</summary><div class="tc-content">${escHtml(text)}</div>`;
446
+ el.appendChild(details);
447
+ }
448
+ }
449
+ }
450
+ finishPromptIfIdle();
451
+ scrollToBottom();
452
+ break;
453
+ }
454
+
455
+ case 'plan': {
456
+ finishThinking();
457
+ finishAssistant();
458
+ const el = document.createElement('div');
459
+ el.className = 'plan';
460
+ el.innerHTML = '<div class="plan-title">― plan</div>' +
461
+ msg.entries.map(e => {
462
+ const s = { pending: '○', in_progress: '◉', completed: '●' }[e.status] || '?';
463
+ return `<div class="plan-entry">${s} ${escHtml(e.content)}</div>`;
464
+ }).join('');
465
+ appendMessageElement(el);
466
+ break;
467
+ }
468
+
469
+ case 'permission_request': {
470
+ if (state.turnEnded) break;
471
+ state.pendingPermissionRequestIds.add(msg.requestId);
472
+ setBusy(true);
473
+ finishThinking();
474
+ const permEl = document.createElement('div');
475
+ permEl.className = 'permission';
476
+ permEl.dataset.requestId = msg.requestId;
477
+ permEl.dataset.title = msg.title || '';
478
+ permEl.innerHTML = `<span class="title">⚿ ${escHtml(msg.title)}</span> `;
479
+ msg.options.forEach(opt => {
480
+ const btn = document.createElement('button');
481
+ const isAllow = (opt.kind || '').includes('allow');
482
+ btn.className = isAllow ? 'allow' : 'deny';
483
+ btn.textContent = opt.name;
484
+ btn.onclick = () => {
485
+ const isDeny = (opt.kind || '').includes('reject') || (opt.kind || '').includes('deny');
486
+ try {
487
+ state.ws.send(JSON.stringify({
488
+ type: 'permission_response',
489
+ sessionId: state.sessionId,
490
+ requestId: msg.requestId,
491
+ optionId: opt.optionId,
492
+ optionName: opt.name,
493
+ denied: isDeny,
494
+ }));
495
+ } catch { /* connection may be broken; cleanup still runs */ }
496
+ state.pendingPermissionRequestIds.delete(msg.requestId);
497
+ permEl.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(msg.title)} — ${escHtml(opt.name)}</span>`;
498
+ finishPromptIfIdle();
499
+ };
500
+ permEl.appendChild(btn);
501
+ });
502
+ appendMessageElement(permEl);
503
+ break;
504
+ }
505
+
506
+ case 'permission_resolved': {
507
+ state.pendingPermissionRequestIds.delete(msg.requestId);
508
+ const permTarget = document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
509
+ if (msg.sessionId === state.sessionId && permTarget) {
510
+ const title = permTarget.dataset.title ? `⚿ ${permTarget.dataset.title}` : '⚿';
511
+ const action = msg.denied ? 'denied' : msg.optionName || 'allowed';
512
+ permTarget.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
513
+ }
514
+ finishPromptIfIdle();
515
+ break;
516
+ }
517
+
518
+ case 'bash_command': {
519
+ if (msg.sessionId === state.sessionId) {
520
+ addBashBlock(msg.command, true);
521
+ setBusy(true);
522
+ }
523
+ break;
524
+ }
525
+
526
+ case 'bash_output': {
527
+ if (msg.sessionId !== state.sessionId) break;
528
+ if (state.currentBashEl) {
529
+ const out = state.currentBashEl.querySelector('.bash-output');
530
+ if (msg.stream === 'stderr') {
531
+ const span = document.createElement('span');
532
+ span.className = 'stderr';
533
+ span.textContent = msg.text;
534
+ out.appendChild(span);
535
+ } else {
536
+ out.appendChild(document.createTextNode(msg.text));
537
+ }
538
+ out.classList.add('has-content');
539
+ out.scrollTop = out.scrollHeight;
540
+ scrollToBottom();
541
+ }
542
+ break;
543
+ }
544
+
545
+ case 'bash_done': {
546
+ if (msg.sessionId !== state.sessionId) break;
547
+ finishBash(state.currentBashEl, msg.code, msg.signal);
548
+ if (msg.error) addSystem(`err: ${msg.error}`);
549
+ setBusy(false);
550
+ dom.input.focus();
551
+ break;
552
+ }
553
+
554
+ case 'prompt_done':
555
+ clearCancelTimer();
556
+ if (msg.stopReason === 'cancelled') {
557
+ cancelPendingTurnUI();
558
+ }
559
+ state.turnEnded = true;
560
+ state.pendingPromptDone = true;
561
+ finishPromptIfIdle();
562
+ break;
563
+
564
+ case 'session_deleted':
565
+ if (msg.sessionId === state.sessionId) {
566
+ addSystem('warn: This session has been deleted.');
567
+ dom.input.disabled = true;
568
+ dom.sendBtn.disabled = true;
569
+ dom.input.placeholder = 'Session deleted';
570
+ }
571
+ break;
572
+
573
+ case 'session_expired':
574
+ resetSessionUI();
575
+ addSystem('warn: Previous session expired, created new one.');
576
+ requestNewSession();
577
+ break;
578
+
579
+ case 'config_set': {
580
+ setConfigValue(msg.configId, msg.value);
581
+ const opt = getConfigOption(msg.configId);
582
+ const label = opt?.name || msg.configId;
583
+ const valueName = opt?.options.find(o => o.value === msg.value)?.name || msg.value;
584
+ addSystem(`ok: ${label}: ${valueName}`);
585
+ if (msg.configId === 'mode') updateModeUI();
586
+ break;
587
+ }
588
+
589
+ case 'config_option_update':
590
+ if (msg.configOptions?.length) updateConfigOptions(msg.configOptions);
591
+ break;
592
+
593
+ case 'session_title_updated':
594
+ if (msg.sessionId === state.sessionId) {
595
+ state.sessionTitle = msg.title;
596
+ updateSessionInfo(state.sessionId, state.sessionTitle);
597
+ }
598
+ break;
599
+
600
+ case 'error':
601
+ state.awaitingNewSession = false;
602
+ state.pendingToolCallIds.clear();
603
+ state.pendingPermissionRequestIds.clear();
604
+ state.pendingPromptDone = false;
605
+ hideWaiting();
606
+ finishThinking();
607
+ finishAssistant();
608
+ addSystem(`err: ${msg.message}`);
609
+ setBusy(false);
610
+ break;
611
+ }
612
+ }
@@ -0,0 +1,58 @@
1
+ // Image attach, preview, and paste handling
2
+
3
+ import { state, dom } from './state.mmjqzu9r.js';
4
+
5
+ function readFileAsBase64(file) {
6
+ return new Promise((resolve) => {
7
+ const reader = new FileReader();
8
+ reader.onload = () => {
9
+ const base64 = reader.result.split(',')[1];
10
+ resolve({ data: base64, mimeType: file.type, previewUrl: reader.result });
11
+ };
12
+ reader.readAsDataURL(file);
13
+ });
14
+ }
15
+
16
+ function addPendingImage(img) {
17
+ state.pendingImages.push(img);
18
+ renderAttachPreview();
19
+ dom.input.focus();
20
+ }
21
+
22
+ export function renderAttachPreview() {
23
+ dom.attachPreview.innerHTML = '';
24
+ if (state.pendingImages.length === 0) {
25
+ dom.attachPreview.classList.remove('active');
26
+ return;
27
+ }
28
+ dom.attachPreview.classList.add('active');
29
+ state.pendingImages.forEach((img, i) => {
30
+ const thumb = document.createElement('span');
31
+ thumb.className = 'attach-thumb';
32
+ thumb.innerHTML = `<img src="${img.previewUrl}"><button class="remove">×</button>`;
33
+ thumb.querySelector('.remove').onclick = () => {
34
+ state.pendingImages.splice(i, 1);
35
+ renderAttachPreview();
36
+ };
37
+ dom.attachPreview.appendChild(thumb);
38
+ });
39
+ }
40
+
41
+ // --- Event listeners ---
42
+
43
+ dom.attachBtn.onclick = () => dom.fileInput.click();
44
+ dom.fileInput.onchange = async () => {
45
+ for (const f of dom.fileInput.files) {
46
+ if (f.type.startsWith('image/')) addPendingImage(await readFileAsBase64(f));
47
+ }
48
+ dom.fileInput.value = '';
49
+ };
50
+
51
+ dom.input.addEventListener('paste', async (e) => {
52
+ for (const item of e.clipboardData.items) {
53
+ if (item.type.startsWith('image/')) {
54
+ e.preventDefault();
55
+ addPendingImage(await readFileAsBase64(item.getAsFile()));
56
+ }
57
+ }
58
+ });