agentgui 1.0.130 → 1.0.138

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/static/index.html CHANGED
@@ -5,9 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <meta name="description" content="AgentGUI - Real-time Claude Code Execution Visualization">
7
7
  <title>AgentGUI</title>
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.ico">
8
9
 
9
- <link href="https://unpkg.com/rippleui@0.14.0/dist/ripple.css" rel="stylesheet">
10
- <link href="https://unpkg.com/prism@1.29.0/themes/prism-dark.css" rel="stylesheet">
10
+ <link href="https://cdn.jsdelivr.net/npm/rippleui@1.12.1/dist/css/styles.css" rel="stylesheet">
11
+ <link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-dark.css" rel="stylesheet">
11
12
  <link href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" rel="stylesheet">
12
13
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
13
14
 
@@ -161,6 +162,26 @@
161
162
 
162
163
  .conversation-item.active .conversation-item-meta { color: rgba(255,255,255,0.7); }
163
164
 
165
+ .conversation-streaming-badge {
166
+ display: inline-flex;
167
+ align-items: center;
168
+ margin-right: 0.375rem;
169
+ vertical-align: middle;
170
+ }
171
+
172
+ .streaming-dot {
173
+ display: inline-block;
174
+ width: 0.5rem;
175
+ height: 0.5rem;
176
+ border-radius: 50%;
177
+ background-color: var(--color-success);
178
+ animation: pulse 1.5s ease-in-out infinite;
179
+ }
180
+
181
+ .conversation-item.active .streaming-dot {
182
+ background-color: #fff;
183
+ }
184
+
164
185
  .conversation-item {
165
186
  display: flex;
166
187
  align-items: center;
@@ -801,6 +822,7 @@
801
822
  /* ===== File browser ===== */
802
823
  .file-browser-container { flex:1; min-height:0; overflow:hidden; }
803
824
  .file-browser-iframe { width:100%; height:100%; border:none; }
825
+ .voice-iframe { width:100%; height:100%; border:none; }
804
826
 
805
827
  /* ===== RESPONSIVE: MOBILE ===== */
806
828
  @media (max-width: 768px) {
@@ -894,6 +916,191 @@
894
916
  scrollbar-color: #475569 transparent;
895
917
  }
896
918
 
919
+ /* ===== VOICE VIEW ===== */
920
+ .voice-container {
921
+ flex: 1;
922
+ min-height: 0;
923
+ overflow: hidden;
924
+ display: flex;
925
+ flex-direction: column;
926
+ }
927
+
928
+ .voice-scroll {
929
+ flex: 1;
930
+ overflow-y: auto;
931
+ overflow-x: hidden;
932
+ padding: 1.5rem 2rem;
933
+ -webkit-overflow-scrolling: touch;
934
+ }
935
+
936
+ .voice-messages {
937
+ display: flex;
938
+ flex-direction: column;
939
+ gap: 1rem;
940
+ }
941
+
942
+ .voice-block {
943
+ padding: 1rem 1.25rem;
944
+ border-radius: 0.75rem;
945
+ background: var(--color-bg-secondary);
946
+ line-height: 1.7;
947
+ font-size: 1rem;
948
+ max-width: 85%;
949
+ margin-right: auto;
950
+ position: relative;
951
+ }
952
+
953
+ .voice-block.speaking {
954
+ box-shadow: 0 0 0 2px var(--color-primary);
955
+ }
956
+
957
+ .voice-block-user {
958
+ background: var(--color-primary);
959
+ color: white;
960
+ margin-left: auto;
961
+ margin-right: 0;
962
+ }
963
+
964
+ .voice-input-section {
965
+ flex-shrink: 0;
966
+ background: var(--color-bg-primary);
967
+ padding: 0.75rem 1rem;
968
+ border-top: 1px solid var(--color-border);
969
+ }
970
+
971
+ .voice-input-wrapper {
972
+ display: flex;
973
+ gap: 0.5rem;
974
+ align-items: center;
975
+ }
976
+
977
+ .voice-transcript {
978
+ flex: 1;
979
+ min-height: 40px;
980
+ max-height: 100px;
981
+ padding: 0.5rem 0.875rem;
982
+ border-radius: 0.75rem;
983
+ background: var(--color-bg-secondary);
984
+ color: var(--color-text-primary);
985
+ font-size: 0.9375rem;
986
+ line-height: 1.5;
987
+ overflow-y: auto;
988
+ white-space: pre-wrap;
989
+ word-break: break-word;
990
+ }
991
+
992
+ .voice-transcript:empty::before {
993
+ content: attr(data-placeholder);
994
+ color: var(--color-text-secondary);
995
+ }
996
+
997
+ .voice-mic-btn {
998
+ display: flex;
999
+ align-items: center;
1000
+ justify-content: center;
1001
+ width: 44px;
1002
+ height: 44px;
1003
+ background: var(--color-bg-secondary);
1004
+ color: var(--color-text-secondary);
1005
+ border: 2px solid var(--color-border);
1006
+ border-radius: 50%;
1007
+ cursor: pointer;
1008
+ flex-shrink: 0;
1009
+ transition: all 0.2s;
1010
+ }
1011
+
1012
+ .voice-mic-btn:hover {
1013
+ border-color: var(--color-primary);
1014
+ color: var(--color-primary);
1015
+ }
1016
+
1017
+ .voice-mic-btn.loading {
1018
+ opacity: 0.5;
1019
+ cursor: wait;
1020
+ animation: mic-loading-spin 2s linear infinite;
1021
+ }
1022
+
1023
+ @keyframes mic-loading-spin {
1024
+ 0% { border-color: var(--color-border); }
1025
+ 50% { border-color: var(--color-primary); }
1026
+ 100% { border-color: var(--color-border); }
1027
+ }
1028
+
1029
+ .voice-mic-btn.recording {
1030
+ background: var(--color-error);
1031
+ border-color: var(--color-error);
1032
+ color: white;
1033
+ animation: pulse 1s ease-in-out infinite;
1034
+ }
1035
+
1036
+ .voice-mic-btn svg {
1037
+ width: 20px;
1038
+ height: 20px;
1039
+ }
1040
+
1041
+ .voice-send-btn {
1042
+ flex-shrink: 0;
1043
+ }
1044
+
1045
+ .voice-tts-controls {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ justify-content: space-between;
1049
+ margin-top: 0.5rem;
1050
+ font-size: 0.8rem;
1051
+ color: var(--color-text-secondary);
1052
+ }
1053
+
1054
+ .voice-toggle-label {
1055
+ display: flex;
1056
+ align-items: center;
1057
+ gap: 0.375rem;
1058
+ cursor: pointer;
1059
+ user-select: none;
1060
+ }
1061
+
1062
+ .voice-toggle-label input[type="checkbox"] {
1063
+ accent-color: var(--color-primary);
1064
+ }
1065
+
1066
+ .voice-stop-btn {
1067
+ padding: 0.25rem 0.75rem;
1068
+ background: var(--color-bg-secondary);
1069
+ border: 1px solid var(--color-border);
1070
+ border-radius: 0.375rem;
1071
+ cursor: pointer;
1072
+ font-size: 0.75rem;
1073
+ color: var(--color-text-secondary);
1074
+ transition: all 0.15s;
1075
+ }
1076
+
1077
+ .voice-stop-btn:hover {
1078
+ background: var(--color-error);
1079
+ color: white;
1080
+ border-color: var(--color-error);
1081
+ }
1082
+
1083
+ .voice-empty {
1084
+ text-align: center;
1085
+ color: var(--color-text-secondary);
1086
+ padding: 4rem 2rem;
1087
+ font-size: 1rem;
1088
+ }
1089
+
1090
+ .voice-empty-icon {
1091
+ font-size: 3rem;
1092
+ margin-bottom: 1rem;
1093
+ opacity: 0.3;
1094
+ }
1095
+
1096
+ .voice-block .voice-result-stats {
1097
+ font-size: 0.8rem;
1098
+ color: var(--color-text-secondary);
1099
+ margin-top: 0.5rem;
1100
+ padding-top: 0.5rem;
1101
+ border-top: 1px solid var(--color-border);
1102
+ }
1103
+
897
1104
  /* ===== RESPONSIVE: TABLET ===== */
898
1105
  @media (min-width: 769px) and (max-width: 1024px) {
899
1106
  :root { --sidebar-width: 260px; }
@@ -1629,6 +1836,7 @@
1629
1836
  <div class="view-toggle-bar" id="viewToggleBar" style="display:none;">
1630
1837
  <button class="view-toggle-btn active" data-view="chat">Chat</button>
1631
1838
  <button class="view-toggle-btn" data-view="files">Files</button>
1839
+ <button class="view-toggle-btn" data-view="voice">Voice</button>
1632
1840
  </div>
1633
1841
 
1634
1842
  <!-- Messages scroll area -->
@@ -1646,7 +1854,40 @@
1646
1854
 
1647
1855
  <!-- File browser (hidden by default) -->
1648
1856
  <div id="fileBrowserContainer" class="file-browser-container" style="display:none;">
1649
- <iframe id="fileBrowserIframe" class="file-browser-iframe" sandbox="allow-same-origin allow-scripts allow-forms allow-popups"></iframe>
1857
+ <iframe id="fileBrowserIframe" class="file-browser-iframe"></iframe>
1858
+ </div>
1859
+
1860
+ <!-- Voice STT/TTS view -->
1861
+ <div id="voiceContainer" class="voice-container" style="display:none;">
1862
+ <div id="voiceScroll" class="voice-scroll">
1863
+ <div class="voice-messages" id="voiceMessages"></div>
1864
+ </div>
1865
+ <div class="voice-input-section">
1866
+ <div class="voice-input-wrapper">
1867
+ <select class="agent-selector voice-agent-selector" data-voice-agent-selector title="Select agent"></select>
1868
+ <div class="voice-transcript" id="voiceTranscript" data-placeholder="Tap mic and speak..."></div>
1869
+ <button class="voice-mic-btn" id="voiceMicBtn" title="Toggle recording" aria-label="Voice input">
1870
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1871
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
1872
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
1873
+ <line x1="12" y1="19" x2="12" y2="23"/>
1874
+ <line x1="8" y1="23" x2="16" y2="23"/>
1875
+ </svg>
1876
+ </button>
1877
+ <button class="send-btn voice-send-btn" id="voiceSendBtn" title="Send message" aria-label="Send message">
1878
+ <svg viewBox="0 0 24 24" fill="currentColor">
1879
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
1880
+ </svg>
1881
+ </button>
1882
+ </div>
1883
+ <div class="voice-tts-controls">
1884
+ <label class="voice-toggle-label">
1885
+ <input type="checkbox" id="voiceTTSToggle" checked>
1886
+ <span>Auto-speak responses</span>
1887
+ </label>
1888
+ <button class="voice-stop-btn" id="voiceStopSpeaking" title="Stop speaking">Stop</button>
1889
+ </div>
1890
+ </div>
1650
1891
  </div>
1651
1892
 
1652
1893
  <!-- Input area: fixed at bottom -->
@@ -1710,6 +1951,7 @@
1710
1951
  <script src="/gm/js/ui-components.js"></script>
1711
1952
  <script src="/gm/js/conversations.js"></script>
1712
1953
  <script src="/gm/js/client.js"></script>
1954
+ <script type="module" src="/gm/js/voice.js"></script>
1713
1955
  <script src="/gm/js/features.js"></script>
1714
1956
 
1715
1957
  <script>
@@ -192,6 +192,7 @@ class AgentGUIClient {
192
192
  */
193
193
  updateUrlForConversation(conversationId, sessionId) {
194
194
  if (!this.isValidId(conversationId)) return;
195
+ if (!this.routerState) return;
195
196
 
196
197
  this.routerState.currentConversationId = conversationId;
197
198
  if (sessionId && this.isValidId(sessionId)) {
@@ -344,6 +345,9 @@ class AgentGUIClient {
344
345
 
345
346
  handleWebSocketMessage(data) {
346
347
  try {
348
+ // Dispatch to window so other modules (conversations.js) can listen
349
+ window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
350
+
347
351
  switch (data.type) {
348
352
  case 'streaming_start':
349
353
  this.handleStreamingStart(data);
@@ -388,6 +392,15 @@ class AgentGUIClient {
388
392
 
389
393
  handleStreamingStart(data) {
390
394
  console.log('Streaming started:', data);
395
+
396
+ // If this streaming event is for a different conversation than what we are viewing,
397
+ // just track the state but do not modify the DOM or start polling
398
+ if (this.state.currentConversation?.id !== data.conversationId) {
399
+ console.log('Streaming started for non-active conversation:', data.conversationId);
400
+ this.emit('streaming:start', data);
401
+ return;
402
+ }
403
+
391
404
  this.state.isStreaming = true;
392
405
  this.state.currentSession = {
393
406
  id: data.sessionId,
@@ -490,8 +503,21 @@ class AgentGUIClient {
490
503
 
491
504
  handleStreamingError(data) {
492
505
  console.error('Streaming error:', data);
506
+
507
+ const conversationId = data.conversationId || this.state.currentSession?.conversationId;
508
+
509
+ // If this event is for a conversation we are NOT currently viewing, just track state
510
+ if (conversationId && this.state.currentConversation?.id !== conversationId) {
511
+ console.log('Streaming error for non-active conversation:', conversationId);
512
+ this.emit('streaming:error', data);
513
+ return;
514
+ }
515
+
493
516
  this.state.isStreaming = false;
494
517
 
518
+ // Stop polling for chunks
519
+ this.stopChunkPolling();
520
+
495
521
  const sessionId = data.sessionId || this.state.currentSession?.id;
496
522
  const streamingEl = document.getElementById(`streaming-${sessionId}`);
497
523
  if (streamingEl) {
@@ -507,6 +533,16 @@ class AgentGUIClient {
507
533
 
508
534
  handleStreamingComplete(data) {
509
535
  console.log('Streaming completed:', data);
536
+
537
+ const conversationId = data.conversationId || this.state.currentSession?.conversationId;
538
+
539
+ // If this event is for a conversation we are NOT currently viewing, just track state
540
+ if (conversationId && this.state.currentConversation?.id !== conversationId) {
541
+ console.log('Streaming completed for non-active conversation:', conversationId);
542
+ this.emit('streaming:complete', data);
543
+ return;
544
+ }
545
+
510
546
  this.state.isStreaming = false;
511
547
 
512
548
  // Stop polling for chunks
@@ -528,7 +564,6 @@ class AgentGUIClient {
528
564
  }
529
565
 
530
566
  // Save scroll position after streaming completes
531
- const conversationId = data.conversationId || this.state.currentSession?.conversationId;
532
567
  if (conversationId) {
533
568
  this.saveScrollPosition(conversationId);
534
569
  }
@@ -655,7 +690,8 @@ class AgentGUIClient {
655
690
  </div>
656
691
  `;
657
692
  } else {
658
- return `<div class="message-code"><pre>${this.escapeHtml(code)}</pre></div>`;
693
+ const lineCount = code.split('\n').length;
694
+ 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>`;
659
695
  }
660
696
  }
661
697
 
@@ -697,7 +733,8 @@ class AgentGUIClient {
697
733
  </div>
698
734
  `;
699
735
  } else {
700
- html += `<div class="message-code"><pre>${this.escapeHtml(block.code)}</pre></div>`;
736
+ const blkLineCount = block.code.split('\n').length;
737
+ 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>`;
701
738
  }
702
739
  } else if (block.type === 'tool_use') {
703
740
  let inputHtml = '';
@@ -1099,9 +1136,21 @@ class AgentGUIClient {
1099
1136
 
1100
1137
  async loadConversationMessages(conversationId) {
1101
1138
  try {
1139
+ // Save scroll position of current conversation before switching
1140
+ if (this.state.currentConversation?.id) {
1141
+ this.saveScrollPosition(this.state.currentConversation.id);
1142
+ }
1143
+
1102
1144
  // Stop any existing polling when switching conversations
1103
1145
  this.stopChunkPolling();
1104
1146
 
1147
+ // Clear streaming state from previous conversation view
1148
+ // (the actual streaming continues on the server, we just stop tracking it on the UI side)
1149
+ if (this.state.isStreaming && this.state.currentConversation?.id !== conversationId) {
1150
+ this.state.isStreaming = false;
1151
+ this.state.currentSession = null;
1152
+ }
1153
+
1105
1154
  const convResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}`);
1106
1155
  const { conversation, isActivelyStreaming, latestSession } = await convResponse.json();
1107
1156
  this.state.currentConversation = conversation;
@@ -1114,7 +1163,10 @@ class AgentGUIClient {
1114
1163
  }
1115
1164
 
1116
1165
  // Check if there's an active streaming session that needs to be resumed
1117
- const shouldResumeStreaming = isActivelyStreaming && latestSession && latestSession.status === 'active';
1166
+ // isActivelyStreaming comes from the server checking both in-memory activeExecutions map
1167
+ // AND database session status. Use it as primary signal, with session status as confirmation.
1168
+ const shouldResumeStreaming = isActivelyStreaming && latestSession &&
1169
+ (latestSession.status === 'active' || latestSession.status === 'pending');
1118
1170
 
1119
1171
  // Try to fetch chunks first (Wave 3 architecture)
1120
1172
  try {
@@ -1202,17 +1254,22 @@ class AgentGUIClient {
1202
1254
  startTime: latestSession.created_at
1203
1255
  };
1204
1256
 
1205
- // Subscribe to WebSocket updates
1257
+ // Subscribe to WebSocket updates for BOTH conversation and session
1206
1258
  if (this.wsManager.isConnected) {
1207
1259
  this.wsManager.subscribeToSession(latestSession.id);
1260
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
1208
1261
  }
1209
1262
 
1263
+ // Update URL with session ID
1264
+ this.updateUrlForConversation(conversationId, latestSession.id);
1265
+
1210
1266
  // Get the timestamp of the last chunk to start polling from
1267
+ // Use the last chunk's created_at to avoid re-fetching already-rendered chunks
1211
1268
  const lastChunkTime = chunks.length > 0
1212
1269
  ? chunks[chunks.length - 1].created_at
1213
- : Date.now();
1270
+ : 0;
1214
1271
 
1215
- // Start polling for new chunks
1272
+ // Start polling for new chunks from where we left off
1216
1273
  this.chunkPollState.lastFetchTimestamp = lastChunkTime;
1217
1274
  this.startChunkPolling(conversationId);
1218
1275
 
@@ -1300,7 +1357,8 @@ class AgentGUIClient {
1300
1357
  </div>
1301
1358
  `;
1302
1359
  } else {
1303
- contentHtml += `<div class="message-code"><pre>${this.escapeHtml(block.code)}</pre></div>`;
1360
+ const cBlkLineCount = block.code.split('\n').length;
1361
+ contentHtml += `<div class="message-code"><details class="collapsible-code"><summary class="collapsible-code-summary">${this.escapeHtml(block.language || 'code')} - ${cBlkLineCount} line${cBlkLineCount !== 1 ? 's' : ''}</summary><pre style="margin:0;border-radius:0 0 0.375rem 0.375rem">${this.escapeHtml(block.code)}</pre></details></div>`;
1304
1362
  }
1305
1363
  } else if (block.type === 'tool_use') {
1306
1364
  let inputHtml = '';
@@ -12,13 +12,16 @@ class ConversationManager {
12
12
  this.emptyEl = document.querySelector('[data-conversation-empty]');
13
13
  this.newBtn = document.querySelector('[data-new-conversation]');
14
14
  this.sidebarEl = document.querySelector('[data-sidebar]');
15
+ this.streamingConversations = new Set();
15
16
 
16
17
  this.folderBrowser = {
17
18
  modal: null,
18
19
  listEl: null,
19
20
  breadcrumbEl: null,
20
21
  currentPath: '~',
21
- homePath: '~'
22
+ homePath: '~',
23
+ cwdPath: null,
24
+ homePathReady: null
22
25
  };
23
26
 
24
27
  if (!this.listEl) return;
@@ -54,7 +57,7 @@ class ConversationManager {
54
57
  if (e.target === this.folderBrowser.modal) this.closeFolderBrowser();
55
58
  });
56
59
 
57
- this.fetchHomePath();
60
+ this.folderBrowser.homePathReady = this.fetchHomePath();
58
61
  }
59
62
 
60
63
  async fetchHomePath() {
@@ -63,20 +66,25 @@ class ConversationManager {
63
66
  if (res.ok) {
64
67
  const data = await res.json();
65
68
  this.folderBrowser.homePath = data.home || '~';
69
+ this.folderBrowser.cwdPath = data.cwd || null;
66
70
  }
67
71
  } catch (e) {
68
72
  console.error('Failed to fetch home path:', e);
69
73
  }
70
74
  }
71
75
 
72
- openFolderBrowser() {
76
+ async openFolderBrowser() {
73
77
  if (!this.folderBrowser.modal) {
74
78
  this.createNew();
75
79
  return;
76
80
  }
77
- this.folderBrowser.currentPath = '~';
81
+ if (this.folderBrowser.homePathReady) {
82
+ await this.folderBrowser.homePathReady;
83
+ }
84
+ const startPath = this.folderBrowser.cwdPath || '~';
85
+ this.folderBrowser.currentPath = startPath;
78
86
  this.folderBrowser.modal.classList.add('visible');
79
- this.loadFolders('~');
87
+ this.loadFolders(startPath);
80
88
  }
81
89
 
82
90
  closeFolderBrowser() {
@@ -191,6 +199,14 @@ class ConversationManager {
191
199
 
192
200
  const data = await res.json();
193
201
  this.conversations = data.conversations || [];
202
+
203
+ // Seed streaming state from database isStreaming flag
204
+ for (const conv of this.conversations) {
205
+ if (conv.isStreaming === 1 || conv.isStreaming === true) {
206
+ this.streamingConversations.add(conv.id);
207
+ }
208
+ }
209
+
194
210
  this.render();
195
211
  } catch (err) {
196
212
  console.error('Failed to load conversations:', err);
@@ -225,6 +241,8 @@ class ConversationManager {
225
241
  li.dataset.convId = conv.id;
226
242
  if (conv.id === this.activeId) li.classList.add('active');
227
243
 
244
+ const isStreaming = conv.isStreaming === 1 || conv.isStreaming === true || this.streamingConversations?.has(conv.id);
245
+
228
246
  const title = conv.title || `Conversation ${conv.id.slice(0, 8)}`;
229
247
  const timestamp = conv.created_at ? new Date(conv.created_at).toLocaleDateString() : 'Unknown';
230
248
  const agent = conv.agentType || 'unknown';
@@ -232,9 +250,13 @@ class ConversationManager {
232
250
  const metaParts = [agent, timestamp];
233
251
  if (wd) metaParts.push(wd);
234
252
 
253
+ const streamingBadge = isStreaming
254
+ ? '<span class="conversation-streaming-badge" title="Streaming in progress"><span class="streaming-dot"></span></span>'
255
+ : '';
256
+
235
257
  li.innerHTML = `
236
258
  <div class="conversation-item-content">
237
- <div class="conversation-item-title">${this.escapeHtml(title)}</div>
259
+ <div class="conversation-item-title">${streamingBadge}${this.escapeHtml(title)}</div>
238
260
  <div class="conversation-item-meta">${metaParts.join(' • ')}</div>
239
261
  </div>
240
262
  <button class="conversation-item-delete" title="Delete conversation" data-delete-conv="${conv.id}">
@@ -335,6 +357,12 @@ class ConversationManager {
335
357
  this.updateConversation(msg.conversation.id, msg.conversation);
336
358
  } else if (msg.type === 'conversation_deleted') {
337
359
  this.deleteConversation(msg.conversationId);
360
+ } else if (msg.type === 'streaming_start' && msg.conversationId) {
361
+ this.streamingConversations.add(msg.conversationId);
362
+ this.render();
363
+ } else if ((msg.type === 'streaming_complete' || msg.type === 'streaming_error') && msg.conversationId) {
364
+ this.streamingConversations.delete(msg.conversationId);
365
+ this.render();
338
366
  }
339
367
  });
340
368
  }
@@ -195,31 +195,31 @@
195
195
  var chatArea = document.getElementById('output-scroll');
196
196
  var execPanel = document.querySelector('.input-section');
197
197
  var fileBrowser = document.getElementById('fileBrowserContainer');
198
- var iframe = document.getElementById('fileBrowserIframe');
198
+ var fileIframe = document.getElementById('fileBrowserIframe');
199
+ var voiceContainer = document.getElementById('voiceContainer');
199
200
 
200
201
  if (!bar) return;
201
202
 
202
- // Update active button
203
203
  bar.querySelectorAll('.view-toggle-btn').forEach(function(btn) {
204
204
  btn.classList.toggle('active', btn.dataset.view === view);
205
205
  });
206
206
 
207
- if (view === 'files') {
208
- if (chatArea) chatArea.style.display = 'none';
209
- if (execPanel) execPanel.style.display = 'none';
210
- if (fileBrowser) {
211
- fileBrowser.style.display = 'flex';
212
- if (iframe && currentConversation) {
213
- var src = BASE + '/files/' + currentConversation + '/';
214
- if (iframe.src !== location.origin + src) {
215
- iframe.src = src;
216
- }
217
- }
207
+ if (chatArea) chatArea.style.display = view === 'chat' ? '' : 'none';
208
+ if (execPanel) execPanel.style.display = view === 'chat' ? '' : 'none';
209
+ if (fileBrowser) fileBrowser.style.display = view === 'files' ? 'flex' : 'none';
210
+ if (voiceContainer) voiceContainer.style.display = view === 'voice' ? 'flex' : 'none';
211
+
212
+ if (view === 'files' && fileIframe && currentConversation) {
213
+ var src = BASE + '/files/' + currentConversation + '/';
214
+ if (fileIframe.src !== location.origin + src) {
215
+ fileIframe.src = src;
218
216
  }
219
- } else {
220
- if (chatArea) chatArea.style.display = '';
221
- if (execPanel) execPanel.style.display = '';
222
- if (fileBrowser) fileBrowser.style.display = 'none';
217
+ }
218
+
219
+ if (view === 'voice' && window.voiceModule) {
220
+ window.voiceModule.activate();
221
+ } else if (view !== 'voice' && window.voiceModule) {
222
+ window.voiceModule.deactivate();
223
223
  }
224
224
  }
225
225