agentgui 1.0.8 → 1.0.10

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/database.js CHANGED
@@ -99,56 +99,54 @@ function migrateFromJson() {
99
99
  const content = fs.readFileSync(oldJsonPath, 'utf-8');
100
100
  const data = JSON.parse(content);
101
101
 
102
- if (data.conversations) {
103
- for (const id in data.conversations) {
104
- const conv = data.conversations[id];
105
- db.run(
106
- `INSERT OR REPLACE INTO conversations (id, agentId, title, created_at, updated_at, status) VALUES (?, ?, ?, ?, ?, ?)`,
107
- [conv.id, conv.agentId, conv.title || null, conv.created_at, conv.updated_at, conv.status || 'active']
108
- );
102
+ const migrationStmt = db.transaction(() => {
103
+ if (data.conversations) {
104
+ for (const id in data.conversations) {
105
+ const conv = data.conversations[id];
106
+ db.prepare(
107
+ `INSERT OR REPLACE INTO conversations (id, agentId, title, created_at, updated_at, status) VALUES (?, ?, ?, ?, ?, ?)`
108
+ ).run(conv.id, conv.agentId, conv.title || null, conv.created_at, conv.updated_at, conv.status || 'active');
109
+ }
109
110
  }
110
- }
111
111
 
112
- if (data.messages) {
113
- for (const id in data.messages) {
114
- const msg = data.messages[id];
115
- db.run(
116
- `INSERT OR REPLACE INTO messages (id, conversationId, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
117
- [msg.id, msg.conversationId, msg.role, msg.content, msg.created_at]
118
- );
112
+ if (data.messages) {
113
+ for (const id in data.messages) {
114
+ const msg = data.messages[id];
115
+ db.prepare(
116
+ `INSERT OR REPLACE INTO messages (id, conversationId, role, content, created_at) VALUES (?, ?, ?, ?, ?)`
117
+ ).run(msg.id, msg.conversationId, msg.role, msg.content, msg.created_at);
118
+ }
119
119
  }
120
- }
121
120
 
122
- if (data.sessions) {
123
- for (const id in data.sessions) {
124
- const sess = data.sessions[id];
125
- db.run(
126
- `INSERT OR REPLACE INTO sessions (id, conversationId, status, started_at, completed_at, response, error) VALUES (?, ?, ?, ?, ?, ?, ?)`,
127
- [sess.id, sess.conversationId, sess.status, sess.started_at, sess.completed_at || null, sess.response || null, sess.error || null]
128
- );
121
+ if (data.sessions) {
122
+ for (const id in data.sessions) {
123
+ const sess = data.sessions[id];
124
+ db.prepare(
125
+ `INSERT OR REPLACE INTO sessions (id, conversationId, status, started_at, completed_at, response, error) VALUES (?, ?, ?, ?, ?, ?, ?)`
126
+ ).run(sess.id, sess.conversationId, sess.status, sess.started_at, sess.completed_at || null, sess.response || null, sess.error || null);
127
+ }
129
128
  }
130
- }
131
129
 
132
- if (data.events) {
133
- for (const id in data.events) {
134
- const evt = data.events[id];
135
- db.run(
136
- `INSERT OR REPLACE INTO events (id, type, conversationId, sessionId, data, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
137
- [evt.id, evt.type, evt.conversationId || null, evt.sessionId || null, JSON.stringify(evt.data), evt.created_at]
138
- );
130
+ if (data.events) {
131
+ for (const id in data.events) {
132
+ const evt = data.events[id];
133
+ db.prepare(
134
+ `INSERT OR REPLACE INTO events (id, type, conversationId, sessionId, data, created_at) VALUES (?, ?, ?, ?, ?, ?)`
135
+ ).run(evt.id, evt.type, evt.conversationId || null, evt.sessionId || null, JSON.stringify(evt.data), evt.created_at);
136
+ }
139
137
  }
140
- }
141
138
 
142
- if (data.idempotencyKeys) {
143
- for (const key in data.idempotencyKeys) {
144
- const entry = data.idempotencyKeys[key];
145
- db.run(
146
- `INSERT OR REPLACE INTO idempotencyKeys (key, value, created_at, ttl) VALUES (?, ?, ?, ?)`,
147
- [key, JSON.stringify(entry.value), entry.created_at, entry.ttl]
148
- );
139
+ if (data.idempotencyKeys) {
140
+ for (const key in data.idempotencyKeys) {
141
+ const entry = data.idempotencyKeys[key];
142
+ db.prepare(
143
+ `INSERT OR REPLACE INTO idempotencyKeys (key, value, created_at, ttl) VALUES (?, ?, ?, ?)`
144
+ ).run(key, JSON.stringify(entry.value), entry.created_at, entry.ttl);
145
+ }
149
146
  }
150
- }
147
+ });
151
148
 
149
+ migrationStmt();
152
150
  fs.renameSync(oldJsonPath, `${oldJsonPath}.migrated`);
153
151
  console.log('Migrated data from JSON to SQLite');
154
152
  } catch (e) {
@@ -188,8 +186,8 @@ export const queries = {
188
186
  },
189
187
 
190
188
  getAllConversations() {
191
- const stmt = db.prepare('SELECT * FROM conversations ORDER BY updated_at DESC');
192
- return stmt.all();
189
+ const stmt = db.prepare('SELECT * FROM conversations WHERE status != ? ORDER BY updated_at DESC');
190
+ return stmt.all('deleted');
193
191
  },
194
192
 
195
193
  updateConversation(id, data) {
@@ -221,11 +219,12 @@ export const queries = {
221
219
 
222
220
  const id = generateId('msg');
223
221
  const now = Date.now();
222
+ const storedContent = typeof content === 'string' ? content : JSON.stringify(content);
224
223
 
225
224
  const stmt = db.prepare(
226
225
  `INSERT INTO messages (id, conversationId, role, content, created_at) VALUES (?, ?, ?, ?, ?)`
227
226
  );
228
- stmt.run(id, conversationId, role, content, now);
227
+ stmt.run(id, conversationId, role, storedContent, now);
229
228
 
230
229
  const updateConvStmt = db.prepare('UPDATE conversations SET updated_at = ? WHERE id = ?');
231
230
  updateConvStmt.run(now, conversationId);
@@ -294,7 +293,8 @@ export const queries = {
294
293
  if (!session) return null;
295
294
 
296
295
  const status = data.status !== undefined ? data.status : session.status;
297
- const response = data.response !== undefined ? data.response : session.response;
296
+ const rawResponse = data.response !== undefined ? data.response : session.response;
297
+ const response = rawResponse && typeof rawResponse === 'object' ? JSON.stringify(rawResponse) : rawResponse;
298
298
  const error = data.error !== undefined ? data.error : session.error;
299
299
  const completed_at = data.completed_at !== undefined ? data.completed_at : session.completed_at;
300
300
 
@@ -387,22 +387,28 @@ export const queries = {
387
387
  const conv = this.getConversation(id);
388
388
  if (!conv) return false;
389
389
 
390
- db.prepare('DELETE FROM events WHERE conversationId = ?').run(id);
391
- db.prepare('DELETE FROM sessions WHERE conversationId = ?').run(id);
392
- db.prepare('DELETE FROM messages WHERE conversationId = ?').run(id);
393
- db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
390
+ const deleteStmt = db.transaction(() => {
391
+ db.prepare('DELETE FROM events WHERE conversationId = ?').run(id);
392
+ db.prepare('DELETE FROM sessions WHERE conversationId = ?').run(id);
393
+ db.prepare('DELETE FROM messages WHERE conversationId = ?').run(id);
394
+ db.prepare('UPDATE conversations SET status = ? WHERE id = ?').run('deleted', id);
395
+ });
394
396
 
397
+ deleteStmt();
395
398
  return true;
396
399
  },
397
400
 
398
401
  cleanup() {
399
402
  const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
403
+ const now = Date.now();
400
404
 
401
- db.prepare('DELETE FROM events WHERE created_at < ?').run(thirtyDaysAgo);
402
- db.prepare('DELETE FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?').run(thirtyDaysAgo);
405
+ const cleanupStmt = db.transaction(() => {
406
+ db.prepare('DELETE FROM events WHERE created_at < ?').run(thirtyDaysAgo);
407
+ db.prepare('DELETE FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?').run(thirtyDaysAgo);
408
+ db.prepare('DELETE FROM idempotencyKeys WHERE (created_at + ttl) < ?').run(now);
409
+ });
403
410
 
404
- const now = Date.now();
405
- db.prepare('DELETE FROM idempotencyKeys WHERE (created_at + ttl) < ?').run(now);
411
+ cleanupStmt();
406
412
  },
407
413
 
408
414
  setIdempotencyKey(key, value) {
@@ -435,36 +441,37 @@ export const queries = {
435
441
  },
436
442
 
437
443
  discoverClaudeCodeConversations() {
438
- const claudeHomeDir = path.join(os.homedir(), '.claude-code');
439
- const conversationsDir = path.join(claudeHomeDir, 'conversations');
440
-
441
- if (!fs.existsSync(conversationsDir)) {
442
- return [];
443
- }
444
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
445
+ if (!fs.existsSync(projectsDir)) return [];
444
446
 
445
447
  const discovered = [];
446
448
  try {
447
- const items = fs.readdirSync(conversationsDir, { withFileTypes: true });
448
- for (const item of items) {
449
- if (!item.isDirectory()) continue;
450
-
451
- const metadataPath = path.join(conversationsDir, item.name, 'metadata.json');
452
- const messagesPath = path.join(conversationsDir, item.name, 'messages.json');
453
-
454
- if (!fs.existsSync(metadataPath) || !fs.existsSync(messagesPath)) continue;
449
+ const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
450
+ for (const dir of dirs) {
451
+ if (!dir.isDirectory()) continue;
452
+ const dirPath = path.join(projectsDir, dir.name);
453
+ const indexPath = path.join(dirPath, 'sessions-index.json');
454
+ if (!fs.existsSync(indexPath)) continue;
455
455
 
456
456
  try {
457
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
458
- const messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
459
-
460
- discovered.push({
461
- id: item.name,
462
- metadata,
463
- messages,
464
- source: 'claude-code'
465
- });
457
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
458
+ const projectPath = index.originalPath || dir.name.replace(/^-/, '/').replace(/-/g, '/');
459
+ for (const entry of (index.entries || [])) {
460
+ if (!entry.sessionId || entry.messageCount === 0) continue;
461
+ discovered.push({
462
+ id: entry.sessionId,
463
+ jsonlPath: entry.fullPath || path.join(dirPath, `${entry.sessionId}.jsonl`),
464
+ title: entry.summary || entry.firstPrompt || 'Claude Code Session',
465
+ projectPath,
466
+ created: entry.created ? new Date(entry.created).getTime() : entry.fileMtime,
467
+ modified: entry.modified ? new Date(entry.modified).getTime() : entry.fileMtime,
468
+ messageCount: entry.messageCount,
469
+ gitBranch: entry.gitBranch,
470
+ source: 'claude-code'
471
+ });
472
+ }
466
473
  } catch (e) {
467
- console.error(`Error reading Claude Code conversation ${item.name}:`, e.message);
474
+ console.error(`Error reading index ${indexPath}:`, e.message);
468
475
  }
469
476
  }
470
477
  } catch (e) {
@@ -474,39 +481,81 @@ export const queries = {
474
481
  return discovered;
475
482
  },
476
483
 
484
+ parseJsonlMessages(jsonlPath) {
485
+ if (!fs.existsSync(jsonlPath)) return [];
486
+ const messages = [];
487
+ try {
488
+ const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n');
489
+ for (const line of lines) {
490
+ if (!line.trim()) continue;
491
+ try {
492
+ const obj = JSON.parse(line);
493
+ if (obj.type === 'user' && obj.message?.content) {
494
+ const content = typeof obj.message.content === 'string'
495
+ ? obj.message.content
496
+ : Array.isArray(obj.message.content)
497
+ ? obj.message.content.filter(c => c.type === 'text').map(c => c.text).join('\n')
498
+ : JSON.stringify(obj.message.content);
499
+ if (content && !content.startsWith('[{"tool_use_id"')) {
500
+ messages.push({ id: obj.uuid || generateId('msg'), role: 'user', content, created_at: new Date(obj.timestamp).getTime() });
501
+ }
502
+ } else if (obj.type === 'assistant' && obj.message?.content) {
503
+ let text = '';
504
+ const content = obj.message.content;
505
+ if (Array.isArray(content)) {
506
+ for (const c of content) {
507
+ if (c.type === 'text' && c.text) text += c.text;
508
+ }
509
+ } else if (typeof content === 'string') {
510
+ text = content;
511
+ }
512
+ if (text) {
513
+ messages.push({ id: obj.uuid || generateId('msg'), role: 'assistant', content: text, created_at: new Date(obj.timestamp).getTime() });
514
+ }
515
+ }
516
+ } catch (_) {}
517
+ }
518
+ } catch (e) {
519
+ console.error(`Error parsing JSONL ${jsonlPath}:`, e.message);
520
+ }
521
+ return messages;
522
+ },
523
+
477
524
  importClaudeCodeConversations() {
478
525
  const discovered = this.discoverClaudeCodeConversations();
479
526
  const imported = [];
480
527
 
481
528
  for (const conv of discovered) {
482
529
  try {
483
- const existingConv = db.prepare('SELECT id FROM conversations WHERE id = ?').get(conv.id);
530
+ const existingConv = db.prepare('SELECT id, status FROM conversations WHERE id = ?').get(conv.id);
484
531
  if (existingConv) {
485
- imported.push({ id: conv.id, status: 'skipped', reason: 'Already imported' });
532
+ imported.push({ id: conv.id, status: 'skipped', reason: existingConv.status === 'deleted' ? 'deleted' : 'exists' });
486
533
  continue;
487
534
  }
488
535
 
489
- const title = conv.metadata?.title || 'Claude Code Conversation';
490
- const createdAt = conv.metadata?.created_at || Date.now();
491
- const updatedAt = conv.metadata?.updated_at || Date.now();
492
-
493
- db.prepare(
494
- `INSERT INTO conversations (id, agentId, title, created_at, updated_at, status) VALUES (?, ?, ?, ?, ?, ?)`
495
- ).run(conv.id, 'claude-code', title, createdAt, updatedAt, 'active');
496
-
497
- for (const msg of (conv.messages || [])) {
498
- try {
499
- db.prepare(
500
- `INSERT INTO messages (id, conversationId, role, content, created_at) VALUES (?, ?, ?, ?, ?)`
501
- ).run(msg.id || generateId('msg'), conv.id, msg.role || 'user', msg.content || '', msg.created_at || Date.now());
502
- } catch (e) {
503
- console.error(`Error importing message in conversation ${conv.id}:`, e.message);
536
+ const projectName = conv.projectPath ? path.basename(conv.projectPath) : '';
537
+ const title = conv.title || 'Claude Code Session';
538
+ const displayTitle = projectName ? `[${projectName}] ${title}` : title;
539
+
540
+ const messages = this.parseJsonlMessages(conv.jsonlPath);
541
+
542
+ const importStmt = db.transaction(() => {
543
+ db.prepare(
544
+ `INSERT INTO conversations (id, agentId, title, created_at, updated_at, status) VALUES (?, ?, ?, ?, ?, ?)`
545
+ ).run(conv.id, 'claude-code', displayTitle, conv.created, conv.modified, 'active');
546
+
547
+ for (const msg of messages) {
548
+ try {
549
+ db.prepare(
550
+ `INSERT INTO messages (id, conversationId, role, content, created_at) VALUES (?, ?, ?, ?, ?)`
551
+ ).run(msg.id, conv.id, msg.role, msg.content, msg.created_at);
552
+ } catch (_) {}
504
553
  }
505
- }
554
+ });
506
555
 
507
- imported.push({ id: conv.id, status: 'imported', title });
556
+ importStmt();
557
+ imported.push({ id: conv.id, status: 'imported', title: displayTitle, messages: messages.length });
508
558
  } catch (e) {
509
- console.error(`Error importing Claude Code conversation ${conv.id}:`, e.message);
510
559
  imported.push({ id: conv.id, status: 'error', error: e.message });
511
560
  }
512
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -330,7 +330,7 @@ async function processMessage(conversationId, messageId, sessionId, content, age
330
330
  const result = await conn.sendPrompt(content);
331
331
  conn.onUpdate = null;
332
332
 
333
- const responseText = fullText || (result?.stopReason ? `Completed: ${result.stopReason}` : 'No response.');
333
+ const responseText = fullText || result?.result || (result?.stopReason ? `Completed: ${result.stopReason}` : 'No response.');
334
334
  const messageContent = blocks.length > 0 ? { text: responseText, blocks } : responseText;
335
335
 
336
336
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
package/static/app.js CHANGED
@@ -454,6 +454,25 @@ class GMGUIApp {
454
454
  this.renderAgentCards();
455
455
  }
456
456
 
457
+ groupConsecutiveMessages(messages) {
458
+ if (!messages.length) return [];
459
+ const grouped = [];
460
+ let current = { ...messages[0], content: typeof messages[0].content === 'string' ? messages[0].content : messages[0].content };
461
+ for (let i = 1; i < messages.length; i++) {
462
+ const msg = messages[i];
463
+ if (msg.role === current.role && msg.role === 'assistant') {
464
+ const curText = typeof current.content === 'string' ? current.content : (current.content?.text || '');
465
+ const msgText = typeof msg.content === 'string' ? msg.content : (msg.content?.text || '');
466
+ current = { ...current, content: curText + '\n\n' + msgText };
467
+ } else {
468
+ grouped.push(current);
469
+ current = { ...msg };
470
+ }
471
+ }
472
+ grouped.push(current);
473
+ return grouped;
474
+ }
475
+
457
476
  async displayConversation(id) {
458
477
  this.currentConversation = id;
459
478
  const conv = this.conversations.get(id);
@@ -479,7 +498,8 @@ class GMGUIApp {
479
498
  `;
480
499
  this.renderAgentCards();
481
500
  } else {
482
- messages.forEach(msg => this.addMessageToDisplay(msg));
501
+ const grouped = this.groupConsecutiveMessages(messages);
502
+ grouped.forEach(msg => this.addMessageToDisplay(msg));
483
503
 
484
504
  if (this.settings.autoScroll) {
485
505
  div.scrollTop = div.scrollHeight;
@@ -490,43 +510,75 @@ class GMGUIApp {
490
510
  }
491
511
 
492
512
 
513
+ sanitizeHtml(raw) {
514
+ const tmp = document.createElement('div');
515
+ tmp.innerHTML = raw;
516
+ tmp.querySelectorAll('script,iframe,object,embed,form,meta,link').forEach(el => el.remove());
517
+ tmp.querySelectorAll('*').forEach(el => {
518
+ for (const attr of Array.from(el.attributes)) {
519
+ if (attr.name.startsWith('on')) el.removeAttribute(attr.name);
520
+ if (attr.name === 'href' && attr.value.trim().toLowerCase().startsWith('javascript:')) el.removeAttribute(attr.name);
521
+ }
522
+ });
523
+ return tmp.innerHTML;
524
+ }
525
+
526
+ looksLikeHtml(text) {
527
+ const trimmed = text.trim();
528
+ if (/^<[a-z][\s\S]*>/i.test(trimmed)) return true;
529
+ if (/<\/(div|span|p|table|ul|ol|h[1-6]|section|article|header|footer|nav|main|aside|details|summary|figure|figcaption|blockquote|pre|code|a|strong|em|img|br|hr)>/i.test(trimmed)) return true;
530
+ const tagCount = (trimmed.match(/<[a-z][^>]*>/gi) || []).length;
531
+ if (tagCount >= 3) return true;
532
+ return false;
533
+ }
534
+
493
535
  parseAndRenderContent(content) {
494
536
  const elements = [];
495
- if (typeof content === 'string') {
496
- const htmlCodeBlockRegex = /```html\n([\s\S]*?)\n```/g;
497
- let lastIndex = 0;
498
- let match;
499
-
500
- while ((match = htmlCodeBlockRegex.exec(content)) !== null) {
501
- if (match.index > lastIndex) {
502
- const textBefore = content.substring(lastIndex, match.index);
503
- if (textBefore.trim()) {
504
- const bubble = document.createElement('div');
505
- bubble.className = 'message-bubble';
506
- bubble.textContent = textBefore;
507
- elements.push(bubble);
508
- }
509
- }
537
+ if (typeof content !== 'string') return null;
510
538
 
511
- const htmlContent = match[1];
512
- const htmlEl = this.createHtmlBlock({ html: htmlContent });
513
- elements.push(htmlEl);
514
- lastIndex = htmlCodeBlockRegex.lastIndex;
515
- }
539
+ const htmlCodeBlockRegex = /```html\n([\s\S]*?)\n```/g;
540
+ let lastIndex = 0;
541
+ let match;
516
542
 
517
- if (lastIndex < content.length) {
518
- const textAfter = content.substring(lastIndex);
519
- if (textAfter.trim()) {
520
- const bubble = document.createElement('div');
521
- bubble.className = 'message-bubble';
522
- bubble.textContent = textAfter;
523
- elements.push(bubble);
543
+ while ((match = htmlCodeBlockRegex.exec(content)) !== null) {
544
+ if (match.index > lastIndex) {
545
+ const textBefore = content.substring(lastIndex, match.index);
546
+ if (textBefore.trim()) {
547
+ elements.push(this.renderTextOrHtml(textBefore));
524
548
  }
525
549
  }
550
+ elements.push(this.createSandboxedHtml(match[1]));
551
+ lastIndex = htmlCodeBlockRegex.lastIndex;
552
+ }
553
+
554
+ if (lastIndex < content.length) {
555
+ const remaining = content.substring(lastIndex);
556
+ if (remaining.trim()) {
557
+ elements.push(this.renderTextOrHtml(remaining));
558
+ }
559
+ }
526
560
 
527
- return elements.length > 0 ? elements : null;
561
+ return elements.length > 0 ? elements : null;
562
+ }
563
+
564
+ renderTextOrHtml(text) {
565
+ if (this.looksLikeHtml(text)) {
566
+ return this.createSandboxedHtml(text);
528
567
  }
529
- return null;
568
+ const bubble = document.createElement('div');
569
+ bubble.className = 'message-bubble';
570
+ bubble.textContent = text;
571
+ return bubble;
572
+ }
573
+
574
+ createSandboxedHtml(rawHtml) {
575
+ const wrap = document.createElement('div');
576
+ wrap.className = 'html-block rendered-html';
577
+ const content = document.createElement('div');
578
+ content.className = 'html-content';
579
+ content.innerHTML = this.sanitizeHtml(rawHtml);
580
+ wrap.appendChild(content);
581
+ return wrap;
530
582
  }
531
583
 
532
584
  addMessageToDisplay(msg) {
@@ -624,7 +676,8 @@ class GMGUIApp {
624
676
  const conv = this.conversations.get(this.currentConversation);
625
677
 
626
678
  const idempotencyKey = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
627
- this.addMessageToDisplay({ role: 'user', content: message });
679
+ const tempId = `pending-${idempotencyKey}`;
680
+ this.addMessageToDisplay({ role: 'user', content: message, id: tempId });
628
681
  input.value = '';
629
682
  this.updateSendButtonState();
630
683
 
@@ -646,6 +699,8 @@ class GMGUIApp {
646
699
  return;
647
700
  }
648
701
  const data = await res.json();
702
+ const optimisticEl = document.querySelector(`[data-message-id="${tempId}"]`);
703
+ if (optimisticEl) optimisticEl.dataset.messageId = data.message.id;
649
704
  this.idempotencyKeys.set(idempotencyKey, data.session.id);
650
705
  this.startPollingMessages(this.currentConversation);
651
706
  } catch (e) {
@@ -665,8 +720,12 @@ class GMGUIApp {
665
720
  if (this.pollingInterval) clearInterval(this.pollingInterval);
666
721
 
667
722
  let pollCount = 0;
668
- const maxNoResponsePolls = 60; // Stop polling after 60 polls with no change
669
- let lastMessageCount = 0;
723
+ const maxNoResponsePolls = 60;
724
+ let lastKnownIds = new Set(
725
+ Array.from(document.querySelectorAll('#chatMessages [data-message-id]'))
726
+ .map(el => el.dataset.messageId)
727
+ .filter(id => id && !id.startsWith('pending-'))
728
+ );
670
729
 
671
730
  this.pollingInterval = setInterval(async () => {
672
731
  try {
@@ -674,17 +733,19 @@ class GMGUIApp {
674
733
  const data = await res.json();
675
734
  const messages = data.messages || [];
676
735
 
677
- // If we got new messages, render them
678
- if (messages.length > lastMessageCount) {
679
- const newMessages = messages.slice(lastMessageCount);
680
- newMessages.forEach(msg => {
736
+ let added = false;
737
+ messages.forEach(msg => {
738
+ if (msg.id && !lastKnownIds.has(msg.id)) {
681
739
  const existingEl = document.querySelector(`[data-message-id="${msg.id}"]`);
682
740
  if (!existingEl) {
683
741
  this.addMessageToDisplay(msg);
742
+ added = true;
684
743
  }
685
- });
686
- lastMessageCount = messages.length;
687
- pollCount = 0; // Reset counter when we get activity
744
+ lastKnownIds.add(msg.id);
745
+ }
746
+ });
747
+ if (added) {
748
+ pollCount = 0;
688
749
 
689
750
  if (this.settings.autoScroll) {
690
751
  const div = document.getElementById('chatMessages');
@@ -775,7 +836,7 @@ class GMGUIApp {
775
836
 
776
837
  createHtmlBlock(event) {
777
838
  const wrap = document.createElement('div');
778
- wrap.className = 'html-block';
839
+ wrap.className = 'html-block rendered-html';
779
840
  if (event.id) wrap.id = `html-${event.id}`;
780
841
  if (event.title) {
781
842
  const header = document.createElement('div');
@@ -785,7 +846,7 @@ class GMGUIApp {
785
846
  }
786
847
  const content = document.createElement('div');
787
848
  content.className = 'html-content';
788
- content.innerHTML = event.html;
849
+ content.innerHTML = this.sanitizeHtml(event.html);
789
850
  wrap.appendChild(content);
790
851
  return wrap;
791
852
  }
@@ -812,22 +873,22 @@ class GMGUIApp {
812
873
 
813
874
  updateSendButtonState() {
814
875
  const input = document.getElementById('messageInput');
815
- const btn = document.getElementById('sendBtn');
816
- if (btn) btn.disabled = !input || !input.value.trim();
876
+ const sendBtn = document.getElementById('sendBtn');
877
+ if (sendBtn) sendBtn.disabled = !input || !input.value.trim();
817
878
  }
818
879
 
819
880
  openFolderBrowser() {
820
- const modal = document.getElementById('folderBrowserModal');
821
- if (!modal) return;
881
+ const dlgModal = document.getElementById('folderBrowserModal');
882
+ if (!dlgModal) return;
822
883
  const pathInput = document.getElementById('folderPath');
823
884
  pathInput.value = '~/';
824
885
  this.loadFolderContents(this.expandHome('~/'));
825
- modal.classList.add('active');
886
+ dlgModal.classList.add('active');
826
887
  }
827
888
 
828
889
  closeFolderBrowser() {
829
- const modal = document.getElementById('folderBrowserModal');
830
- if (modal) modal.classList.remove('active');
890
+ const dlgModal = document.getElementById('folderBrowserModal');
891
+ if (dlgModal) dlgModal.classList.remove('active');
831
892
  }
832
893
 
833
894
  async loadFolderContents(folderPath) {
@@ -895,13 +956,13 @@ function escapeHtml(text) {
895
956
  }
896
957
 
897
958
  function showNewChatModal() {
898
- const modal = document.getElementById('newChatModal');
899
- if (modal) modal.classList.add('active');
959
+ const dlgModal = document.getElementById('newChatModal');
960
+ if (dlgModal) dlgModal.classList.add('active');
900
961
  }
901
962
 
902
963
  function closeNewChatModal() {
903
- const modal = document.getElementById('newChatModal');
904
- if (modal) modal.classList.remove('active');
964
+ const dlgModal = document.getElementById('newChatModal');
965
+ if (dlgModal) dlgModal.classList.remove('active');
905
966
  }
906
967
 
907
968
  function createChatInWorkspace() {