Skip to content

fix(export): serve transcript Markdown from server (Chromium blob: download hang)#4

Merged
zhonghuaY merged 1 commit intofix/sidebar-rename-models-fullwidthfrom
fix/transcript-md-server-endpoint
Apr 29, 2026
Merged

fix(export): serve transcript Markdown from server (Chromium blob: download hang)#4
zhonghuaY merged 1 commit intofix/sidebar-rename-models-fullwidthfrom
fix/transcript-md-server-endpoint

Conversation

@zhonghuaY
Copy link
Copy Markdown
Owner

Why

PR #3 fixed the most common hang causes for the Transcript download (sync URL.revokeObjectURL race + <a> not attached to DOM). However, users still reported the download UI never reaching 'complete' even though the file was fully written to disk.

The remaining issue is a Chromium quirk with blob: URL downloads: the browser's progress UI cannot reliably tell when a blob stream ends, so the download bar stays in an indeterminate 'in progress' state forever, even though the file is on disk.

What

Bypass blob URLs entirely. Serve the markdown from the server with proper Content-Disposition + Content-Length — exactly like the existing /api/session/export JSON path, which works flawlessly.

Server (api/routes.py)

  • New _build_session_markdown(session) mirrors the JS transcript() in messages.js (skip tool messages, join array text blocks, append attachments marker)
  • New _handle_session_export_markdown(handler, parsed) runs redact_session_data() then writes text/markdown; charset=utf-8 with Content-Disposition: attachment; filename="<title>.md", Content-Length, Cache-Control: no-store
  • Dispatcher routes /api/session/export?format=md to the new handler; default JSON path unchanged

Client (static/boot.js)

  • #btnDownload click handler now navigates to /api/session/export?session_id=...&format=md
  • Keeps the working <a> pattern (display:none + appendChild + click() + setTimeout removeChild)
  • All blob URL machinery removed
  • Still wrapped in try/catch with showToast on success and failure

Tests

tests/test_session_export_markdown.py (9 tests, all green):

  • 400 on missing session_id, 404 on unknown session
  • correct Content-Type / Content-Disposition / Content-Length headers
  • markdown body renders user/assistant messages with correct heading levels
  • tool messages are skipped
  • array content text blocks are joined; non-text blocks ignored
  • attachments emit the _Files: a, b_ marker
  • default JSON export still works (no regression)
  • boot.js handler is pinned to the server endpoint (createObjectURL no longer referenced for transcript)

tests/test_transcript_download_robust.py (from PR #3) is removed — it asserted the blob-URL pattern that we're deleting.

Manual verification

  1. Open any session, click ⚙️ Settings → Download Transcript (.md)
  2. Browser shows a normal download (Content-Length present, Content-Disposition attachment)
  3. Download progress reaches 100% and 'complete' state cleanly — no spinner stuck

Co-authored-by: Copilot [email protected]

Prior fix (#3) made the blob-URL download more robust (DOM-attached anchor,
deferred URL revoke, try/catch) but did NOT solve the actual hang user reported:
the file would write completely to disk, yet the browser download UI never
reached the 'complete' state.

Root cause is a Chromium quirk with blob: URL downloads where the download
progress UI cannot reliably tell when the stream ends — the file is on disk
but the download bar shows it as still in-progress indefinitely.

Solution: bypass blob URLs entirely. Serve the markdown from the server
with proper Content-Disposition + Content-Length, mirroring the JSON
export path which works flawlessly.

Server side (api/routes.py)
- new _build_session_markdown(session) function mirrors the JS transcript()
  in messages.js: skip tool messages, join array text blocks, append
  attachments marker
- new _handle_session_export_markdown(handler, parsed) mirrors the JSON
  export structure: redact_session_data() before rendering, set
  Content-Type: text/markdown; charset=utf-8, Content-Disposition: attachment
  with filename, Content-Length, Cache-Control: no-store
- /api/session/export dispatcher now branches on ?format=md vs JSON default

Client side (static/boot.js)
- #btnDownload click handler now navigates to /api/session/export?format=md&...
- still uses the working pattern from downloadFile() in workspace.js:
  a.style.display='none' + appendChild + click() + setTimeout removeChild
- removed all blob URL machinery (no createObjectURL, no revokeObjectURL)
- still wrapped in try/catch with showToast on success and failure

Tests (tests/test_session_export_markdown.py, 9 tests)
- 400 on missing session_id, 404 on unknown session
- correct Content-Type / Content-Disposition / Content-Length headers
- markdown body renders user/assistant messages with correct heading levels
- tool messages are skipped (not exported)
- array content text blocks are joined, non-text blocks ignored
- attachments emit the _Files: a, b_ marker
- default JSON export still works (no regression)
- boot.js click handler is pinned to the server endpoint (no createObjectURL)

Replaces tests/test_transcript_download_robust.py from PR #3 — that file
asserted the blob-URL pattern which we're now removing entirely.

Co-authored-by: Copilot <[email protected]>
@zhonghuaY zhonghuaY merged commit 66ac465 into fix/sidebar-rename-models-fullwidth Apr 29, 2026
@zhonghuaY zhonghuaY deleted the fix/transcript-md-server-endpoint branch April 29, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants