agentgui 1.0.862 → 1.0.863

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