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