agentgui 1.0.102 → 1.0.104

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.
@@ -71,6 +71,25 @@ class AgentGUIClient {
71
71
  await this.connectWebSocket();
72
72
  }
73
73
 
74
+ // Initialize chunk polling state
75
+ this.chunkPollState = {
76
+ isPolling: false,
77
+ lastFetchTimestamp: 0,
78
+ pollTimer: null,
79
+ backoffDelay: 100,
80
+ maxBackoffDelay: 400,
81
+ abortController: null
82
+ };
83
+
84
+ // Initialize router state
85
+ this.routerState = {
86
+ currentConversationId: null,
87
+ currentSessionId: null
88
+ };
89
+
90
+ // Restore state from URL on page load
91
+ this.restoreStateFromUrl();
92
+
74
93
  this.state.isInitialized = true;
75
94
  this.emit('initialized');
76
95
 
@@ -134,6 +153,118 @@ class AgentGUIClient {
134
153
  });
135
154
  }
136
155
 
156
+ /**
157
+ * Router state management: restore conversation from URL
158
+ * Format: ?conversation=<id>&session=<id>
159
+ */
160
+ restoreStateFromUrl() {
161
+ const params = new URLSearchParams(window.location.search);
162
+ const conversationId = params.get('conversation');
163
+ const sessionId = params.get('session');
164
+
165
+ if (conversationId && this.isValidId(conversationId)) {
166
+ this.routerState.currentConversationId = conversationId;
167
+ if (sessionId && this.isValidId(sessionId)) {
168
+ this.routerState.currentSessionId = sessionId;
169
+ }
170
+ console.log('Restoring conversation from URL:', conversationId);
171
+ this.loadConversationMessages(conversationId);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Validate ID format to prevent XSS
177
+ * Alphanumeric, dash, underscore only
178
+ */
179
+ isValidId(id) {
180
+ if (!id || typeof id !== 'string') return false;
181
+ return /^[a-zA-Z0-9_-]+$/.test(id) && id.length < 256;
182
+ }
183
+
184
+ /**
185
+ * Update URL when conversation is selected
186
+ * Uses History API (pushState) for clean URLs
187
+ */
188
+ updateUrlForConversation(conversationId, sessionId) {
189
+ if (!this.isValidId(conversationId)) return;
190
+
191
+ this.routerState.currentConversationId = conversationId;
192
+ if (sessionId && this.isValidId(sessionId)) {
193
+ this.routerState.currentSessionId = sessionId;
194
+ }
195
+
196
+ const params = new URLSearchParams();
197
+ params.set('conversation', conversationId);
198
+ if (sessionId && this.isValidId(sessionId)) {
199
+ params.set('session', sessionId);
200
+ }
201
+
202
+ const url = `${window.location.pathname}?${params.toString()}`;
203
+ window.history.pushState({ conversationId, sessionId }, '', url);
204
+ }
205
+
206
+ /**
207
+ * Save scroll position to localStorage
208
+ * Key format: scroll_<conversationId>
209
+ */
210
+ saveScrollPosition(conversationId) {
211
+ if (!this.isValidId(conversationId)) return;
212
+
213
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
214
+ if (scrollContainer) {
215
+ const position = scrollContainer.scrollTop;
216
+ try {
217
+ localStorage.setItem(`scroll_${conversationId}`, position.toString());
218
+ console.log(`Saved scroll position for ${conversationId}: ${position}`);
219
+ } catch (e) {
220
+ console.warn('Failed to save scroll position:', e);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Restore scroll position from localStorage
227
+ * Restores after conversation loads
228
+ */
229
+ restoreScrollPosition(conversationId) {
230
+ if (!this.isValidId(conversationId)) return;
231
+
232
+ try {
233
+ const position = localStorage.getItem(`scroll_${conversationId}`);
234
+ if (position !== null) {
235
+ const scrollTop = parseInt(position, 10);
236
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
237
+ if (scrollContainer && !isNaN(scrollTop)) {
238
+ requestAnimationFrame(() => {
239
+ scrollContainer.scrollTop = scrollTop;
240
+ console.log(`Restored scroll position for ${conversationId}: ${scrollTop}`);
241
+ });
242
+ }
243
+ }
244
+ } catch (e) {
245
+ console.warn('Failed to restore scroll position:', e);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Setup scroll position tracking
251
+ * Debounced to avoid excessive localStorage writes
252
+ */
253
+ setupScrollTracking() {
254
+ const scrollContainer = document.getElementById(this.config.scrollContainerId);
255
+ if (!scrollContainer) return;
256
+
257
+ let scrollTimer = null;
258
+ scrollContainer.addEventListener('scroll', () => {
259
+ if (scrollTimer) clearTimeout(scrollTimer);
260
+ scrollTimer = setTimeout(() => {
261
+ if (this.state.currentConversation?.id) {
262
+ this.saveScrollPosition(this.state.currentConversation.id);
263
+ }
264
+ }, 500); // Debounce 500ms
265
+ });
266
+ }
267
+
137
268
  /**
138
269
  * Setup UI elements
139
270
  */
@@ -174,6 +305,9 @@ class AgentGUIClient {
174
305
  themeToggle.addEventListener('click', () => this.toggleTheme());
175
306
  }
176
307
 
308
+ // Setup scroll position tracking for current conversation
309
+ this.setupScrollTracking();
310
+
177
311
  window.addEventListener('create-new-conversation', (event) => {
178
312
  const detail = event.detail || {};
179
313
  this.createNewConversation(detail.workingDirectory, detail.title);
@@ -181,7 +315,9 @@ class AgentGUIClient {
181
315
 
182
316
  // Listen for conversation selection
183
317
  window.addEventListener('conversation-selected', (event) => {
184
- this.loadConversationMessages(event.detail.conversationId);
318
+ const conversationId = event.detail.conversationId;
319
+ this.updateUrlForConversation(conversationId);
320
+ this.loadConversationMessages(conversationId);
185
321
  });
186
322
  }
187
323
 
@@ -255,6 +391,9 @@ class AgentGUIClient {
255
391
  this.state.sessionEvents = [];
256
392
  this.state.streamingBlocks = [];
257
393
 
394
+ // Update URL with session ID during streaming
395
+ this.updateUrlForConversation(data.conversationId, data.sessionId);
396
+
258
397
  if (this.wsManager.isConnected) {
259
398
  this.wsManager.subscribeToSession(data.sessionId);
260
399
  }
@@ -281,97 +420,26 @@ class AgentGUIClient {
281
420
  this.scrollToBottom();
282
421
  }
283
422
 
423
+ // Start polling for chunks from database
424
+ this.startChunkPolling(data.conversationId);
425
+
284
426
  this.disableControls();
285
427
  this.emit('streaming:start', data);
286
428
  }
287
429
 
288
430
  handleStreamingProgress(data) {
431
+ // NOTE: With chunk-based architecture, blocks are rendered from polling
432
+ // This handler is kept for backward compatibility and to trigger polling updates
433
+ // But actual rendering happens in renderChunk() via polling
434
+
289
435
  if (!data.block) return;
290
436
 
291
437
  const block = data.block;
292
438
  if (!this.state.streamingBlocks) this.state.streamingBlocks = [];
293
439
  this.state.streamingBlocks.push(block);
294
440
 
295
- const sessionId = data.sessionId || this.state.currentSession?.id;
296
- const streamingEl = document.getElementById(`streaming-${sessionId}`);
297
- if (!streamingEl) return;
298
-
299
- const blocksEl = streamingEl.querySelector('.streaming-blocks');
300
- if (!blocksEl) return;
301
-
302
- const indicator = streamingEl.querySelector('.streaming-indicator');
303
- let indicatorText = 'Responding...';
304
-
305
- if (block.type === 'system') {
306
- const div = document.createElement('div');
307
- div.className = 'streaming-block-system';
308
- const toolCount = block.tools ? block.tools.length : 0;
309
- div.innerHTML = `<span class="system-model-badge">${this.escapeHtml(block.model || 'unknown')}</span> <span class="system-info">${toolCount} tools available</span>`;
310
- blocksEl.appendChild(div);
311
- indicatorText = 'Initializing...';
312
- } else if (block.type === 'text' && block.text) {
313
- const existingTextEl = blocksEl.querySelector('.streaming-text-current');
314
- if (existingTextEl && !data.isResult) {
315
- existingTextEl.innerHTML = this.renderBlockContent(block);
316
- } else {
317
- const prevTextEl = blocksEl.querySelector('.streaming-text-current');
318
- if (prevTextEl) prevTextEl.classList.remove('streaming-text-current');
319
- const div = document.createElement('div');
320
- div.className = 'message-text streaming-text-current';
321
- div.innerHTML = this.renderBlockContent(block);
322
- blocksEl.appendChild(div);
323
- }
324
- indicatorText = 'Responding...';
325
- } else if (block.type === 'tool_use') {
326
- const prevTextEl = blocksEl.querySelector('.streaming-text-current');
327
- if (prevTextEl) prevTextEl.classList.remove('streaming-text-current');
328
-
329
- const div = document.createElement('div');
330
- div.className = 'streaming-block-tool-use';
331
- div.dataset.toolUseId = block.id || '';
332
- let inputHtml = '';
333
- if (block.input && Object.keys(block.input).length > 0) {
334
- const inputStr = JSON.stringify(block.input, null, 2);
335
- inputHtml = `<details class="tool-input-details"><summary class="tool-input-summary">Input</summary><pre class="tool-input-pre">${this.escapeHtml(inputStr)}</pre></details>`;
336
- }
337
- div.innerHTML = `<div class="tool-use-header"><span class="tool-use-icon">&#9881;</span> <span class="tool-use-name">${this.escapeHtml(block.name || 'unknown')}</span></div>${inputHtml}`;
338
- blocksEl.appendChild(div);
339
- indicatorText = `Using ${block.name || 'tool'}...`;
340
- } else if (block.type === 'tool_result') {
341
- const div = document.createElement('div');
342
- div.className = 'streaming-block-tool-result' + (block.is_error ? ' tool-result-error' : '');
343
- const content = block.content || '';
344
- const displayContent = content.length > 2000 ? content.substring(0, 2000) + '\n... (truncated)' : content;
345
- div.innerHTML = `<div class="tool-result-header">${block.is_error ? '<span class="tool-result-error-badge">Error</span>' : '<span class="tool-result-ok-badge">Result</span>'}</div><pre class="tool-result-pre">${this.escapeHtml(displayContent)}</pre>`;
346
- blocksEl.appendChild(div);
347
- indicatorText = 'Processing result...';
348
- } else if (block.type === 'result') {
349
- const div = document.createElement('div');
350
- div.className = 'streaming-block-result' + (block.is_error ? ' result-error' : '');
351
- const duration = block.duration_ms ? (block.duration_ms / 1000).toFixed(1) + 's' : '';
352
- const cost = block.total_cost_usd ? '$' + block.total_cost_usd.toFixed(4) : '';
353
- const turns = block.num_turns ? block.num_turns + ' turns' : '';
354
- const parts = [duration, cost, turns].filter(Boolean);
355
- div.innerHTML = `<span class="result-status">${block.is_error ? 'Failed' : 'Complete'}</span>${parts.length ? ' <span class="result-stats">' + parts.join(' / ') + '</span>' : ''}`;
356
- blocksEl.appendChild(div);
357
- indicatorText = 'Complete';
358
- }
359
-
360
- if (indicator) {
361
- const labelEl = indicator.querySelector('.streaming-indicator-label');
362
- if (labelEl) {
363
- labelEl.textContent = indicatorText;
364
- } else {
365
- const existingLabel = indicator.querySelector('span:last-child');
366
- if (existingLabel && !existingLabel.classList.contains('animate-spin')) existingLabel.remove();
367
- const label = document.createElement('span');
368
- label.className = 'streaming-indicator-label';
369
- label.textContent = indicatorText;
370
- indicator.appendChild(label);
371
- }
372
- }
373
-
374
- this.scrollToBottom();
441
+ // WebSocket is now just a notification trigger, not data source
442
+ // Actual blocks come from database polling in startChunkPolling()
375
443
  }
376
444
 
377
445
  renderBlockContent(block) {
@@ -426,6 +494,9 @@ class AgentGUIClient {
426
494
  console.log('Streaming completed:', data);
427
495
  this.state.isStreaming = false;
428
496
 
497
+ // Stop polling for chunks
498
+ this.stopChunkPolling();
499
+
429
500
  const sessionId = data.sessionId || this.state.currentSession?.id;
430
501
  const streamingEl = document.getElementById(`streaming-${sessionId}`);
431
502
  if (streamingEl) {
@@ -441,6 +512,12 @@ class AgentGUIClient {
441
512
  streamingEl.appendChild(ts);
442
513
  }
443
514
 
515
+ // Save scroll position after streaming completes
516
+ const conversationId = data.conversationId || this.state.currentSession?.conversationId;
517
+ if (conversationId) {
518
+ this.saveScrollPosition(conversationId);
519
+ }
520
+
444
521
  this.enableControls();
445
522
  this.emit('streaming:complete', data);
446
523
  }
@@ -702,6 +779,148 @@ class AgentGUIClient {
702
779
  }
703
780
  }
704
781
 
782
+ /**
783
+ * Fetch chunks from database for a conversation
784
+ * Supports incremental updates with since parameter
785
+ */
786
+ async fetchChunks(conversationId, since = 0) {
787
+ if (!conversationId) return [];
788
+
789
+ try {
790
+ const params = new URLSearchParams();
791
+ if (since > 0) {
792
+ params.append('since', since.toString());
793
+ }
794
+
795
+ const url = `${window.__BASE_URL}/api/conversations/${conversationId}/chunks?${params.toString()}`;
796
+ const response = await fetch(url);
797
+
798
+ if (!response.ok) {
799
+ throw new Error(`HTTP ${response.status}`);
800
+ }
801
+
802
+ const data = await response.json();
803
+ if (!data.ok || !Array.isArray(data.chunks)) {
804
+ throw new Error('Invalid chunks response');
805
+ }
806
+
807
+ // Parse JSON data field for each chunk
808
+ const chunks = data.chunks.map(chunk => ({
809
+ ...chunk,
810
+ block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
811
+ }));
812
+
813
+ return chunks;
814
+ } catch (error) {
815
+ console.error('Error fetching chunks:', error);
816
+ throw error;
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Poll for new chunks at regular intervals
822
+ * Uses exponential backoff on errors
823
+ */
824
+ async startChunkPolling(conversationId) {
825
+ if (!conversationId) return;
826
+
827
+ const pollState = this.chunkPollState;
828
+ if (pollState.isPolling) return; // Already polling
829
+
830
+ pollState.isPolling = true;
831
+ pollState.lastFetchTimestamp = Date.now();
832
+ pollState.backoffDelay = 100;
833
+
834
+ console.log('Starting chunk polling for conversation:', conversationId);
835
+
836
+ const pollOnce = async () => {
837
+ if (!pollState.isPolling) return;
838
+
839
+ try {
840
+ const chunks = await this.fetchChunks(conversationId, pollState.lastFetchTimestamp);
841
+
842
+ if (chunks.length > 0) {
843
+ // Reset backoff on success
844
+ pollState.backoffDelay = 100;
845
+
846
+ // Update last fetch timestamp
847
+ const lastChunk = chunks[chunks.length - 1];
848
+ pollState.lastFetchTimestamp = lastChunk.created_at;
849
+
850
+ // Render new chunks
851
+ chunks.forEach(chunk => {
852
+ if (chunk.block && chunk.block.type) {
853
+ this.renderChunk(chunk);
854
+ }
855
+ });
856
+ }
857
+
858
+ // Schedule next poll
859
+ if (pollState.isPolling) {
860
+ pollState.pollTimer = setTimeout(pollOnce, 100);
861
+ }
862
+ } catch (error) {
863
+ console.warn('Chunk poll error, applying backoff:', error.message);
864
+
865
+ // Apply exponential backoff
866
+ pollState.backoffDelay = Math.min(
867
+ pollState.backoffDelay * 2,
868
+ pollState.maxBackoffDelay
869
+ );
870
+
871
+ // Schedule next poll with backoff
872
+ if (pollState.isPolling) {
873
+ pollState.pollTimer = setTimeout(pollOnce, pollState.backoffDelay);
874
+ }
875
+ }
876
+ };
877
+
878
+ // Start polling loop
879
+ pollOnce();
880
+ }
881
+
882
+ /**
883
+ * Stop polling for chunks
884
+ */
885
+ stopChunkPolling() {
886
+ const pollState = this.chunkPollState;
887
+
888
+ if (pollState.pollTimer) {
889
+ clearTimeout(pollState.pollTimer);
890
+ pollState.pollTimer = null;
891
+ }
892
+
893
+ if (pollState.abortController) {
894
+ pollState.abortController.abort();
895
+ pollState.abortController = null;
896
+ }
897
+
898
+ pollState.isPolling = false;
899
+ console.log('Stopped chunk polling');
900
+ }
901
+
902
+ /**
903
+ * Render a single chunk to the output
904
+ */
905
+ renderChunk(chunk) {
906
+ if (!chunk || !chunk.block) return;
907
+
908
+ const sessionId = chunk.sessionId;
909
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
910
+ if (!streamingEl) return;
911
+
912
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
913
+ if (!blocksEl) return;
914
+
915
+ const block = chunk.block;
916
+ const element = this.renderer.renderBlock(block, chunk);
917
+
918
+ if (element) {
919
+ blocksEl.appendChild(element);
920
+ this.scrollToBottom();
921
+ }
922
+ }
923
+
705
924
  /**
706
925
  * Load agents
707
926
  */
@@ -832,27 +1051,100 @@ class AgentGUIClient {
832
1051
  const { conversation } = await convResponse.json();
833
1052
  this.state.currentConversation = conversation;
834
1053
 
1054
+ // Update URL with conversation ID
1055
+ this.updateUrlForConversation(conversationId);
1056
+
835
1057
  if (this.wsManager.isConnected) {
836
1058
  this.wsManager.sendMessage({ type: 'subscribe', conversationId });
837
1059
  }
838
1060
 
839
- const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
840
- if (!messagesResponse.ok) throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
841
- const messagesData = await messagesResponse.json();
842
-
843
- const outputEl = document.getElementById('output');
844
- if (outputEl) {
845
- const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
846
- outputEl.innerHTML = `
847
- <div class="conversation-header">
848
- <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
849
- <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
850
- </div>
851
- <div class="conversation-messages">
852
- ${this.renderMessages(messagesData.messages || [])}
853
- </div>
854
- `;
855
- this.scrollToBottom();
1061
+ // Try to fetch chunks first (Wave 3 architecture)
1062
+ try {
1063
+ const chunks = await this.fetchChunks(conversationId, 0);
1064
+
1065
+ const outputEl = document.getElementById('output');
1066
+ if (outputEl) {
1067
+ const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
1068
+ outputEl.innerHTML = `
1069
+ <div class="conversation-header">
1070
+ <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
1071
+ <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
1072
+ </div>
1073
+ <div class="conversation-messages"></div>
1074
+ `;
1075
+
1076
+ // Render all chunks
1077
+ const messagesEl = outputEl.querySelector('.conversation-messages');
1078
+ if (chunks.length > 0) {
1079
+ // Group chunks by session
1080
+ const sessionChunks = {};
1081
+ chunks.forEach(chunk => {
1082
+ if (!sessionChunks[chunk.sessionId]) {
1083
+ sessionChunks[chunk.sessionId] = [];
1084
+ }
1085
+ sessionChunks[chunk.sessionId].push(chunk);
1086
+ });
1087
+
1088
+ // Render each session's chunks
1089
+ Object.entries(sessionChunks).forEach(([sessionId, sessionChunkList]) => {
1090
+ const messageDiv = document.createElement('div');
1091
+ messageDiv.className = 'message message-assistant';
1092
+ messageDiv.id = `message-${sessionId}`;
1093
+ messageDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks"></div>';
1094
+
1095
+ const blocksEl = messageDiv.querySelector('.message-blocks');
1096
+ sessionChunkList.forEach(chunk => {
1097
+ if (chunk.block && chunk.block.type) {
1098
+ const element = this.renderer.renderBlock(chunk.block, chunk);
1099
+ if (element) {
1100
+ blocksEl.appendChild(element);
1101
+ }
1102
+ }
1103
+ });
1104
+
1105
+ const ts = document.createElement('div');
1106
+ ts.className = 'message-timestamp';
1107
+ ts.textContent = new Date(sessionChunkList[sessionChunkList.length - 1].created_at).toLocaleString();
1108
+ messageDiv.appendChild(ts);
1109
+
1110
+ messagesEl.appendChild(messageDiv);
1111
+ });
1112
+ } else {
1113
+ // Fall back to messages if no chunks
1114
+ const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1115
+ if (messagesResponse.ok) {
1116
+ const messagesData = await messagesResponse.json();
1117
+ messagesEl.innerHTML = this.renderMessages(messagesData.messages || []);
1118
+ }
1119
+ }
1120
+
1121
+ // Restore scroll position after rendering
1122
+ this.restoreScrollPosition(conversationId);
1123
+ }
1124
+ } catch (chunkError) {
1125
+ console.warn('Failed to fetch chunks, falling back to messages:', chunkError);
1126
+
1127
+ // Fallback: use messages
1128
+ const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1129
+ if (!messagesResponse.ok) throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
1130
+ const messagesData = await messagesResponse.json();
1131
+
1132
+ const outputEl = document.getElementById('output');
1133
+ if (outputEl) {
1134
+ const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
1135
+ outputEl.innerHTML = `
1136
+ <div class="conversation-header">
1137
+ <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
1138
+ <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
1139
+ </div>
1140
+ <div class="conversation-messages">
1141
+ ${this.renderMessages(messagesData.messages || [])}
1142
+ </div>
1143
+ `;
1144
+
1145
+ // Restore scroll position after rendering
1146
+ this.restoreScrollPosition(conversationId);
1147
+ }
856
1148
  }
857
1149
  } catch (error) {
858
1150
  console.error('Failed to load conversation messages:', error);
@@ -1002,6 +1294,7 @@ class AgentGUIClient {
1002
1294
  * Cleanup resources
1003
1295
  */
1004
1296
  destroy() {
1297
+ this.stopChunkPolling();
1005
1298
  this.renderer.destroy();
1006
1299
  this.wsManager.destroy();
1007
1300
  this.eventHandlers = {};