agentgui 1.0.853 → 1.0.855

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,3 +1,8 @@
1
+ /**
2
+ * AgentGUI Client
3
+ * Main application orchestrator that integrates WebSocket, event processing,
4
+ * and streaming renderer for real-time Claude Code execution visualization
5
+ */
1
6
 
2
7
  class AgentGUIClient {
3
8
  constructor(config = {}) {
@@ -9,11 +14,13 @@ class AgentGUIClient {
9
14
  ...config
10
15
  };
11
16
 
17
+ // Initialize components - reuse global wsManager/wsClient if available
12
18
  this.renderer = new StreamingRenderer(config.renderer || {});
13
19
  this.wsManager = window.wsManager || new WebSocketManager(config.websocket || {});
14
20
  if (!window.wsManager) window.wsManager = this.wsManager;
15
21
  this.eventProcessor = new EventProcessor(config.eventProcessor || {});
16
22
 
23
+ // Application state
17
24
  this.state = {
18
25
  isInitialized: false,
19
26
  currentSession: null,
@@ -24,19 +31,24 @@ class AgentGUIClient {
24
31
  agents: []
25
32
  };
26
33
 
34
+ // Conversation DOM cache: store rendered DOM + scroll position per conversationId
27
35
  this.conversationCache = new Map();
28
36
  this.MAX_CACHE_SIZE = 10;
29
37
 
38
+ // Conversation list cache with TTL
30
39
  this.conversationListCache = {
31
40
  data: [],
32
41
  timestamp: 0,
33
- ttl: 30000
42
+ ttl: 30000 // 30 seconds
34
43
  };
35
44
 
45
+ // Draft prompts per conversation
36
46
  this.draftPrompts = new Map();
37
47
 
48
+ // Event handlers
38
49
  this.eventHandlers = {};
39
50
 
51
+ // UI state
40
52
  this.ui = {
41
53
  statusIndicator: null,
42
54
  messageInput: null,
@@ -50,15 +62,18 @@ class AgentGUIClient {
50
62
  this._isLoadingConversation = false;
51
63
  this._modelCache = new Map();
52
64
 
53
- this._renderedSeqs = {};
65
+ this._renderedSeqs = {}; // plain object: sessionId → Set<number>
54
66
  this._inflightRequests = new Map();
55
67
  this._previousConvAbort = null;
56
68
 
69
+ // Background conversation cache: keeps last 50 conversations' streaming blocks in memory
70
+ // Map<conversationId, { items: {seq,packed}[], seqSet: Set<number>, sessionId: string }>
57
71
  this._bgCache = new Map();
58
72
  this.BG_CACHE_MAX = 50;
59
73
 
60
- this._loadInProgress = {};
61
- this._currentRequestId = 0;
74
+ // PHASE 2: Request Lifetime Tracking
75
+ this._loadInProgress = {}; // { [conversationId]: { requestId, abortController, timestamp, prevConversationId } }
76
+ this._currentRequestId = 0; // Auto-incrementing request counter
62
77
 
63
78
 
64
79
  this._scrollTarget = 0;
@@ -70,6 +85,7 @@ class AgentGUIClient {
70
85
  this._lastSendTime = 0;
71
86
  this._countdownTimer = null;
72
87
 
88
+ // Router state
73
89
  this.routerState = {
74
90
  currentConversationId: null,
75
91
  currentSessionId: null
@@ -80,12 +96,17 @@ class AgentGUIClient {
80
96
 
81
97
  _dbg(...args) { if (this._debug) console.log('[AgentGUI]', ...args); }
82
98
 
99
+ /**
100
+ * Initialize the client
101
+ */
83
102
  async init() {
84
103
  try {
85
104
  this._dbg('Initializing AgentGUI client');
86
105
 
106
+ // Start WebSocket connection immediately (don't wait for UI setup)
87
107
  const wsReady = this.config.autoConnect ? this.connectWebSocket() : Promise.resolve();
88
108
 
109
+ // Initialize renderer and UI in parallel with WS connection
89
110
  this.renderer.init(this.config.outputContainerId, this.config.scrollContainerId);
90
111
 
91
112
  if (typeof ImageLoader !== 'undefined') {
@@ -96,6 +117,7 @@ class AgentGUIClient {
96
117
  this.setupRendererListeners();
97
118
  this.setupUI();
98
119
 
120
+ // Wait for WS, then load data in parallel
99
121
  await wsReady;
100
122
  await Promise.all([
101
123
  this.loadAgents(),
@@ -103,8 +125,10 @@ class AgentGUIClient {
103
125
  this.checkSpeechStatus()
104
126
  ]);
105
127
 
128
+ // Enable controls for initial interaction
106
129
  this.enableControls();
107
130
 
131
+ // Restore state from URL on page load
108
132
  this.restoreStateFromUrl();
109
133
 
110
134
  this.state.isInitialized = true;
@@ -120,6 +144,3414 @@ class AgentGUIClient {
120
144
  }
121
145
  }
122
146
 
147
+ /**
148
+ * Setup WebSocket event listeners
149
+ */
150
+ setupWebSocketListeners() {
151
+ this.wsManager.on('connected', () => {
152
+ this._dbg('WebSocket connected');
153
+ this.updateConnectionStatus('connected');
154
+ this._subscribeToConversationUpdates();
155
+ // On reconnect (not initial connect), invalidate current conversation's DOM
156
+ // cache so we fetch fresh chunks rather than serving potentially stale DOM.
157
+ if (this.wsManager.stats.totalReconnects > 0 && this.state.currentConversation?.id) {
158
+ this.invalidateCache(this.state.currentConversation.id);
159
+ }
160
+ this._recoverMissedChunks();
161
+ this.updateSendButtonState();
162
+ this.enablePromptArea();
163
+ if (this.state.currentConversation?.id) {
164
+ this.updateBusyPromptArea(this.state.currentConversation.id);
165
+ }
166
+ this.emit('ws:connected');
167
+ // Check if server was updated while client was loaded - reload if version changed
168
+ if (window.__SERVER_VERSION) {
169
+ fetch((window.__BASE_URL || '') + '/api/version').then(r => r.json()).then(d => {
170
+ if (d.version && d.version !== window.__SERVER_VERSION) {
171
+ this._dbg(`Server updated ${window.__SERVER_VERSION} → ${d.version}, reloading`);
172
+ window.location.reload();
173
+ }
174
+ }).catch(() => {});
175
+ }
176
+ });
177
+
178
+ this.wsManager.on('disconnected', () => {
179
+ this._dbg('WebSocket disconnected');
180
+ this.updateConnectionStatus('disconnected');
181
+ this.updateSendButtonState();
182
+ this.disablePromptArea();
183
+ this.emit('ws:disconnected');
184
+ });
185
+
186
+ this.wsManager.on('reconnecting', (data) => {
187
+ this._dbg('WebSocket reconnecting:', data);
188
+ this.updateConnectionStatus('reconnecting');
189
+ });
190
+
191
+ this.wsManager.on('message', (data) => {
192
+ this.handleWebSocketMessage(data);
193
+ });
194
+
195
+ this.wsManager.on('error', (data) => {
196
+ console.error('WebSocket error:', data);
197
+ });
198
+
199
+ this.wsManager.on('latency_update', (data) => {
200
+ this._updateConnectionIndicator(data.quality);
201
+ });
202
+
203
+ this.wsManager.on('connection_degrading', () => {
204
+ const dot = document.querySelector('.connection-dot');
205
+ if (dot) dot.classList.add('degrading');
206
+ });
207
+
208
+ this.wsManager.on('connection_recovering', () => {
209
+ const dot = document.querySelector('.connection-dot');
210
+ if (dot) dot.classList.remove('degrading');
211
+ });
212
+
213
+ // Switch to idle view when selecting non-streaming conversation
214
+ window.addEventListener('conversation-selected', (e) => {
215
+ const convId = e.detail.conversationId;
216
+ // Save draft from previous conversation before switching
217
+ this.saveDraftPrompt();
218
+
219
+ const isStreaming = this._convIsStreaming(convId);
220
+ if (!isStreaming && window.switchView) {
221
+ window.switchView('chat');
222
+ }
223
+
224
+ // Restore draft for new conversation synchronously (setTimeout caused races)
225
+ this.restoreDraftPrompt(convId);
226
+
227
+ if (typeof updateWelcomeScreen === 'function') updateWelcomeScreen();
228
+ });
229
+
230
+ // Preserve controls state across tab switches
231
+ window.addEventListener('view-switched', (e) => {
232
+ const view = e.detail.view;
233
+ if (view === 'chat') {
234
+ const convId = this.state.currentConversation?.id;
235
+ const isStreaming = this._convIsStreaming(convId);
236
+ if (isStreaming) {
237
+ this.disableControls();
238
+ } else {
239
+ this.enableControls();
240
+ }
241
+ }
242
+ });
243
+ }
244
+
245
+ // Authoritative streaming check: conv machine is source of truth, Map is fallback cache
246
+ _convIsStreaming(convId) {
247
+ if (!convId) return false;
248
+ if (typeof convMachineAPI !== 'undefined') return convMachineAPI.isStreaming(convId);
249
+ return this.state.streamingConversations.has(convId);
250
+ }
251
+
252
+ // Mark conversation as streaming in both machine and cache Map
253
+ _setConvStreaming(convId, streaming, sessionId, agentId) {
254
+ if (!convId) return;
255
+ if (streaming) {
256
+ this.state.streamingConversations.set(convId, true);
257
+ if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'STREAM_START', sessionId, agentId });
258
+ } else {
259
+ this.state.streamingConversations.delete(convId);
260
+ if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'COMPLETE' });
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Setup renderer event listeners
266
+ */
267
+ setupRendererListeners() {
268
+ this.renderer.on('batch:complete', (data) => {
269
+ this._dbg('Batch rendered:', data);
270
+ this.updateMetrics(data.metrics);
271
+ });
272
+
273
+ this.renderer.on('error:render', (data) => {
274
+ console.error('Render error:', data.error);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Router state management: restore conversation from URL
280
+ * Format: /conversations/<conversationId>?session=<sessionId>
281
+ */
282
+ restoreStateFromUrl() {
283
+ // Parse path-based URL: /conversations/<conversationId>
284
+ const pathMatch = window.location.pathname.match(/\/conversations\/([^\/]+)$/);
285
+ const conversationId = pathMatch ? pathMatch[1] : null;
286
+
287
+ // Session ID still in query params
288
+ const params = new URLSearchParams(window.location.search);
289
+ const sessionId = params.get('session');
290
+
291
+ if (conversationId && this.isValidId(conversationId)) {
292
+ this.routerState.currentConversationId = conversationId;
293
+ if (sessionId && this.isValidId(sessionId)) {
294
+ this.routerState.currentSessionId = sessionId;
295
+ }
296
+ this._dbg('Restoring conversation from URL:', conversationId);
297
+ this._isLoadingConversation = true;
298
+ if (window.conversationManager) {
299
+ window.conversationManager.select(conversationId);
300
+ } else {
301
+ this.loadConversationMessages(conversationId).catch((err) => {
302
+ console.warn('Failed to restore conversation from URL, loading latest instead:', err);
303
+ // If the URL conversation doesn't exist, try loading the most recent conversation
304
+ if (this.state.conversations && this.state.conversations.length > 0) {
305
+ const latestConv = this.state.conversations[0];
306
+ this._dbg('Loading latest conversation instead:', latestConv.id);
307
+ return this.loadConversationMessages(latestConv.id);
308
+ } else {
309
+ // No conversations available - show welcome screen
310
+ this._showWelcomeScreen();
311
+ }
312
+ }).finally(() => {
313
+ this._isLoadingConversation = false;
314
+ });
315
+ }
316
+ } else {
317
+ // No conversation in URL - show welcome screen
318
+ this._showWelcomeScreen();
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Validate ID format to prevent XSS
324
+ * Alphanumeric, dash, underscore only
325
+ */
326
+ isValidId(id) {
327
+ if (!id || typeof id !== 'string') return false;
328
+ return /^[a-zA-Z0-9_-]+$/.test(id) && id.length < 256;
329
+ }
330
+
331
+ /**
332
+ * Update URL when conversation is selected
333
+ * Uses History API (pushState) for clean URLs
334
+ * Format: /conversations/<conversationId>?session=<sessionId>
335
+ */
336
+ updateUrlForConversation(conversationId, sessionId) {
337
+ if (!this.isValidId(conversationId)) return;
338
+ if (!this.routerState) return;
339
+
340
+ this.routerState.currentConversationId = conversationId;
341
+ if (sessionId && this.isValidId(sessionId)) {
342
+ this.routerState.currentSessionId = sessionId;
343
+ }
344
+
345
+ // Use path-based URL for conversation
346
+ const basePath = window.location.pathname.replace(/\/conversations\/[^\/]+$/, '').replace(/\/$/, '');
347
+ let url = `${basePath}/conversations/${conversationId}`;
348
+
349
+ // Session ID still in query params for optional state
350
+ if (sessionId && this.isValidId(sessionId)) {
351
+ url += `?session=${sessionId}`;
352
+ }
353
+
354
+ window.history.pushState({ conversationId, sessionId }, '', url);
355
+ }
356
+
357
+ /**
358
+ * Save scroll position to localStorage
359
+ * Key format: scroll_<conversationId>
360
+ */
361
+ saveScrollPosition(conversationId) {
362
+ if (!this.isValidId(conversationId)) return;
363
+
364
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
365
+ if (scrollContainer) {
366
+ const position = scrollContainer.scrollTop;
367
+ try {
368
+ localStorage.setItem(`scroll_${conversationId}`, position.toString());
369
+ } catch (e) {
370
+ console.warn('Failed to save scroll position:', e);
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Restore scroll position from localStorage
377
+ * Restores after conversation loads
378
+ */
379
+ restoreScrollPosition(conversationId) {
380
+ if (!this.isValidId(conversationId)) return;
381
+
382
+ try {
383
+ const position = localStorage.getItem(`scroll_${conversationId}`);
384
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
385
+ if (!scrollContainer) return;
386
+
387
+ if (position !== null) {
388
+ const scrollTop = parseInt(position, 10);
389
+ if (!isNaN(scrollTop)) {
390
+ requestAnimationFrame(() => {
391
+ requestAnimationFrame(() => {
392
+ const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
393
+ scrollContainer.scrollTop = Math.min(scrollTop, maxScroll);
394
+ });
395
+ });
396
+ }
397
+ } else {
398
+ requestAnimationFrame(() => {
399
+ requestAnimationFrame(() => {
400
+ scrollContainer.scrollTop = 0;
401
+ });
402
+ });
403
+ }
404
+ } catch (e) {
405
+ console.warn('Failed to restore scroll position:', e);
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Setup scroll position tracking
411
+ * Debounced to avoid excessive localStorage writes
412
+ */
413
+ setupScrollTracking() {
414
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
415
+ if (!scrollContainer) return;
416
+
417
+ this._userScrolledUp = false;
418
+ let scrollTimer = null;
419
+ let lastScrollTop = scrollContainer.scrollTop;
420
+ scrollContainer.addEventListener('scroll', () => {
421
+ const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
422
+ if (scrollContainer.scrollTop < lastScrollTop && distFromBottom > 200) {
423
+ this._userScrolledUp = true;
424
+ } else if (distFromBottom < 50) {
425
+ this._userScrolledUp = false;
426
+ this._removeNewContentPill();
427
+ }
428
+ lastScrollTop = scrollContainer.scrollTop;
429
+ if (scrollTimer) clearTimeout(scrollTimer);
430
+ scrollTimer = setTimeout(() => {
431
+ if (this.state.currentConversation?.id) {
432
+ this.saveScrollPosition(this.state.currentConversation.id);
433
+ }
434
+ }, 500);
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Setup UI elements
440
+ */
441
+ setupUI() {
442
+ const container = document.getElementById(this.config.containerId);
443
+ if (!container) {
444
+ throw new Error(`Container not found: ${this.config.containerId}`);
445
+ }
446
+
447
+ // Get references to key UI elements
448
+ this.ui.statusIndicator = document.querySelector('[data-status-indicator]');
449
+ this.ui.messageInput = document.querySelector('[data-message-input]');
450
+ this.ui.sendButton = document.querySelector('[data-send-button]');
451
+ this.ui.cliSelector = document.querySelector('[data-cli-selector]');
452
+ this.ui.agentSelector = document.querySelector('[data-agent-selector]');
453
+ this.ui.modelSelector = document.querySelector('[data-model-selector]');
454
+
455
+ // Auto-save drafts on input
456
+ if (this.ui.messageInput) {
457
+ this.ui.messageInput.addEventListener('input', () => {
458
+ this.saveDraftPrompt();
459
+ });
460
+
461
+ // Restore draft when conversation loads
462
+ const currentConvId = this.state.currentConversation?.id;
463
+ if (currentConvId) {
464
+ this.restoreDraftPrompt(currentConvId);
465
+ }
466
+ }
467
+
468
+ if (this.ui.cliSelector) {
469
+ this.ui.cliSelector.addEventListener('change', () => {
470
+ if (!this._agentLocked) {
471
+ this.loadSubAgentsForCli(this.ui.cliSelector.value);
472
+ this.loadModelsForAgent(this.ui.cliSelector.value);
473
+ this.saveAgentAndModelToConversation();
474
+ }
475
+ });
476
+ }
477
+
478
+ if (this.ui.agentSelector) {
479
+ this.ui.agentSelector.addEventListener('change', () => {
480
+ // Load models for parent CLI agent when sub-agent changes
481
+ const parentAgentId = this.ui.cliSelector?.value;
482
+ if (parentAgentId) {
483
+ this.loadModelsForAgent(parentAgentId);
484
+ }
485
+ if (!this._agentLocked) {
486
+ this.saveAgentAndModelToConversation();
487
+ }
488
+ });
489
+ }
490
+
491
+ if (this.ui.modelSelector) {
492
+ this.ui.modelSelector.addEventListener('change', () => {
493
+ this.saveAgentAndModelToConversation();
494
+ });
495
+ }
496
+
497
+ // Setup event listeners
498
+ if (this.ui.sendButton) {
499
+ this.ui.sendButton.addEventListener('click', () => this.startExecution());
500
+ }
501
+
502
+ this.setupChatMicButton();
503
+
504
+ this.ui.stopButton = document.getElementById('stopBtn');
505
+ this.ui.injectButton = document.getElementById('injectBtn');
506
+ this.ui.queueButton = document.getElementById('queueBtn');
507
+
508
+ if (this.ui.stopButton) {
509
+ this.ui.stopButton.addEventListener('click', async () => {
510
+ if (!this.state.currentConversation) return;
511
+ try {
512
+ const data = await window.wsClient.rpc('conv.cancel', { id: this.state.currentConversation.id });
513
+ this._dbg('Stop response:', data);
514
+ } catch (err) {
515
+ console.error('Failed to stop:', err);
516
+ }
517
+ });
518
+ }
519
+
520
+ if (this.ui.injectButton) {
521
+ this.ui.injectButton.addEventListener('click', async () => {
522
+ if (!this.state.currentConversation) return;
523
+ const isStreaming = this._convIsStreaming(this.state.currentConversation.id);
524
+
525
+ if (isStreaming) {
526
+ const message = this.ui.messageInput?.value || '';
527
+ if (!message.trim()) {
528
+ this.showError('Please enter a message to steer');
529
+ return;
530
+ }
531
+
532
+ const steerMsg = message;
533
+ if (this.ui.messageInput) {
534
+ this.ui.messageInput.value = '';
535
+ this.ui.messageInput.style.height = 'auto';
536
+ }
537
+
538
+ // Stop agent and resume with new message
539
+ window.wsClient.rpc('conv.steer', { id: this.state.currentConversation.id, content: steerMsg })
540
+ .catch(err => {
541
+ console.error('Failed to steer:', err);
542
+ this.showError('Failed to steer: ' + err.message);
543
+ });
544
+ } else {
545
+ const instructions = await window.UIDialog.prompt('Enter instructions to inject into the running agent:', '', 'Inject Instructions');
546
+ if (!instructions) return;
547
+ window.wsClient.rpc('conv.inject', { id: this.state.currentConversation.id, content: instructions })
548
+ .catch(err => console.error('Failed to inject:', err));
549
+ }
550
+ });
551
+ }
552
+
553
+ if (this.ui.queueButton) {
554
+ this.ui.queueButton.addEventListener('click', async () => {
555
+ if (!this.state.currentConversation) return;
556
+ const message = this.ui.messageInput?.value || '';
557
+ if (!message.trim()) {
558
+ this.showError('Please enter a message to queue');
559
+ return;
560
+ }
561
+ try {
562
+ // Queue uses msg.send which will enqueue if streaming is active
563
+ const data = await window.wsClient.rpc('msg.send', { id: this.state.currentConversation.id, content: message });
564
+ this._dbg('Queue response:', data);
565
+ if (this.ui.messageInput) {
566
+ this.ui.messageInput.value = '';
567
+ this.ui.messageInput.style.height = 'auto';
568
+ }
569
+ } catch (err) {
570
+ console.error('Failed to queue:', err);
571
+ this.showError('Failed to queue: ' + err.message);
572
+ }
573
+ });
574
+ }
575
+
576
+ if (this.ui.messageInput) {
577
+ this.ui.messageInput.addEventListener('keydown', (e) => {
578
+ if (e.key === 'Enter' && e.ctrlKey) {
579
+ this.startExecution();
580
+ }
581
+ });
582
+
583
+ this.ui.messageInput.addEventListener('input', () => {
584
+ const el = this.ui.messageInput;
585
+ el.style.height = 'auto';
586
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
587
+ });
588
+ }
589
+
590
+ document.addEventListener('keydown', (e) => {
591
+ if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
592
+ e.preventDefault();
593
+ const newBtn = document.querySelector('[data-new-conversation], #newConversationBtn, .new-conversation-btn');
594
+ if (newBtn) newBtn.click();
595
+ }
596
+ if (e.key === 'b' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
597
+ e.preventDefault();
598
+ const toggleBtn = document.querySelector('[data-sidebar-toggle]');
599
+ if (toggleBtn) toggleBtn.click();
600
+ }
601
+ if (e.key === 'Escape') {
602
+ const activeEl = document.activeElement;
603
+ if (activeEl && activeEl.tagName === 'TEXTAREA') { activeEl.blur(); return; }
604
+ if (this.state.isStreaming) {
605
+ const cancelBtn = document.querySelector('#cancelBtn, [data-cancel-btn]');
606
+ if (cancelBtn && cancelBtn.offsetParent !== null) cancelBtn.click();
607
+ }
608
+ }
609
+ });
610
+
611
+ // Setup theme toggle
612
+ const themeToggle = document.querySelector('[data-theme-toggle]');
613
+ if (themeToggle) {
614
+ themeToggle.addEventListener('click', () => this.toggleTheme());
615
+ }
616
+
617
+ if (this.ui.outputEl) {
618
+ this.ui.outputEl.addEventListener('click', async (e) => {
619
+ const editBtn = e.target.closest('[data-edit-msg]');
620
+ if (!editBtn || !this.state.currentConversation) return;
621
+ const msgEl = editBtn.closest('.message-user');
622
+ const textEl = msgEl?.querySelector('.message-text');
623
+ if (!textEl) return;
624
+ const original = textEl.textContent || '';
625
+ const edited = await window.UIDialog?.prompt('Edit message:', original, 'Edit & Re-run');
626
+ if (!edited || edited === original) return;
627
+ this.ui.messageInput.value = edited;
628
+ this.startExecution();
629
+ });
630
+ }
631
+
632
+ this.setupScrollTracking();
633
+
634
+ // Wire input card textarea to hidden legacy textarea
635
+ const inputCardTextarea = document.getElementById('inputCardTextarea');
636
+ const legacyTextarea = document.querySelector('[data-message-input]');
637
+ if (inputCardTextarea && legacyTextarea) {
638
+ // Sync value from card to legacy
639
+ inputCardTextarea.addEventListener('input', function() {
640
+ legacyTextarea.value = this.value;
641
+ // Auto-grow
642
+ this.style.height = 'auto';
643
+ this.style.height = Math.min(this.scrollHeight, 180) + 'px';
644
+ // Dispatch input event on legacy textarea for any listeners
645
+ legacyTextarea.dispatchEvent(new Event('input', { bubbles: true }));
646
+ });
647
+ inputCardTextarea.addEventListener('keydown', function(e) {
648
+ // Forward keydown to legacy textarea
649
+ legacyTextarea.dispatchEvent(new KeyboardEvent('keydown', {
650
+ key: e.key, code: e.code, ctrlKey: e.ctrlKey, metaKey: e.metaKey,
651
+ shiftKey: e.shiftKey, altKey: e.altKey, bubbles: true, cancelable: true
652
+ }));
653
+ if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); }
654
+ });
655
+ // Sync agent/model selectors
656
+ const inputCardAgentSelect = document.getElementById('inputCardAgentSelect');
657
+ const inputCardModelSelect = document.getElementById('inputCardModelSelect');
658
+ const legacyAgentSelect = document.querySelector('[data-cli-selector]');
659
+ const legacyModelSelect = document.querySelector('[data-model-selector]');
660
+ if (inputCardAgentSelect && legacyAgentSelect) {
661
+ // Copy options when legacy changes
662
+ const syncAgentOptions = () => {
663
+ inputCardAgentSelect.innerHTML = legacyAgentSelect.innerHTML;
664
+ inputCardAgentSelect.value = legacyAgentSelect.value;
665
+ };
666
+ new MutationObserver(syncAgentOptions).observe(legacyAgentSelect, { childList: true, subtree: true });
667
+ inputCardAgentSelect.addEventListener('change', () => { legacyAgentSelect.value = inputCardAgentSelect.value; legacyAgentSelect.dispatchEvent(new Event('change', { bubbles: true })); });
668
+ syncAgentOptions();
669
+ }
670
+ if (inputCardModelSelect && legacyModelSelect) {
671
+ const syncModelOptions = () => {
672
+ inputCardModelSelect.innerHTML = legacyModelSelect.innerHTML;
673
+ inputCardModelSelect.value = legacyModelSelect.value;
674
+ };
675
+ new MutationObserver(syncModelOptions).observe(legacyModelSelect, { childList: true, subtree: true });
676
+ inputCardModelSelect.addEventListener('change', () => { legacyModelSelect.value = inputCardModelSelect.value; legacyModelSelect.dispatchEvent(new Event('change', { bubbles: true })); });
677
+ syncModelOptions();
678
+ }
679
+ // Clear input card after send
680
+ legacyTextarea.addEventListener('input', () => {
681
+ if (legacyTextarea.value !== inputCardTextarea.value) {
682
+ inputCardTextarea.value = legacyTextarea.value;
683
+ inputCardTextarea.style.height = 'auto';
684
+ if (legacyTextarea.value) inputCardTextarea.style.height = Math.min(inputCardTextarea.scrollHeight, 180) + 'px';
685
+ }
686
+ });
687
+ }
688
+
689
+ // Sidebar overflow menu
690
+ const sidebarOverflowBtn = document.getElementById('sidebarOverflowBtn');
691
+ const sidebarOverflowMenu = document.getElementById('sidebarOverflowMenu');
692
+ if (sidebarOverflowBtn && sidebarOverflowMenu) {
693
+ sidebarOverflowBtn.addEventListener('click', (e) => {
694
+ e.stopPropagation();
695
+ sidebarOverflowMenu.classList.toggle('open');
696
+ });
697
+ document.addEventListener('click', () => sidebarOverflowMenu.classList.remove('open'));
698
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') sidebarOverflowMenu.classList.remove('open'); });
699
+ }
700
+
701
+ // Streaming status bar
702
+ function updateStreamingStatusBar(streaming, agentName) {
703
+ const bar = document.getElementById('streamingStatusBar');
704
+ if (!bar) return;
705
+ if (streaming) {
706
+ const agentEl = document.getElementById('streamingStatusAgent');
707
+ if (agentEl && agentName) agentEl.textContent = agentName;
708
+ bar.classList.add('visible');
709
+ } else {
710
+ bar.classList.remove('visible');
711
+ }
712
+ }
713
+ window.updateStreamingStatusBar = updateStreamingStatusBar;
714
+
715
+ const streamingCancelBtn = document.getElementById('streamingStatusCancel');
716
+ if (streamingCancelBtn) {
717
+ streamingCancelBtn.addEventListener('click', () => {
718
+ const stopBtn = document.getElementById('stopBtn');
719
+ if (stopBtn) stopBtn.click();
720
+ });
721
+ }
722
+
723
+ window.addEventListener('create-new-conversation', (event) => {
724
+ this.unlockAgentAndModel();
725
+ const detail = event.detail || {};
726
+ this.createNewConversation(detail.workingDirectory, detail.title);
727
+ });
728
+
729
+ window.addEventListener('preparing-new-conversation', () => {
730
+ this.unlockAgentAndModel();
731
+ });
732
+
733
+ // Listen for conversation selection (deduplicate rapid clicks)
734
+ window.addEventListener('conversation-selected', async (event) => {
735
+ const conversationId = event.detail.conversationId;
736
+ if (this._isLoadingConversation && this._loadingConversationId === conversationId) return;
737
+ this._loadingConversationId = conversationId;
738
+ this.updateUrlForConversation(conversationId);
739
+ this._isLoadingConversation = true;
740
+ try {
741
+ await this.loadConversationMessages(conversationId);
742
+ } finally {
743
+ this._isLoadingConversation = false;
744
+ this._loadingConversationId = null;
745
+ }
746
+ });
747
+
748
+ // Listen for active conversation deletion
749
+ window.addEventListener('conversation-deselected', () => {
750
+ window.ConversationState?.clear('deselected');
751
+ this.state.currentConversation = null;
752
+ this.state.currentSession = null;
753
+ this.updateUrlForConversation(null);
754
+ this.enableControls();
755
+ this._showWelcomeScreen();
756
+ if (this.ui.messageInput) {
757
+ this.ui.messageInput.value = '';
758
+ this.ui.messageInput.style.height = 'auto';
759
+ }
760
+ this.unlockAgentAndModel();
761
+ if (typeof updateWelcomeScreen === 'function') updateWelcomeScreen();
762
+ });
763
+ }
764
+
765
+ setupChatMicButton() {
766
+ const chatMicBtn = document.getElementById('chatMicBtn');
767
+ if (!chatMicBtn) return;
768
+
769
+ let isRecording = false;
770
+
771
+ const startRecording = async () => {
772
+ if (isRecording) return;
773
+ isRecording = true;
774
+ chatMicBtn.classList.add('recording');
775
+ const result = await window.STTHandler.startRecording();
776
+ if (!result.success) {
777
+ isRecording = false;
778
+ chatMicBtn.classList.remove('recording');
779
+ alert('Microphone access denied: ' + result.error);
780
+ }
781
+ };
782
+
783
+ const stopRecording = async () => {
784
+ if (!isRecording) return;
785
+ isRecording = false;
786
+ chatMicBtn.classList.remove('recording');
787
+ const result = await window.STTHandler.stopRecording();
788
+ if (result.success) {
789
+ if (this.ui.messageInput) {
790
+ this.ui.messageInput.value = result.text;
791
+ }
792
+ } else {
793
+ alert('Transcription failed: ' + result.error);
794
+ }
795
+ };
796
+
797
+ chatMicBtn.addEventListener('mousedown', (e) => {
798
+ e.preventDefault();
799
+ startRecording();
800
+ });
801
+
802
+ chatMicBtn.addEventListener('mouseup', (e) => {
803
+ e.preventDefault();
804
+ stopRecording();
805
+ });
806
+
807
+ chatMicBtn.addEventListener('mouseleave', (e) => {
808
+ if (isRecording) {
809
+ stopRecording();
810
+ }
811
+ });
812
+
813
+ chatMicBtn.addEventListener('touchstart', (e) => {
814
+ e.preventDefault();
815
+ startRecording();
816
+ });
817
+
818
+ chatMicBtn.addEventListener('touchend', (e) => {
819
+ e.preventDefault();
820
+ stopRecording();
821
+ });
822
+
823
+ chatMicBtn.addEventListener('touchcancel', (e) => {
824
+ if (isRecording) {
825
+ stopRecording();
826
+ }
827
+ });
828
+ }
829
+
830
+ /**
831
+ * Connect to WebSocket
832
+ */
833
+ async connectWebSocket() {
834
+ try {
835
+ await this.wsManager.connect();
836
+ this.updateConnectionStatus('connected');
837
+ } catch (error) {
838
+ console.error('WebSocket connection failed:', error);
839
+ this.updateConnectionStatus('error');
840
+ throw error;
841
+ }
842
+ }
843
+
844
+ handleWebSocketMessage(data) {
845
+ try {
846
+ // Dispatch to window so other modules (conversations.js) can listen
847
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
848
+
849
+ switch (data.type) {
850
+ case 'streaming_start':
851
+ this.handleStreamingStart(data).catch(e => console.error('handleStreamingStart error:', e));
852
+ break;
853
+ case 'streaming_resumed':
854
+ this.handleStreamingResumed(data).catch(e => console.error('handleStreamingResumed error:', e));
855
+ break;
856
+ case 'streaming_progress':
857
+ this.handleStreamingProgress(data);
858
+ break;
859
+ case 'streaming_complete':
860
+ this.handleStreamingComplete(data);
861
+ break;
862
+ case 'streaming_error':
863
+ this.handleStreamingError(data);
864
+ break;
865
+ case 'conversation_created':
866
+ this.handleConversationCreated(data);
867
+ break;
868
+ case 'all_conversations_deleted':
869
+ this.handleAllConversationsDeleted(data);
870
+ break;
871
+ case 'message_created':
872
+ this.handleMessageCreated(data);
873
+ break;
874
+ case 'conversation_updated':
875
+ this.handleConversationUpdated(data);
876
+ break;
877
+ case 'queue_status':
878
+ this.handleQueueStatus(data);
879
+ break;
880
+ case 'queue_updated':
881
+ this.handleQueueUpdated(data);
882
+ break;
883
+ case 'queue_item_dequeued':
884
+ this.handleQueueItemDequeued(data);
885
+ break;
886
+ case 'rate_limit_hit':
887
+ this.handleRateLimitHit(data);
888
+ break;
889
+ case 'rate_limit_clear':
890
+ this.handleRateLimitClear(data);
891
+ break;
892
+ case 'model_download_progress':
893
+ this._handleModelDownloadProgress(data.progress || data);
894
+ break;
895
+ case 'tts_setup_progress':
896
+ this._handleTTSSetupProgress(data);
897
+ break;
898
+ default:
899
+ break;
900
+ }
901
+ } catch (error) {
902
+ console.error('Message handling error:', error);
903
+ }
904
+ }
905
+
906
+ queueEvent(data) {
907
+ try {
908
+ const processed = this.eventProcessor.processEvent(data);
909
+ if (!processed) return;
910
+ if (data.sessionId && this.state.currentSession?.id === data.sessionId) {
911
+ this.state.sessionEvents.push(processed);
912
+ }
913
+ } catch (error) {
914
+ console.error('Event queuing error:', error);
915
+ }
916
+ }
917
+
918
+ async handleStreamingStart(data) {
919
+ this._dbg('Streaming started:', data);
920
+ if (window.promptMachineAPI) window.promptMachineAPI.send({ type: 'STREAMING', conversationId: data.conversationId });
921
+ this._clearThinkingCountdown();
922
+ if (this._lastSendTime > 0) {
923
+ const actual = Date.now() - this._lastSendTime;
924
+ const predicted = this.wsManager?.latency?.predicted || 0;
925
+ const serverTime = Math.max(500, actual - predicted);
926
+ this._serverProcessingEstimate = 0.7 * this._serverProcessingEstimate + 0.3 * serverTime;
927
+ }
928
+
929
+ // Subscribe to the session so blocks are not lost, but skip conversation
930
+ // re-subscribe when resumed=true to prevent infinite loop (server sends
931
+ // streaming_start on subscribe when activeExecutions exists, which would
932
+ // trigger another subscribe here, looping forever)
933
+ if (this.wsManager.isConnected) {
934
+ this.wsManager.subscribeToSession(data.sessionId);
935
+ if (!data.resumed) {
936
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId: data.conversationId });
937
+ }
938
+ }
939
+
940
+ // If this streaming event is for a different conversation than what we are viewing,
941
+ // just track the state but do not modify the DOM or start polling
942
+ if (this.state.currentConversation?.id !== data.conversationId) {
943
+ this._dbg('Streaming started for non-active conversation:', data.conversationId);
944
+ this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
945
+ this._dbg('[SYNC] streaming_start - non-active conv:', { convId: data.conversationId, sessionId: data.sessionId, streamingCount: this.state.streamingConversations.size });
946
+ this.updateBusyPromptArea(data.conversationId);
947
+ this.emit('streaming:start', data);
948
+
949
+ // Auto-load if no conversation is currently selected (e.g. server resumed on startup)
950
+ if (!this.state.currentConversation && !this._isLoadingConversation) {
951
+ this._isLoadingConversation = true;
952
+ this.loadConversationMessages(data.conversationId).finally(() => {
953
+ this._isLoadingConversation = false;
954
+ });
955
+ }
956
+ return;
957
+ }
958
+
959
+ this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
960
+ this.updateBusyPromptArea(data.conversationId);
961
+ this.state.currentSession = {
962
+ id: data.sessionId,
963
+ conversationId: data.conversationId,
964
+ agentId: data.agentId,
965
+ startTime: Date.now()
966
+ };
967
+ this.state.sessionEvents = [];
968
+
969
+ // Update URL with session ID during streaming
970
+ this.updateUrlForConversation(data.conversationId, data.sessionId);
971
+
972
+ if (this.wsManager.isConnected) {
973
+ this.wsManager.subscribeToSession(data.sessionId);
974
+ }
975
+
976
+ const outputEl = document.getElementById('output');
977
+ if (outputEl) {
978
+ let messagesEl = outputEl.querySelector('.conversation-messages');
979
+ if (!messagesEl) {
980
+ const conv = this.state.currentConversation;
981
+ const wdInfo = conv?.workingDirectory ? `${this.escapeHtml(conv.workingDirectory)}` : '';
982
+ const timestamp = new Date(conv?.created_at || Date.now()).toLocaleDateString();
983
+ const metaParts = [timestamp];
984
+ if (conv?.agentType || conv?.agentId) metaParts.push(this.escapeHtml(conv.agentType || conv.agentId));
985
+ if (conv?.messageCount > 0) metaParts.push(`${conv.messageCount} msgs`);
986
+ if (wdInfo) metaParts.push(wdInfo);
987
+ outputEl.innerHTML = `
988
+ <div class="conversation-header">
989
+ <h2>${this.escapeHtml(conv?.title || 'Conversation')}</h2>
990
+ <p class="text-secondary">${metaParts.join(' - ')}</p>
991
+ </div>
992
+ <div class="conversation-messages"></div>
993
+ `;
994
+ messagesEl = outputEl.querySelector('.conversation-messages');
995
+ }
996
+ let streamingDiv = document.getElementById(`streaming-${data.sessionId}`);
997
+ if (!streamingDiv) {
998
+ streamingDiv = document.createElement('div');
999
+ streamingDiv.className = 'message message-assistant streaming-message';
1000
+ streamingDiv.id = `streaming-${data.sessionId}`;
1001
+ streamingDiv.innerHTML = `
1002
+ <div class="message-role">Assistant</div>
1003
+ <div class="message-blocks streaming-blocks"></div>
1004
+ <div class="streaming-indicator" style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;">
1005
+ <span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span>
1006
+ <span class="streaming-indicator-label">Thinking...</span>
1007
+ </div>
1008
+ `;
1009
+ messagesEl.appendChild(streamingDiv);
1010
+ } else {
1011
+ streamingDiv.classList.add('streaming-message');
1012
+ streamingDiv.querySelectorAll('.streaming-indicator').forEach(ind => ind.remove());
1013
+ const indDiv = document.createElement('div');
1014
+ indDiv.className = 'streaming-indicator';
1015
+ indDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
1016
+ indDiv.innerHTML = `<span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span><span class="streaming-indicator-label">Thinking...</span>`;
1017
+ streamingDiv.appendChild(indDiv);
1018
+ }
1019
+ this.scrollToBottom(true);
1020
+ }
1021
+
1022
+ this._streamStartedAt = Date.now();
1023
+ this._sessionCost = 0;
1024
+ if (this._elapsedTimer) clearInterval(this._elapsedTimer);
1025
+ this._elapsedTimer = setInterval(() => {
1026
+ const label = streamingDiv?.querySelector('.streaming-indicator-label');
1027
+ if (label) {
1028
+ const sec = ((Date.now() - this._streamStartedAt) / 1000) | 0;
1029
+ const time = sec < 60 ? sec + 's' : Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
1030
+ label.textContent = this._sessionCost > 0 ? time + ' · $' + this._sessionCost.toFixed(4) : time;
1031
+ }
1032
+ }, 1000);
1033
+
1034
+ // Reset rendered block seq tracker for this session
1035
+ this._renderedSeqs[data.sessionId] = new Set();
1036
+
1037
+ // Show queue/steer UI when streaming starts (for busy prompt)
1038
+ this.showStreamingPromptButtons();
1039
+
1040
+ if (typeof window.updateStreamingStatusBar === 'function') {
1041
+ window.updateStreamingStatusBar(true, data.agentId || this.state.currentConversation?.agentId || '');
1042
+ }
1043
+
1044
+ // IMMUTABLE: Prompt area remains enabled - user can queue/steer messages
1045
+ this.emit('streaming:start', data);
1046
+ }
1047
+
1048
+ async handleStreamingResumed(data) {
1049
+ this._dbg('Streaming resumed:', data);
1050
+ const conv = this.state.currentConversation || { id: data.conversationId };
1051
+ await this.handleStreamingStart({
1052
+ type: 'streaming_start',
1053
+ sessionId: data.sessionId,
1054
+ conversationId: data.conversationId,
1055
+ agentId: conv.agentType || conv.agentId || 'claude-code',
1056
+ resumed: true,
1057
+ timestamp: data.timestamp
1058
+ });
1059
+ }
1060
+
1061
+ handleStreamingProgress(data) {
1062
+ try { return this._handleStreamingProgressInner(data); } catch (e) { console.error('[render-error] streaming progress:', e); }
1063
+ }
1064
+ _handleStreamingProgressInner(data) {
1065
+ if (!data.block || !data.sessionId) return;
1066
+
1067
+ // Deduplicate by seq number to guarantee exactly-once rendering
1068
+ const seen = this._renderedSeqs[data.sessionId] || (this._renderedSeqs[data.sessionId] = new Set());
1069
+ if (data.seq !== undefined) {
1070
+ if (seen.has(data.seq)) return;
1071
+ seen.add(data.seq);
1072
+ }
1073
+
1074
+ const block = data.block;
1075
+
1076
+ // Cache block for background conversations (all 50 cached convs, not just active)
1077
+ const convId = data.conversationId;
1078
+ if (convId) {
1079
+ let entry = this._bgCache.get(convId);
1080
+ if (!entry) {
1081
+ // Evict oldest if at capacity
1082
+ if (this._bgCache.size >= this.BG_CACHE_MAX) {
1083
+ const oldestKey = this._bgCache.keys().next().value;
1084
+ this._bgCache.delete(oldestKey);
1085
+ }
1086
+ entry = { items: [], seqSet: new Set(), sessionId: data.sessionId };
1087
+ this._bgCache.set(convId, entry);
1088
+ }
1089
+ if (data.seq === undefined || !entry.seqSet.has(data.seq)) {
1090
+ if (data.seq !== undefined) entry.seqSet.add(data.seq);
1091
+ entry.sessionId = data.sessionId;
1092
+ // Store seq alongside packed data so _flushBgCache can dedup against _renderedSeqs
1093
+ try {
1094
+ const packed = typeof msgpackr !== 'undefined' ? msgpackr.pack(block) : block;
1095
+ entry.items.push({ seq: data.seq, packed });
1096
+ } catch (_) { entry.items.push({ seq: data.seq, packed: block }); }
1097
+ }
1098
+ }
1099
+
1100
+ // Only render for the currently-visible session
1101
+ if (this.state.currentSession?.id !== data.sessionId) return;
1102
+
1103
+ const streamingEl = document.getElementById(`streaming-${data.sessionId}`);
1104
+ if (!streamingEl) return;
1105
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
1106
+ if (!blocksEl) return;
1107
+
1108
+ if (block.type === 'tool_result') {
1109
+ const tid = block.tool_use_id;
1110
+ const toolUseEl = (tid && blocksEl.querySelector(`.block-tool-use[data-tool-use-id="${tid}"]`))
1111
+ || (blocksEl.lastElementChild?.classList?.contains('block-tool-use') ? blocksEl.lastElementChild : null);
1112
+ if (toolUseEl) {
1113
+ this.renderer.mergeResultIntoToolUse(toolUseEl, block);
1114
+ this.scrollToBottom();
1115
+ return;
1116
+ }
1117
+ }
1118
+
1119
+ if (block.type === 'tool_status') {
1120
+ const tid = block.tool_use_id;
1121
+ const toolUseEl = tid && blocksEl.querySelector(`.block-tool-use[data-tool-use-id="${tid}"]`);
1122
+ if (toolUseEl) {
1123
+ const summary = toolUseEl.querySelector(':scope > summary');
1124
+ if (summary) {
1125
+ let badge = summary.querySelector('.folded-tool-status-live');
1126
+ if (!badge) { badge = document.createElement('span'); badge.className = 'folded-tool-status-live'; summary.appendChild(badge); }
1127
+ const isRunning = block.status === 'in_progress';
1128
+ badge.style.cssText = 'margin-left:auto;font-size:0.65rem;opacity:0.7;display:inline-flex;align-items:center;gap:0.25rem';
1129
+ badge.innerHTML = (isRunning ? '<span class="animate-spin" style="display:inline-block;width:0.6rem;height:0.6rem;border:1.5px solid var(--color-border);border-top-color:var(--color-info);border-radius:50%"></span>' : '')
1130
+ + '<span>' + (isRunning ? 'Running' : (block.status || '')) + '</span>';
1131
+ }
1132
+ this.scrollToBottom();
1133
+ return;
1134
+ }
1135
+ }
1136
+
1137
+ if (block.type === 'hook_progress') {
1138
+ const hookEvent = (block.hookEvent || '').split(':')[0];
1139
+ if (hookEvent === 'PreToolUse' || hookEvent === 'PostToolUse') {
1140
+ const lastTool = blocksEl.querySelector('.block-tool-use:last-of-type') || blocksEl.lastElementChild;
1141
+ if (lastTool?.classList?.contains('block-tool-use')) {
1142
+ const summary = lastTool.querySelector(':scope > summary');
1143
+ if (summary) {
1144
+ let hookBadge = summary.querySelector('.folded-tool-hook');
1145
+ if (!hookBadge) { hookBadge = document.createElement('span'); hookBadge.className = 'folded-tool-hook'; hookBadge.style.cssText = 'margin-left:0.375rem;font-size:0.6rem;opacity:0.4;font-weight:400'; summary.appendChild(hookBadge); }
1146
+ hookBadge.textContent = block.hookEvent?.split(':').pop() || hookEvent;
1147
+ }
1148
+ return;
1149
+ }
1150
+ }
1151
+ return;
1152
+ }
1153
+
1154
+ if (block.type === 'result' && block.total_cost_usd) {
1155
+ this._sessionCost = (this._sessionCost || 0) + block.total_cost_usd;
1156
+ }
1157
+
1158
+ const el = this.renderer.renderBlock(block, data, blocksEl);
1159
+ if (el) {
1160
+ blocksEl.appendChild(el);
1161
+ this.scrollToBottom();
1162
+ }
1163
+ }
1164
+
1165
+ renderBlockContent(block) {
1166
+ if (block.type === 'text' && block.text) {
1167
+ const text = block.text;
1168
+ if (this.isHtmlContent(text)) {
1169
+ return `<div class="html-content">${this.sanitizeHtml(text)}</div>`;
1170
+ }
1171
+ const parts = this.parseMarkdownCodeBlocks(text);
1172
+ if (parts.length === 1 && parts[0].type === 'text') {
1173
+ return this.escapeHtml(text);
1174
+ }
1175
+ return parts.map(part => {
1176
+ if (part.type === 'html') {
1177
+ return `<div class="html-content">${this.sanitizeHtml(part.content)}</div>`;
1178
+ } else if (part.type === 'code') {
1179
+ return this.renderCodeBlock(part.language, part.code);
1180
+ }
1181
+ return this.escapeHtml(part.content);
1182
+ }).join('');
1183
+ }
1184
+ // Fallback for unknown block types: show formatted key-value pairs
1185
+ const fieldsHtml = Object.entries(block)
1186
+ .filter(([key]) => key !== 'type')
1187
+ .map(([key, value]) => {
1188
+ let displayValue = typeof value === 'string' ? value : JSON.stringify(value);
1189
+ if (displayValue.length > 100) displayValue = displayValue.substring(0, 100) + '...';
1190
+ return `<div style="font-size:0.75rem;margin-bottom:0.25rem"><span style="font-weight:600">${this.escapeHtml(key)}:</span> <code>${this.escapeHtml(displayValue)}</code></div>`;
1191
+ }).join('');
1192
+ return `<div style="padding:0.5rem;background:var(--color-bg-secondary);border-radius:0.375rem;border:1px solid var(--color-border)"><div style="font-size:0.7rem;font-weight:600;text-transform:uppercase;margin-bottom:0.25rem">${this.escapeHtml(block.type)}</div>${fieldsHtml}</div>`;
1193
+ }
1194
+
1195
+ scrollToBottom(force = false) {
1196
+ const scrollContainer = document.getElementById('output-scroll');
1197
+ if (!scrollContainer) return;
1198
+ const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
1199
+
1200
+ if (this._userScrolledUp && !force) {
1201
+ this._unseenCount = (this._unseenCount || 0) + 1;
1202
+ this._showNewContentPill();
1203
+ return;
1204
+ }
1205
+
1206
+ if (!force && distFromBottom > 150) {
1207
+ this._unseenCount = (this._unseenCount || 0) + 1;
1208
+ this._showNewContentPill();
1209
+ return;
1210
+ }
1211
+
1212
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
1213
+ this._removeNewContentPill();
1214
+ this._scrollAnimating = false;
1215
+ }
1216
+
1217
+ _showNewContentPill() {
1218
+ let pill = document.getElementById('new-content-pill');
1219
+ const scrollContainer = document.getElementById('output-scroll');
1220
+ if (!scrollContainer) return;
1221
+ if (!pill) {
1222
+ pill = document.createElement('button');
1223
+ pill.id = 'new-content-pill';
1224
+ pill.className = 'new-content-pill';
1225
+ pill.addEventListener('click', () => {
1226
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
1227
+ this._removeNewContentPill();
1228
+ });
1229
+ scrollContainer.appendChild(pill);
1230
+ }
1231
+ pill.textContent = (this._unseenCount || 1) + ' new';
1232
+ }
1233
+
1234
+ _removeNewContentPill() {
1235
+ this._unseenCount = 0;
1236
+ const pill = document.getElementById('new-content-pill');
1237
+ if (pill) pill.remove();
1238
+ }
1239
+
1240
+ handleStreamingError(data) {
1241
+ console.error('Streaming error:', data);
1242
+ if (window.promptMachineAPI) window.promptMachineAPI.send({ type: 'READY' });
1243
+ this._clearThinkingCountdown();
1244
+ if (this._elapsedTimer) { clearInterval(this._elapsedTimer); this._elapsedTimer = null; }
1245
+
1246
+ // Hide stop and inject buttons on error
1247
+ if (this.ui.stopButton) this.ui.stopButton.classList.remove('visible');
1248
+ if (this.ui.injectButton) this.ui.injectButton.classList.remove('visible');
1249
+ if (this.ui.sendButton) this.ui.sendButton.style.display = '';
1250
+
1251
+ const conversationId = data.conversationId || this.state.currentSession?.conversationId;
1252
+
1253
+ // If this event is for a conversation we are NOT currently viewing, just track state
1254
+ if (conversationId && this.state.currentConversation?.id !== conversationId) {
1255
+ this._dbg('Streaming error for non-active conversation:', conversationId);
1256
+ this._setConvStreaming(conversationId, false);
1257
+ this.updateBusyPromptArea(conversationId);
1258
+ this.emit('streaming:error', data);
1259
+ return;
1260
+ }
1261
+
1262
+ this._setConvStreaming(conversationId, false);
1263
+ this.updateBusyPromptArea(conversationId);
1264
+
1265
+ // Clear queue indicator on error
1266
+ const queueEl = document.querySelector('.queue-indicator');
1267
+ if (queueEl) queueEl.remove();
1268
+
1269
+ // If this is a premature ACP end, render distinct warning block
1270
+ if (data.isPrematureEnd) {
1271
+ this.renderer.queueEvent({
1272
+ type: 'streaming_error',
1273
+ isPrematureEnd: true,
1274
+ exitCode: data.exitCode,
1275
+ error: data.error,
1276
+ stderrText: data.stderrText,
1277
+ sessionId: data.sessionId,
1278
+ conversationId: data.conversationId,
1279
+ timestamp: data.timestamp || Date.now()
1280
+ });
1281
+ }
1282
+
1283
+ const sessionId = data.sessionId || this.state.currentSession?.id;
1284
+
1285
+ // Remove all orphaned streaming indicators (handles case where session never started)
1286
+ const outputEl2 = document.getElementById('output');
1287
+ if (outputEl2) {
1288
+ outputEl2.querySelectorAll('.streaming-indicator').forEach(ind => {
1289
+ ind.innerHTML = `<span style="color:var(--color-error);">Error: ${this.escapeHtml(data.error || 'Unknown error')}</span>`;
1290
+ });
1291
+ }
1292
+
1293
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
1294
+ if (streamingEl) {
1295
+ streamingEl.classList.remove('streaming-message');
1296
+ const indicator = streamingEl.querySelector('.streaming-indicator');
1297
+ if (indicator) {
1298
+ indicator.innerHTML = `<span style="color:var(--color-error);">Error: ${this.escapeHtml(data.error || 'Unknown error')}</span>`;
1299
+ }
1300
+ // Remove all thinking blocks on error
1301
+ streamingEl.querySelectorAll('.block-thinking').forEach(block => block.remove());
1302
+ } else {
1303
+ const outputEl3 = document.getElementById('output');
1304
+ const messagesEl3 = outputEl3 && outputEl3.querySelector('.conversation-messages');
1305
+ if (messagesEl3 && data.error) {
1306
+ const errDiv = document.createElement('div');
1307
+ errDiv.className = 'message';
1308
+ errDiv.style = 'padding:0.75rem;border:1px solid var(--color-error, #e53e3e);border-radius:4px;margin:0.5rem 0;';
1309
+ errDiv.innerHTML = `<span style="color:var(--color-error, #e53e3e);">Error: ${this.escapeHtml(data.error)}</span>`;
1310
+ messagesEl3.appendChild(errDiv);
1311
+ }
1312
+ }
1313
+
1314
+ this.unlockAgentAndModel();
1315
+ this.enableControls();
1316
+ if (typeof window.updateStreamingStatusBar === 'function') window.updateStreamingStatusBar(false);
1317
+ this.emit('streaming:error', data);
1318
+ }
1319
+
1320
+ handleStreamingComplete(data) {
1321
+ this._dbg('Streaming completed:', data);
1322
+ if (window.promptMachineAPI) window.promptMachineAPI.send({ type: 'READY' });
1323
+ this._clearThinkingCountdown();
1324
+ if (this._elapsedTimer) { clearInterval(this._elapsedTimer); this._elapsedTimer = null; }
1325
+
1326
+ const conversationId = data.conversationId || this.state.currentSession?.conversationId;
1327
+ if (conversationId) this.invalidateCache(conversationId);
1328
+
1329
+ if (conversationId && this.state.currentConversation?.id !== conversationId) {
1330
+ this._dbg('Streaming completed for non-active conversation:', conversationId);
1331
+ this._setConvStreaming(conversationId, false);
1332
+ this._dbg('[SYNC] streaming_complete - non-active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size });
1333
+ this.updateBusyPromptArea(conversationId);
1334
+ this.emit('streaming:complete', data);
1335
+ return;
1336
+ }
1337
+
1338
+ this._setConvStreaming(conversationId, false);
1339
+ this._dbg('[SYNC] streaming_complete - active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size, interrupted: data.interrupted });
1340
+ this.updateBusyPromptArea(conversationId);
1341
+
1342
+ const sessionId = data.sessionId || this.state.currentSession?.id;
1343
+
1344
+ // Unsubscribe from session to prevent subscription leak
1345
+ if (sessionId && this.wsManager) {
1346
+ try {
1347
+ this.wsManager.unsubscribeFromSession(sessionId);
1348
+ } catch (e) {
1349
+ // Session may not exist, ignore
1350
+ }
1351
+ }
1352
+
1353
+ // Clear queue indicator when streaming completes
1354
+ const queueEl = document.querySelector('.queue-indicator');
1355
+ if (queueEl) queueEl.remove();
1356
+
1357
+ // Remove ALL streaming indicators from the entire messages container
1358
+ const outputEl2 = document.getElementById('output');
1359
+ if (outputEl2) {
1360
+ outputEl2.querySelectorAll('.streaming-indicator').forEach(ind => ind.remove());
1361
+ // Remove session start/complete blocks that clutter the chat
1362
+ outputEl2.querySelectorAll('.event-streaming-start, .event-streaming-complete').forEach(block => block.remove());
1363
+ }
1364
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
1365
+ if (streamingEl) {
1366
+ streamingEl.classList.remove('streaming-message');
1367
+ const prevTextEl = streamingEl.querySelector('.streaming-text-current');
1368
+ if (prevTextEl) prevTextEl.classList.remove('streaming-text-current');
1369
+
1370
+ // Remove all thinking blocks (block-thinking elements)
1371
+ streamingEl.querySelectorAll('.block-thinking').forEach(block => block.remove());
1372
+
1373
+ const ts = document.createElement('div');
1374
+ ts.className = 'message-timestamp';
1375
+ ts.textContent = new Date().toLocaleString();
1376
+ streamingEl.appendChild(ts);
1377
+ }
1378
+
1379
+ if (conversationId) {
1380
+ this.saveScrollPosition(conversationId);
1381
+ }
1382
+
1383
+ // Recover any blocks missed during streaming (e.g. WS reconnects)
1384
+ this._recoverMissedChunks().catch(err => {
1385
+ console.warn('Chunk recovery failed:', err.message);
1386
+ });
1387
+
1388
+ this.enableControls();
1389
+ if (typeof window.updateStreamingStatusBar === 'function') window.updateStreamingStatusBar(false);
1390
+ this.emit('streaming:complete', data);
1391
+
1392
+ this._promptPushIfWeOwnRemote();
1393
+ }
1394
+
1395
+ async _promptPushIfWeOwnRemote() {
1396
+ try {
1397
+ const { ownsRemote, hasChanges, hasUnpushed, remoteUrl } = await window.wsClient.rpc('git.check');
1398
+ if (ownsRemote && (hasChanges || hasUnpushed)) {
1399
+ const conv = this.state.currentConversation;
1400
+ if (conv) {
1401
+ this.streamToConversation(conv.id, 'Push the changes to the remote repository.', conv.agentId);
1402
+ }
1403
+ }
1404
+ } catch (e) {
1405
+ console.warn('Auto-push check failed:', e);
1406
+ }
1407
+ }
1408
+
1409
+ /**
1410
+ * Handle conversation created
1411
+ */
1412
+ handleConversationCreated(data) {
1413
+ if (data.conversation) {
1414
+ if (this.state.conversations.some(c => c.id === data.conversation.id)) {
1415
+ return;
1416
+ }
1417
+ this.state.conversations.push(data.conversation);
1418
+ this.emit('conversation:created', data.conversation);
1419
+ }
1420
+ }
1421
+
1422
+ handleMessageCreated(data) {
1423
+ if (data.conversationId !== this.state.currentConversation?.id || !data.message) {
1424
+ this.emit('message:created', data);
1425
+ return;
1426
+ }
1427
+
1428
+ this._dbg('[SYNC] message_created:', { msgId: data.message.id, role: data.message.role, convId: data.conversationId });
1429
+
1430
+ // Update messageCount in current conversation state for user messages
1431
+ if (data.message.role === 'user' && this.state.currentConversation) {
1432
+ this.state.currentConversation.messageCount = (this.state.currentConversation.messageCount || 0) + 1;
1433
+ }
1434
+
1435
+ if (data.message.role === 'assistant' && this._convIsStreaming(data.conversationId)) {
1436
+ this.emit('message:created', data);
1437
+ return;
1438
+ }
1439
+
1440
+ const outputEl = document.querySelector('.conversation-messages');
1441
+ if (!outputEl) {
1442
+ this.emit('message:created', data);
1443
+ return;
1444
+ }
1445
+
1446
+ if (data.message.role === 'user') {
1447
+ // Find pending message by matching content to avoid duplicates
1448
+ const pending = outputEl.querySelector('.message-sending');
1449
+ if (pending) {
1450
+ pending.id = '';
1451
+ pending.setAttribute('data-msg-id', data.message.id);
1452
+ pending.classList.remove('message-sending');
1453
+ const ts = pending.querySelector('.message-timestamp');
1454
+ if (ts) {
1455
+ ts.style.opacity = '1';
1456
+ ts.textContent = new Date(data.message.created_at).toLocaleString();
1457
+ }
1458
+ this.emit('message:created', data);
1459
+ return;
1460
+ }
1461
+ // Also check for pending ID (in case message-sending was already removed by _confirmOptimisticMessage)
1462
+ const pendingById = outputEl.querySelector('[id^="pending-"]');
1463
+ if (pendingById) {
1464
+ pendingById.id = '';
1465
+ pendingById.setAttribute('data-msg-id', data.message.id);
1466
+ const ts = pendingById.querySelector('.message-timestamp');
1467
+ if (ts) {
1468
+ ts.style.opacity = '1';
1469
+ ts.textContent = new Date(data.message.created_at).toLocaleString();
1470
+ }
1471
+ this.emit('message:created', data);
1472
+ return;
1473
+ }
1474
+ // Check if a user message with this ID already exists (prevents duplicate on race condition)
1475
+ const existingMsg = outputEl.querySelector(`[data-msg-id="${data.message.id}"]`);
1476
+ if (existingMsg) {
1477
+ this.emit('message:created', data);
1478
+ return;
1479
+ }
1480
+ }
1481
+
1482
+ outputEl.querySelectorAll('p.text-secondary').forEach(p => p.remove());
1483
+ const safeRole = this.escapeHtml(data.message.role || 'user');
1484
+ const safeId = this.escapeHtml(data.message.id || '');
1485
+ const messageHtml = `
1486
+ <div class="message message-${safeRole}" data-msg-id="${safeId}">
1487
+ <div class="message-role">${safeRole.charAt(0).toUpperCase() + safeRole.slice(1)}</div>
1488
+ ${this.renderMessageContent(data.message.content)}
1489
+ <div class="message-timestamp">${new Date(data.message.created_at).toLocaleString()}</div>
1490
+ </div>
1491
+ `;
1492
+ outputEl.insertAdjacentHTML('beforeend', messageHtml);
1493
+ this.scrollToBottom();
1494
+ this.emit('message:created', data);
1495
+ }
1496
+
1497
+ handleConversationUpdated(data) {
1498
+ // Update current conversation metadata if this is the active conversation
1499
+ if (data.conversation && data.conversation.id === this.state.currentConversation?.id) {
1500
+ this.state.currentConversation = data.conversation;
1501
+ }
1502
+ // Emit event for sidebar/other listeners
1503
+ this.emit('conversation:updated', data);
1504
+ }
1505
+
1506
+ handleQueueStatus(data) {
1507
+ if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(data.conversationId, { type: 'QUEUE_UPDATE', queueLength: data.queueLength || 0 });
1508
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1509
+ this.fetchAndRenderQueue(data.conversationId);
1510
+ }
1511
+
1512
+ handleQueueUpdated(data) {
1513
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1514
+ this.fetchAndRenderQueue(data.conversationId);
1515
+ }
1516
+
1517
+ handleQueueItemDequeued(data) {
1518
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1519
+ // Item was dequeued and execution started - remove from queue indicator
1520
+ // and update queue display
1521
+ this.fetchAndRenderQueue(data.conversationId);
1522
+ }
1523
+
1524
+ async fetchAndRenderQueue(conversationId) {
1525
+ const outputEl = document.querySelector('.conversation-messages');
1526
+ if (!outputEl) return;
1527
+
1528
+ try {
1529
+ const { queue } = await window.wsClient.rpc('q.ls', { id: conversationId });
1530
+
1531
+ let queueEl = outputEl.querySelector('.queue-indicator');
1532
+ if (!queue || queue.length === 0) {
1533
+ if (queueEl) queueEl.remove();
1534
+ return;
1535
+ }
1536
+
1537
+ if (!queueEl) {
1538
+ queueEl = document.createElement('div');
1539
+ queueEl.className = 'queue-indicator';
1540
+ outputEl.appendChild(queueEl);
1541
+ }
1542
+
1543
+ queueEl.innerHTML = queue.map((q, i) => `
1544
+ <div class="queue-item" data-message-id="${q.messageId}" style="padding:0.5rem 1rem;margin:0.5rem 0;border-radius:0.375rem;background:var(--color-warning);color:#000;font-size:0.875rem;display:flex;align-items:center;gap:0.5rem;">
1545
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${i + 1}. ${this.escapeHtml(q.content)}</span>
1546
+ <button class="queue-edit-btn" data-index="${i}" style="padding:0.25rem 0.5rem;background:transparent;border:1px solid #000;border-radius:0.25rem;cursor:pointer;font-size:0.75rem;">Edit</button>
1547
+ <button class="queue-delete-btn" data-index="${i}" style="padding:0.25rem 0.5rem;background:transparent;border:1px solid #000;border-radius:0.25rem;cursor:pointer;font-size:0.75rem;">Delete</button>
1548
+ </div>
1549
+ `).join('');
1550
+
1551
+ if (!queueEl._listenersAttached) {
1552
+ queueEl._listenersAttached = true;
1553
+ queueEl.addEventListener('click', async (e) => {
1554
+ if (e.target.classList.contains('queue-delete-btn')) {
1555
+ const index = parseInt(e.target.dataset.index);
1556
+ const msgId = queue[index].messageId;
1557
+ if (await window.UIDialog.confirm('Delete this queued message?', 'Delete Message')) {
1558
+ await window.wsClient.rpc('q.del', { id: conversationId, messageId: msgId });
1559
+ }
1560
+ } else if (e.target.classList.contains('queue-edit-btn')) {
1561
+ const index = parseInt(e.target.dataset.index);
1562
+ const q = queue[index];
1563
+ const newContent = await window.UIDialog.prompt('Edit message:', q.content, 'Edit Queued Message');
1564
+ if (newContent !== null && newContent !== q.content) {
1565
+ window.wsClient.rpc('q.upd', { id: conversationId, messageId: q.messageId, content: newContent });
1566
+ }
1567
+ }
1568
+ });
1569
+ }
1570
+ } catch (err) {
1571
+ console.error('Failed to fetch queue:', err);
1572
+ }
1573
+ }
1574
+
1575
+ handleRateLimitHit(data) {
1576
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1577
+ this._setConvStreaming(data.conversationId, false);
1578
+
1579
+ this.enableControls();
1580
+
1581
+ const cooldownMs = data.retryAfterMs || 60000;
1582
+ this._rateLimitSafetyTimer = setTimeout(() => {
1583
+ this.enableControls();
1584
+ }, cooldownMs + 10000);
1585
+
1586
+ const sessionId = data.sessionId || this.state.currentSession?.id;
1587
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
1588
+ if (streamingEl) {
1589
+ const indicator = streamingEl.querySelector('.streaming-indicator');
1590
+ if (indicator) {
1591
+ const retrySeconds = Math.ceil(cooldownMs / 1000);
1592
+ indicator.innerHTML = `<span style="color:var(--color-warning);">Rate limited. Retrying in ${retrySeconds}s...</span>`;
1593
+ let remaining = retrySeconds;
1594
+ const countdownTimer = setInterval(() => {
1595
+ remaining--;
1596
+ if (remaining <= 0) {
1597
+ clearInterval(countdownTimer);
1598
+ indicator.innerHTML = '<span style="color:var(--color-info);">Restarting...</span>';
1599
+ } else {
1600
+ indicator.innerHTML = `<span style="color:var(--color-warning);">Rate limited. Retrying in ${remaining}s...</span>`;
1601
+ }
1602
+ }, 1000);
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ handleRateLimitClear(data) {
1608
+ if (data.conversationId !== this.state.currentConversation?.id) return;
1609
+ if (this._rateLimitSafetyTimer) {
1610
+ clearTimeout(this._rateLimitSafetyTimer);
1611
+ this._rateLimitSafetyTimer = null;
1612
+ }
1613
+ this.enableControls();
1614
+ }
1615
+
1616
+ handleAllConversationsDeleted(data) {
1617
+ window.ConversationState?.clear('all_deleted');
1618
+ this.state.currentConversation = null;
1619
+ this.state.conversations = [];
1620
+ this.state.sessionEvents = [];
1621
+ this.conversationCache.clear();
1622
+ this.conversationListCache = { data: [], timestamp: 0, ttl: 30000 };
1623
+ this.draftPrompts.clear();
1624
+ window.dispatchEvent(new CustomEvent('conversation-deselected'));
1625
+ const outputEl = document.getElementById('output');
1626
+ if (outputEl) outputEl.innerHTML = '';
1627
+ }
1628
+
1629
+ isHtmlContent(text) {
1630
+ const htmlPattern = /<(?:div|table|section|article|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6]|p|blockquote|pre|code|span|strong|em|a|img|br|hr|li|td|tr|th|thead|tbody|tfoot)\b[^>]*>/i;
1631
+ return htmlPattern.test(text);
1632
+ }
1633
+
1634
+ sanitizeHtml(html) {
1635
+ const dangerous = /<\s*\/?\s*(script|iframe|object|embed|applet|form|input|button|select|textarea)\b[^>]*>/gi;
1636
+ let cleaned = html.replace(dangerous, '');
1637
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
1638
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
1639
+ cleaned = cleaned.replace(/javascript\s*:/gi, '');
1640
+ return cleaned;
1641
+ }
1642
+
1643
+ parseMarkdownCodeBlocks(text) {
1644
+ const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
1645
+ const parts = [];
1646
+ let lastIndex = 0;
1647
+ let match;
1648
+
1649
+ while ((match = codeBlockRegex.exec(text)) !== null) {
1650
+ if (match.index > lastIndex) {
1651
+ const segment = text.substring(lastIndex, match.index);
1652
+ parts.push({
1653
+ type: this.isHtmlContent(segment) ? 'html' : 'text',
1654
+ content: segment
1655
+ });
1656
+ }
1657
+ parts.push({
1658
+ type: 'code',
1659
+ language: match[1] || 'plain',
1660
+ code: match[2]
1661
+ });
1662
+ lastIndex = codeBlockRegex.lastIndex;
1663
+ }
1664
+
1665
+ if (lastIndex < text.length) {
1666
+ const segment = text.substring(lastIndex);
1667
+ parts.push({
1668
+ type: this.isHtmlContent(segment) ? 'html' : 'text',
1669
+ content: segment
1670
+ });
1671
+ }
1672
+
1673
+ if (parts.length === 0) {
1674
+ return [{ type: this.isHtmlContent(text) ? 'html' : 'text', content: text }];
1675
+ }
1676
+
1677
+ return parts;
1678
+ }
1679
+
1680
+ /**
1681
+ * Render a markdown code block part
1682
+ */
1683
+ renderCodeBlock(language, code) {
1684
+ if (language.toLowerCase() === 'html') {
1685
+ return `
1686
+ <div class="message-code">
1687
+ <div class="html-rendered-label">
1688
+ Rendered HTML
1689
+ </div>
1690
+ <div class="html-content">
1691
+ ${this.sanitizeHtml(code)}
1692
+ </div>
1693
+ </div>
1694
+ `;
1695
+ } else {
1696
+ const lineCount = code.split('\n').length;
1697
+ return `<div class="message-code"><details class="collapsible-code"><summary class="collapsible-code-summary">${this.escapeHtml(language)} - ${lineCount} line${lineCount !== 1 ? 's' : ''}</summary><pre style="margin:0;border-radius:0 0 0.375rem 0.375rem">${this.escapeHtml(code)}</pre></details></div>`;
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * Render message content based on type
1703
+ */
1704
+ renderMessageContent(content) {
1705
+ if (typeof content === 'string') {
1706
+ if (this.isHtmlContent(content)) {
1707
+ return `<div class="message-text"><div class="html-content">${this.sanitizeHtml(content)}</div></div>`;
1708
+ }
1709
+ return `<div class="message-text">${this.escapeHtml(content)}</div>`;
1710
+ } else if (content && typeof content === 'object' && content.type === 'claude_execution') {
1711
+ let html = '<div class="message-blocks">';
1712
+ if (content.blocks && Array.isArray(content.blocks)) {
1713
+ let pendingToolUseClose = false;
1714
+ let pendingHasInput = false;
1715
+ content.blocks.forEach((block, blockIdx, blocks) => {
1716
+ if (block.type !== 'tool_result' && pendingToolUseClose) {
1717
+ if (pendingHasInput) html += '</div>';
1718
+ html += '</details>';
1719
+ pendingToolUseClose = false;
1720
+ pendingHasInput = false;
1721
+ }
1722
+ if (block.type === 'text') {
1723
+ const parts = this.parseMarkdownCodeBlocks(block.text);
1724
+ parts.forEach(part => {
1725
+ if (part.type === 'html') {
1726
+ html += `<div class="message-text"><div class="html-content">${this.sanitizeHtml(part.content)}</div></div>`;
1727
+ } else if (part.type === 'text') {
1728
+ html += `<div class="message-text">${this.escapeHtml(part.content)}</div>`;
1729
+ } else if (part.type === 'code') {
1730
+ html += this.renderCodeBlock(part.language, part.code);
1731
+ }
1732
+ });
1733
+ } else if (block.type === 'code_block') {
1734
+ // Render HTML code blocks as actual HTML elements
1735
+ if (block.language === 'html') {
1736
+ html += `
1737
+ <div class="message-code">
1738
+ <div class="html-rendered-label">
1739
+ Rendered HTML
1740
+ </div>
1741
+ <div class="html-content">
1742
+ ${this.sanitizeHtml(block.code)}
1743
+ </div>
1744
+ </div>
1745
+ `;
1746
+ } else {
1747
+ const blkLineCount = block.code.split('\n').length;
1748
+ html += `<div class="message-code"><details class="collapsible-code"><summary class="collapsible-code-summary">${this.escapeHtml(block.language || 'code')} - ${blkLineCount} line${blkLineCount !== 1 ? 's' : ''}</summary><pre style="margin:0;border-radius:0 0 0.375rem 0.375rem">${this.escapeHtml(block.code)}</pre></details></div>`;
1749
+ }
1750
+ } else if (block.type === 'tool_use') {
1751
+ let inputContentHtml = '';
1752
+ const hasInput = block.input && Object.keys(block.input).length > 0;
1753
+ if (hasInput) {
1754
+ const inputStr = JSON.stringify(block.input, null, 2);
1755
+ inputContentHtml = `<pre class="tool-input-pre">${this.escapeHtml(inputStr)}</pre>`;
1756
+ }
1757
+ const tn = block.name || 'unknown';
1758
+ const hasRenderer = typeof StreamingRenderer !== 'undefined';
1759
+ const dName = hasRenderer ? StreamingRenderer.getToolDisplayName(tn) : tn;
1760
+ const tTitle = hasRenderer && block.input ? StreamingRenderer.getToolTitle(tn, block.input) : '';
1761
+ const iconHtml = hasRenderer && this.renderer ? `<span class="folded-tool-icon">${this.renderer.getToolIcon(tn)}</span>` : '';
1762
+ const typeClass = hasRenderer && this.renderer ? this.renderer._getBlockTypeClass('tool_use') : 'block-type-tool_use';
1763
+ const toolColorClass = hasRenderer && this.renderer ? this.renderer._getToolColorClass(tn) : 'tool-color-default';
1764
+ const nextBlock = blocks[blockIdx + 1];
1765
+ const resultClass = nextBlock?.type === 'tool_result' ? (nextBlock.is_error ? 'has-error tool-result-error' : 'has-success tool-result-success') : '';
1766
+ const resultStatusIcon = nextBlock?.type === 'tool_result'
1767
+ ? `<span class="folded-tool-status">${nextBlock.is_error
1768
+ ? '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'
1769
+ : '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'
1770
+ }</span>` : '';
1771
+ if (hasInput) {
1772
+ html += `<details class="block-tool-use folded-tool ${typeClass} ${toolColorClass} ${resultClass}"><summary class="folded-tool-bar">${iconHtml}<span class="folded-tool-name">${this.escapeHtml(dName)}</span>${tTitle ? `<span class="folded-tool-desc">${this.escapeHtml(tTitle)}</span>` : ''}${resultStatusIcon}</summary><div class="folded-tool-body">${inputContentHtml}`;
1773
+ pendingHasInput = true;
1774
+ } else {
1775
+ html += `<details class="block-tool-use folded-tool ${typeClass} ${toolColorClass} ${resultClass}"><summary class="folded-tool-bar">${iconHtml}<span class="folded-tool-name">${this.escapeHtml(dName)}</span>${tTitle ? `<span class="folded-tool-desc">${this.escapeHtml(tTitle)}</span>` : ''}${resultStatusIcon}</summary>`;
1776
+ pendingHasInput = false;
1777
+ }
1778
+ pendingToolUseClose = true;
1779
+ } else if (block.type === 'tool_result') {
1780
+ const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
1781
+ const smartHtml = typeof StreamingRenderer !== 'undefined' ? StreamingRenderer.renderSmartContentHTML(content, this.escapeHtml.bind(this), true) : `<pre class="tool-result-pre">${this.escapeHtml(content.length > 2000 ? content.substring(0, 2000) + '\n... (truncated)' : content)}</pre>`;
1782
+ const resultContentHtml = `<div class="folded-tool-result-content">${smartHtml}</div>`;
1783
+ if (pendingToolUseClose) {
1784
+ if (pendingHasInput) {
1785
+ html += resultContentHtml + '</div></details>';
1786
+ } else {
1787
+ html += `<div class="folded-tool-body">${resultContentHtml}</div></details>`;
1788
+ }
1789
+ pendingToolUseClose = false;
1790
+ } else {
1791
+ html += resultContentHtml;
1792
+ }
1793
+ }
1794
+ });
1795
+ if (pendingToolUseClose) {
1796
+ if (pendingHasInput) html += '</div>';
1797
+ html += '</details>';
1798
+ }
1799
+ }
1800
+ html += '</div>';
1801
+ return html;
1802
+ } else {
1803
+ // Fallback for non-array content: format as key-value pairs
1804
+ if (typeof content === 'object' && content !== null) {
1805
+ const fieldsHtml = Object.entries(content)
1806
+ .map(([key, value]) => {
1807
+ let displayValue = typeof value === 'string' ? value : JSON.stringify(value);
1808
+ if (displayValue.length > 150) displayValue = displayValue.substring(0, 150) + '...';
1809
+ return `<div style="font-size:0.8rem;margin-bottom:0.375rem"><span style="font-weight:600">${this.escapeHtml(key)}:</span> <code style="background:var(--color-bg-secondary);padding:0.125rem 0.25rem;border-radius:0.25rem">${this.escapeHtml(displayValue)}</code></div>`;
1810
+ }).join('');
1811
+ return `<div class="message-text" style="background:var(--color-bg-secondary);padding:0.75rem;border-radius:0.375rem">${fieldsHtml}</div>`;
1812
+ }
1813
+ return `<div class="message-text">${this.escapeHtml(String(content))}</div>`;
1814
+ }
1815
+ }
1816
+
1817
+ async startExecution() {
1818
+ const prompt = this.ui.messageInput?.value || '';
1819
+
1820
+ if (!prompt.trim()) {
1821
+ this.showError('Please enter a prompt');
1822
+ return;
1823
+ }
1824
+
1825
+ this.disableControls();
1826
+ const ttsActive = window.TTSHandler && window.TTSHandler.getAutoSpeak && window.TTSHandler.getAutoSpeak();
1827
+ const savedPrompt = ttsActive ? prompt + '\n\n[Respond optimized for text-to-speech: use short sentences, simple words, and focus on clarity.]' : prompt;
1828
+ if (this.ui.messageInput) {
1829
+ this.ui.messageInput.value = '';
1830
+ this.ui.messageInput.style.height = 'auto';
1831
+ }
1832
+
1833
+ const pendingId = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
1834
+
1835
+ // Conv machine is authoritative: check machine state for optimistic message gating
1836
+ const isStreaming = this._convIsStreaming(this.state.currentConversation?.id);
1837
+ if (!isStreaming) {
1838
+ this._showOptimisticMessage(pendingId, savedPrompt);
1839
+ }
1840
+
1841
+ try {
1842
+ let conv = this.state.currentConversation;
1843
+
1844
+ if (this._isLoadingConversation) {
1845
+ this.showError('Conversation still loading. Please try again.');
1846
+ this.enableControls();
1847
+ return;
1848
+ }
1849
+
1850
+ if (conv && typeof conv === 'string') {
1851
+ this.showError('Conversation state invalid. Please reload.');
1852
+ this.enableControls();
1853
+ return;
1854
+ }
1855
+
1856
+ if (conv?.id) {
1857
+ const isNewConversation = !conv.messageCount && !this._convIsStreaming(conv.id);
1858
+ const agentId = (isNewConversation ? this.getCurrentAgent() : null) || conv?.agentType || this.getCurrentAgent();
1859
+ const subAgent = this.getEffectiveSubAgent() || conv?.subAgent || null;
1860
+ const model = this.ui.modelSelector?.value || null;
1861
+
1862
+ this.lockAgentAndModel(agentId, model);
1863
+ await this.streamToConversation(conv.id, savedPrompt, agentId, model, subAgent);
1864
+ this.clearDraft(conv.id);
1865
+ // Only confirm optimistic message if it was shown (not queued)
1866
+ if (!isStreaming) {
1867
+ this._confirmOptimisticMessage(pendingId);
1868
+ }
1869
+ } else {
1870
+ const agentId = this.getCurrentAgent();
1871
+ const subAgent = this.getEffectiveSubAgent() || null;
1872
+ const model = this.ui.modelSelector?.value || null;
1873
+
1874
+ const body = { agentId, title: savedPrompt.substring(0, 50) };
1875
+ if (model) body.model = model;
1876
+ if (subAgent) body.subAgent = subAgent;
1877
+ const { conversation } = await window.wsClient.rpc('conv.new', body);
1878
+ window.ConversationState?.selectConversation(conversation.id, 'conversation_created', 1);
1879
+ this.state.currentConversation = conversation;
1880
+ this.lockAgentAndModel(agentId, model);
1881
+
1882
+ if (window.conversationManager) {
1883
+ window.conversationManager.loadConversations();
1884
+ window.conversationManager.select(conversation.id);
1885
+ }
1886
+
1887
+ await this.streamToConversation(conversation.id, savedPrompt, agentId, model, subAgent);
1888
+ this.clearDraft(conversation.id);
1889
+ this._confirmOptimisticMessage(pendingId);
1890
+ }
1891
+ } catch (error) {
1892
+ console.error('Execution error:', error);
1893
+ // Only fail optimistic message if it was shown
1894
+ if (!isStreaming) {
1895
+ this._failOptimisticMessage(pendingId, savedPrompt, error.message);
1896
+ }
1897
+ this.enableControls();
1898
+ }
1899
+ }
1900
+
1901
+ _showOptimisticMessage(pendingId, content) {
1902
+ const messagesEl = document.querySelector('.conversation-messages');
1903
+ if (!messagesEl) return;
1904
+ messagesEl.querySelectorAll('p.text-secondary').forEach(p => p.remove());
1905
+ const div = document.createElement('div');
1906
+ div.className = 'message message-user message-sending';
1907
+ div.id = pendingId;
1908
+ div.innerHTML = `<div class="message-role">User</div><div class="message-text">${this.escapeHtml(content)}</div><div class="message-timestamp" style="opacity:0.5">Sending...</div>`;
1909
+ messagesEl.appendChild(div);
1910
+ this.scrollToBottom(true);
1911
+ }
1912
+
1913
+ _confirmOptimisticMessage(pendingId) {
1914
+ const el = document.getElementById(pendingId);
1915
+ if (!el) return;
1916
+ el.classList.remove('message-sending');
1917
+ const ts = el.querySelector('.message-timestamp');
1918
+ if (ts) {
1919
+ ts.style.opacity = '1';
1920
+ ts.textContent = new Date().toLocaleString();
1921
+ }
1922
+ }
1923
+
1924
+ _failOptimisticMessage(pendingId, content, errorMsg) {
1925
+ const el = document.getElementById(pendingId);
1926
+ if (!el) return;
1927
+ el.classList.remove('message-sending');
1928
+ el.classList.add('message-send-failed');
1929
+ const ts = el.querySelector('.message-timestamp');
1930
+ if (ts) {
1931
+ ts.style.opacity = '1';
1932
+ ts.innerHTML = `<span style="color:var(--color-error)">Failed: ${this.escapeHtml(errorMsg)}</span>`;
1933
+ }
1934
+ if (this.ui.messageInput) {
1935
+ this.ui.messageInput.value = content;
1936
+ }
1937
+ }
1938
+
1939
+ _subscribeToConversationUpdates() {
1940
+ if (!this.state.conversations || this.state.conversations.length === 0) return;
1941
+ for (const conv of this.state.conversations) {
1942
+ this.wsManager.subscribeToConversation(conv.id);
1943
+ }
1944
+ }
1945
+
1946
+ // Flush background-cached blocks into the active streaming container
1947
+ _flushBgCache(conversationId, sessionId) {
1948
+ const entry = this._bgCache.get(conversationId);
1949
+ if (!entry || entry.items.length === 0) return;
1950
+ if (entry.sessionId !== sessionId) { this._bgCache.delete(conversationId); return; }
1951
+
1952
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
1953
+ if (!streamingEl) return;
1954
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
1955
+ if (!blocksEl) return;
1956
+
1957
+ const seenSeqs = this._renderedSeqs[sessionId] || (this._renderedSeqs[sessionId] = new Set());
1958
+ for (const item of entry.items) {
1959
+ // Skip blocks already rendered (dedup by seq)
1960
+ if (item.seq !== undefined && seenSeqs.has(item.seq)) continue;
1961
+ try {
1962
+ const block = (typeof msgpackr !== 'undefined' && item.packed instanceof Uint8Array)
1963
+ ? msgpackr.unpack(item.packed) : item.packed;
1964
+ const el = this.renderer.renderBlock(block, { sessionId }, blocksEl);
1965
+ if (el) {
1966
+ if (item.seq !== undefined) seenSeqs.add(item.seq);
1967
+ blocksEl.appendChild(el);
1968
+ }
1969
+ } catch (_) {}
1970
+ }
1971
+ this._bgCache.delete(conversationId);
1972
+ this.scrollToBottom();
1973
+ }
1974
+
1975
+ async _recoverMissedChunks() {
1976
+ if (!this.state.currentSession?.id) return;
1977
+ // Note: do NOT gate on streamingConversations - this is called from handleStreamingComplete
1978
+ // where we've already removed the conversation from the set. Allow recovery always.
1979
+
1980
+ const sessionId = this.state.currentSession.id;
1981
+ // Use lastSeq=-1 when no WS messages received yet (fresh load/full disconnect).
1982
+ // Server query is `sequence > sinceSeq`, so -1 returns all chunks from seq 0.
1983
+ // _renderedSeqs dedup prevents double-rendering anything already shown.
1984
+ const lastSeq = this.wsManager.getLastSeq(sessionId);
1985
+
1986
+ try {
1987
+ const { chunks: rawChunks } = await window.wsClient.rpc('sess.chunks', { id: sessionId, sinceSeq: lastSeq });
1988
+ if (!rawChunks || rawChunks.length === 0) return;
1989
+
1990
+ const chunks = rawChunks.map(c => ({
1991
+ ...c,
1992
+ block: typeof c.data === 'string' ? JSON.parse(c.data) : c.data
1993
+ })).filter(c => c.block && c.block.type);
1994
+
1995
+ const seenSeqs = (this._renderedSeqs || {})[sessionId];
1996
+ const dedupedChunks = chunks.filter(c => !seenSeqs || !seenSeqs.has(c.sequence));
1997
+
1998
+ if (dedupedChunks.length > 0) {
1999
+ for (const chunk of dedupedChunks) {
2000
+ try { this.renderChunk(chunk); } catch (chunkErr) { console.warn('Skipping bad chunk:', chunkErr.message); }
2001
+ }
2002
+ }
2003
+ } catch (e) {
2004
+ console.warn('Chunk recovery failed:', e.message);
2005
+ }
2006
+ }
2007
+
2008
+ _dedupedFetch(key, fetchFn) {
2009
+ if (this._inflightRequests.has(key)) {
2010
+ return this._inflightRequests.get(key);
2011
+ }
2012
+ const promise = fetchFn().finally(() => {
2013
+ this._inflightRequests.delete(key);
2014
+ });
2015
+ this._inflightRequests.set(key, promise);
2016
+ return promise;
2017
+ }
2018
+
2019
+ _insertPlaceholder(sessionId) {
2020
+ this._removePlaceholder();
2021
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
2022
+ if (!streamingEl) return;
2023
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
2024
+ if (!blocksEl) return;
2025
+ const ph = document.createElement('div');
2026
+ ph.className = 'chunk-placeholder';
2027
+ ph.id = 'chunk-placeholder-active';
2028
+ blocksEl.appendChild(ph);
2029
+ this._placeholderAutoRemove = setTimeout(() => this._removePlaceholder(), 500);
2030
+ }
2031
+
2032
+ _removePlaceholder() {
2033
+ if (this._placeholderAutoRemove) { clearTimeout(this._placeholderAutoRemove); this._placeholderAutoRemove = null; }
2034
+ const ph = document.getElementById('chunk-placeholder-active');
2035
+ if (ph && ph.parentNode) ph.remove();
2036
+ }
2037
+
2038
+ _trackBlockHeight(block, element) {
2039
+ if (!element || !block?.type) return;
2040
+ const h = element.offsetHeight;
2041
+ if (h <= 0) return;
2042
+ if (!this._blockHeightAvg) this._blockHeightAvg = {};
2043
+ const t = block.type;
2044
+ if (!this._blockHeightAvg[t]) this._blockHeightAvg[t] = { sum: 0, count: 0 };
2045
+ this._blockHeightAvg[t].sum += h;
2046
+ this._blockHeightAvg[t].count++;
2047
+ }
2048
+
2049
+ _estimatedBlockHeight(type) {
2050
+ const defaults = { text: 40, tool_use: 60, tool_result: 40 };
2051
+ if (this._blockHeightAvg?.[type]?.count >= 3) {
2052
+ return this._blockHeightAvg[type].sum / this._blockHeightAvg[type].count;
2053
+ }
2054
+ return defaults[type] || 40;
2055
+ }
2056
+
2057
+ _startThinkingCountdown() {
2058
+ this._clearThinkingCountdown();
2059
+ if (!this._lastSendTime) return;
2060
+ const predicted = this.wsManager?.latency?.predicted || 0;
2061
+ const estimatedWait = predicted + this._serverProcessingEstimate;
2062
+ if (estimatedWait < 1000) return;
2063
+ let remaining = Math.ceil(estimatedWait / 1000);
2064
+ const update = () => {
2065
+ const indicator = document.querySelector('.streaming-indicator');
2066
+ if (!indicator) return;
2067
+ if (remaining > 0) {
2068
+ indicator.textContent = `Thinking... (~${remaining}s)`;
2069
+ remaining--;
2070
+ this._countdownTimer = setTimeout(update, 1000);
2071
+ } else {
2072
+ indicator.textContent = 'Thinking... (taking longer than expected)';
2073
+ }
2074
+ };
2075
+ this._countdownTimer = setTimeout(update, 100);
2076
+ }
2077
+
2078
+ _clearThinkingCountdown() {
2079
+ if (this._countdownTimer) { clearTimeout(this._countdownTimer); this._countdownTimer = null; }
2080
+ }
2081
+
2082
+ _setupDebugHooks() {
2083
+ if (typeof window === 'undefined') return;
2084
+ const self = this;
2085
+ window.__debug = {
2086
+ getState: () => ({
2087
+ latencyEma: self.wsManager?._latencyEma || null,
2088
+ serverProcessingEstimate: self._serverProcessingEstimate,
2089
+ latencyTrend: self.wsManager?.latency?.trend || null
2090
+ }),
2091
+
2092
+ // Sync-to-display debugging
2093
+ getSyncState: () => ({
2094
+ currentConversation: self.state.currentConversation,
2095
+ isStreaming: self._convIsStreaming(self.state.currentConversation?.id),
2096
+ streamingConversations: Array.from(self.state.streamingConversations),
2097
+ convMachineStates: typeof convMachineAPI !== 'undefined' ? Object.fromEntries([...window.__convMachines].map(([k, a]) => [k, a.getSnapshot().value])) : {},
2098
+ wsConnectionState: self.wsManager?._wsActor?.getSnapshot().value || 'unknown',
2099
+ rendererEventQueueLength: self.renderer?.eventQueue?.length || 0,
2100
+ rendererEventHistoryLength: self.renderer?.eventHistory?.length || 0,
2101
+ }),
2102
+
2103
+ // Message DOM state
2104
+ getMessageState: () => {
2105
+ const output = document.querySelector('.conversation-messages');
2106
+ if (!output) return { error: 'No conversation output found' };
2107
+ const messageCount = output.querySelectorAll('.message').length;
2108
+ const queueItems = output.querySelectorAll('.queue-item').length;
2109
+ const pendingMessages = output.querySelectorAll('.message-sending').length;
2110
+ return { messageCount, queueItems, pendingMessages };
2111
+ }
2112
+ };
2113
+ }
2114
+
2115
+ /**
2116
+ * Show native loading spinner on document element
2117
+ */
2118
+ showLoadingSpinner() {
2119
+ document.documentElement.style.pointerEvents = 'auto';
2120
+ // Show native CSS loading indicator (not removing, just visual cue)
2121
+ const indicator = document.querySelector('[data-model-dl-indicator]');
2122
+ if (indicator && !indicator.classList.contains('visible')) {
2123
+ indicator.classList.add('visible');
2124
+ }
2125
+ }
2126
+
2127
+ /**
2128
+ * Hide native loading spinner
2129
+ */
2130
+ hideLoadingSpinner() {
2131
+ const indicator = document.querySelector('[data-model-dl-indicator]');
2132
+ if (indicator && indicator.classList.contains('visible')) {
2133
+ indicator.classList.remove('visible');
2134
+ }
2135
+ }
2136
+
2137
+ /**
2138
+ * Show welcome screen when no conversation is selected
2139
+ */
2140
+ _showWelcomeScreen() {
2141
+ const outputEl = document.getElementById('output');
2142
+ if (!outputEl) return;
2143
+ // Build agent options from loaded agents list
2144
+ const agents = this.state.agents || [];
2145
+ const agentOptions = agents.map(a =>
2146
+ `<option value="${this.escapeHtml(a.id)}">${this.escapeHtml(a.name.split(/[\s\-]+/)[0])}</option>`
2147
+ ).join('');
2148
+ outputEl.innerHTML = `
2149
+ <div style="display:flex;align-items:center;justify-content:center;height:100%;flex-direction:column;gap:2rem;padding:2rem;">
2150
+ <div style="text-align:center;">
2151
+ <h1 style="margin:0;font-size:2.5rem;color:var(--color-text-primary);">Welcome to AgentGUI</h1>
2152
+ <p style="margin:1rem 0 0 0;font-size:1.1rem;color:var(--color-text-secondary);">Start a new conversation or select one from the sidebar</p>
2153
+ </div>
2154
+ ${agents.length > 0 ? `
2155
+ <div style="display:flex;flex-direction:column;align-items:center;gap:0.75rem;">
2156
+ <label style="font-size:0.85rem;color:var(--color-text-secondary);font-weight:500;">Select Agent</label>
2157
+ <select id="welcomeAgentSelect" style="padding:0.5rem 1rem;border-radius:0.375rem;border:1px solid var(--color-border);background:var(--color-bg-secondary);color:var(--color-text-primary);font-size:1rem;cursor:pointer;">
2158
+ ${agentOptions}
2159
+ </select>
2160
+ </div>
2161
+ ` : ''}
2162
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1rem;max-width:600px;">
2163
+ <div style="padding:1.5rem;border-radius:0.5rem;background:var(--color-bg-secondary);border:1px solid var(--color-border);">
2164
+ <h3 style="margin:0 0 0.5rem 0;color:var(--color-primary);">New Conversation</h3>
2165
+ <p style="margin:0;font-size:0.9rem;color:var(--color-text-secondary);">Create a new chat with any AI agent</p>
2166
+ </div>
2167
+ <div style="padding:1.5rem;border-radius:0.5rem;background:var(--color-bg-secondary);border:1px solid var(--color-border);">
2168
+ <h3 style="margin:0 0 0.5rem 0;color:var(--color-primary);">Available Agents</h3>
2169
+ <p style="margin:0;font-size:0.9rem;color:var(--color-text-secondary);">${agents.length > 0 ? agents.map(a => a.name.split(/[\s\-]+/)[0]).join(', ') : 'Claude Code, Gemini, OpenCode, and more'}</p>
2170
+ </div>
2171
+ </div>
2172
+ </div>
2173
+ `;
2174
+ // Sync welcome agent select with the bottom bar cli selector
2175
+ const welcomeSel = document.getElementById('welcomeAgentSelect');
2176
+ if (welcomeSel) {
2177
+ if (this.ui.cliSelector) welcomeSel.value = this.ui.cliSelector.value;
2178
+ welcomeSel.addEventListener('change', () => {
2179
+ if (this.ui.cliSelector) {
2180
+ this.ui.cliSelector.value = welcomeSel.value;
2181
+ this.ui.cliSelector.dispatchEvent(new Event('change'));
2182
+ }
2183
+ });
2184
+ }
2185
+ }
2186
+
2187
+ _showSkeletonLoading(conversationId) {
2188
+ const outputEl = document.getElementById('output');
2189
+ if (!outputEl) return;
2190
+ const conv = this.state.conversations.find(c => c.id === conversationId);
2191
+ const title = conv?.title || 'Conversation';
2192
+ const wdInfo = conv?.workingDirectory ? `${this.escapeHtml(conv.workingDirectory)}` : '';
2193
+ const timestamp = conv ? new Date(conv.created_at).toLocaleDateString() : '';
2194
+ const metaParts = [timestamp];
2195
+ if (wdInfo) metaParts.push(wdInfo);
2196
+ outputEl.innerHTML = `
2197
+ <div class="conversation-header">
2198
+ <h2>${this.escapeHtml(title)}</h2>
2199
+ <p class="text-secondary">${metaParts.join(' - ')}</p>
2200
+ </div>
2201
+ <div class="conversation-messages">
2202
+ <div class="skeleton-loading">
2203
+ <div class="skeleton-block skeleton-pulse" style="height:3rem;margin-bottom:0.75rem;border-radius:0.5rem;background:var(--color-bg-secondary);"></div>
2204
+ <div class="skeleton-block skeleton-pulse" style="height:6rem;margin-bottom:0.75rem;border-radius:0.5rem;background:var(--color-bg-secondary);"></div>
2205
+ <div class="skeleton-block skeleton-pulse" style="height:2rem;margin-bottom:0.75rem;border-radius:0.5rem;background:var(--color-bg-secondary);"></div>
2206
+ <div class="skeleton-block skeleton-pulse" style="height:5rem;margin-bottom:0.75rem;border-radius:0.5rem;background:var(--color-bg-secondary);"></div>
2207
+ </div>
2208
+ </div>
2209
+ `;
2210
+ }
2211
+
2212
+ async streamToConversation(conversationId, prompt, agentId, model, subAgent) {
2213
+ try {
2214
+ if (this.wsManager.isConnected) {
2215
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
2216
+ }
2217
+
2218
+ let finalPrompt = prompt;
2219
+ const streamBody = { id: conversationId, content: finalPrompt, agentId };
2220
+ if (model) streamBody.model = model;
2221
+ if (subAgent) streamBody.subAgent = subAgent;
2222
+ let result;
2223
+ try {
2224
+ result = await window.wsClient.rpc('msg.stream', streamBody);
2225
+ } catch (e) {
2226
+ if (e.code === 404) {
2227
+ console.warn('Conversation not found, recreating:', conversationId);
2228
+ const conv = this.state.currentConversation;
2229
+ const createBody = { agentId, title: conv?.title || prompt.substring(0, 50), workingDirectory: conv?.workingDirectory || null };
2230
+ if (model) createBody.model = model;
2231
+ if (subAgent) createBody.subAgent = subAgent;
2232
+ const { conversation: newConv } = await window.wsClient.rpc('conv.new', createBody);
2233
+ window.ConversationState?.selectConversation(newConv.id, 'stream_recreate', 1);
2234
+ this.state.currentConversation = newConv;
2235
+ if (window.conversationManager) {
2236
+ window.conversationManager.loadConversations();
2237
+ window.conversationManager.select(newConv.id);
2238
+ }
2239
+ this.updateUrlForConversation(newConv.id);
2240
+ return this.streamToConversation(newConv.id, prompt, agentId, model, subAgent);
2241
+ }
2242
+ throw e;
2243
+ }
2244
+
2245
+ if (result.queued) {
2246
+ this._dbg('Message queued, position:', result.queuePosition);
2247
+ this.enableControls();
2248
+ return;
2249
+ }
2250
+
2251
+ if (result.session && this.wsManager.isConnected) {
2252
+ this.wsManager.subscribeToSession(result.session.id);
2253
+ }
2254
+
2255
+ this._lastSendTime = Date.now();
2256
+ this.emit('execution:started', result);
2257
+ } catch (error) {
2258
+ console.error('Stream execution error:', error);
2259
+ this.showError('Failed to stream execution: ' + error.message);
2260
+ this.enableControls();
2261
+ }
2262
+ }
2263
+
2264
+ _hydrateSessionBlocks(blocksEl, list) {
2265
+ const blockFrag = document.createDocumentFragment();
2266
+ const deferred = [];
2267
+ for (const chunk of list) {
2268
+ if (!chunk.block?.type) continue;
2269
+ const bt = chunk.block.type;
2270
+ if (bt === 'tool_result' || bt === 'tool_status' || bt === 'hook_progress') { deferred.push(chunk); continue; }
2271
+ const el = this.renderer.renderBlock(chunk.block, chunk, blockFrag);
2272
+ if (!el) continue;
2273
+ el.classList.add('block-loaded');
2274
+ blockFrag.appendChild(el);
2275
+ }
2276
+ blocksEl.appendChild(blockFrag);
2277
+ // Build tool-use element index once for O(1) lookups instead of O(n) querySelectorAll per chunk
2278
+ const toolUseIndex = new Map();
2279
+ blocksEl.querySelectorAll('.block-tool-use[data-tool-use-id]').forEach(el => toolUseIndex.set(el.dataset.toolUseId, el));
2280
+ for (const chunk of deferred) {
2281
+ const b = chunk.block;
2282
+ if (b.type === 'tool_result') {
2283
+ const toolUseEl = (b.tool_use_id && toolUseIndex.get(b.tool_use_id))
2284
+ || (blocksEl.lastElementChild?.classList.contains('block-tool-use') ? blocksEl.lastElementChild : null);
2285
+ if (toolUseEl) this.renderer.mergeResultIntoToolUse(toolUseEl, b);
2286
+ } else if (b.type === 'tool_status') {
2287
+ const toolUseEl = b.tool_use_id && toolUseIndex.get(b.tool_use_id);
2288
+ if (toolUseEl) {
2289
+ const isError = b.status === 'failed';
2290
+ const isDone = b.status === 'completed';
2291
+ if (isDone || isError) toolUseEl.classList.add(isError ? 'tool-result-error' : 'tool-result-success');
2292
+ }
2293
+ }
2294
+ }
2295
+ }
2296
+
2297
+ _getLazyObserver() {
2298
+ if (this._lazyObserver) return this._lazyObserver;
2299
+ if (typeof IntersectionObserver === 'undefined') return null;
2300
+ this._lazyObserver = new IntersectionObserver((entries) => {
2301
+ for (const entry of entries) {
2302
+ if (!entry.isIntersecting) continue;
2303
+ const msgDiv = entry.target;
2304
+ const pendingChunks = msgDiv._lazyChunks;
2305
+ if (!pendingChunks) continue;
2306
+ delete msgDiv._lazyChunks;
2307
+ this._lazyObserver.unobserve(msgDiv);
2308
+ const blocksEl = msgDiv.querySelector('.message-blocks');
2309
+ if (blocksEl) this._hydrateSessionBlocks(blocksEl, pendingChunks);
2310
+ }
2311
+ }, { rootMargin: '400px 0px' });
2312
+ return this._lazyObserver;
2313
+ }
2314
+
2315
+ _renderConversationContent(messagesContainer, chunks, userMessages, activeSessionId) {
2316
+ if (!chunks || chunks.length === 0) return;
2317
+ const sessionMap = new Map();
2318
+ for (const chunk of chunks) {
2319
+ if (!sessionMap.has(chunk.sessionId)) sessionMap.set(chunk.sessionId, []);
2320
+ sessionMap.get(chunk.sessionId).push(chunk);
2321
+ }
2322
+
2323
+ const sessionIds = [...sessionMap.keys()];
2324
+ const EAGER_TAIL = 8;
2325
+ const eagerSet = new Set(sessionIds.slice(-EAGER_TAIL));
2326
+ if (activeSessionId) eagerSet.add(activeSessionId);
2327
+ const observer = sessionIds.length > EAGER_TAIL ? this._getLazyObserver() : null;
2328
+
2329
+ const frag = document.createDocumentFragment();
2330
+ let ui = 0;
2331
+ for (const [sid, list] of sessionMap) {
2332
+ const sessionStart = list[0].created_at;
2333
+ while (ui < userMessages.length && userMessages[ui].created_at <= sessionStart) {
2334
+ const m = userMessages[ui++];
2335
+ const uDiv = document.createElement('div');
2336
+ uDiv.className = 'message message-user';
2337
+ uDiv.setAttribute('data-msg-id', m.id);
2338
+ uDiv.innerHTML = `<div class="message-role">User<button class="msg-edit-btn" data-edit-msg="${this.escapeHtml(m.id)}" title="Edit and re-run">&#9998;</button></div>${this.renderMessageContent(m.content)}<div class="message-timestamp">${new Date(m.created_at).toLocaleString()}</div>`;
2339
+ frag.appendChild(uDiv);
2340
+ }
2341
+ const isActive = sid === activeSessionId;
2342
+ const msgDiv = document.createElement('div');
2343
+ msgDiv.className = `message message-assistant${isActive ? ' streaming-message' : ''}`;
2344
+ msgDiv.id = isActive ? `streaming-${sid}` : `message-${sid}`;
2345
+ msgDiv.setAttribute('data-session-id', sid);
2346
+ msgDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks streaming-blocks"></div>';
2347
+ const blocksEl = msgDiv.querySelector('.message-blocks');
2348
+
2349
+ if (observer && !eagerSet.has(sid)) {
2350
+ msgDiv._lazyChunks = list;
2351
+ observer.observe(msgDiv);
2352
+ } else {
2353
+ this._hydrateSessionBlocks(blocksEl, list);
2354
+ }
2355
+
2356
+ if (isActive) {
2357
+ const ind = document.createElement('div');
2358
+ ind.className = 'streaming-indicator';
2359
+ ind.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
2360
+ ind.innerHTML = '<span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span><span class="streaming-indicator-label">Processing...</span>';
2361
+ msgDiv.appendChild(ind);
2362
+ } else {
2363
+ const ts = document.createElement('div');
2364
+ ts.className = 'message-timestamp';
2365
+ ts.textContent = new Date(list[list.length - 1].created_at).toLocaleString();
2366
+ msgDiv.appendChild(ts);
2367
+ }
2368
+ frag.appendChild(msgDiv);
2369
+ }
2370
+ while (ui < userMessages.length) {
2371
+ const m = userMessages[ui++];
2372
+ const uDiv = document.createElement('div');
2373
+ uDiv.className = 'message message-user';
2374
+ uDiv.setAttribute('data-msg-id', m.id);
2375
+ uDiv.innerHTML = `<div class="message-role">User<button class="msg-edit-btn" data-edit-msg="${this.escapeHtml(m.id)}" title="Edit and re-run">&#9998;</button></div>${this.renderMessageContent(m.content)}<div class="message-timestamp">${new Date(m.created_at).toLocaleString()}</div>`;
2376
+ frag.appendChild(uDiv);
2377
+ }
2378
+ messagesContainer.appendChild(frag);
2379
+ }
2380
+
2381
+ renderChunk(chunk) {
2382
+ try { return this._renderChunkInner(chunk); } catch (e) { console.error('[render-error] chunk:', e); }
2383
+ }
2384
+ _renderChunkInner(chunk) {
2385
+ if (!chunk || !chunk.block) return;
2386
+ const seq = chunk.sequence;
2387
+ if (seq !== undefined) {
2388
+ const seen = (this._renderedSeqs = this._renderedSeqs || {})[chunk.sessionId] || (this._renderedSeqs[chunk.sessionId] = new Set());
2389
+ if (seen.has(seq)) return;
2390
+ seen.add(seq);
2391
+ }
2392
+ const streamingEl = document.getElementById(`streaming-${chunk.sessionId}`);
2393
+ if (!streamingEl) return;
2394
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
2395
+ if (!blocksEl) return;
2396
+ if (chunk.block.type === 'tool_result') {
2397
+ const matchById = chunk.block.tool_use_id && blocksEl.querySelector(`.block-tool-use[data-tool-use-id="${chunk.block.tool_use_id}"]`);
2398
+ const lastEl = blocksEl.lastElementChild;
2399
+ const toolUseEl = matchById || (lastEl?.classList?.contains('block-tool-use') ? lastEl : null);
2400
+ if (toolUseEl) {
2401
+ this.renderer.mergeResultIntoToolUse(toolUseEl, chunk.block);
2402
+ this.scrollToBottom();
2403
+ return;
2404
+ }
2405
+ }
2406
+ if (chunk.block.type === 'tool_status' || chunk.block.type === 'hook_progress') return;
2407
+ const element = this.renderer.renderBlock(chunk.block, chunk, blocksEl);
2408
+ if (!element) { this.scrollToBottom(); return; }
2409
+ blocksEl.appendChild(element);
2410
+ this.scrollToBottom();
2411
+ }
2412
+
2413
+ /**
2414
+ * Load agents
2415
+ */
2416
+ async loadAgents() {
2417
+ return this._dedupedFetch('loadAgents', async () => {
2418
+ try {
2419
+ const { agents } = await window.wsClient.rpc('agent.ls');
2420
+ this.state.agents = agents;
2421
+
2422
+ const displayAgents = agents;
2423
+
2424
+ if (this.ui.cliSelector) {
2425
+ if (displayAgents.length > 0) {
2426
+ this.ui.cliSelector.innerHTML = displayAgents
2427
+ .map(a => `<option value="${a.id}">${a.name.split(/[\s\-]+/)[0]}</option>`)
2428
+ .join('');
2429
+ this.ui.cliSelector.style.display = 'inline-block';
2430
+ } else {
2431
+ this.ui.cliSelector.innerHTML = '';
2432
+ this.ui.cliSelector.style.display = 'none';
2433
+ }
2434
+ }
2435
+
2436
+ window.dispatchEvent(new CustomEvent('agents-loaded', { detail: { agents } }));
2437
+ window.discoveredAgents = agents;
2438
+ if (typeof populateWelcomeAgents === 'function') populateWelcomeAgents();
2439
+ if (typeof updateWelcomeScreen === 'function') updateWelcomeScreen();
2440
+
2441
+ if (displayAgents.length > 0 && !this._agentLocked) {
2442
+ const firstId = displayAgents[0].id;
2443
+ await this.loadSubAgentsForCli(firstId);
2444
+ this.loadModelsForAgent(this.getEffectiveAgentId());
2445
+ }
2446
+ return agents;
2447
+ } catch (error) {
2448
+ console.error('Failed to load agents:', error);
2449
+ return [];
2450
+ }
2451
+ });
2452
+ }
2453
+
2454
+ async loadSubAgentsForCli(cliAgentId) {
2455
+ if (this.ui.agentSelector) {
2456
+ this.ui.agentSelector.innerHTML = '';
2457
+ this.ui.agentSelector.style.display = 'none';
2458
+ }
2459
+ try {
2460
+ const { subAgents } = await window.wsClient.rpc('agent.subagents', { id: cliAgentId });
2461
+ if (subAgents && subAgents.length > 0 && this.ui.agentSelector) {
2462
+ this.ui.agentSelector.innerHTML = subAgents
2463
+ .map(a => `<option value="${a.id}">${a.name.split(/[\s\-]+/)[0]}</option>`)
2464
+ .join('');
2465
+ this.ui.agentSelector.style.display = 'inline-block';
2466
+ this._dbg(`[Agent Selector] Loaded ${subAgents.length} sub-agents for ${cliAgentId}`);
2467
+ // Auto-select first sub-agent and load its models
2468
+ const firstSubAgentId = subAgents[0].id;
2469
+ this.ui.agentSelector.value = firstSubAgentId;
2470
+ this.loadModelsForAgent(cliAgentId); // models keyed to parent agent
2471
+ } else {
2472
+ this._dbg(`[Agent Selector] No sub-agents found for ${cliAgentId}`);
2473
+ // Load models for the CLI agent itself (fallback for agents without sub-agents)
2474
+ const cliToAcpMap = {
2475
+ 'cli-opencode': 'opencode',
2476
+ 'cli-gemini': 'gemini',
2477
+ 'cli-kilo': 'kilo',
2478
+ 'cli-codex': 'codex'
2479
+ };
2480
+ const acpAgentId = cliToAcpMap[cliAgentId] || cliAgentId;
2481
+ this.loadModelsForAgent(acpAgentId);
2482
+ }
2483
+ } catch (err) {
2484
+ // No sub-agents available for this CLI tool — keep hidden
2485
+ console.warn(`[Agent Selector] Failed to load sub-agents for ${cliAgentId}:`, err.message);
2486
+ // Fallback: load models for the corresponding ACP agent
2487
+ const cliToAcpMap = {
2488
+ 'cli-opencode': 'opencode',
2489
+ 'cli-gemini': 'gemini',
2490
+ 'cli-kilo': 'kilo',
2491
+ 'cli-codex': 'codex'
2492
+ };
2493
+ const acpAgentId = cliToAcpMap[cliAgentId] || cliAgentId;
2494
+ this.loadModelsForAgent(acpAgentId);
2495
+ }
2496
+ }
2497
+
2498
+ async checkSpeechStatus() {
2499
+ try {
2500
+ const status = await window.wsClient.rpc('speech.status');
2501
+ if (status.modelsComplete) {
2502
+ this._modelDownloadProgress = { done: true, complete: true };
2503
+ this._modelDownloadInProgress = false;
2504
+ } else if (status.modelsDownloading) {
2505
+ this._modelDownloadProgress = status.modelsProgress || { downloading: true };
2506
+ this._modelDownloadInProgress = true;
2507
+ } else {
2508
+ this._modelDownloadProgress = { done: false };
2509
+ this._modelDownloadInProgress = false;
2510
+ }
2511
+ } catch (error) {
2512
+ console.error('Failed to check speech status:', error);
2513
+ this._modelDownloadProgress = { done: false };
2514
+ this._modelDownloadInProgress = false;
2515
+ }
2516
+ }
2517
+
2518
+ async loadModelsForAgent(agentId) {
2519
+ if (!agentId || !this.ui.modelSelector) return;
2520
+ const cached = this._modelCache.get(agentId);
2521
+ if (cached) {
2522
+ this._populateModelSelector(cached);
2523
+ return;
2524
+ }
2525
+ try {
2526
+ const { models } = await window.wsClient.rpc('agent.models', { id: agentId });
2527
+ this._modelCache.set(agentId, models);
2528
+ this._populateModelSelector(models);
2529
+ } catch (error) {
2530
+ console.error('Failed to load models:', error);
2531
+ this._populateModelSelector([]);
2532
+ }
2533
+ }
2534
+
2535
+ _populateModelSelector(models) {
2536
+ if (!this.ui.modelSelector) return;
2537
+ if (!models || models.length === 0) {
2538
+ this.ui.modelSelector.innerHTML = '';
2539
+ this.ui.modelSelector.setAttribute('data-empty', 'true');
2540
+ return;
2541
+ }
2542
+ this.ui.modelSelector.removeAttribute('data-empty');
2543
+ this.ui.modelSelector.innerHTML = models
2544
+ .map(m => `<option value="${m.id}">${this.escapeHtml(m.label)}</option>`)
2545
+ .join('');
2546
+ }
2547
+
2548
+ lockAgentAndModel(agentId, model) {
2549
+ this._agentLocked = true;
2550
+ if (this.ui.cliSelector) {
2551
+ this.ui.cliSelector.disabled = true;
2552
+ }
2553
+
2554
+ this.loadModelsForAgent(agentId).then(() => {
2555
+ if (this.ui.modelSelector && model) {
2556
+ this.ui.modelSelector.value = model;
2557
+ }
2558
+ });
2559
+ }
2560
+
2561
+ unlockAgentAndModel() {
2562
+ this._agentLocked = false;
2563
+ if (this.ui.cliSelector) {
2564
+ this.ui.cliSelector.disabled = false;
2565
+ if (this.ui.cliSelector.options.length > 0) {
2566
+ this.ui.cliSelector.style.display = 'inline-block';
2567
+ }
2568
+ }
2569
+ if (this.ui.modelSelector) {
2570
+ this.ui.modelSelector.disabled = false;
2571
+ }
2572
+ }
2573
+
2574
+ /**
2575
+ * Apply agent and model selection based on conversation state
2576
+ * Consolidates duplicate logic for cached and fresh conversation loads
2577
+ */
2578
+ applyAgentAndModelSelection(conversation, hasActivity) {
2579
+ const agentId = conversation.agentId || conversation.agentType || null;
2580
+ const model = conversation.model || null;
2581
+ const subAgent = conversation.subAgent || null;
2582
+
2583
+ if (hasActivity) {
2584
+ this._setCliSelectorToAgent(agentId);
2585
+ this.lockAgentAndModel(agentId, model);
2586
+ } else {
2587
+ this.unlockAgentAndModel();
2588
+ this._setCliSelectorToAgent(agentId);
2589
+
2590
+ const effectiveAgentId = agentId || this.getEffectiveAgentId();
2591
+ this.loadSubAgentsForCli(effectiveAgentId).then(() => {
2592
+ if (subAgent && this.ui.agentSelector) {
2593
+ this.ui.agentSelector.value = subAgent;
2594
+ }
2595
+ });
2596
+ this.loadModelsForAgent(effectiveAgentId).then(() => {
2597
+ if (model && this.ui.modelSelector) {
2598
+ this.ui.modelSelector.value = model;
2599
+ }
2600
+ });
2601
+ }
2602
+ }
2603
+
2604
+ _setCliSelectorToAgent(agentId) {
2605
+ if (this.ui.cliSelector) {
2606
+ this.ui.cliSelector.value = agentId;
2607
+ if (!this.ui.cliSelector.value) {
2608
+ this.ui.cliSelector.selectedIndex = 0;
2609
+ }
2610
+ }
2611
+ }
2612
+
2613
+ /**
2614
+ * Load conversations
2615
+ */
2616
+ async loadConversations() {
2617
+ // Return cached conversations if still fresh
2618
+ const now = Date.now();
2619
+ if (this.conversationListCache.data.length > 0 &&
2620
+ (now - this.conversationListCache.timestamp) < this.conversationListCache.ttl) {
2621
+ this.state.conversations = this.conversationListCache.data;
2622
+ return this.conversationListCache.data;
2623
+ }
2624
+
2625
+ return this._dedupedFetch('loadConversations', async () => {
2626
+ try {
2627
+ const { conversations } = await window.wsClient.rpc('conv.ls');
2628
+ this.state.conversations = conversations;
2629
+ // Update cache
2630
+ this.conversationListCache.data = conversations;
2631
+ this.conversationListCache.timestamp = Date.now();
2632
+ return conversations;
2633
+ } catch (error) {
2634
+ console.error('Failed to load conversations:', error);
2635
+ return [];
2636
+ }
2637
+ });
2638
+ }
2639
+
2640
+ /**
2641
+ * Update connection status UI
2642
+ */
2643
+ updateConnectionStatus(status) {
2644
+ if (this.ui.statusIndicator) {
2645
+ this.ui.statusIndicator.dataset.status = status;
2646
+ this.ui.statusIndicator.textContent = status.charAt(0).toUpperCase() + status.slice(1);
2647
+ }
2648
+ if (status === 'disconnected' || status === 'reconnecting') {
2649
+ this._updateConnectionIndicator(status);
2650
+ } else if (status === 'connected') {
2651
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
2652
+ }
2653
+ }
2654
+
2655
+ _updateConnectionIndicator(quality) {
2656
+ if (this._indicatorDebounce && !this._modelDownloadInProgress) return;
2657
+ this._indicatorDebounce = true;
2658
+ setTimeout(() => { this._indicatorDebounce = false; }, 1000);
2659
+
2660
+ let indicator = document.getElementById('connection-indicator');
2661
+ if (!indicator) {
2662
+ indicator = document.createElement('div');
2663
+ indicator.id = 'connection-indicator';
2664
+ indicator.className = 'connection-indicator';
2665
+ indicator.innerHTML = '<span class="connection-dot"></span><span class="connection-label"></span>';
2666
+ indicator.addEventListener('click', () => this._toggleConnectionTooltip());
2667
+ const header = document.querySelector('.header-right') || document.querySelector('.app-header');
2668
+ if (header) {
2669
+ header.style.position = 'relative';
2670
+ header.appendChild(indicator);
2671
+ }
2672
+ }
2673
+
2674
+ const dot = indicator.querySelector('.connection-dot');
2675
+ const label = indicator.querySelector('.connection-label');
2676
+ if (!dot || !label) return;
2677
+
2678
+ // Check if model download is in progress
2679
+ if (this._modelDownloadInProgress) {
2680
+ dot.className = 'connection-dot downloading';
2681
+ const progress = this._modelDownloadProgress;
2682
+ if (progress && progress.totalBytes > 0) {
2683
+ const pct = Math.round((progress.totalDownloaded / progress.totalBytes) * 100);
2684
+ label.textContent = `Models ${pct}%`;
2685
+ } else if (progress && progress.downloading) {
2686
+ label.textContent = 'Downloading...';
2687
+ } else {
2688
+ label.textContent = 'Loading models...';
2689
+ }
2690
+ return;
2691
+ }
2692
+
2693
+ dot.className = 'connection-dot';
2694
+ if (quality === 'disconnected' || quality === 'reconnecting') {
2695
+ dot.classList.add(quality);
2696
+ label.textContent = quality === 'reconnecting' ? 'Reconnecting...' : 'Disconnected';
2697
+ } else {
2698
+ dot.classList.add(quality);
2699
+ const latency = this.wsManager?.latency;
2700
+ label.textContent = latency?.avg > 0 ? Math.round(latency.avg) + 'ms' : '';
2701
+ }
2702
+ }
2703
+
2704
+ _handleModelDownloadProgress(progress) {
2705
+ this._modelDownloadProgress = progress;
2706
+ if (progress.status === 'failed' || progress.error) {
2707
+ this._modelDownloadInProgress = false;
2708
+ console.error('[Models] Download error:', progress.error || progress.status);
2709
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
2710
+ return;
2711
+ }
2712
+ if (progress.done || progress.status === 'completed') {
2713
+ this._modelDownloadInProgress = false;
2714
+ this._dbg('[Models] Download complete');
2715
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
2716
+ return;
2717
+ }
2718
+ if (progress.started || progress.downloading || progress.status === 'downloading' || progress.status === 'connecting') {
2719
+ this._modelDownloadInProgress = true;
2720
+ this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
2721
+ }
2722
+ }
2723
+
2724
+ _handleTTSSetupProgress(data) {
2725
+ if (data.step && data.status) {
2726
+ this._dbg('[TTS Setup]', data.step, ':', data.status, data.message || '');
2727
+ }
2728
+ }
2729
+
2730
+ _toggleConnectionTooltip() {
2731
+ let tooltip = document.getElementById('connection-tooltip');
2732
+ if (tooltip) { tooltip.remove(); return; }
2733
+
2734
+ const indicator = document.getElementById('connection-indicator');
2735
+ if (!indicator) return;
2736
+
2737
+ tooltip = document.createElement('div');
2738
+ tooltip.id = 'connection-tooltip';
2739
+ tooltip.className = 'connection-tooltip';
2740
+
2741
+ const latency = this.wsManager?.latency || {};
2742
+ const stats = this.wsManager?.stats || {};
2743
+ const state = this.wsManager?.connectionState || 'unknown';
2744
+
2745
+ tooltip.innerHTML = [
2746
+ `<div>State: ${state}</div>`,
2747
+ `<div>Latency: ${Math.round(latency.avg || 0)}ms</div>`,
2748
+ `<div>Predicted: ${Math.round(latency.predicted || 0)}ms (Kalman)</div>`,
2749
+ `<div>Trend: ${latency.trend || 'unknown'}</div>`,
2750
+ `<div>Jitter: ${Math.round(latency.jitter || 0)}ms</div>`,
2751
+ `<div>Quality: ${latency.quality || 'unknown'}</div>`,
2752
+ `<div>Reconnects: ${stats.totalReconnects || 0}</div>`,
2753
+ `<div>Uptime: ${stats.lastConnectedTime ? Math.round((Date.now() - stats.lastConnectedTime) / 1000) + 's' : 'N/A'}</div>`
2754
+ ].join('');
2755
+
2756
+ indicator.appendChild(tooltip);
2757
+ setTimeout(() => { if (tooltip.parentNode) tooltip.remove(); }, 5000);
2758
+ }
2759
+
2760
+ /**
2761
+ * Update metrics display
2762
+ */
2763
+ updateMetrics(metrics) {
2764
+ const metricsDisplay = document.querySelector('[data-metrics]');
2765
+ if (metricsDisplay && metrics) {
2766
+ metricsDisplay.textContent = `Batches: ${metrics.totalBatches} | Events: ${metrics.totalEvents} | Avg render: ${metrics.avgRenderTime.toFixed(2)}ms`;
2767
+ }
2768
+ }
2769
+
2770
+ /**
2771
+ * Disable UI controls during execution - prevents double-sends
2772
+ */
2773
+ disableControls() {
2774
+ if (this.ui.sendButton) this.ui.sendButton.disabled = true;
2775
+ if (window.promptMachineAPI) window.promptMachineAPI.send({ type: 'DISABLED' });
2776
+ }
2777
+
2778
+ enableControls() {
2779
+ if (this.ui.sendButton) {
2780
+ this.ui.sendButton.disabled = !this.wsManager?.isConnected;
2781
+ }
2782
+ if (window.promptMachineAPI) window.promptMachineAPI.send({ type: 'READY' });
2783
+ this.updateBusyPromptArea(this.state.currentConversation?.id);
2784
+ }
2785
+
2786
+ /**
2787
+ * Toggle theme
2788
+ */
2789
+ toggleTheme() {
2790
+ const isDark = document.documentElement.classList.toggle('dark');
2791
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
2792
+ }
2793
+
2794
+ /**
2795
+ * Create a new empty conversation
2796
+ */
2797
+ async createNewConversation(workingDirectory, title) {
2798
+ try {
2799
+ const agentId = this.getEffectiveAgentId();
2800
+ const model = this.ui.modelSelector?.value || null;
2801
+ const convTitle = title || 'New Conversation';
2802
+ const body = { agentId, title: convTitle };
2803
+ if (workingDirectory) body.workingDirectory = workingDirectory;
2804
+ if (model) body.model = model;
2805
+
2806
+ const { conversation } = await window.wsClient.rpc('conv.new', body);
2807
+
2808
+ await this.loadConversations();
2809
+
2810
+ if (window.conversationManager) {
2811
+ await window.conversationManager.loadConversations();
2812
+ window.conversationManager.select(conversation.id);
2813
+ }
2814
+
2815
+ if (this.ui.messageInput) {
2816
+ this.ui.messageInput.value = '';
2817
+ this.ui.messageInput.focus();
2818
+ }
2819
+ } catch (error) {
2820
+ console.error('Failed to create new conversation:', error);
2821
+ this.showError(`Failed to create conversation: ${error.message}`);
2822
+ }
2823
+ }
2824
+
2825
+ cacheCurrentConversation() {
2826
+ const convId = this.state.currentConversation?.id;
2827
+ if (!convId) return;
2828
+ const outputEl = document.getElementById('output');
2829
+ if (!outputEl || !outputEl.firstChild) return;
2830
+ if (this._convIsStreaming(convId)) return;
2831
+
2832
+ this.saveScrollPosition(convId);
2833
+ const clone = outputEl.cloneNode(true);
2834
+ this.conversationCache.set(convId, {
2835
+ dom: clone,
2836
+ conversation: this.state.currentConversation,
2837
+ timestamp: Date.now()
2838
+ });
2839
+
2840
+ if (this.conversationCache.size > this.MAX_CACHE_SIZE) {
2841
+ const oldest = this.conversationCache.keys().next().value;
2842
+ this.conversationCache.delete(oldest);
2843
+ }
2844
+ }
2845
+
2846
+ invalidateCache(conversationId) {
2847
+ this.conversationCache.delete(conversationId);
2848
+ }
2849
+
2850
+ /**
2851
+ * PHASE 2: Create a new load request with lifetime tracking
2852
+ * Assigns unique requestId, tracks in _loadInProgress, returns abort signal
2853
+ * Automatically cancels previous loads to this conversation
2854
+ */
2855
+ _makeLoadRequest(conversationId) {
2856
+ const requestId = ++this._currentRequestId;
2857
+ const abortController = new AbortController();
2858
+
2859
+ // Cancel previous request to this conversation
2860
+ if (this._loadInProgress[conversationId]) {
2861
+ const prevReq = this._loadInProgress[conversationId];
2862
+ try {
2863
+ prevReq.abortController.abort();
2864
+ } catch (e) {}
2865
+ }
2866
+
2867
+ this._loadInProgress[conversationId] = {
2868
+ requestId,
2869
+ abortController,
2870
+ timestamp: Date.now(),
2871
+ prevConversationId: this.state.currentConversation?.id
2872
+ };
2873
+
2874
+ return { requestId, abortController: abortController.signal };
2875
+ }
2876
+
2877
+ /**
2878
+ * PHASE 2: Verify request is still current before rendering
2879
+ * Returns true if requestId matches current load for this conversation
2880
+ * Returns false if newer request arrived, or request was cancelled
2881
+ */
2882
+ _verifyRequestId(conversationId, requestId) {
2883
+ const current = this._loadInProgress[conversationId];
2884
+ if (!current) return false;
2885
+ if (current.requestId !== requestId) return false;
2886
+ return true;
2887
+ }
2888
+
2889
+ /**
2890
+ * PHASE 2: Complete/cleanup a load request
2891
+ */
2892
+ _completeLoadRequest(conversationId, requestId) {
2893
+ const req = this._loadInProgress[conversationId];
2894
+ if (req && req.requestId === requestId) {
2895
+ delete this._loadInProgress[conversationId];
2896
+ }
2897
+ }
2898
+
2899
+ async loadConversationMessages(conversationId) {
2900
+ performance.mark(`conv-load-start:${conversationId}`);
2901
+ try {
2902
+ if (this._previousConvAbort) {
2903
+ this._previousConvAbort.abort();
2904
+ }
2905
+ this._previousConvAbort = new AbortController();
2906
+ const convSignal = this._previousConvAbort.signal;
2907
+
2908
+ const prevConversationId = this.state.currentConversation?.id;
2909
+ const availableFallback = this.state.conversations?.find(c => c.id !== conversationId) || null;
2910
+ this.cacheCurrentConversation();
2911
+
2912
+ this.removeScrollUpDetection();
2913
+ if (this.renderer.resetScrollState) this.renderer.resetScrollState();
2914
+ this._userScrolledUp = false;
2915
+ this._removeNewContentPill();
2916
+
2917
+ if (this.ui.messageInput) {
2918
+ this.ui.messageInput.value = '';
2919
+ this.ui.messageInput.style.height = 'auto';
2920
+ // Note: prompt disabled state will be set immutably based on shouldResumeStreaming
2921
+ // after conversation data loads, don't set here
2922
+ }
2923
+
2924
+ if (this.ui.stopButton) this.ui.stopButton.classList.remove('visible');
2925
+ if (this.ui.injectButton) this.ui.injectButton.classList.remove('visible');
2926
+ if (this.ui.queueButton) this.ui.queueButton.classList.remove('visible');
2927
+ if (this.ui.sendButton) this.ui.sendButton.style.display = '';
2928
+
2929
+ var prevId = this.state.currentConversation?.id;
2930
+ if (prevId && prevId !== conversationId) {
2931
+ if (this.wsManager.isConnected && !this._convIsStreaming(prevId)) {
2932
+ this.wsManager.sendMessage({ type: 'unsubscribe', conversationId: prevId });
2933
+ }
2934
+ this.state.currentSession = null;
2935
+ }
2936
+
2937
+ const cachedConv = this.state.conversations.find(c => c.id === conversationId);
2938
+ if (cachedConv && this.state.currentConversation?.id !== conversationId) {
2939
+ window.ConversationState?.selectConversation(conversationId, 'cache_load', 1);
2940
+ this.state.currentConversation = cachedConv;
2941
+ }
2942
+
2943
+ this.updateUrlForConversation(conversationId);
2944
+ if (this.wsManager.isConnected) {
2945
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
2946
+ }
2947
+
2948
+ const cached = this.conversationCache.get(conversationId);
2949
+ if (cached) { this.conversationCache.delete(conversationId); this.conversationCache.set(conversationId, cached); }
2950
+ if (cached && (Date.now() - cached.timestamp) < 600000) {
2951
+ const outputEl = document.getElementById('output');
2952
+ if (outputEl) {
2953
+ const children = [];
2954
+ while (cached.dom.firstChild) children.push(cached.dom.firstChild);
2955
+ outputEl.replaceChildren(...children);
2956
+ window.ConversationState?.selectConversation(conversationId, 'dom_cache_load', 1);
2957
+ this.state.currentConversation = cached.conversation;
2958
+ window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation: cached.conversation } }));
2959
+ const cachedHasActivity = cached.conversation.messageCount > 0 || this._convIsStreaming(conversationId);
2960
+ this.applyAgentAndModelSelection(cached.conversation, cachedHasActivity);
2961
+ this.conversationCache.delete(conversationId);
2962
+ if (this._lazyObserver) { this._lazyObserver.disconnect(); this._lazyObserver = null; }
2963
+ this._flushBgCache && this._flushBgCache(conversationId);
2964
+ this.setupScrollUpDetection && this.setupScrollUpDetection();
2965
+ this.syncPromptState(conversationId);
2966
+ this.restoreScrollPosition(conversationId);
2967
+ return;
2968
+ }
2969
+ }
2970
+
2971
+ this.conversationCache.delete(conversationId);
2972
+
2973
+ if (this._lazyObserver) { this._lazyObserver.disconnect(); this._lazyObserver = null; }
2974
+ this._showSkeletonLoading(conversationId);
2975
+ // Yield to let skeleton paint before blocking on RPC
2976
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
2977
+
2978
+ let fullData;
2979
+ try {
2980
+ fullData = await window.wsClient.rpc('conv.full', { id: conversationId });
2981
+ performance.mark(`conv-data-received:${conversationId}`);
2982
+ if (convSignal.aborted) return;
2983
+ } catch (wsErr) {
2984
+ if (wsErr.code === 404) {
2985
+ console.warn('Conversation no longer exists:', conversationId);
2986
+ window.ConversationState?.clear('conversation_not_found');
2987
+ this.state.currentConversation = null;
2988
+ if (window.conversationManager) window.conversationManager.loadConversations();
2989
+ const fallbackConv = prevConversationId ? prevConversationId : availableFallback?.id;
2990
+ if (fallbackConv && fallbackConv !== conversationId) {
2991
+ this._dbg('Resuming from fallback conversation:', fallbackConv);
2992
+ this.showError('Conversation not found. Resuming previous conversation.');
2993
+ await this.loadConversationMessages(fallbackConv);
2994
+ } else {
2995
+ const outputEl = document.getElementById('output');
2996
+ if (outputEl) outputEl.innerHTML = '<p class="text-secondary" style="padding:2rem;text-align:center">Conversation not found. It may have been lost during a server restart.</p>';
2997
+ this.enableControls();
2998
+ }
2999
+ return;
3000
+ }
3001
+ try {
3002
+ const base = window.__BASE_URL || '';
3003
+ const [convRes, chunksRes, msgsRes] = await Promise.all([
3004
+ fetch(`${base}/api/conversations/${conversationId}`),
3005
+ fetch(`${base}/api/conversations/${conversationId}/chunks`),
3006
+ fetch(`${base}/api/conversations/${conversationId}/messages?limit=500`)
3007
+ ]);
3008
+ const convData = await convRes.json();
3009
+ const chunksData = await chunksRes.json();
3010
+ const msgsData = await msgsRes.json();
3011
+ fullData = {
3012
+ conversation: convData.conversation,
3013
+ isActivelyStreaming: false,
3014
+ latestSession: null,
3015
+ chunks: chunksData.chunks || [],
3016
+ totalChunks: chunksData.totalChunks || (chunksData.chunks || []).length,
3017
+ messages: msgsData.messages || []
3018
+ };
3019
+ if (convSignal.aborted) return;
3020
+ } catch (restErr) {
3021
+ throw wsErr;
3022
+ }
3023
+ }
3024
+ if (convSignal.aborted) return;
3025
+ const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, totalChunks, messages: allMessages } = fullData;
3026
+
3027
+ window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
3028
+ this.state.currentConversation = conversation;
3029
+ window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation } }));
3030
+ const hasActivity = (allMessages && allMessages.length > 0) || isActivelyStreaming || latestSession || this._convIsStreaming(conversationId);
3031
+ this.applyAgentAndModelSelection(conversation, hasActivity);
3032
+
3033
+ // Parse chunk data and fetch queue in parallel
3034
+ const queuePromise = window.wsClient.rpc('q.ls', { id: conversationId }).catch(() => ({ queue: [] }));
3035
+ const chunks = (rawChunks || []).map(chunk => ({
3036
+ ...chunk,
3037
+ block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
3038
+ }));
3039
+
3040
+ const { queue: queueResult } = await queuePromise;
3041
+ const queuedMessageIds = new Set((queueResult || []).map(q => q.messageId));
3042
+ const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
3043
+ const hasMoreChunks = totalChunks && chunks.length < totalChunks;
3044
+
3045
+ const clientKnowsStreaming = this._convIsStreaming(conversationId);
3046
+ const shouldResumeStreaming = latestSession &&
3047
+ (latestSession.status === 'active' || latestSession.status === 'pending');
3048
+
3049
+ if (shouldResumeStreaming) {
3050
+ this._setConvStreaming(conversationId, true, latestSession?.id, null);
3051
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
3052
+ } else {
3053
+ this._setConvStreaming(conversationId, false);
3054
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
3055
+ }
3056
+
3057
+ if (this.ui.messageInput) {
3058
+ this.ui.messageInput.disabled = false;
3059
+ }
3060
+
3061
+ const outputEl = document.getElementById('output');
3062
+ if (outputEl) {
3063
+ const wdInfo = conversation.workingDirectory ? `${this.escapeHtml(conversation.workingDirectory)}` : '';
3064
+ const timestamp = new Date(conversation.created_at).toLocaleDateString();
3065
+ const metaParts = [timestamp];
3066
+ if (wdInfo) metaParts.push(wdInfo);
3067
+ outputEl.innerHTML = `
3068
+ <div class="conversation-header">
3069
+ <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
3070
+ <p class="text-secondary">${metaParts.join(' - ')}</p>
3071
+ </div>
3072
+ <div class="conversation-messages"></div>
3073
+ `;
3074
+
3075
+ const messagesEl = outputEl.querySelector('.conversation-messages');
3076
+
3077
+ if (hasMoreChunks) {
3078
+ const loadMoreBtn = document.createElement('button');
3079
+ loadMoreBtn.className = 'btn btn-secondary';
3080
+ loadMoreBtn.style.cssText = 'width:100%;margin-bottom:1rem;padding:0.5rem;font-size:0.8rem;';
3081
+ loadMoreBtn.textContent = `Load earlier messages (${totalChunks - chunks.length} more chunks)`;
3082
+ loadMoreBtn.addEventListener('click', async () => {
3083
+ loadMoreBtn.disabled = true;
3084
+ loadMoreBtn.textContent = 'Loading...';
3085
+ try {
3086
+ try {
3087
+ await window.wsClient.rpc('conv.full', { id: conversationId, allChunks: true });
3088
+ } catch (wsErr) {}
3089
+ this.invalidateCache(conversationId);
3090
+ await this.loadConversationMessages(conversationId);
3091
+ } catch (e) {
3092
+ loadMoreBtn.textContent = 'Failed to load. Try again.';
3093
+ loadMoreBtn.disabled = false;
3094
+ }
3095
+ });
3096
+ messagesEl.appendChild(loadMoreBtn);
3097
+ }
3098
+
3099
+ if (chunks.length > 0) {
3100
+ const activeSessionId = (shouldResumeStreaming && latestSession) ? latestSession.id : null;
3101
+ performance.mark(`conv-render-start:${conversationId}`);
3102
+ // Yield before heavy render to let header paint
3103
+ await new Promise(r => requestAnimationFrame(r));
3104
+ if (!convSignal.aborted) this._renderConversationContent(messagesEl, chunks, userMessages, activeSessionId);
3105
+ performance.mark(`conv-render-complete:${conversationId}`);
3106
+ performance.measure(`conv-render:${conversationId}`, `conv-render-start:${conversationId}`, `conv-render-complete:${conversationId}`);
3107
+ performance.measure(`conv-data-fetch:${conversationId}`, `conv-load-start:${conversationId}`, `conv-data-received:${conversationId}`);
3108
+ } else {
3109
+ if (!convSignal.aborted) messagesEl.appendChild(this.renderMessagesFragment(allMessages || []));
3110
+ }
3111
+
3112
+ if (!convSignal.aborted && shouldResumeStreaming && latestSession && chunks.length === 0) {
3113
+ const streamDiv = document.createElement('div');
3114
+ streamDiv.id = `streaming-${latestSession.id}`;
3115
+ streamDiv.className = 'streaming-message';
3116
+ const indicatorDiv = document.createElement('div');
3117
+ indicatorDiv.className = 'streaming-indicator';
3118
+ indicatorDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
3119
+ indicatorDiv.innerHTML = `
3120
+ <span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span>
3121
+ <span class="streaming-indicator-label">Agent is starting...</span>
3122
+ `;
3123
+ streamDiv.appendChild(indicatorDiv);
3124
+ messagesEl.appendChild(streamDiv);
3125
+ }
3126
+
3127
+ if (shouldResumeStreaming && latestSession) {
3128
+ this._setConvStreaming(conversationId, true, latestSession.id, null);
3129
+ this.state.currentSession = {
3130
+ id: latestSession.id,
3131
+ conversationId: conversationId,
3132
+ agentId: conversation.agentType || null,
3133
+ startTime: latestSession.created_at
3134
+ };
3135
+
3136
+ if (this.wsManager.isConnected) {
3137
+ this.wsManager.subscribeToSession(latestSession.id);
3138
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
3139
+ }
3140
+
3141
+ this.updateUrlForConversation(conversationId, latestSession.id);
3142
+
3143
+ // Flush any blocks accumulated in the background cache while this conv wasn't active
3144
+ this._flushBgCache(conversationId, latestSession.id);
3145
+
3146
+ // IMMUTABLE: Prompt remains enabled - syncPromptState will set correct state
3147
+ this.syncPromptState(conversationId);
3148
+ } else {
3149
+ this.syncPromptState(conversationId);
3150
+ }
3151
+
3152
+ // Re-enable send button after skeleton loading completes
3153
+ if (this.ui.sendButton) {
3154
+ this.ui.sendButton.disabled = false;
3155
+ }
3156
+
3157
+ this.restoreScrollPosition(conversationId);
3158
+ this.setupScrollUpDetection(conversationId);
3159
+
3160
+ // Fetch and display queue items so queued messages show in yellow blocks, not as user messages
3161
+ this.fetchAndRenderQueue(conversationId);
3162
+
3163
+ }
3164
+ } catch (error) {
3165
+ if (error.name === 'AbortError') return;
3166
+ console.error('Failed to load conversation messages:', error);
3167
+ // Resume from last successful conversation if available, or fall back to any available conversation
3168
+ const fallbackConv = prevConversationId ? prevConversationId : availableFallback?.id;
3169
+ if (fallbackConv && fallbackConv !== conversationId) {
3170
+ this._dbg('Resuming from fallback conversation due to error:', fallbackConv);
3171
+ this.showError('Failed to load conversation. Resuming previous conversation.');
3172
+ try {
3173
+ await this.loadConversationMessages(fallbackConv);
3174
+ } catch (fallbackError) {
3175
+ console.error('Failed to resume fallback conversation:', fallbackError);
3176
+ this.showError('Failed to load conversation: ' + error.message);
3177
+ }
3178
+ } else {
3179
+ this.showError('Failed to load conversation: ' + error.message);
3180
+ }
3181
+ }
3182
+ }
3183
+
3184
+ syncPromptState(conversationId) {
3185
+ const conversation = this.state.currentConversation;
3186
+ if (!conversation || conversation.id !== conversationId) return;
3187
+
3188
+ if (this.ui.messageInput) {
3189
+ this.ui.messageInput.disabled = false;
3190
+ }
3191
+
3192
+ this.updateBusyPromptArea(conversationId);
3193
+ }
3194
+
3195
+ updateBusyPromptArea(conversationId) {
3196
+ if (this.state.currentConversation?.id !== conversationId) return;
3197
+ const isStreaming = this._convIsStreaming(conversationId);
3198
+ const isConnected = this.wsManager?.isConnected;
3199
+
3200
+ const injectBtn = document.getElementById('injectBtn');
3201
+ const queueBtn = document.getElementById('queueBtn');
3202
+ const stopBtn = document.getElementById('stopBtn');
3203
+
3204
+ [injectBtn, queueBtn, stopBtn].forEach(btn => {
3205
+ if (!btn) return;
3206
+ btn.classList.toggle('visible', isStreaming);
3207
+ btn.disabled = !isConnected;
3208
+ });
3209
+
3210
+ if (this.ui.sendButton) this.ui.sendButton.style.display = isStreaming ? 'none' : '';
3211
+ }
3212
+
3213
+ removeScrollUpDetection() {
3214
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
3215
+ if (scrollContainer && this._scrollUpHandler) {
3216
+ scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
3217
+ this._scrollUpHandler = null;
3218
+ }
3219
+ }
3220
+
3221
+ setupScrollUpDetection(conversationId) {
3222
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
3223
+ if (!scrollContainer) return;
3224
+
3225
+ if (!this._scrollDetectionState) this._scrollDetectionState = {};
3226
+
3227
+ const detectionState = {
3228
+ isLoading: false,
3229
+ oldestTimestamp: Date.now(),
3230
+ oldestMessageId: null,
3231
+ conversation: conversationId
3232
+ };
3233
+
3234
+ const handleScroll = async () => {
3235
+ const scrollTop = scrollContainer.scrollTop;
3236
+ const scrollHeight = scrollContainer.scrollHeight;
3237
+ const clientHeight = scrollContainer.clientHeight;
3238
+ const THRESHOLD = 300;
3239
+
3240
+ if (scrollTop < THRESHOLD && !detectionState.isLoading && scrollHeight > clientHeight) {
3241
+ detectionState.isLoading = true;
3242
+
3243
+ try {
3244
+ const messagesEl = document.querySelector('.conversation-messages');
3245
+ if (!messagesEl) {
3246
+ detectionState.isLoading = false;
3247
+ return;
3248
+ }
3249
+
3250
+ const firstMessageEl = messagesEl.querySelector('.message[data-msg-id]');
3251
+ if (!firstMessageEl) {
3252
+ const firstChunkEl = messagesEl.querySelector('[data-chunk-created]');
3253
+ if (firstChunkEl) {
3254
+ detectionState.oldestTimestamp = parseInt(firstChunkEl.getAttribute('data-chunk-created')) || 0;
3255
+ }
3256
+ } else {
3257
+ detectionState.oldestMessageId = firstMessageEl.getAttribute('data-msg-id');
3258
+ }
3259
+
3260
+ let result;
3261
+ if (detectionState.oldestMessageId) {
3262
+ try {
3263
+ result = await window.wsClient.rpc('msg.ls.earlier', {
3264
+ id: conversationId,
3265
+ before: detectionState.oldestMessageId,
3266
+ limit: 50
3267
+ });
3268
+ } catch (e) {
3269
+ const base = window.__BASE_URL || '';
3270
+ const r = await fetch(`${base}/api/conversations/${conversationId}/messages?limit=50`);
3271
+ const d = await r.json();
3272
+ result = { messages: d.messages || [] };
3273
+ }
3274
+ } else if (detectionState.oldestTimestamp > 0) {
3275
+ try {
3276
+ result = await window.wsClient.rpc('conv.chunks.earlier', {
3277
+ id: conversationId,
3278
+ before: detectionState.oldestTimestamp,
3279
+ limit: 500
3280
+ });
3281
+ } catch (e) {
3282
+ const base = window.__BASE_URL || '';
3283
+ const r = await fetch(`${base}/api/conversations/${conversationId}/chunks`);
3284
+ const d = await r.json();
3285
+ result = { chunks: d.chunks || [] };
3286
+ }
3287
+ }
3288
+
3289
+ if (result && ((result.messages && result.messages.length > 0) || (result.chunks && result.chunks.length > 0))) {
3290
+ const scrollHeightBefore = scrollContainer.scrollHeight;
3291
+ const scrollTopBefore = scrollContainer.scrollTop;
3292
+ const newContent = document.createDocumentFragment();
3293
+
3294
+ if (result.messages && result.messages.length > 0) {
3295
+ result.messages.forEach(msg => {
3296
+ const div = document.createElement('div');
3297
+ div.className = `message message-${msg.role}`;
3298
+ div.setAttribute('data-msg-id', msg.id);
3299
+ div.innerHTML = `<div class="message-role">${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}</div>${this.renderMessageContent(msg.content)}<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>`;
3300
+ newContent.appendChild(div);
3301
+ });
3302
+ }
3303
+
3304
+ if (result.chunks && result.chunks.length > 0) {
3305
+ result.chunks.forEach(chunk => {
3306
+ const blockEl = this.renderer.renderBlock(chunk.data, {}, false);
3307
+ if (blockEl) {
3308
+ const wrapper = document.createElement('div');
3309
+ wrapper.setAttribute('data-chunk-created', chunk.created_at);
3310
+ wrapper.appendChild(blockEl);
3311
+ newContent.appendChild(wrapper);
3312
+ }
3313
+ });
3314
+ }
3315
+
3316
+ if (messagesEl.firstChild) {
3317
+ messagesEl.insertBefore(newContent, messagesEl.firstChild);
3318
+ } else {
3319
+ messagesEl.appendChild(newContent);
3320
+ }
3321
+
3322
+ const scrollHeightAfter = scrollContainer.scrollHeight;
3323
+ scrollContainer.scrollTop = scrollTopBefore + (scrollHeightAfter - scrollHeightBefore);
3324
+ }
3325
+ } catch (error) {
3326
+ console.error('Failed to load earlier messages:', error);
3327
+ } finally {
3328
+ detectionState.isLoading = false;
3329
+ }
3330
+ }
3331
+ };
3332
+
3333
+ scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
3334
+ this._scrollUpHandler = handleScroll;
3335
+ scrollContainer.addEventListener('scroll', this._scrollUpHandler, { passive: true });
3336
+ }
3337
+
3338
+ renderMessagesFragment(messages) {
3339
+ const frag = document.createDocumentFragment();
3340
+ if (messages.length === 0) {
3341
+ const p = document.createElement('p');
3342
+ p.className = 'text-secondary';
3343
+ p.textContent = 'No messages in this conversation yet';
3344
+ frag.appendChild(p);
3345
+ return frag;
3346
+ }
3347
+ for (const msg of messages) {
3348
+ const div = document.createElement('div');
3349
+ div.className = `message message-${msg.role}`;
3350
+ div.innerHTML = `<div class="message-role">${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}</div>${this.renderMessageContent(msg.content)}<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>`;
3351
+ frag.appendChild(div);
3352
+ }
3353
+ return frag;
3354
+ }
3355
+
3356
+ renderMessages(messages) {
3357
+ if (messages.length === 0) {
3358
+ return '<p class="text-secondary">No messages in this conversation yet</p>';
3359
+ }
3360
+ return messages.map(msg => `<div class="message message-${msg.role}"><div class="message-role">${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}</div>${this.renderMessageContent(msg.content)}<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div></div>`).join('');
3361
+ }
3362
+
3363
+ /**
3364
+ * Escape HTML to prevent XSS
3365
+ */
3366
+ escapeHtml(text) {
3367
+ return window._escHtml(text);
3368
+ }
3369
+
3370
+ /**
3371
+ * Show error message
3372
+ */
3373
+ showError(message) {
3374
+ console.error(message);
3375
+ if (window.UIDialog) {
3376
+ window.UIDialog.alert(message, 'Error');
3377
+ }
3378
+ }
3379
+
3380
+ /**
3381
+ * Add event listener
3382
+ */
3383
+ on(event, callback) {
3384
+ if (!this.eventHandlers[event]) {
3385
+ this.eventHandlers[event] = [];
3386
+ }
3387
+ this.eventHandlers[event].push(callback);
3388
+ }
3389
+
3390
+ /**
3391
+ * Emit event
3392
+ */
3393
+ emit(event, data) {
3394
+ if (this.eventHandlers[event]) {
3395
+ this.eventHandlers[event].forEach(callback => {
3396
+ try {
3397
+ callback(data);
3398
+ } catch (error) {
3399
+ console.error(`Event handler error for ${event}:`, error);
3400
+ }
3401
+ });
3402
+ }
3403
+ }
3404
+
3405
+ /**
3406
+ * Get current selected agent
3407
+ */
3408
+ getEffectiveAgentId() {
3409
+ return this.ui.cliSelector?.value || null;
3410
+ }
3411
+
3412
+ getEffectiveSubAgent() {
3413
+ if (this.ui.agentSelector?.value && this.ui.agentSelector.style.display !== 'none') {
3414
+ return this.ui.agentSelector.value;
3415
+ }
3416
+ return null;
3417
+ }
3418
+
3419
+ getCurrentAgent() {
3420
+ return this.getEffectiveAgentId();
3421
+ }
3422
+
3423
+ saveAgentAndModelToConversation() {
3424
+ const convId = this.state.currentConversation?.id;
3425
+ if (!convId || this._agentLocked) return;
3426
+ const agentId = this.getEffectiveAgentId();
3427
+ const subAgent = this.getEffectiveSubAgent();
3428
+ const model = this.getCurrentModel();
3429
+ window.wsClient.rpc('conv.upd', { id: convId, agentType: agentId, subAgent: subAgent || undefined, model: model || undefined }).catch(() => {});
3430
+ }
3431
+
3432
+ /**
3433
+ * Get current selected model
3434
+ */
3435
+ getCurrentModel() {
3436
+ return this.ui.modelSelector?.value || null;
3437
+ }
3438
+
3439
+ /**
3440
+ * Get metrics
3441
+ */
3442
+ getMetrics() {
3443
+ return {
3444
+ renderer: this.renderer.getMetrics(),
3445
+ websocket: this.wsManager.getStatus(),
3446
+ eventProcessor: this.eventProcessor.getStats(),
3447
+ state: this.state
3448
+ };
3449
+ }
3450
+
3451
+ /**
3452
+ * Save draft prompt for current conversation
3453
+ */
3454
+ saveDraftPrompt() {
3455
+ const convId = this.state.currentConversation?.id;
3456
+ if (convId && this.ui.messageInput) {
3457
+ const draft = this.ui.messageInput.value;
3458
+ this.draftPrompts.set(convId, draft);
3459
+ if (draft) {
3460
+ localStorage.setItem(`draft-${convId}`, draft);
3461
+ }
3462
+ }
3463
+ }
3464
+
3465
+ /**
3466
+ * Restore draft prompt for conversation
3467
+ */
3468
+ restoreDraftPrompt(conversationId) {
3469
+ if (!this.ui.messageInput) return;
3470
+
3471
+ let draft = this.draftPrompts.get(conversationId) || '';
3472
+ if (!draft) {
3473
+ draft = localStorage.getItem(`draft-${conversationId}`) || '';
3474
+ if (draft) this.draftPrompts.set(conversationId, draft);
3475
+ }
3476
+
3477
+ this.ui.messageInput.value = draft;
3478
+ }
3479
+
3480
+ /**
3481
+ * Clear draft for conversation
3482
+ */
3483
+ clearDraft(conversationId) {
3484
+ this.draftPrompts.delete(conversationId);
3485
+ localStorage.removeItem(`draft-${conversationId}`);
3486
+ }
3487
+
3488
+ /**
3489
+ * Update send button state based on WebSocket connection
3490
+ */
3491
+ updateSendButtonState() {
3492
+ if (this.ui.sendButton) {
3493
+ this.ui.sendButton.disabled = !this.wsManager.isConnected;
3494
+ }
3495
+ // Also disable queue and inject buttons if disconnected
3496
+ if (this.ui.injectButton && this.ui.injectButton.classList.contains('visible')) {
3497
+ this.ui.injectButton.disabled = !this.wsManager.isConnected;
3498
+ }
3499
+ if (this.ui.queueButton && this.ui.queueButton.classList.contains('visible')) {
3500
+ this.ui.queueButton.disabled = !this.wsManager.isConnected;
3501
+ }
3502
+ }
3503
+
3504
+ /**
3505
+ * Disable prompt area - NEVER CALLED. Prompt must always be enabled.
3506
+ * Keeping method for backward compatibility but it does nothing.
3507
+ */
3508
+ disablePromptArea() {
3509
+ // NEVER disable messageInput - prompt must always be writable
3510
+ }
3511
+
3512
+ /**
3513
+ * Enable prompt area (input and inject button) on connect
3514
+ */
3515
+ enablePromptArea() {
3516
+ if (this.ui.messageInput) {
3517
+ this.ui.messageInput.disabled = false;
3518
+ }
3519
+ const injectBtn = document.getElementById('injectBtn');
3520
+ if (injectBtn) injectBtn.disabled = false;
3521
+ }
3522
+
3523
+ /**
3524
+ * Show queue/inject buttons when streaming (busy prompt state)
3525
+ */
3526
+ showStreamingPromptButtons() {
3527
+ if (this.ui.injectButton) {
3528
+ this.ui.injectButton.classList.add('visible');
3529
+ this.ui.injectButton.disabled = !this.wsManager.isConnected;
3530
+ }
3531
+ if (this.ui.queueButton) {
3532
+ this.ui.queueButton.classList.add('visible');
3533
+ this.ui.queueButton.disabled = !this.wsManager.isConnected;
3534
+ }
3535
+ }
3536
+
3537
+ /**
3538
+ * Ensure prompt area is always enabled and shows queue/inject when agent streaming
3539
+ */
3540
+ ensurePromptAreaAlwaysEnabled() {
3541
+ if (this.ui.messageInput) {
3542
+ this.ui.messageInput.disabled = false;
3543
+ }
3544
+ }
3545
+
3546
+ /**
3547
+ * Cleanup resources
3548
+ */
3549
+ destroy() {
3550
+
3551
+ this.renderer.destroy();
3552
+ this.wsManager.destroy();
3553
+ this.eventHandlers = {};
3554
+ }
123
3555
  }
124
3556
 
125
3557
  window.__convPerfMetrics = () => {
@@ -127,9 +3559,62 @@ window.__convPerfMetrics = () => {
127
3559
  return entries.map(e => ({ name: e.name, ms: Math.round(e.duration) }));
128
3560
  };
129
3561
 
3562
+ function updateWelcomeScreen() {
3563
+ const welcomeScreen = document.getElementById('welcomeScreen');
3564
+ const outputScroll = document.getElementById('output-scroll');
3565
+ if (!welcomeScreen) return;
3566
+ const hasConversation = window.ConversationState?.activeId || document.getElementById('output')?.children?.length > 0;
3567
+ if (hasConversation) {
3568
+ welcomeScreen.classList.remove('visible');
3569
+ if (outputScroll) outputScroll.style.display = '';
3570
+ } else {
3571
+ welcomeScreen.classList.add('visible');
3572
+ if (outputScroll) outputScroll.style.display = 'none';
3573
+ }
3574
+ }
3575
+ window.updateWelcomeScreen = updateWelcomeScreen;
3576
+
3577
+ function populateWelcomeAgents() {
3578
+ const grid = document.getElementById('welcomeAgentsGrid');
3579
+ if (!grid) return;
3580
+ const agentColors = {
3581
+ 'claude-code': { color: '#f97316', icon: '🤖', desc: 'Anthropic\'s coding agent' },
3582
+ 'cli-opencode': { color: '#3b82f6', icon: '⚡', desc: 'Fast open-source agent' },
3583
+ 'cli-gemini': { color: '#10b981', icon: '♊', desc: 'Google\'s coding agent' },
3584
+ 'cli-kilo': { color: '#8b5cf6', icon: '⚙️', desc: 'Kilo code agent' },
3585
+ 'cli-codex': { color: '#ef4444', icon: '🔴', desc: 'OpenAI Codex agent' },
3586
+ };
3587
+ const agents = window.discoveredAgents || [];
3588
+ if (agents.length === 0) return;
3589
+ grid.innerHTML = '';
3590
+ agents.slice(0, 6).forEach(agent => {
3591
+ const info = agentColors[agent.id] || { color: '#6b7280', icon: '🤖', desc: 'AI coding agent' };
3592
+ const card = document.createElement('div');
3593
+ card.className = 'welcome-agent-card';
3594
+ card.innerHTML = `
3595
+ <div class="welcome-agent-icon" style="background:${info.color}20;color:${info.color}">${info.icon}</div>
3596
+ <div class="welcome-agent-name">${agent.name || agent.id}</div>
3597
+ <div class="welcome-agent-desc">${info.desc}</div>
3598
+ `;
3599
+ card.addEventListener('click', () => {
3600
+ const agentSel = document.querySelector('[data-cli-selector]');
3601
+ if (agentSel) { agentSel.value = agent.id; agentSel.dispatchEvent(new Event('change', { bubbles: true })); }
3602
+ const inputTA = document.getElementById('inputCardTextarea') || document.querySelector('[data-message-input]');
3603
+ if (inputTA) inputTA.focus();
3604
+ const newBtn = document.getElementById('newConversationBtn');
3605
+ if (newBtn && !window.ConversationState?.activeId) newBtn.click();
3606
+ updateWelcomeScreen();
3607
+ });
3608
+ grid.appendChild(card);
3609
+ });
3610
+ }
3611
+
3612
+ // Global instance
130
3613
  let agentGUIClient = null;
131
3614
 
3615
+ // Initialize on DOM ready
132
3616
  document.addEventListener('DOMContentLoaded', async () => {
3617
+ updateWelcomeScreen();
133
3618
  try {
134
3619
  agentGUIClient = new AgentGUIClient();
135
3620
  window.agentGuiClient = agentGUIClient;
@@ -140,6 +3625,7 @@ document.addEventListener('DOMContentLoaded', async () => {
140
3625
  }
141
3626
  });
142
3627
 
3628
+ // Export for testing
143
3629
  if (typeof module !== 'undefined' && module.exports) {
144
3630
  module.exports = AgentGUIClient;
145
3631
  }