@lelouchhe/webagent 0.1.7 → 0.1.10

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