From 427840c7f33663bb825f343228091ddf52b986e1 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 21 Dec 2025 20:32:10 -0600 Subject: [PATCH] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded group chat exports to true GitHub-flavored Markdown rendering! 🚀 - Added `marked` dependency and configured GFM plus line-break handling! ✨ - Exported messages now support tables, blockquotes, rules, and strikethrough! 🔥 - Code formatting improved: inline code and fenced blocks render correctly! 🧠 - Image embedding revamped: replaces references with base64 before parsing! 🖼️ - New branded export header with Maestro icon, tagline, and links! 🎉 - Export title now reads “Maestro Group Chat Export” for clarity! 🏷️ - Footer attribution updated to runmaestro.ai for consistency! 🌐 - Markdown styling massively enhanced: headings, lists, tables, and code look great! 🎨 - AutoRunLightbox tests updated for portal backdrop and higher z-index! ✅ --- package-lock.json | 23 +- package.json | 16 +- .../components/AutoRunLightbox.test.tsx | 38 +- .../renderer/utils/groupChatExport.test.ts | 123 ++++-- src/renderer/utils/groupChatExport.ts | 354 ++++++++++++++---- 5 files changed, 410 insertions(+), 144 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc2ffc6f..6b0336d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.10.0", + "version": "0.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.10.0", + "version": "0.10.2", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -31,6 +31,7 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.0.0", "qrcode": "^1.5.4", @@ -10151,9 +10152,9 @@ } }, "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -10543,6 +10544,18 @@ "uuid": "^11.1.0" } }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", diff --git a/package.json b/package.json index b7c16261..07da1c45 100644 --- a/package.json +++ b/package.json @@ -121,15 +121,24 @@ "target": [ { "target": "AppImage", - "arch": ["x64", "arm64"] + "arch": [ + "x64", + "arm64" + ] }, { "target": "deb", - "arch": ["x64", "arm64"] + "arch": [ + "x64", + "arm64" + ] }, { "target": "rpm", - "arch": ["x64", "arm64"] + "arch": [ + "x64", + "arm64" + ] } ], "category": "Development", @@ -198,6 +207,7 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.0.0", "qrcode": "^1.5.4", diff --git a/src/__tests__/renderer/components/AutoRunLightbox.test.tsx b/src/__tests__/renderer/components/AutoRunLightbox.test.tsx index 13d72303..1d313238 100644 --- a/src/__tests__/renderer/components/AutoRunLightbox.test.tsx +++ b/src/__tests__/renderer/components/AutoRunLightbox.test.tsx @@ -147,8 +147,8 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - // Should render the backdrop container - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + // Should render the backdrop container (rendered via portal to document.body) + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); expect(backdrop).toBeInTheDocument(); }); @@ -341,7 +341,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onNavigate }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'ArrowRight' }); expect(onNavigate).toHaveBeenCalledWith('image2.png'); @@ -355,7 +355,7 @@ describe('AutoRunLightbox', () => { }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'ArrowLeft' }); expect(onNavigate).toHaveBeenCalledWith('image1.png'); @@ -365,7 +365,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); const event = fireEvent.keyDown(backdrop!, { key: 'ArrowRight' }); // fireEvent.keyDown returns false when preventDefault is called @@ -376,7 +376,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); const event = fireEvent.keyDown(backdrop!, { key: 'ArrowLeft' }); expect(event).toBe(false); @@ -390,7 +390,7 @@ describe('AutoRunLightbox', () => { }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'ArrowRight' }); expect(onNavigate).not.toHaveBeenCalled(); @@ -406,7 +406,7 @@ describe('AutoRunLightbox', () => { }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'ArrowRight' }); expect(onNavigate).not.toHaveBeenCalled(); @@ -450,7 +450,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onDelete }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'Delete' }); // Confirm modal should appear @@ -469,7 +469,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onDelete }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'Backspace' }); // Confirm modal should appear @@ -486,7 +486,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); const event = fireEvent.keyDown(backdrop!, { key: 'Delete' }); expect(event).toBe(false); @@ -500,7 +500,7 @@ describe('AutoRunLightbox', () => { }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.keyDown(backdrop!, { key: 'Delete' }); expect(onDelete).not.toHaveBeenCalled(); @@ -510,7 +510,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onDelete: undefined }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); // This should not throw or cause issues fireEvent.keyDown(backdrop!, { key: 'Delete' }); // No assertion needed - just verifying it doesn't throw @@ -750,7 +750,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onClose }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); fireEvent.click(backdrop!); expect(onClose).toHaveBeenCalled(); @@ -871,7 +871,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); expect(backdrop).toHaveAttribute('tabIndex', '-1'); // The ref={(el) => el?.focus()} should focus the element expect(document.activeElement).toBe(backdrop); @@ -951,7 +951,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); expect(backdrop).toHaveClass('bg-black/90'); }); @@ -959,7 +959,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); expect(backdrop).toHaveClass('flex'); expect(backdrop).toHaveClass('items-center'); expect(backdrop).toHaveClass('justify-center'); @@ -1034,7 +1034,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps({ onNavigate }); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); // Rapid arrow key presses for (let i = 0; i < 10; i++) { @@ -1101,7 +1101,7 @@ describe('AutoRunLightbox', () => { const props = createDefaultProps(); renderWithProviders(); - const backdrop = document.querySelector('.fixed.inset-0.z-50'); + const backdrop = document.querySelector('.fixed.inset-0.z-\\[9999\\]'); expect(backdrop).toHaveAttribute('tabIndex', '-1'); }); }); diff --git a/src/__tests__/renderer/utils/groupChatExport.test.ts b/src/__tests__/renderer/utils/groupChatExport.test.ts index 74f9d6e9..32263a10 100644 --- a/src/__tests__/renderer/utils/groupChatExport.test.ts +++ b/src/__tests__/renderer/utils/groupChatExport.test.ts @@ -124,7 +124,7 @@ describe('groupChatExport', () => { const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('My Custom Chat - Group Chat Export'); + expect(html).toContain('My Custom Chat - Maestro Group Chat Export'); }); it('includes group chat name in header', () => { @@ -149,6 +149,49 @@ describe('groupChatExport', () => { }); }); + describe('branding', () => { + it('includes Maestro branding section', () => { + const groupChat = createMockGroupChat(); + const messages = createMockMessages(); + + const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); + + expect(html).toContain('class="branding"'); + expect(html).toContain('Maestro'); + expect(html).toContain('Multi-agent orchestration'); + }); + + it('includes runmaestro.ai link', () => { + const groupChat = createMockGroupChat(); + const messages = createMockMessages(); + + const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); + + expect(html).toContain('href="https://runmaestro.ai"'); + expect(html).toContain('runmaestro.ai'); + }); + + it('includes GitHub link', () => { + const groupChat = createMockGroupChat(); + const messages = createMockMessages(); + + const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); + + expect(html).toContain('href="https://github.com/pedramamini/Maestro"'); + expect(html).toContain('GitHub'); + }); + + it('includes Maestro logo SVG', () => { + const groupChat = createMockGroupChat(); + const messages = createMockMessages(); + + const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); + + expect(html).toContain('class="branding-logo"'); + expect(html).toContain(' { it('uses theme colors in CSS variables', () => { const groupChat = createMockGroupChat(); @@ -310,27 +353,29 @@ describe('groupChatExport', () => { }); }); - describe('content escaping and formatting', () => { - it('escapes HTML special characters in message content', () => { + describe('markdown rendering with marked library', () => { + it('renders tables correctly', () => { const groupChat = createMockGroupChat(); const messages: GroupChatMessage[] = [ - { timestamp: '2023-12-21T10:00:00Z', from: 'user', content: '
test
' }, + { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '| A | B |\n|---|---|\n| 1 | 2 |' }, ]; const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - // The message content should be escaped in the rendered HTML - expect(html).toContain('<div>test</div>'); + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain(''); }); - it('escapes HTML in group chat name in title', () => { - const groupChat = createMockGroupChat({ name: 'Bold Name' }); - const messages = createMockMessages(); + it('renders horizontal rules correctly', () => { + const groupChat = createMockGroupChat(); + const messages: GroupChatMessage[] = [ + { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: 'Before\n\n---\n\nAfter' }, + ]; const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - // The title should have escaped HTML - expect(html).toContain('<b>Bold Name</b> - Group Chat Export'); + expect(html).toContain(' { @@ -341,8 +386,7 @@ describe('groupChatExport', () => { const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('inline-code'); - expect(html).toContain('npm install'); + expect(html).toContain('npm install'); }); it('converts code blocks to pre tags', () => { @@ -357,7 +401,7 @@ describe('groupChatExport', () => { const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('code-block'); + expect(html).toContain('
');
         expect(html).toContain('const x = 1;');
       });
 
@@ -386,14 +430,17 @@ describe('groupChatExport', () => {
       it('converts markdown headers', () => {
         const groupChat = createMockGroupChat();
         const messages: GroupChatMessage[] = [
-          { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '# Heading 1\n## Heading 2\n### Heading 3' },
+          { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '# Heading 1\n\n## Heading 2\n\n### Heading 3' },
         ];
 
         const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme);
 
-        expect(html).toContain('

Heading 1

'); - expect(html).toContain('

Heading 2

'); - expect(html).toContain('

Heading 3

'); + expect(html).toContain(' { @@ -406,6 +453,7 @@ describe('groupChatExport', () => { expect(html).toContain('
  • Item 1
  • '); expect(html).toContain('
  • Item 2
  • '); + expect(html).toContain('
      '); }); it('converts markdown links', () => { @@ -416,18 +464,30 @@ describe('groupChatExport', () => { const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('this link'); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('this link'); }); - it('converts newlines to br tags', () => { + it('converts blockquotes', () => { const groupChat = createMockGroupChat(); const messages: GroupChatMessage[] = [ - { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: 'Line 1\nLine 2' }, + { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '> This is a quote' }, ]; const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('
      '); + expect(html).toContain('
      '); + }); + + it('converts strikethrough text', () => { + const groupChat = createMockGroupChat(); + const messages: GroupChatMessage[] = [ + { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '~~deleted~~' }, + ]; + + const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); + + expect(html).toContain('deleted'); }); }); @@ -442,10 +502,9 @@ describe('groupChatExport', () => { const html = generateGroupChatExportHtml(groupChat, messages, [], images, mockTheme); expect(html).toContain('src="data:image/png;base64,abc123"'); - expect(html).toContain('class="embedded-image"'); }); - it('handles image references in different formats', () => { + it('handles [Image: filename] pattern', () => { const groupChat = createMockGroupChat(); const messages: GroupChatMessage[] = [ { timestamp: '2023-12-21T10:00:00Z', from: 'Agent1', content: '[Image: photo.jpg]' }, @@ -533,24 +592,14 @@ describe('groupChatExport', () => { }); describe('footer', () => { - it('includes Maestro attribution', () => { + it('includes Maestro attribution with runmaestro.ai', () => { const groupChat = createMockGroupChat(); const messages = createMockMessages(); const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - expect(html).toContain('Maestro'); - expect(html).toContain('https://maestro.sh'); - }); - - it('does not include JSON extraction tip', () => { - const groupChat = createMockGroupChat(); - const messages = createMockMessages(); - - const html = generateGroupChatExportHtml(groupChat, messages, [], {}, mockTheme); - - // JSON was removed, so no extraction tip - expect(html).not.toContain('embedded JSON'); + expect(html).toContain('Exported from'); + expect(html).toContain('href="https://runmaestro.ai"'); }); }); diff --git a/src/renderer/utils/groupChatExport.ts b/src/renderer/utils/groupChatExport.ts index e563c0ee..957bfe49 100644 --- a/src/renderer/utils/groupChatExport.ts +++ b/src/renderer/utils/groupChatExport.ts @@ -3,9 +3,10 @@ * @description Export utility for Group Chat conversations. * * Generates a self-contained HTML file with the user's current theme colors - * and properly rendered markdown content. + * and properly rendered GitHub Flavored Markdown content using the marked library. */ +import { marked } from 'marked'; import type { GroupChat, GroupChatMessage, @@ -13,6 +14,12 @@ import type { Theme, } from '../types'; +// Configure marked for GFM (tables, strikethrough, etc.) +marked.setOptions({ + gfm: true, + breaks: true, +}); + /** * Escape HTML special characters */ @@ -68,63 +75,34 @@ function getParticipantColor( } /** - * Convert markdown-style formatting to HTML - * Accepts an images map to embed base64 images + * Escape special regex characters in a string + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Process content to embed images and render markdown */ function formatContent(content: string, images: Record = {}): string { - let html = escapeHtml(content); + let processed = content; - // Code blocks (```) - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => { - return `
      ${code.trim()}
      `; - }); + // Replace image references with base64 data URLs before markdown processing + for (const [filename, dataUrl] of Object.entries(images)) { + // Replace markdown image syntax ![alt](path/to/filename) + processed = processed.replace( + new RegExp(`!\\[([^\\]]*)\\]\\([^)]*${escapeRegExp(filename)}[^)]*\\)`, 'g'), + `![${filename}](${dataUrl})` + ); + // Replace [Image: filename] pattern + processed = processed.replace( + new RegExp(`\\[Image:\\s*${escapeRegExp(filename)}\\s*\\]`, 'gi'), + `![${filename}](${dataUrl})` + ); + } - // Inline code (`) - html = html.replace(/`([^`]+)`/g, '$1'); - - // Bold (**) - html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); - - // Italic (*) - html = html.replace(/\*([^*]+)\*/g, '$1'); - - // Headers (# ## ###) - html = html.replace(/^### (.+)$/gm, '

      $1

      '); - html = html.replace(/^## (.+)$/gm, '

      $1

      '); - html = html.replace(/^# (.+)$/gm, '

      $1

      '); - - // Bullet lists - html = html.replace(/^- (.+)$/gm, '
    • $1
    • '); - html = html.replace(/(
    • .*<\/li>\n?)+/g, '
        $&
      '); - - // Numbered lists - html = html.replace(/^\d+\. (.+)$/gm, '
    • $1
    • '); - - // Markdown images ![alt](url) - must come before links - html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { - // Check if this image filename has a base64 version - const filename = url.split('/').pop() || url; - const dataUrl = images[filename]; - if (dataUrl) { - return `${alt}`; - } - return `${alt}`; - }); - - // [Image: filename] pattern - html = html.replace(/\[Image: ([^\]]+)\]/gi, (_match, filename) => { - const dataUrl = images[filename.trim()]; - if (dataUrl) { - return `${filename.trim()}`; - } - return _match; // Leave as-is if no image data - }); - - // Links [text](url) - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); - - // Newlines to
      (but not inside code blocks or after block elements) - html = html.replace(/\n(?!<\/?(pre|ul|ol|li|h[1-3]))/g, '
      '); + // Render markdown to HTML using marked (synchronous) + const html = marked.parse(processed, { async: false }) as string; return html; } @@ -189,12 +167,15 @@ export function generateGroupChatExportHtml( // Build HTML document with theme colors const colors = theme.colors; + // Maestro app icon as base64 PNG (72x72) + const maestroIconBase64 = 'data:image/png;base64,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'; + return ` - ${escapeHtml(groupChat.name)} - Group Chat Export + ${escapeHtml(groupChat.name)} - Maestro Group Chat Export