diff --git a/CHANGELOG.md b/CHANGELOG.md index e47c04a7..0b1d4d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ ### Fixed +## [v0.50.240] — 2026-04-30 + +### Added +- **Compact tool activity mode (`simplified_tool_calling`)** — new setting (default on) groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn instead of showing every step as a separate visible row. Keeps long agent runs readable while keeping full transparency a click away. Also adds a **Calm Console** theme (`calm`) with earth/slate palette and serif assistant prose. (`api/config.py`, `static/ui.js`, `static/panels.js`, `static/boot.js`, `static/style.css`, `DESIGN.md`) @Michaelyklam — PR #1282 +- **PDF first-page preview** — `MEDIA:` links to `.pdf` files now lazy-load a canvas preview of page 1 via PDF.js CDN (4 MB cap, download fallback). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed ``; + }) + .catch(()=>{ + const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1'; + el.outerHTML=`
📎 ${esc(fname)}
${t('html_error')}
`; + }); + }); +} + function renderMermaidBlocks(){ const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])'); if(!blocks.length) return; @@ -3434,30 +3882,44 @@ function renderKatexBlocks(){ function _thinkingMarkup(text=''){ const clean=_sanitizeThinkingDisplayText(text); + const openClass=isSimplifiedToolCalling()?'':' open'; return (clean&&String(clean).trim()) - ? `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(String(clean).trim())}
` + ? `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(String(clean).trim())}
` : `
`; } function finalizeThinkingCard(){ - const row=$('thinkingRow'); - if(!row) return; - // If the row is still just a spinner (no thinking content rendered), - // remove it entirely — it's the initial waiting dots. - const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row'); - if(!hasContent && row.getAttribute('data-thinking-active')==='1'){ - row.remove(); + if(!isSimplifiedToolCalling()){ + const row=$('thinkingRow'); + if(!row) return; + // If the row is still just a spinner (no thinking content rendered), + // remove it entirely — it's the initial waiting dots. + const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row'); + if(!hasContent && row.getAttribute('data-thinking-active')==='1'){ + row.remove(); + return; + } + // If the user was watching (scroll pinned = at bottom), scroll the thinking + // card back to the top so the completed response is visible underneath without + // the thinking content blocking it. If they scrolled up to read history, + // leave their scroll position intact. + if(_scrollPinned){ + const body=row&&row.querySelector('.thinking-card-body'); + if(body) body.scrollTop=0; + } + row.removeAttribute('id'); + row.removeAttribute('data-thinking-active'); return; } - // If the user was watching (scroll pinned = at bottom), scroll the thinking - // card back to the top so the completed response is visible underneath without - // the thinking content blocking it. If they scrolled up to read history, - // leave their scroll position intact. - if(_scrollPinned){ - const body=row&&row.querySelector('.thinking-card-body'); - if(body) body.scrollTop=0; + const turn=$('liveAssistantTurn'); + const group=turn&&turn.querySelector('.tool-call-group[data-live-tool-call-group="1"]'); + if(group){ + group.classList.add('tool-call-group-collapsed'); + const summary=group.querySelector('.tool-call-group-summary'); + if(summary) summary.setAttribute('aria-expanded','false'); + const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); + if(active) active.removeAttribute('data-thinking-active'); + _syncToolCallGroupSummary(group); } - row.removeAttribute('id'); - row.removeAttribute('data-thinking-active'); } function appendThinking(text=''){ // Guard: ignore if session was switched during an async SSE stream. @@ -3472,40 +3934,81 @@ function appendThinking(text=''){ $('msgInner').appendChild(turn); } const blocks=_assistantTurnBlocks(turn); - let row=$('thinkingRow'); + if(!blocks) return; + if(!isSimplifiedToolCalling()){ + let row=$('thinkingRow'); + if(!row){ + row=document.createElement('div'); + row.className='assistant-segment'; + row.id='thinkingRow'; + row.setAttribute('data-thinking-active','1'); + // Insert after whichever comes last: a live assistant segment or a tool card. + // This mirrors appendLiveToolCard's anchor logic so thinking always appears + // in the right position in the interleaved sequence. + // Also skip #toolRunningRow (dots) — thinking should go before dots, not after. + const allChildren=Array.from(blocks.children); + const anchor=allChildren.filter(el=> + el.id!=='toolRunningRow' && + el.matches('[data-live-assistant="1"],.tool-card-row') + ).pop(); + if(anchor) anchor.insertAdjacentElement('afterend', row); + else blocks.appendChild(row); + } + row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment'; + row.innerHTML=_thinkingMarkup(text); + scrollIfPinned(); + // Auto-scroll the thinking card body to bottom if the user is watching + // (scroll pinned). If the user scrolled up to read history, leave it alone. + if(_scrollPinned){ + const body=row&&row.querySelector('.thinking-card-body'); + if(body) body.scrollTop=body.scrollHeight; + } + return; + } + if(!String(text||'').trim()){ + scrollIfPinned(); + return; + } + const allChildren=Array.from(blocks.children); + const anchor=allChildren.filter(el=> + el.id!=='toolRunningRow' && + el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking') + ).pop(); + const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor}); + const body=group&&group.querySelector('.tool-call-group-body'); + if(!body) return; + let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(!row){ row=document.createElement('div'); - row.className='assistant-segment'; - row.id='thinkingRow'; + row.className='agent-activity-thinking'; row.setAttribute('data-thinking-active','1'); - // Insert after whichever comes last: a live assistant segment or a tool card. - // This mirrors appendLiveToolCard's anchor logic so thinking always appears - // in the right position in the interleaved sequence. - // Also skip #toolRunningRow (dots) — thinking should go before dots, not after. - const allChildren=Array.from(blocks.children); - const anchor=allChildren.filter(el=> - el.id!=='toolRunningRow' && - el.matches('[data-live-assistant="1"],.tool-card-row') - ).pop(); - if(anchor) anchor.insertAdjacentElement('afterend', row); - else blocks.appendChild(row); + body.insertBefore(row, body.firstChild); } - row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment'; row.innerHTML=_thinkingMarkup(text); + _syncToolCallGroupSummary(group); scrollIfPinned(); - // Auto-scroll the thinking card body to bottom if the user is watching - // (scroll pinned). If the user scrolled up to read history, leave it alone. if(_scrollPinned){ - const body=row&&row.querySelector('.thinking-card-body'); - if(body) body.scrollTop=body.scrollHeight; + const thinkingBody=row&&row.querySelector('.thinking-card-body'); + if(thinkingBody) thinkingBody.scrollTop=thinkingBody.scrollHeight; } } function updateThinking(text=''){appendThinking(text);} function removeThinking(){ - const el=$('thinkingRow'); - if(el) el.remove(); + if(!isSimplifiedToolCalling()){ + const el=$('thinkingRow'); + if(el) el.remove(); + const turn=$('liveAssistantTurn'); + const blocks=_assistantTurnBlocks(turn); + if(turn&&blocks&&!blocks.children.length) turn.remove(); + return; + } const turn=$('liveAssistantTurn'); const blocks=_assistantTurnBlocks(turn); + if(blocks) blocks.querySelectorAll('.agent-activity-thinking').forEach(el=>el.remove()); + if(blocks) blocks.querySelectorAll('.tool-call-group[data-agent-activity-group="1"]').forEach(group=>{ + _syncToolCallGroupSummary(group); + if(!group.querySelector('.tool-card-row,.agent-activity-thinking')) group.remove(); + }); if(turn&&blocks&&!blocks.children.length) turn.remove(); } @@ -3884,6 +4387,19 @@ function renderTray(){ chip.className='attach-chip attach-chip--image'; chip.dataset.blobUrl=blobUrl; chip.innerHTML=`${esc(f.name)}`; + } else if(_SVG_EXTS.test(f.name)){ + const blobUrl=URL.createObjectURL(f); + chip.className='attach-chip attach-chip--image'; + chip.dataset.blobUrl=blobUrl; + chip.innerHTML=`${esc(f.name)}`; + } else if(_AUDIO_EXTS.test(f.name)){ + const blobUrl=URL.createObjectURL(f); + chip.className='attach-chip attach-chip--audio'; + chip.innerHTML=`🎵 ${esc(f.name)}`; + } else if(_VIDEO_EXTS.test(f.name)){ + const blobUrl=URL.createObjectURL(f); + chip.className='attach-chip attach-chip--video'; + chip.innerHTML=`🎬 ${esc(f.name)}`; } else { chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} `; } diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py new file mode 100644 index 00000000..fce794b2 --- /dev/null +++ b/tests/test_csv_table_rendering.py @@ -0,0 +1,139 @@ +"""Test: CSV table rendering (#485)""" +import re + + +def test_csv_extension_regex(): + """Verify _CSV_EXTS regex is defined.""" + with open('static/ui.js') as f: + src = f.read() + assert '_CSV_EXTS' in src, "Missing _CSV_EXTS regex" + assert '.csv' in src, "CSV regex should match .csv extension" + + +def test_csv_fence_block_handler(): + """Verify fenced ```csv blocks are handled.""" + with open('static/ui.js') as f: + src = f.read() + assert "lang==='csv'" in src, "Missing csv language detection in fence handler" + assert 'csv-table' in src, "Missing csv-table class for fenced CSV rendering" + assert 'csv-table-wrap' in src, "Missing csv-table-wrap class" + + +def test_csv_fence_renders_table_structure(): + """Verify fenced CSV blocks produce proper table HTML.""" + with open('static/ui.js') as f: + src = f.read() + # Should have thead, tbody, th, td + assert '' in src, "CSV table should have " + assert '' in src, "CSV table should have " + # In the fence handler section + fence_section = src[src.find("lang==='csv'"):src.find("lang==='csv'") + 800] + assert '' in fence_section, "CSV headers should use " + assert '' in fence_section, "CSV body should use " + + +def test_csv_fence_fallback_for_insufficient_rows(): + """Verify CSV with < 2 rows falls back to code block.""" + with open('static/ui.js') as f: + src = f.read() + fence_section = src[src.find("lang==='csv'"):src.find("lang==='csv'") + 800] + assert 'rows.length>=2' in fence_section, "Should check for at least 2 rows" + assert '
"
+
+
+def test_csv_media_file_handler():
+    """Verify MEDIA: CSV files trigger inline loading."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'csv-inline-load' in src, "Missing csv-inline-load class for MEDIA: CSV"
+    assert 'csv_loading' in src, "Missing csv_loading i18n key usage"
+
+
+def test_loadCsvInline_function():
+    """Verify loadCsvInline lazy-load function exists."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'function loadCsvInline()' in src, "Missing loadCsvInline function"
+
+
+def test_csv_inline_max_size():
+    """Verify CSV inline rendering has a size cap."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
+    assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
+    assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
+
+
+def test_csv_auto_detect_separator():
+    """Verify CSV handler auto-detects separator."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
+    assert 'separators' in csv_section, "Should have separator detection"
+    assert ';' in csv_section, "Should detect semicolon separator"
+    assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
+
+
+def test_csv_quote_stripping():
+    """Verify CSV handler strips surrounding quotes from fields."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert "replace(/^[\"']|[\"']$/g,'')" in src, "Should strip quotes from CSV fields"
+
+
+def test_csv_error_handling():
+    """Verify CSV error and empty data handling."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500]
+    assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
+    assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
+
+
+def test_csv_loadCsvInline_called_after_render():
+    """Verify loadCsvInline is called in requestAnimationFrame after rendering."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert src.count('loadCsvInline()') >= 2, \
+        "loadCsvInline should be called at least twice (initial render + cache restore)"
+
+
+def test_csv_line_ending_normalization():
+    """Verify CSV handler normalizes line endings."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
+    assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
+    assert '\\r' in csv_section, "Should handle \\r line endings"
+
+
+def test_csv_i18n_keys():
+    """Verify CSV i18n keys exist in all 7 locales."""
+    with open('static/i18n.js') as f:
+        src = f.read()
+    required_keys = ['csv_loading', 'csv_too_large', 'csv_no_data', 'csv_error']
+    for key in required_keys:
+        count = src.count(f"{key}:")
+        assert count == 7, f"Key '{key}' found {count} times, expected 7"
+
+
+def test_csv_css_classes():
+    """Verify CSV table CSS classes are defined."""
+    with open('static/style.css') as f:
+        src = f.read()
+    required_classes = ['csv-table-wrap', 'csv-table', 'csv-table th', 'csv-table td']
+    for cls in required_classes:
+        assert cls in src, f"Missing CSS: {cls}"
+    # Check for hover effect
+    assert 'csv-table tbody tr:hover' in src, "Missing hover effect for CSV rows"
+
+
+def test_csv_not_matched_by_image_exts():
+    """Verify .csv is NOT in _IMAGE_EXTS."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    match = re.search(r"const _IMAGE_EXTS=/([^/]+)/i", src)
+    assert match
+    exts = match.group(1)
+    assert 'csv' not in exts.lower(), ".csv should NOT be in _IMAGE_EXTS"
diff --git a/tests/test_excalidraw_inline_embed.py b/tests/test_excalidraw_inline_embed.py
new file mode 100644
index 00000000..a698062e
--- /dev/null
+++ b/tests/test_excalidraw_inline_embed.py
@@ -0,0 +1,227 @@
+"""Test: Excalidraw inline embed (#479)"""
+import re
+
+
+def test_excalidraw_extension_regex():
+    """Verify _EXCALIDRAW_EXTS regex is defined."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert '_EXCALIDRAW_EXTS' in src, "Missing _EXCALIDRAW_EXTS regex"
+    assert '.excalidraw' in src, "Excalidraw regex should match .excalidraw"
+
+
+def test_excalidraw_media_handler():
+    """Verify MEDIA: .excalidraw files trigger inline loading."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'excalidraw-inline-load' in src, "Missing excalidraw-inline-load class"
+    assert 'excalidraw_loading' in src, "Missing excalidraw_loading i18n key usage"
+
+
+def test_loadExcalidrawInline_function():
+    """Verify loadExcalidrawInline lazy-load function exists."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function"
+
+
+def test_excalidraw_json_validation():
+    """Verify Excalidraw handler validates JSON format."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
+    assert 'JSON.parse' in func, "Should parse JSON"
+    assert 'excalidraw_invalid' in func, "Should handle invalid format"
+    assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'"
+
+
+def test_excalidraw_size_cap():
+    """Verify Excalidraw inline rendering has a size cap."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
+    assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant"
+    assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files"
+
+
+def test_excalidraw_error_handling():
+    """Verify Excalidraw error handling."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500]
+    assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
+
+
+def test_excalidraw_svg_renderer_exists():
+    """Verify SVG renderer for Excalidraw elements exists."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'function _renderExcalidrawCanvases()' in src, "Missing _renderExcalidrawCanvases function"
+    start = src.find('function _renderExcalidrawCanvases()')
+    end = src.find('// ── PDF inline preview', start)
+    render = src[start:end if end != -1 else start + 8000]
+    assert ' element"
+
+
+def test_excalidraw_bounds_calculation():
+    """Verify SVG renderer calculates viewBox from element bounds."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    start = src.find('function _renderExcalidrawCanvases()')
+    end = src.find('// ── PDF inline preview', start)
+    render = src[start:end if end != -1 else start + 8000]
+    assert 'viewBox' in render, "Should calculate SVG viewBox"
+    assert 'minX' in render, "Should track minimum X bound"
+    assert 'maxX' in render, "Should track maximum X bound"
+
+
+def test_excalidraw_empty_elements():
+    """Verify empty diagrams show a message."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    start = src.find('function _renderExcalidrawCanvases()')
+    end = src.find('// ── PDF inline preview', start)
+    render = src[start:end if end != -1 else start + 8000]
+    assert 'excalidraw_empty' in render, "Should handle empty diagrams"
+    assert 'excalidraw_render_error' in render, "Should handle render errors"
+
+
+def test_excalidraw_download_link():
+    """Verify Excalidraw embed includes download link."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
+    assert 'excalidraw-open-link' in func, "Should include open/download link"
+    assert 'excalidraw_download' in func, "Should use excalidraw_download i18n"
+
+
+def test_excalidraw_called_after_render():
+    """Verify loadExcalidrawInline is called after message rendering."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert src.count('loadExcalidrawInline()') >= 2, \
+        "loadExcalidrawInline should be called at least twice"
+
+
+def test_excalidraw_embed_wrap_structure():
+    """Verify Excalidraw embed uses proper container structure."""
+    with open('static/ui.js') as f:
+        src = f.read()
+    assert 'excalidraw-embed-wrap' in src, "Missing excalidraw-embed-wrap container"
+    assert 'excalidraw-canvas' in src, "Missing excalidraw-canvas div"
+    assert 'data-excalidraw' in src, "Missing data-excalidraw attribute"
+
+
+def test_excalidraw_i18n_keys():
+    """Verify Excalidraw i18n keys exist in all 7 locales."""
+    with open('static/i18n.js') as f:
+        src = f.read()
+    required_keys = [
+        'excalidraw_loading', 'excalidraw_too_large', 'excalidraw_invalid',
+        'excalidraw_error', 'excalidraw_label', 'excalidraw_download',
+        'excalidraw_empty', 'excalidraw_render_error',
+    ]
+    for key in required_keys:
+        count = src.count(f"{key}:")
+        assert count == 7, f"Key '{key}' found {count} times, expected 7"
+
+
+def test_excalidraw_css_classes():
+    """Verify Excalidraw CSS classes are defined."""
+    with open('static/style.css') as f:
+        src = f.read()
+    required_classes = [
+        'excalidraw-embed-wrap', 'excalidraw-canvas', 'excalidraw-svg',
+        'excalidraw-empty', 'excalidraw-open-link',
+    ]
+    for cls in required_classes:
+        assert cls in src, f"Missing CSS class: .{cls}"
+
+
+# ── XSS regression: SVG attribute injection from JSON values ────────────────
+#
+# The Excalidraw renderer parses JSON from a remote/attacker-controllable file
+# and interpolates field values (strokeColor, backgroundColor, strokeWidth,
+# fontSize, points coordinates) into raw SVG attribute templates. The whole
+# SVG string is then assigned to el.innerHTML — so any value that can
+# contain `"`, `<`, `>` could break out of the attribute and inject DOM.
+#
+# Example attack payload in a malicious .excalidraw file:
+#     {"elements":[{"type":"rectangle","x":0,"y":0,"width":10,"height":10,
+#      "strokeColor":"red\"/>"}]}
+#
+# Defense: string colors/fonts must flow through an HTML attribute escaper;
+# numeric fields (strokeWidth, fontSize, x/y/width/height, point coords) must
+# be coerced via Number()/isFinite gates so they cannot carry strings.
+
+def _excalidraw_render_block():
+    with open('static/ui.js') as f:
+        src = f.read()
+    start = src.find('function _renderExcalidrawCanvases')
+    assert start != -1, '_renderExcalidrawCanvases not found'
+    # End at next sibling section
+    end = src.find('// ── PDF inline preview', start)
+    assert end != -1, 'end marker not found'
+    return src[start:end]
+
+
+def test_excalidraw_string_color_fields_are_attribute_escaped():
+    """strokeColor / backgroundColor flow into stroke="..." / fill="..." in
+    SVG attributes. They must run through an HTML attribute escaper before
+    interpolation, otherwise a value like 'red"/>