agentgui 1.0.147 → 1.0.148

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/lib/speech.js CHANGED
@@ -1,4 +1,3 @@
1
- import { pipeline, env } from '@huggingface/transformers';
2
1
  import { createRequire } from 'module';
3
2
  import fs from 'fs';
4
3
  import path from 'path';
@@ -13,13 +12,21 @@ const SPEAKER_EMBEDDINGS_URL = 'https://huggingface.co/datasets/Xenova/transform
13
12
  const SPEAKER_EMBEDDINGS_PATH = path.join(DATA_DIR, 'speaker_embeddings.bin');
14
13
  const SAMPLE_RATE_TTS = 16000;
15
14
  const SAMPLE_RATE_STT = 16000;
15
+ const MIN_WAV_SIZE = 44;
16
16
 
17
+ let transformersModule = null;
17
18
  let sttPipeline = null;
18
19
  let ttsPipeline = null;
19
20
  let speakerEmbeddings = null;
20
21
  let sttLoading = false;
21
22
  let ttsLoading = false;
22
23
 
24
+ async function loadTransformers() {
25
+ if (transformersModule) return transformersModule;
26
+ transformersModule = await import('@huggingface/transformers');
27
+ return transformersModule;
28
+ }
29
+
23
30
  function whisperModelPath() {
24
31
  try {
25
32
  const webtalkDir = path.dirname(require.resolve('webtalk'));
@@ -46,10 +53,12 @@ async function getSTT() {
46
53
  if (sttPipeline) return sttPipeline;
47
54
  if (sttLoading) {
48
55
  while (sttLoading) await new Promise(r => setTimeout(r, 100));
56
+ if (!sttPipeline) throw new Error('STT pipeline failed to load');
49
57
  return sttPipeline;
50
58
  }
51
59
  sttLoading = true;
52
60
  try {
61
+ const { pipeline, env } = await loadTransformers();
53
62
  const modelPath = whisperModelPath();
54
63
  const isLocal = !modelPath.includes('/') || fs.existsSync(modelPath);
55
64
  env.allowLocalModels = true;
@@ -60,6 +69,9 @@ async function getSTT() {
60
69
  local_files_only: isLocal,
61
70
  });
62
71
  return sttPipeline;
72
+ } catch (err) {
73
+ sttPipeline = null;
74
+ throw new Error('STT model load failed: ' + err.message);
63
75
  } finally {
64
76
  sttLoading = false;
65
77
  }
@@ -69,10 +81,12 @@ async function getTTS() {
69
81
  if (ttsPipeline) return ttsPipeline;
70
82
  if (ttsLoading) {
71
83
  while (ttsLoading) await new Promise(r => setTimeout(r, 100));
84
+ if (!ttsPipeline) throw new Error('TTS pipeline failed to load');
72
85
  return ttsPipeline;
73
86
  }
74
87
  ttsLoading = true;
75
88
  try {
89
+ const { pipeline, env } = await loadTransformers();
76
90
  env.allowRemoteModels = true;
77
91
  ttsPipeline = await pipeline('text-to-speech', 'Xenova/speecht5_tts', {
78
92
  device: 'cpu',
@@ -80,6 +94,9 @@ async function getTTS() {
80
94
  });
81
95
  await ensureSpeakerEmbeddings();
82
96
  return ttsPipeline;
97
+ } catch (err) {
98
+ ttsPipeline = null;
99
+ throw new Error('TTS model load failed: ' + err.message);
83
100
  } finally {
84
101
  ttsLoading = false;
85
102
  }
@@ -159,12 +176,22 @@ function encodeWav(float32Audio, sampleRate) {
159
176
  }
160
177
 
161
178
  async function transcribe(audioBuffer) {
162
- const stt = await getSTT();
163
- let audio;
164
179
  const buf = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer);
180
+ if (buf.length < MIN_WAV_SIZE) {
181
+ throw new Error('Audio too short (' + buf.length + ' bytes)');
182
+ }
183
+ let audio;
165
184
  const isWav = buf.length > 4 && buf.toString('ascii', 0, 4) === 'RIFF';
166
185
  if (isWav) {
167
- const decoded = decodeWavToFloat32(buf);
186
+ let decoded;
187
+ try {
188
+ decoded = decodeWavToFloat32(buf);
189
+ } catch (err) {
190
+ throw new Error('WAV decode failed: ' + err.message);
191
+ }
192
+ if (!decoded.audio || decoded.audio.length === 0) {
193
+ throw new Error('WAV contains no audio samples');
194
+ }
168
195
  audio = resampleTo16k(decoded.audio, decoded.sampleRate);
169
196
  } else {
170
197
  const sampleCount = Math.floor(buf.byteLength / 4);
@@ -173,8 +200,20 @@ async function transcribe(audioBuffer) {
173
200
  new Uint8Array(aligned).set(buf.subarray(0, sampleCount * 4));
174
201
  audio = new Float32Array(aligned);
175
202
  }
176
- const result = await stt(audio);
177
- return result.text || '';
203
+ if (audio.length < 100) {
204
+ throw new Error('Audio too short for transcription');
205
+ }
206
+ const stt = await getSTT();
207
+ let result;
208
+ try {
209
+ result = await stt(audio);
210
+ } catch (err) {
211
+ throw new Error('Transcription engine error: ' + err.message);
212
+ }
213
+ if (!result || typeof result.text !== 'string') {
214
+ return '';
215
+ }
216
+ return result.text;
178
217
  }
179
218
 
180
219
  async function synthesize(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -8,7 +8,11 @@ import { execSync } from 'child_process';
8
8
  import { createRequire } from 'module';
9
9
  import { queries } from './database.js';
10
10
  import { runClaudeWithStreaming } from './lib/claude-runner.js';
11
- import { transcribe, synthesize, getStatus as getSpeechStatus } from './lib/speech.js';
11
+ let speechModule = null;
12
+ async function getSpeech() {
13
+ if (!speechModule) speechModule = await import('./lib/speech.js');
14
+ return speechModule;
15
+ }
12
16
 
13
17
  const require = createRequire(import.meta.url);
14
18
  const express = require('express');
@@ -327,6 +331,27 @@ const server = http.createServer(async (req, res) => {
327
331
  return;
328
332
  }
329
333
 
334
+ const fullLoadMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/full$/);
335
+ if (fullLoadMatch && req.method === 'GET') {
336
+ const conversationId = fullLoadMatch[1];
337
+ const conv = queries.getConversation(conversationId);
338
+ if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
339
+ const latestSession = queries.getLatestSession(conversationId);
340
+ const isActivelyStreaming = activeExecutions.has(conversationId) ||
341
+ (latestSession && latestSession.status === 'active');
342
+ const chunks = queries.getConversationChunks(conversationId);
343
+ const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
344
+ res.writeHead(200, { 'Content-Type': 'application/json' });
345
+ res.end(JSON.stringify({
346
+ conversation: conv,
347
+ isActivelyStreaming,
348
+ latestSession,
349
+ chunks,
350
+ messages: msgResult.messages
351
+ }));
352
+ return;
353
+ }
354
+
330
355
  const conversationChunksMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/chunks$/);
331
356
  if (conversationChunksMatch && req.method === 'GET') {
332
357
  const conversationId = conversationChunksMatch[1];
@@ -450,13 +475,16 @@ const server = http.createServer(async (req, res) => {
450
475
  res.end(JSON.stringify({ error: 'No audio data' }));
451
476
  return;
452
477
  }
478
+ const { transcribe } = await getSpeech();
453
479
  const text = await transcribe(audioBuffer);
454
480
  res.writeHead(200, { 'Content-Type': 'application/json' });
455
- res.end(JSON.stringify({ text: text.trim() }));
481
+ res.end(JSON.stringify({ text: (text || '').trim() }));
456
482
  } catch (err) {
457
483
  debugLog('[STT] Error: ' + err.message);
458
- res.writeHead(500, { 'Content-Type': 'application/json' });
459
- res.end(JSON.stringify({ error: err.message }));
484
+ if (!res.headersSent) {
485
+ res.writeHead(500, { 'Content-Type': 'application/json' });
486
+ }
487
+ res.end(JSON.stringify({ error: err.message || 'STT failed' }));
460
488
  }
461
489
  return;
462
490
  }
@@ -470,20 +498,29 @@ const server = http.createServer(async (req, res) => {
470
498
  res.end(JSON.stringify({ error: 'No text provided' }));
471
499
  return;
472
500
  }
501
+ const { synthesize } = await getSpeech();
473
502
  const wavBuffer = await synthesize(text);
474
503
  res.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': wavBuffer.length });
475
504
  res.end(wavBuffer);
476
505
  } catch (err) {
477
506
  debugLog('[TTS] Error: ' + err.message);
478
- res.writeHead(500, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ error: err.message }));
507
+ if (!res.headersSent) {
508
+ res.writeHead(500, { 'Content-Type': 'application/json' });
509
+ }
510
+ res.end(JSON.stringify({ error: err.message || 'TTS failed' }));
480
511
  }
481
512
  return;
482
513
  }
483
514
 
484
515
  if (routePath === '/api/speech-status' && req.method === 'GET') {
485
- res.writeHead(200, { 'Content-Type': 'application/json' });
486
- res.end(JSON.stringify(getSpeechStatus()));
516
+ try {
517
+ const { getStatus } = await getSpeech();
518
+ res.writeHead(200, { 'Content-Type': 'application/json' });
519
+ res.end(JSON.stringify(getStatus()));
520
+ } catch (err) {
521
+ res.writeHead(200, { 'Content-Type': 'application/json' });
522
+ res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
523
+ }
487
524
  return;
488
525
  }
489
526
 
@@ -420,7 +420,6 @@ class AgentGUIClient {
420
420
  if (outputEl) {
421
421
  let messagesEl = outputEl.querySelector('.conversation-messages');
422
422
  if (!messagesEl) {
423
- // Load existing conversation history before starting the stream
424
423
  const conv = this.state.currentConversation;
425
424
  const wdInfo = conv?.workingDirectory ? ` - ${this.escapeHtml(conv.workingDirectory)}` : '';
426
425
  outputEl.innerHTML = `
@@ -431,14 +430,16 @@ class AgentGUIClient {
431
430
  <div class="conversation-messages"></div>
432
431
  `;
433
432
  messagesEl = outputEl.querySelector('.conversation-messages');
434
- // Load prior messages into the container
435
433
  try {
436
- const msgResp = await fetch(window.__BASE_URL + `/api/conversations/${data.conversationId}/messages`);
437
- if (msgResp.ok) {
438
- const msgData = await msgResp.json();
439
- const priorChunks = await this.fetchChunks(data.conversationId, 0);
434
+ const fullResp = await fetch(window.__BASE_URL + `/api/conversations/${data.conversationId}/full`);
435
+ if (fullResp.ok) {
436
+ const fullData = await fullResp.json();
437
+ const priorChunks = (fullData.chunks || []).map(c => ({
438
+ ...c,
439
+ block: typeof c.data === 'string' ? JSON.parse(c.data) : c.data
440
+ }));
441
+ const userMsgs = (fullData.messages || []).filter(m => m.role === 'user');
440
442
  if (priorChunks.length > 0) {
441
- const userMsgs = (msgData.messages || []).filter(m => m.role === 'user');
442
443
  const sessionOrder = [];
443
444
  const sessionGroups = {};
444
445
  priorChunks.forEach(c => {
@@ -468,7 +469,7 @@ class AgentGUIClient {
468
469
  messagesEl.insertAdjacentHTML('beforeend', `<div class="message message-user" data-msg-id="${m.id}"><div class="message-role">User</div>${this.renderMessageContent(m.content)}<div class="message-timestamp">${new Date(m.created_at).toLocaleString()}</div></div>`);
469
470
  }
470
471
  } else {
471
- messagesEl.innerHTML = this.renderMessages(msgData.messages || []);
472
+ messagesEl.innerHTML = this.renderMessages(fullData.messages || []);
472
473
  }
473
474
  }
474
475
  } catch (e) {
@@ -1174,135 +1175,64 @@ class AgentGUIClient {
1174
1175
 
1175
1176
  async loadConversationMessages(conversationId) {
1176
1177
  try {
1177
- // Save scroll position of current conversation before switching
1178
1178
  if (this.state.currentConversation?.id) {
1179
1179
  this.saveScrollPosition(this.state.currentConversation.id);
1180
1180
  }
1181
-
1182
- // Stop any existing polling when switching conversations
1183
1181
  this.stopChunkPolling();
1184
-
1185
- // Clear streaming state from previous conversation view
1186
- // (the actual streaming continues on the server, we just stop tracking it on the UI side)
1187
1182
  if (this.state.isStreaming && this.state.currentConversation?.id !== conversationId) {
1188
1183
  this.state.isStreaming = false;
1189
1184
  this.state.currentSession = null;
1190
1185
  }
1191
1186
 
1192
- const convResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}`);
1193
- const { conversation, isActivelyStreaming, latestSession } = await convResponse.json();
1194
- this.state.currentConversation = conversation;
1195
-
1196
- // Update URL with conversation ID
1197
1187
  this.updateUrlForConversation(conversationId);
1198
-
1199
1188
  if (this.wsManager.isConnected) {
1200
1189
  this.wsManager.sendMessage({ type: 'subscribe', conversationId });
1201
1190
  }
1202
1191
 
1203
- // Check if there's an active streaming session that needs to be resumed
1204
- // isActivelyStreaming comes from the server checking both in-memory activeExecutions map
1205
- // AND database session status. Use it as primary signal, with session status as confirmation.
1206
- const shouldResumeStreaming = isActivelyStreaming && latestSession &&
1207
- (latestSession.status === 'active' || latestSession.status === 'pending');
1192
+ const resp = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/full`);
1193
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1194
+ const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, messages: allMessages } = await resp.json();
1208
1195
 
1209
- // Try to fetch chunks first (Wave 3 architecture)
1210
- try {
1211
- const chunks = await this.fetchChunks(conversationId, 0);
1212
-
1213
- const outputEl = document.getElementById('output');
1214
- if (outputEl) {
1215
- const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
1216
- outputEl.innerHTML = `
1217
- <div class="conversation-header">
1218
- <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
1219
- <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
1220
- </div>
1221
- <div class="conversation-messages"></div>
1222
- `;
1223
-
1224
- // Render all chunks
1225
- const messagesEl = outputEl.querySelector('.conversation-messages');
1226
- if (chunks.length > 0) {
1227
- // Fetch user messages to interleave with session chunks
1228
- let userMessages = [];
1229
- try {
1230
- const msgResp = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1231
- if (msgResp.ok) {
1232
- const msgData = await msgResp.json();
1233
- userMessages = (msgData.messages || []).filter(m => m.role === 'user');
1234
- }
1235
- } catch (_) {}
1236
-
1237
- // Group chunks by session, preserving order
1238
- const sessionOrder = [];
1239
- const sessionChunks = {};
1240
- chunks.forEach(chunk => {
1241
- if (!sessionChunks[chunk.sessionId]) {
1242
- sessionChunks[chunk.sessionId] = [];
1243
- sessionOrder.push(chunk.sessionId);
1244
- }
1245
- sessionChunks[chunk.sessionId].push(chunk);
1246
- });
1196
+ this.state.currentConversation = conversation;
1247
1197
 
1248
- // Build a timeline: match user messages to sessions by timestamp
1249
- let userMsgIdx = 0;
1250
- sessionOrder.forEach((sessionId) => {
1251
- const sessionChunkList = sessionChunks[sessionId];
1252
- const sessionStart = sessionChunkList[0].created_at;
1253
-
1254
- // Render user messages that came before this session
1255
- while (userMsgIdx < userMessages.length && userMessages[userMsgIdx].created_at <= sessionStart) {
1256
- const msg = userMessages[userMsgIdx];
1257
- const userDiv = document.createElement('div');
1258
- userDiv.className = 'message message-user';
1259
- userDiv.setAttribute('data-msg-id', msg.id);
1260
- userDiv.innerHTML = `
1261
- <div class="message-role">User</div>
1262
- ${this.renderMessageContent(msg.content)}
1263
- <div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>
1264
- `;
1265
- messagesEl.appendChild(userDiv);
1266
- userMsgIdx++;
1267
- }
1198
+ const chunks = (rawChunks || []).map(chunk => ({
1199
+ ...chunk,
1200
+ block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
1201
+ }));
1202
+ const userMessages = (allMessages || []).filter(m => m.role === 'user');
1268
1203
 
1269
- const isCurrentActiveSession = shouldResumeStreaming && latestSession && latestSession.id === sessionId;
1270
- const messageDiv = document.createElement('div');
1271
- messageDiv.className = `message message-assistant${isCurrentActiveSession ? ' streaming-message' : ''}`;
1272
- messageDiv.id = isCurrentActiveSession ? `streaming-${sessionId}` : `message-${sessionId}`;
1273
- messageDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks streaming-blocks"></div>';
1274
-
1275
- const blocksEl = messageDiv.querySelector('.message-blocks');
1276
- sessionChunkList.forEach(chunk => {
1277
- if (chunk.block && chunk.block.type) {
1278
- const element = this.renderer.renderBlock(chunk.block, chunk);
1279
- if (element) {
1280
- blocksEl.appendChild(element);
1281
- }
1282
- }
1283
- });
1204
+ const shouldResumeStreaming = isActivelyStreaming && latestSession &&
1205
+ (latestSession.status === 'active' || latestSession.status === 'pending');
1284
1206
 
1285
- if (isCurrentActiveSession) {
1286
- const indicatorDiv = document.createElement('div');
1287
- indicatorDiv.className = 'streaming-indicator';
1288
- indicatorDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
1289
- indicatorDiv.innerHTML = `
1290
- <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>
1291
- <span class="streaming-indicator-label">Processing...</span>
1292
- `;
1293
- messageDiv.appendChild(indicatorDiv);
1294
- } else {
1295
- const ts = document.createElement('div');
1296
- ts.className = 'message-timestamp';
1297
- ts.textContent = new Date(sessionChunkList[sessionChunkList.length - 1].created_at).toLocaleString();
1298
- messageDiv.appendChild(ts);
1299
- }
1207
+ const outputEl = document.getElementById('output');
1208
+ if (outputEl) {
1209
+ const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
1210
+ outputEl.innerHTML = `
1211
+ <div class="conversation-header">
1212
+ <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
1213
+ <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
1214
+ </div>
1215
+ <div class="conversation-messages"></div>
1216
+ `;
1300
1217
 
1301
- messagesEl.appendChild(messageDiv);
1302
- });
1218
+ const messagesEl = outputEl.querySelector('.conversation-messages');
1219
+ if (chunks.length > 0) {
1220
+ const sessionOrder = [];
1221
+ const sessionChunks = {};
1222
+ chunks.forEach(chunk => {
1223
+ if (!sessionChunks[chunk.sessionId]) {
1224
+ sessionChunks[chunk.sessionId] = [];
1225
+ sessionOrder.push(chunk.sessionId);
1226
+ }
1227
+ sessionChunks[chunk.sessionId].push(chunk);
1228
+ });
1229
+
1230
+ let userMsgIdx = 0;
1231
+ sessionOrder.forEach((sessionId) => {
1232
+ const sessionChunkList = sessionChunks[sessionId];
1233
+ const sessionStart = sessionChunkList[0].created_at;
1303
1234
 
1304
- // Render any remaining user messages after the last session
1305
- while (userMsgIdx < userMessages.length) {
1235
+ while (userMsgIdx < userMessages.length && userMessages[userMsgIdx].created_at <= sessionStart) {
1306
1236
  const msg = userMessages[userMsgIdx];
1307
1237
  const userDiv = document.createElement('div');
1308
1238
  userDiv.className = 'message message-user';
@@ -1315,78 +1245,83 @@ class AgentGUIClient {
1315
1245
  messagesEl.appendChild(userDiv);
1316
1246
  userMsgIdx++;
1317
1247
  }
1318
- } else {
1319
- // Fall back to messages if no chunks
1320
- const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1321
- if (messagesResponse.ok) {
1322
- const messagesData = await messagesResponse.json();
1323
- messagesEl.innerHTML = this.renderMessages(messagesData.messages || []);
1324
- }
1325
- }
1326
1248
 
1327
- // Resume streaming if needed
1328
- if (shouldResumeStreaming && latestSession) {
1329
- console.log('Resuming live streaming for session:', latestSession.id);
1330
-
1331
- // Set streaming state
1332
- this.state.isStreaming = true;
1333
- this.state.currentSession = {
1334
- id: latestSession.id,
1335
- conversationId: conversationId,
1336
- agentId: conversation.agentType || 'claude-code',
1337
- startTime: latestSession.created_at
1338
- };
1339
-
1340
- // Subscribe to WebSocket updates for BOTH conversation and session
1341
- if (this.wsManager.isConnected) {
1342
- this.wsManager.subscribeToSession(latestSession.id);
1343
- this.wsManager.sendMessage({ type: 'subscribe', conversationId });
1344
- }
1249
+ const isCurrentActiveSession = shouldResumeStreaming && latestSession && latestSession.id === sessionId;
1250
+ const messageDiv = document.createElement('div');
1251
+ messageDiv.className = `message message-assistant${isCurrentActiveSession ? ' streaming-message' : ''}`;
1252
+ messageDiv.id = isCurrentActiveSession ? `streaming-${sessionId}` : `message-${sessionId}`;
1253
+ messageDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks streaming-blocks"></div>';
1254
+
1255
+ const blocksEl = messageDiv.querySelector('.message-blocks');
1256
+ sessionChunkList.forEach(chunk => {
1257
+ if (chunk.block && chunk.block.type) {
1258
+ const element = this.renderer.renderBlock(chunk.block, chunk);
1259
+ if (element) blocksEl.appendChild(element);
1260
+ }
1261
+ });
1345
1262
 
1346
- // Update URL with session ID
1347
- this.updateUrlForConversation(conversationId, latestSession.id);
1263
+ if (isCurrentActiveSession) {
1264
+ const indicatorDiv = document.createElement('div');
1265
+ indicatorDiv.className = 'streaming-indicator';
1266
+ indicatorDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
1267
+ indicatorDiv.innerHTML = `
1268
+ <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>
1269
+ <span class="streaming-indicator-label">Processing...</span>
1270
+ `;
1271
+ messageDiv.appendChild(indicatorDiv);
1272
+ } else {
1273
+ const ts = document.createElement('div');
1274
+ ts.className = 'message-timestamp';
1275
+ ts.textContent = new Date(sessionChunkList[sessionChunkList.length - 1].created_at).toLocaleString();
1276
+ messageDiv.appendChild(ts);
1277
+ }
1348
1278
 
1349
- // Get the timestamp of the last chunk to start polling from
1350
- // Use the last chunk's created_at to avoid re-fetching already-rendered chunks
1351
- const lastChunkTime = chunks.length > 0
1352
- ? chunks[chunks.length - 1].created_at
1353
- : 0;
1279
+ messagesEl.appendChild(messageDiv);
1280
+ });
1354
1281
 
1355
- // Start polling for new chunks from where we left off
1356
- this.chunkPollState.lastFetchTimestamp = lastChunkTime;
1357
- this.startChunkPolling(conversationId);
1282
+ while (userMsgIdx < userMessages.length) {
1283
+ const msg = userMessages[userMsgIdx];
1284
+ const userDiv = document.createElement('div');
1285
+ userDiv.className = 'message message-user';
1286
+ userDiv.setAttribute('data-msg-id', msg.id);
1287
+ userDiv.innerHTML = `
1288
+ <div class="message-role">User</div>
1289
+ ${this.renderMessageContent(msg.content)}
1290
+ <div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>
1291
+ `;
1292
+ messagesEl.appendChild(userDiv);
1293
+ userMsgIdx++;
1294
+ }
1295
+ } else {
1296
+ messagesEl.innerHTML = this.renderMessages(allMessages || []);
1297
+ }
1358
1298
 
1359
- // Disable controls while streaming
1360
- this.disableControls();
1299
+ if (shouldResumeStreaming && latestSession) {
1300
+ this.state.isStreaming = true;
1301
+ this.state.currentSession = {
1302
+ id: latestSession.id,
1303
+ conversationId: conversationId,
1304
+ agentId: conversation.agentType || 'claude-code',
1305
+ startTime: latestSession.created_at
1306
+ };
1307
+
1308
+ if (this.wsManager.isConnected) {
1309
+ this.wsManager.subscribeToSession(latestSession.id);
1310
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
1361
1311
  }
1362
1312
 
1363
- // Restore scroll position after rendering
1364
- this.restoreScrollPosition(conversationId);
1365
- }
1366
- } catch (chunkError) {
1367
- console.warn('Failed to fetch chunks, falling back to messages:', chunkError);
1368
-
1369
- // Fallback: use messages
1370
- const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1371
- if (!messagesResponse.ok) throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
1372
- const messagesData = await messagesResponse.json();
1373
-
1374
- const outputEl = document.getElementById('output');
1375
- if (outputEl) {
1376
- const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
1377
- outputEl.innerHTML = `
1378
- <div class="conversation-header">
1379
- <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
1380
- <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
1381
- </div>
1382
- <div class="conversation-messages">
1383
- ${this.renderMessages(messagesData.messages || [])}
1384
- </div>
1385
- `;
1386
-
1387
- // Restore scroll position after rendering
1388
- this.restoreScrollPosition(conversationId);
1313
+ this.updateUrlForConversation(conversationId, latestSession.id);
1314
+
1315
+ const lastChunkTime = chunks.length > 0
1316
+ ? chunks[chunks.length - 1].created_at
1317
+ : 0;
1318
+
1319
+ this.chunkPollState.lastFetchTimestamp = lastChunkTime;
1320
+ this.startChunkPolling(conversationId);
1321
+ this.disableControls();
1389
1322
  }
1323
+
1324
+ this.restoreScrollPosition(conversationId);
1390
1325
  }
1391
1326
  } catch (error) {
1392
1327
  console.error('Failed to load conversation messages:', error);