agentgui 1.0.673 → 1.0.675

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.
@@ -546,3 +546,74 @@ export async function autoProvision(broadcast) {
546
546
  }
547
547
  log('Provisioning complete');
548
548
  }
549
+
550
+ // Periodic tool update checker - runs in background every 6 hours
551
+ let updateCheckInterval = null;
552
+ const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
553
+
554
+ export function startPeriodicUpdateCheck(broadcast) {
555
+ const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
556
+
557
+ if (updateCheckInterval) {
558
+ log('Update check already running');
559
+ return;
560
+ }
561
+
562
+ log('Starting periodic tool update checker (every 6 hours)');
563
+
564
+ // Run check immediately on startup (non-blocking)
565
+ setImmediate(() => {
566
+ checkAndUpdateTools(broadcast).catch(err => {
567
+ log(`Initial check failed: ${err.message}`);
568
+ });
569
+ });
570
+
571
+ // Then run periodically every 6 hours
572
+ updateCheckInterval = setInterval(() => {
573
+ checkAndUpdateTools(broadcast).catch(err => {
574
+ log(`Periodic check failed: ${err.message}`);
575
+ });
576
+ }, UPDATE_CHECK_INTERVAL);
577
+ }
578
+
579
+ export function stopPeriodicUpdateCheck() {
580
+ if (updateCheckInterval) {
581
+ clearInterval(updateCheckInterval);
582
+ updateCheckInterval = null;
583
+ console.log('[TOOLS-PERIODIC] Update check stopped');
584
+ }
585
+ }
586
+
587
+ async function checkAndUpdateTools(broadcast) {
588
+ const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
589
+ log('Checking for tool updates...');
590
+
591
+ for (const tool of TOOLS) {
592
+ try {
593
+ const status = await checkToolViaBunx(tool.pkg, tool.pluginId, tool.category, tool.frameWork, false);
594
+
595
+ if (status.upgradeNeeded) {
596
+ log(`Update available for ${tool.id}: ${status.installedVersion} -> ${status.publishedVersion}`);
597
+ broadcast({ type: 'tool_update_available', toolId: tool.id, data: { installedVersion: status.installedVersion, publishedVersion: status.publishedVersion } });
598
+
599
+ // Auto-update in background (non-blocking)
600
+ log(`Auto-updating ${tool.id}...`);
601
+ const result = await update(tool.id, (msg) => {
602
+ broadcast({ type: 'tool_update_progress', toolId: tool.id, data: msg });
603
+ });
604
+
605
+ if (result.success) {
606
+ log(`${tool.id} auto-updated to v${result.version}`);
607
+ broadcast({ type: 'tool_update_complete', toolId: tool.id, data: { ...result, autoUpdated: true } });
608
+ } else {
609
+ log(`${tool.id} auto-update failed: ${result.error}`);
610
+ broadcast({ type: 'tool_update_failed', toolId: tool.id, data: { ...result, autoUpdated: true } });
611
+ }
612
+ }
613
+ } catch (err) {
614
+ log(`Error checking ${tool.id}: ${err.message}`);
615
+ }
616
+ }
617
+
618
+ log('Update check complete');
619
+ }
@@ -7,7 +7,17 @@ function expandTilde(p) { return p && p.startsWith('~') ? path.join(os.homedir()
7
7
 
8
8
  export function register(router, deps) {
9
9
  const { queries, activeExecutions, messageQueues, rateLimitState,
10
- broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts } = deps;
10
+ broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts, cleanupExecution } = deps;
11
+
12
+ // Per-conversation queue seq counter for event ordering
13
+ const queueSeqByConv = new Map();
14
+
15
+ function getNextQueueSeq(conversationId) {
16
+ const current = queueSeqByConv.get(conversationId) || 0;
17
+ const next = current + 1;
18
+ queueSeqByConv.set(conversationId, next);
19
+ return next;
20
+ }
11
21
 
12
22
  router.handle('conv.ls', () => {
13
23
  const conversations = queries.getConversationsList();
@@ -95,8 +105,10 @@ export function register(router, deps) {
95
105
  const { pid, sessionId } = entry;
96
106
  if (pid) { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch {} } }
97
107
  if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
98
- queries.setIsStreaming(p.id, false);
99
- activeExecutions.delete(p.id);
108
+
109
+ // Use atomic cleanup function to ensure state consistency
110
+ cleanupExecution(p.id, false);
111
+
100
112
  broadcastSync({ type: 'streaming_complete', sessionId, conversationId: p.id, interrupted: true, timestamp: Date.now() });
101
113
  return { ok: true, cancelled: true, conversationId: p.id, sessionId };
102
114
  });
@@ -245,6 +257,14 @@ export function register(router, deps) {
245
257
 
246
258
  // Message is queued - don't broadcast as message_created, let queue_status handle the UI update
247
259
  const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
260
+ const seq = getNextQueueSeq(p.id);
261
+ broadcastSync({
262
+ type: 'queue_status',
263
+ conversationId: p.id,
264
+ queueLength: messageQueues.get(p.id)?.length || 1,
265
+ seq,
266
+ timestamp: Date.now()
267
+ });
248
268
  return { message: userMessage, queued: true, queuePosition: qp };
249
269
  });
250
270
 
@@ -260,7 +280,14 @@ export function register(router, deps) {
260
280
  if (idx === -1) notFound('Queued message not found');
261
281
  queue.splice(idx, 1);
262
282
  if (queue.length === 0) messageQueues.delete(p.id);
263
- broadcastSync({ type: 'queue_status', conversationId: p.id, queueLength: queue?.length || 0, timestamp: Date.now() });
283
+ const seq = getNextQueueSeq(p.id);
284
+ broadcastSync({
285
+ type: 'queue_status',
286
+ conversationId: p.id,
287
+ queueLength: queue?.length || 0,
288
+ seq,
289
+ timestamp: Date.now()
290
+ });
264
291
  return { deleted: true };
265
292
  });
266
293
 
@@ -271,7 +298,16 @@ export function register(router, deps) {
271
298
  if (!item) notFound('Queued message not found');
272
299
  if (p.content !== undefined) item.content = p.content;
273
300
  if (p.agentId !== undefined) item.agentId = p.agentId;
274
- broadcastSync({ type: 'queue_updated', conversationId: p.id, messageId: p.messageId, content: item.content, agentId: item.agentId, timestamp: Date.now() });
301
+ const seq = getNextQueueSeq(p.id);
302
+ broadcastSync({
303
+ type: 'queue_updated',
304
+ conversationId: p.id,
305
+ messageId: p.messageId,
306
+ content: item.content,
307
+ agentId: item.agentId,
308
+ seq,
309
+ timestamp: Date.now()
310
+ });
275
311
  return { updated: true, item };
276
312
  });
277
313
  }
@@ -152,6 +152,32 @@ function register(router, deps) {
152
152
  if (!run || run.thread_id !== threadId) err(404, 'Run not found on thread');
153
153
  return run;
154
154
  });
155
+
156
+ router.handle('thread.run.steer', async (p) => {
157
+ const threadId = need(p, 'id'), runId = need(p, 'runId'), instruction = need(p, 'instruction');
158
+ const thread = getThreadOrThrow(threadId);
159
+ const run = getRunOrThrow(runId);
160
+ if (run.thread_id !== threadId) err(400, 'Run does not belong to specified thread');
161
+ if (!['active', 'pending'].includes(run.status)) err(409, 'Run is not active or pending');
162
+
163
+ // Cancel current run
164
+ const cancelled = queries.cancelRun(runId);
165
+ const ex = killExecution(threadId, runId);
166
+ broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: ex?.sessionId, timestamp: Date.now() });
167
+
168
+ // Create new run with steering instruction injected
169
+ // The instruction will be processed as a follow-up in the same thread context
170
+ const newRun = queries.createRun(run.agent_id, threadId, { content: instruction }, run.config);
171
+ startExecution(newRun.run_id, threadId, run.agent_id, { content: instruction }, run.config);
172
+
173
+ return {
174
+ steered: true,
175
+ cancelled_run: cancelled,
176
+ new_run: newRun,
177
+ instruction: instruction,
178
+ message: 'Run interrupted and restarted with steering instruction'
179
+ };
180
+ });
155
181
  }
156
182
 
157
183
  export { register };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.673",
3
+ "version": "1.0.675",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -311,6 +311,45 @@ const debugLog = (msg) => {
311
311
  console.error(`[${timestamp}] ${msg}`);
312
312
  };
313
313
 
314
+ // Atomic cleanup function - ensures consistent state across all data structures
315
+ function cleanupExecution(conversationId, broadcastCompletion = false) {
316
+ debugLog(`[cleanup] Starting cleanup for ${conversationId}`);
317
+
318
+ // Clean in-memory maps in atomic block
319
+ activeExecutions.delete(conversationId);
320
+ const proc = activeProcessesByConvId.get(conversationId);
321
+ activeProcessesByConvId.delete(conversationId);
322
+
323
+ // Cancel steering timeout if present
324
+ const steeringTimeout = steeringTimeouts.get(conversationId);
325
+ if (steeringTimeout) {
326
+ clearTimeout(steeringTimeout);
327
+ steeringTimeouts.delete(conversationId);
328
+ }
329
+
330
+ // Try to kill process if still alive
331
+ if (proc) {
332
+ try {
333
+ if (proc.kill) proc.kill('SIGTERM');
334
+ } catch (e) {
335
+ debugLog(`[cleanup] Error killing process: ${e.message}`);
336
+ }
337
+ }
338
+
339
+ // Clean database state
340
+ queries.setIsStreaming(conversationId, false);
341
+
342
+ if (broadcastCompletion) {
343
+ broadcastSync({
344
+ type: 'execution_cleaned_up',
345
+ conversationId,
346
+ timestamp: Date.now()
347
+ });
348
+ }
349
+
350
+ debugLog(`[cleanup] Cleanup complete for ${conversationId}`);
351
+ }
352
+
314
353
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
315
354
  const rootDir = process.env.PORTABLE_EXE_DIR || __dirname;
316
355
  const PORT = process.env.PORT || 3000;
@@ -3317,6 +3356,9 @@ function createChunkBatcher() {
3317
3356
  return { add, drain };
3318
3357
  }
3319
3358
 
3359
+ // Global broadcast sequence counter for event ordering
3360
+ let broadcastSeq = 0;
3361
+
3320
3362
  function parseRateLimitResetTime(text) {
3321
3363
  const match = text.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(?(UTC|[A-Z]{2,4})\)?/i);
3322
3364
  if (!match) return 300;
@@ -3832,10 +3874,18 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3832
3874
  });
3833
3875
  } finally {
3834
3876
  batcher.drain();
3835
- activeExecutions.delete(conversationId);
3877
+ // Use atomic cleanup but only if not in rate limit recovery
3836
3878
  if (!rateLimitState.has(conversationId)) {
3837
- queries.setIsStreaming(conversationId, false);
3879
+ cleanupExecution(conversationId);
3838
3880
  drainMessageQueue(conversationId);
3881
+ } else {
3882
+ // Rate limit in flight - keep execution entry for now, but clean process handle
3883
+ activeProcessesByConvId.delete(conversationId);
3884
+ const steeringTimeout = steeringTimeouts.get(conversationId);
3885
+ if (steeringTimeout) {
3886
+ clearTimeout(steeringTimeout);
3887
+ steeringTimeouts.delete(conversationId);
3888
+ }
3839
3889
  }
3840
3890
  }
3841
3891
  }
@@ -3871,6 +3921,16 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
3871
3921
  .catch(err => {
3872
3922
  debugLog(`[rate-limit] Retry failed: ${err.message}`);
3873
3923
  console.error(`[rate-limit] Retry error for conv ${conversationId}:`, err);
3924
+ // Clean up state on retry failure
3925
+ cleanupExecution(conversationId);
3926
+ broadcastSync({
3927
+ type: 'streaming_error',
3928
+ sessionId: newSession.id,
3929
+ conversationId,
3930
+ error: `Rate limit retry failed: ${err.message}`,
3931
+ recoverable: false,
3932
+ timestamp: Date.now()
3933
+ });
3874
3934
  });
3875
3935
  }
3876
3936
 
@@ -3915,7 +3975,21 @@ function drainMessageQueue(conversationId) {
3915
3975
  activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
3916
3976
 
3917
3977
  processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
3918
- .catch(err => debugLog(`[queue] Error processing queued message: ${err.message}`));
3978
+ .catch(err => {
3979
+ debugLog(`[queue] Error processing queued message: ${err.message}`);
3980
+ // CRITICAL: Clean up state on error so next message can be retried
3981
+ cleanupExecution(conversationId);
3982
+ broadcastSync({
3983
+ type: 'streaming_error',
3984
+ sessionId: session.id,
3985
+ conversationId,
3986
+ error: `Queue processing failed: ${err.message}`,
3987
+ recoverable: true,
3988
+ timestamp: Date.now()
3989
+ });
3990
+ // Try to drain next message in queue
3991
+ setTimeout(() => drainMessageQueue(conversationId), 100);
3992
+ });
3919
3993
  }
3920
3994
 
3921
3995
 
@@ -4026,7 +4100,8 @@ const wsRouter = new WsRouter();
4026
4100
 
4027
4101
  registerConvHandlers(wsRouter, {
4028
4102
  queries, activeExecutions, messageQueues, rateLimitState,
4029
- broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts
4103
+ broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts,
4104
+ cleanupExecution
4030
4105
  });
4031
4106
 
4032
4107
  console.log('[INIT] About to call registerSessionHandlers, discoveredAgents.length:', discoveredAgents.length);
@@ -4540,10 +4615,12 @@ function onServerReady() {
4540
4615
  }, 6000);
4541
4616
  }).catch(err => console.error('[ACP] Startup error:', err.message));
4542
4617
 
4543
- const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
4618
+ const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
4544
4619
  queries.initializeToolInstallations(toolIds.map(id => ({ id })));
4545
4620
  console.log('[TOOLS] Starting background provisioning...');
4546
- toolManager.autoProvision((evt) => {
4621
+
4622
+ // Create broadcast handler for tool events
4623
+ const toolBroadcaster = (evt) => {
4547
4624
  broadcastSync(evt);
4548
4625
  if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
4549
4626
  const d = evt.data || {};
@@ -4558,7 +4635,17 @@ function onServerReady() {
4558
4635
  queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
4559
4636
  }
4560
4637
  }
4561
- }).catch(err => console.error('[TOOLS] Auto-provision error:', err.message));
4638
+ };
4639
+
4640
+ // Initial provisioning (blocks until complete)
4641
+ toolManager.autoProvision(toolBroadcaster)
4642
+ .catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
4643
+ .then(() => {
4644
+ // Start periodic update checker AFTER initial provisioning completes
4645
+ // This runs in background and doesn't block GUI
4646
+ console.log('[TOOLS] Starting periodic update checker...');
4647
+ toolManager.startPeriodicUpdateCheck(toolBroadcaster);
4648
+ });
4562
4649
 
4563
4650
  ensureModelsDownloaded().then(async ok => {
4564
4651
  if (ok) console.log('[MODELS] Speech models ready');
@@ -157,6 +157,9 @@ class AgentGUIClient {
157
157
  this._recoverMissedChunks();
158
158
  this.updateSendButtonState();
159
159
  this.enablePromptArea();
160
+ if (this.state.currentConversation?.id) {
161
+ this.updateBusyPromptArea(this.state.currentConversation.id);
162
+ }
160
163
  this.emit('ws:connected');
161
164
  // Check if server was updated while client was loaded - reload if version changed
162
165
  if (window.__SERVER_VERSION) {
@@ -2045,8 +2048,6 @@ class AgentGUIClient {
2045
2048
  </div>
2046
2049
  </div>
2047
2050
  `;
2048
- // Keep loading spinner visible during hydration
2049
- this.showLoadingSpinner();
2050
2051
  }
2051
2052
 
2052
2053
  async streamToConversation(conversationId, prompt, agentId, model, subAgent) {
@@ -2084,6 +2085,7 @@ class AgentGUIClient {
2084
2085
 
2085
2086
  if (result.queued) {
2086
2087
  console.log('Message queued, position:', result.queuePosition);
2088
+ this.enableControls();
2087
2089
  return;
2088
2090
  }
2089
2091
 
@@ -2491,22 +2493,20 @@ class AgentGUIClient {
2491
2493
  }
2492
2494
 
2493
2495
  /**
2494
- * Disable UI controls during streaming
2495
- * NOTE: Prompt area is IMMUTABLE - always enabled while connected.
2496
- * Streaming state is rendered via queue/steer buttons, not input disabling.
2496
+ * Disable UI controls during execution - prevents double-sends
2497
2497
  */
2498
2498
  disableControls() {
2499
- // IMMUTABLE: Prompt state managed only by syncPromptState() based on WebSocket connection
2500
- // Never disable input during streaming - use queue/steer visibility instead
2499
+ if (this.ui.sendButton) this.ui.sendButton.disabled = true;
2501
2500
  }
2502
2501
 
2503
2502
  /**
2504
- * Enable UI controls
2505
- * NOTE: Prompt area is always enabled when connected.
2503
+ * Enable UI controls after execution completes or fails
2506
2504
  */
2507
2505
  enableControls() {
2508
- // IMMUTABLE: Prompt state managed only by syncPromptState() based on WebSocket connection
2509
- // Never disable input during streaming - use queue/steer visibility instead
2506
+ if (this.ui.sendButton) {
2507
+ this.ui.sendButton.disabled = !this.wsManager?.isConnected;
2508
+ }
2509
+ this.updateBusyPromptArea(this.state.currentConversation?.id);
2510
2510
  }
2511
2511
 
2512
2512
  /**
@@ -2686,10 +2686,6 @@ class AgentGUIClient {
2686
2686
  this.conversationCache.delete(conversationId);
2687
2687
  this.syncPromptState(conversationId);
2688
2688
  this.restoreScrollPosition(conversationId);
2689
- // Hydration complete - hide loading spinner
2690
- this.hideLoadingSpinner();
2691
- // Prompt state is immutable: computed from shouldResumeStreaming via syncPromptState
2692
- // Do not call enableControls/disableControls here - prompt state is determined by streaming status
2693
2689
  return;
2694
2690
  }
2695
2691
  }
@@ -2700,9 +2696,8 @@ class AgentGUIClient {
2700
2696
 
2701
2697
  let fullData;
2702
2698
  try {
2703
- // Load only recent chunks initially (lazy load older ones)
2704
- // Use chunkLimit of 50 to make page load faster
2705
2699
  fullData = await window.wsClient.rpc('conv.full', { id: conversationId, chunkLimit: 50 });
2700
+ if (convSignal.aborted) return;
2706
2701
  } catch (e) {
2707
2702
  if (e.code === 404) {
2708
2703
  console.warn('Conversation no longer exists:', conversationId);
@@ -2724,6 +2719,7 @@ class AgentGUIClient {
2724
2719
  }
2725
2720
  throw e;
2726
2721
  }
2722
+ if (convSignal.aborted) return;
2727
2723
  const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, totalChunks, messages: allMessages } = fullData;
2728
2724
 
2729
2725
  window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
@@ -2736,18 +2732,32 @@ class AgentGUIClient {
2736
2732
  ...chunk,
2737
2733
  block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
2738
2734
  }));
2739
- const userMessages = (allMessages || []).filter(m => m.role === 'user');
2735
+
2736
+ // Fetch queue to exclude queued messages from being rendered as regular user messages
2737
+ let queuedMessageIds = new Set();
2738
+ try {
2739
+ const { queue } = await window.wsClient.rpc('q.ls', { id: conversationId });
2740
+ if (queue && queue.length > 0) {
2741
+ queuedMessageIds = new Set(queue.map(q => q.messageId));
2742
+ }
2743
+ } catch (e) {
2744
+ console.warn('Failed to fetch queue:', e.message);
2745
+ }
2746
+
2747
+ // Filter out queued messages from user messages - they'll be rendered in queue indicator instead
2748
+ const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
2740
2749
  const hasMoreChunks = totalChunks && chunks.length < totalChunks;
2741
2750
 
2742
2751
  const clientKnowsStreaming = this.state.streamingConversations.has(conversationId);
2743
2752
  const shouldResumeStreaming = latestSession &&
2744
2753
  (latestSession.status === 'active' || latestSession.status === 'pending');
2745
2754
 
2746
- // IMMUTABLE: Update streaming state and disable prompt atomically
2747
2755
  if (shouldResumeStreaming) {
2748
2756
  this.state.streamingConversations.set(conversationId, true);
2757
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
2749
2758
  } else {
2750
2759
  this.state.streamingConversations.delete(conversationId);
2760
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
2751
2761
  }
2752
2762
 
2753
2763
  if (this.ui.messageInput) {
@@ -2948,12 +2958,13 @@ class AgentGUIClient {
2948
2958
 
2949
2959
  this.restoreScrollPosition(conversationId);
2950
2960
  this.setupScrollUpDetection(conversationId);
2951
- // Hydration complete - hide loading spinner
2952
- this.hideLoadingSpinner();
2961
+
2962
+ // Fetch and display queue items so queued messages show in yellow blocks, not as user messages
2963
+ this.fetchAndRenderQueue(conversationId);
2964
+
2953
2965
  }
2954
2966
  } catch (error) {
2955
2967
  if (error.name === 'AbortError') return;
2956
- this.hideLoadingSpinner();
2957
2968
  console.error('Failed to load conversation messages:', error);
2958
2969
  // Resume from last successful conversation if available, or fall back to any available conversation
2959
2970
  const fallbackConv = prevConversationId ? prevConversationId : availableFallback?.id;
@@ -3198,7 +3209,7 @@ class AgentGUIClient {
3198
3209
  }
3199
3210
 
3200
3211
  saveAgentAndModelToConversation() {
3201
- const convId = this.state.currentConversation;
3212
+ const convId = this.state.currentConversation?.id;
3202
3213
  if (!convId || this._agentLocked) return;
3203
3214
  const agentId = this.getEffectiveAgentId();
3204
3215
  const subAgent = this.getEffectiveSubAgent();
@@ -3338,6 +3349,7 @@ let agentGUIClient = null;
3338
3349
  document.addEventListener('DOMContentLoaded', async () => {
3339
3350
  try {
3340
3351
  agentGUIClient = new AgentGUIClient();
3352
+ window.agentGuiClient = agentGUIClient;
3341
3353
  await agentGUIClient.init();
3342
3354
  console.log('AgentGUI ready');
3343
3355
  } catch (error) {
@@ -433,8 +433,11 @@ class ConversationManager {
433
433
 
434
434
  this._updateConversations(convList, 'poll');
435
435
 
436
+ const clientStreamingMap = window.agentGuiClient?.state?.streamingConversations;
436
437
  for (const conv of this.conversations) {
437
- if (conv.isStreaming === 1 || conv.isStreaming === true) {
438
+ const serverStreaming = conv.isStreaming === 1 || conv.isStreaming === true;
439
+ const clientStreaming = clientStreamingMap ? clientStreamingMap.has(conv.id) : false;
440
+ if (serverStreaming || clientStreaming) {
438
441
  this.streamingConversations.add(conv.id);
439
442
  } else {
440
443
  this.streamingConversations.delete(conv.id);
@@ -39,6 +39,7 @@
39
39
  }
40
40
  function closeSidebar() {
41
41
  sidebar.classList.remove('mobile-visible');
42
+ sidebar.classList.add('collapsed'); // Ensure sidebar is hidden on close
42
43
  if (overlay) overlay.classList.remove('visible');
43
44
  }
44
45
  if (toggleBtn) toggleBtn.addEventListener('click', function(e) { e.stopPropagation(); toggleSidebar(); });
@@ -136,23 +136,35 @@ class StreamingRenderer {
136
136
 
137
137
  /**
138
138
  * Check if event is a duplicate
139
+ * For streaming_progress events, use seq+sessionId for precise dedup
140
+ * For other events, use type+id or type+sessionId
139
141
  */
140
142
  isDuplicate(event) {
141
143
  const key = this.getEventKey(event);
142
144
  if (!key) return false;
143
145
 
144
- const lastTime = this.dedupMap.get(key);
145
- const now = Date.now();
146
+ const lastSeq = this.dedupMap.get(key);
146
147
 
147
- if (lastTime && (now - lastTime) < 100) {
148
- return true;
148
+ // For streaming_progress with seq, compare seq numbers directly
149
+ if (event.type === 'streaming_progress' && event.seq !== undefined && lastSeq !== undefined) {
150
+ if (event.seq <= lastSeq) {
151
+ return true; // Same or older seq = duplicate
152
+ }
153
+ this.dedupMap.set(key, event.seq);
154
+ return false;
155
+ }
156
+
157
+ // For other events, use time-based dedup
158
+ const now = Date.now();
159
+ if (lastSeq && typeof lastSeq === 'number' && lastSeq > now - 500) {
160
+ return true; // Recent duplicate
149
161
  }
150
162
 
151
163
  this.dedupMap.set(key, now);
152
164
  if (this.dedupMap.size > 5000) {
153
- const cutoff = now - 1000;
154
- for (const [k, t] of this.dedupMap) {
155
- if (t < cutoff) this.dedupMap.delete(k);
165
+ const cutoff = now - 5000;
166
+ for (const [k, v] of this.dedupMap) {
167
+ if (typeof v === 'number' && v < cutoff) this.dedupMap.delete(k);
156
168
  }
157
169
  }
158
170
  return false;
@@ -160,10 +172,15 @@ class StreamingRenderer {
160
172
 
161
173
  /**
162
174
  * Generate deduplication key for event
175
+ * Use sessionId:seq for streaming_progress, fallback to type:id
163
176
  */
164
177
  getEventKey(event) {
165
178
  if (!event.type) return null;
166
- return `${event.type}:${event.id || event.sessionId || ''}`;
179
+ // For streaming events, use sessionId as primary key
180
+ if (event.sessionId) {
181
+ return `${event.sessionId}:${event.type}`;
182
+ }
183
+ return `${event.type}:${event.id || ''}`;
167
184
  }
168
185
 
169
186
  /**
@@ -480,8 +480,14 @@ class WebSocketManager {
480
480
  }
481
481
 
482
482
  resubscribeAll() {
483
+ // After reconnect, query server state for all conversations with active subscriptions
484
+ // This ensures client streaming state matches server state
485
+ const conversationIds = new Set();
483
486
  for (const key of this.activeSubscriptions) {
484
487
  const [type, id] = key.split(':');
488
+ if (type === 'conversation') {
489
+ conversationIds.add(id);
490
+ }
485
491
  const msg = { type: 'subscribe', timestamp: Date.now() };
486
492
  if (type === 'session') msg.sessionId = id;
487
493
  else msg.conversationId = id;
@@ -490,6 +496,21 @@ class WebSocketManager {
490
496
  this.stats.totalMessagesSent++;
491
497
  } catch (_) {}
492
498
  }
499
+
500
+ // After resubscribing, query streaming state for each conversation
501
+ // This prevents stale UI state after network hiccup
502
+ if (conversationIds.size > 0) {
503
+ conversationIds.forEach(convId => {
504
+ this.sendMessage({
505
+ type: 'conv.get',
506
+ id: convId,
507
+ timestamp: Date.now()
508
+ }).catch(() => {
509
+ // Silently ignore query failures - server will send streaming_start
510
+ // on subscription confirmation if execution is active
511
+ });
512
+ });
513
+ }
493
514
  }
494
515
 
495
516
  unsubscribeFromSession(sessionId) {