Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: improve AI chat markdown rendering' (#173) from feat/ai-chat-markdown into main

scott 72c2265e 14312007

+282 -6
+70
src/css/app.css
··· 6916 6916 font-size: 0.85em; 6917 6917 } 6918 6918 6919 + /* Headers in AI responses */ 6920 + 6921 + .ai-chat-bubble .ai-h { 6922 + margin: var(--space-sm) 0 var(--space-xs); 6923 + line-height: 1.3; 6924 + } 6925 + 6926 + .ai-chat-bubble h1.ai-h { font-size: 1.1rem; } 6927 + .ai-chat-bubble h2.ai-h { font-size: 1rem; } 6928 + .ai-chat-bubble h3.ai-h { font-size: 0.9375rem; } 6929 + .ai-chat-bubble h4.ai-h, 6930 + .ai-chat-bubble h5.ai-h, 6931 + .ai-chat-bubble h6.ai-h { font-size: 0.875rem; } 6932 + 6933 + /* Tables in AI responses */ 6934 + 6935 + .ai-table { 6936 + border-collapse: collapse; 6937 + width: 100%; 6938 + margin: var(--space-xs) 0; 6939 + font-size: 0.8125rem; 6940 + } 6941 + 6942 + .ai-table th, 6943 + .ai-table td { 6944 + border: 1px solid var(--color-border); 6945 + padding: 3px 6px; 6946 + text-align: left; 6947 + } 6948 + 6949 + .ai-table th { 6950 + background: var(--color-surface-alt); 6951 + font-weight: 600; 6952 + } 6953 + 6954 + .ai-table tr:nth-child(even) td { 6955 + background: var(--color-bg); 6956 + } 6957 + 6958 + /* Lists in AI responses */ 6959 + 6960 + .ai-list { 6961 + margin: var(--space-xs) 0; 6962 + padding-left: 1.25em; 6963 + } 6964 + 6965 + .ai-list li { 6966 + margin-bottom: 2px; 6967 + } 6968 + 6969 + /* Horizontal rule in AI responses */ 6970 + 6971 + .ai-hr { 6972 + border: none; 6973 + border-top: 1px solid var(--color-border); 6974 + margin: var(--space-sm) 0; 6975 + } 6976 + 6977 + /* Links in AI responses */ 6978 + 6979 + .ai-chat-bubble a { 6980 + color: var(--color-accent); 6981 + text-decoration: underline; 6982 + text-underline-offset: 2px; 6983 + } 6984 + 6985 + .ai-chat-bubble a:hover { 6986 + color: var(--color-accent-hover); 6987 + } 6988 + 6919 6989 /* Input area */ 6920 6990 6921 6991 .ai-chat-input-area {
+134 -6
src/lib/ai-chat.ts
··· 361 361 362 362 /** Simple markdown-ish rendering: code blocks, inline code, bold, italic, links */ 363 363 export function renderMarkdown(text: string): string { 364 - // Code blocks 365 - let html = escapeHtml(text); 366 - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => { 367 - return `<pre class="ai-code-block" data-lang="${escapeHtml(lang)}"><code>${code.trim()}</code></pre>`; 364 + // Extract code blocks first to protect them from further processing 365 + const codeBlocks: string[] = []; 366 + let html = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => { 367 + const idx = codeBlocks.length; 368 + codeBlocks.push(`<pre class="ai-code-block" data-lang="${escapeHtml(lang)}"><code>${escapeHtml(code.trim())}</code></pre>`); 369 + return `\x00CB${idx}\x00`; 368 370 }); 371 + 372 + // Escape HTML in the rest 373 + html = escapeHtml(html); 374 + 375 + // Process block-level elements line by line 376 + const lines = html.split('\n'); 377 + const output: string[] = []; 378 + let i = 0; 379 + 380 + while (i < lines.length) { 381 + const line = lines[i]; 382 + 383 + // Code block placeholder 384 + const cbMatch = line.match(/\x00CB(\d+)\x00/); 385 + if (cbMatch) { 386 + output.push(codeBlocks[parseInt(cbMatch[1], 10)]); 387 + i++; 388 + continue; 389 + } 390 + 391 + // Horizontal rule 392 + if (/^---+$/.test(line.trim())) { 393 + output.push('<hr class="ai-hr">'); 394 + i++; 395 + continue; 396 + } 397 + 398 + // Headers (## H2, ### H3, etc.) 399 + const hMatch = line.match(/^(#{1,6})\s+(.+)$/); 400 + if (hMatch) { 401 + const level = hMatch[1].length; 402 + output.push(`<h${level} class="ai-h">${inlineMarkdown(hMatch[2])}</h${level}>`); 403 + i++; 404 + continue; 405 + } 406 + 407 + // Table: collect consecutive lines starting with | 408 + if (line.trimStart().startsWith('|')) { 409 + const tableLines: string[] = []; 410 + while (i < lines.length && lines[i].trimStart().startsWith('|')) { 411 + tableLines.push(lines[i]); 412 + i++; 413 + } 414 + output.push(renderTable(tableLines)); 415 + continue; 416 + } 417 + 418 + // Unordered list: collect consecutive lines starting with - or * 419 + if (/^\s*[-*]\s+/.test(line)) { 420 + const items: string[] = []; 421 + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { 422 + items.push(lines[i].replace(/^\s*[-*]\s+/, '')); 423 + i++; 424 + } 425 + output.push('<ul class="ai-list">' + items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('') + '</ul>'); 426 + continue; 427 + } 428 + 429 + // Ordered list: collect consecutive lines starting with digits. 430 + if (/^\s*\d+[.)]\s+/.test(line)) { 431 + const items: string[] = []; 432 + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { 433 + items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); 434 + i++; 435 + } 436 + output.push('<ol class="ai-list">' + items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('') + '</ol>'); 437 + continue; 438 + } 439 + 440 + // Regular line — apply inline markdown 441 + output.push(inlineMarkdown(line)); 442 + i++; 443 + } 444 + 445 + // Join with <br>, but block elements don't need extra breaks 446 + let result = ''; 447 + for (let j = 0; j < output.length; j++) { 448 + const chunk = output[j]; 449 + const isBlock = /^<(pre|h[1-6]|table|ul|ol|hr)[\s>]/.test(chunk); 450 + const prevIsBlock = j > 0 && /^<(pre|h[1-6]|table|ul|ol|hr)[\s>]/.test(output[j - 1]); 451 + if (j > 0 && !isBlock && !prevIsBlock && chunk !== '') { 452 + result += '<br>'; 453 + } 454 + result += chunk; 455 + } 456 + return result; 457 + } 458 + 459 + function inlineMarkdown(text: string): string { 460 + let html = text; 369 461 // Inline code 370 462 html = html.replace(/`([^`]+)`/g, '<code class="ai-inline-code">$1</code>'); 463 + // Links: [text](url) 464 + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); 371 465 // Bold 372 466 html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 373 467 // Italic 374 468 html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); 375 - // Line breaks 376 - html = html.replace(/\n/g, '<br>'); 469 + // Strikethrough 470 + html = html.replace(/~~(.+?)~~/g, '<del>$1</del>'); 377 471 return html; 472 + } 473 + 474 + function renderTable(lines: string[]): string { 475 + // Filter out separator rows (|---|---|) 476 + const dataLines = lines.filter(line => !/^\s*\|[\s\-:|]+\|\s*$/.test(line)); 477 + if (dataLines.length === 0) return ''; 478 + 479 + const parseRow = (line: string): string[] => 480 + line.split('|').slice(1, -1).map(cell => cell.trim()); 481 + 482 + const headerCells = parseRow(dataLines[0]); 483 + const bodyRows = dataLines.slice(1); 484 + 485 + let table = '<table class="ai-table"><thead><tr>'; 486 + for (const cell of headerCells) { 487 + table += `<th>${inlineMarkdown(cell)}</th>`; 488 + } 489 + table += '</tr></thead>'; 490 + 491 + if (bodyRows.length > 0) { 492 + table += '<tbody>'; 493 + for (const row of bodyRows) { 494 + table += '<tr>'; 495 + const cells = parseRow(row); 496 + for (let c = 0; c < headerCells.length; c++) { 497 + table += `<td>${inlineMarkdown(cells[c] || '')}</td>`; 498 + } 499 + table += '</tr>'; 500 + } 501 + table += '</tbody>'; 502 + } 503 + 504 + table += '</table>'; 505 + return table; 378 506 } 379 507 380 508 /**
+78
tests/ai-chat.test.ts
··· 201 201 const html = renderMarkdown('```\nhello\n```'); 202 202 expect(html).toContain('data-lang=""'); 203 203 }); 204 + 205 + it('renders headers', () => { 206 + expect(renderMarkdown('## Summary')).toContain('<h2 class="ai-h">Summary</h2>'); 207 + expect(renderMarkdown('### Details')).toContain('<h3 class="ai-h">Details</h3>'); 208 + expect(renderMarkdown('# Title')).toContain('<h1 class="ai-h">Title</h1>'); 209 + }); 210 + 211 + it('renders headers with inline formatting', () => { 212 + const html = renderMarkdown('## **Bold** header'); 213 + expect(html).toContain('<h2 class="ai-h"><strong>Bold</strong> header</h2>'); 214 + }); 215 + 216 + it('renders unordered lists', () => { 217 + const html = renderMarkdown('- item one\n- item two\n- item three'); 218 + expect(html).toContain('<ul class="ai-list">'); 219 + expect(html).toContain('<li>item one</li>'); 220 + expect(html).toContain('<li>item two</li>'); 221 + expect(html).toContain('<li>item three</li>'); 222 + expect(html).toContain('</ul>'); 223 + }); 224 + 225 + it('renders ordered lists', () => { 226 + const html = renderMarkdown('1. first\n2. second\n3. third'); 227 + expect(html).toContain('<ol class="ai-list">'); 228 + expect(html).toContain('<li>first</li>'); 229 + expect(html).toContain('<li>third</li>'); 230 + }); 231 + 232 + it('renders tables', () => { 233 + const md = '| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |'; 234 + const html = renderMarkdown(md); 235 + expect(html).toContain('<table class="ai-table">'); 236 + expect(html).toContain('<th>Name</th>'); 237 + expect(html).toContain('<th>Age</th>'); 238 + expect(html).toContain('<td>Alice</td>'); 239 + expect(html).toContain('<td>30</td>'); 240 + expect(html).toContain('<td>Bob</td>'); 241 + }); 242 + 243 + it('renders tables with inline formatting', () => { 244 + const md = '| Col |\n|---|\n| **bold** |'; 245 + const html = renderMarkdown(md); 246 + expect(html).toContain('<td><strong>bold</strong></td>'); 247 + }); 248 + 249 + it('renders horizontal rules', () => { 250 + const html = renderMarkdown('above\n---\nbelow'); 251 + expect(html).toContain('<hr class="ai-hr">'); 252 + }); 253 + 254 + it('renders links', () => { 255 + const html = renderMarkdown('Visit [Google](https://google.com) for search'); 256 + expect(html).toContain('<a href="https://google.com" target="_blank" rel="noopener">Google</a>'); 257 + }); 258 + 259 + it('renders strikethrough', () => { 260 + const html = renderMarkdown('This is ~~deleted~~ text'); 261 + expect(html).toContain('<del>deleted</del>'); 262 + }); 263 + 264 + it('renders lists with inline formatting', () => { 265 + const html = renderMarkdown('- **bold** item\n- `code` item'); 266 + expect(html).toContain('<li><strong>bold</strong> item</li>'); 267 + expect(html).toContain('<li><code class="ai-inline-code">code</code> item</li>'); 268 + }); 269 + 270 + it('does not add br between block elements', () => { 271 + const html = renderMarkdown('## Title\n- item'); 272 + expect(html).not.toContain('</h2><br>'); 273 + }); 274 + 275 + it('renders mixed content correctly', () => { 276 + const md = '## Summary\n- First point\n- Second point\n\nSome text here'; 277 + const html = renderMarkdown(md); 278 + expect(html).toContain('<h2 class="ai-h">Summary</h2>'); 279 + expect(html).toContain('<ul class="ai-list">'); 280 + expect(html).toContain('Some text here'); 281 + }); 204 282 }); 205 283 206 284 // ── Model options ──────────────────────────────────────────────────────