Skip to content

feat(widget): conversation export (Markdown, JSON, copy to clipboard) - #157

Merged
PAMulligan merged 28 commits into
mainfrom
55-conversation-export
Sep 20, 2026
Merged

PAMulligan merged 28 commits into
mainfrom
55-conversation-export

Conversation

@PAMulligan

Copy link
Copy Markdown
Contributor

Closes #55.

What

An opt-in More options menu in the chat header: Copy as Markdown, Download as Markdown, Download as JSON. Everything happens in the browser. Nothing is sent to the worker.

Exporting timestamps meant recording them first, so ChatMessage gains an optional createdAt. That is the only change outside the new feature.

Design, with the reasoning behind everything below: docs/plans/2026-09-19-conversation-export-design.md.

Why this is a draft

Two things need a person, not CI:

  1. Please check one interaction in real Safari or on an iPhone. Safari does not focus a button when it is clicked. The whole-branch review found that this made a second press on the menu trigger reopen the menu instead of closing it. It is fixed (c1fbf00) and covered by a test that replays Safari's event order, but it cannot be observed here or in CI: jsdom, Chrome, and Playwright's Linux WebKit all focus buttons on click. To check: enable the option, open the chat, press More options, then press it again. The menu should close. Escape, a press outside, and choosing an item close it regardless.
  2. Two minor follow-ups are known and not yet applied, because the fix wave was closed when its re-review found them. Neither blocks merging. See "Known follow-ups".

Decisions worth a look

  • Off by default, which departs from the issue. Embeds load the floating @1 CDN tag, so an on-by-default feature would add a header button to every live site the moment this releases, including any privacy-sensitive client. Opt-in matches attachments and voice, and can be flipped later without breaking anyone. Only the literal true enables it. With it off, nothing renders and the header is unchanged.
  • The conversation-export attribute is deliberately stricter than attachments. It enables only for "" or "true". A Python or Jinja template renders a false value as "False", and a privacy switch must not be turned on by that. attachments and voice are unchanged.
  • createdAt rides in the request body, like id does. Workers have discarded unknown message fields since the first commit (b53d64a), so this is safe even for a worker that is never redeployed.
  • Message text is exported as written, with protections so one message cannot damage the rest of a rendered transcript: unclosed code fences are closed, single newlines become hard breaks outside code (the widget shows every newline as a line break; Markdown does not), and a line-leading < is escaped. Code inside a fence is never modified.
  • One change touches every embed: the chat's document-level Escape listener now ignores events that are already defaultPrevented. That is what lets Escape close an open menu without closing the window. Nothing else in the widget prevents default on Escape, so existing behaviour is unchanged unless a host page's own script does.

Acceptance criteria

  • Header overflow menu with Copy as Markdown, Download as Markdown, Download as JSON
  • Markdown preserves roles, timestamps, code blocks, and source citations. Messages persisted before this release have no timestamp and export without one.
  • JSON is the raw ChatMessage[] the widget stores, which means without inline attachment bytes
  • Configurable per widget: React prop, window.ClaudiusConfig, <claudius-chat conversation-export>, and widget.conversationExport in client configs (schema, CLI validation, both snippet generators)
  • Unit tests for serialization edge cases

How it was verified

At 40308b4, run locally with the tools directly:

Check Result
Widget tests 673 passing (541 on main)
Scripts tests 86 passing (82 on main)
Worker tests 153 passing, code untouched
ESLint, Prettier, tsc, TypeDoc clean (2 react-refresh warnings on ChatWidget.tsx exist on main too)
Build, publint, attw pass
Size gate all 9 budgets pass
Playwright, chromium-desktop 20/20, including 5 new export specs: a real download of both formats, the real clipboard, Escape
Docs build 62 pages, new anchors resolve

The built production bundle was also driven in Chrome on a desktop viewport and on a mobile viewport, where the chat is a bottom sheet: the menu lies inside the dialog in both, copy and download work, a press outside closes only the menu, and the option stays off for an absent key and for the strings "false" and "true".

Every task was implemented test-first and reviewed separately, then the branch got one whole-branch review. That review fuzzed the serializer against a real CommonMark parser over 20,000 cases, ran 23 mutation checks, and re-ran the new tests with Intl patched to emit U+202F before "PM" the way CI's Node 20 does (local Node 24 does not). All pass. Its findings are fixed in 75f02da..552fe99.

Not verified, and worth knowing:

  • Real Safari, as above.
  • WebKit in CI does not run export.spec.ts. The mobile-safari project only matches mobile.spec.ts, so export has no automated coverage in the bottom-sheet layout. It was checked by hand in a Chromium mobile viewport instead.

Bundle

Measured against main with size-limit. Everything ships in the main bundle because the IIFE build cannot code-split.

Artifact raw gzip brotli
claudius.iife.js +11,269 B +4,072 B +3,243 B
claudius.js +15,452 B +4,407 B +3,659 B
claudius.css +509 B +95 B +71 B

For comparison, attachments cost +3.6 kB and voice +3.9 kB gzip on the IIFE (decimal kB, as size-limit reports). Budgets were raised once, in 99c3e2f, to the sizes measured then plus 5%. The review fixes that followed added 262 B gzip to the IIFE, which fits in that headroom, so no limit was raised again.

Known follow-ups

Not in this PR. The first two are small, and the first has a verified fix ready.

  1. A crafted line can dodge the HTML-block escape. The exception that stops line-leading autolinks such as <https://example.com> being mangled also lets <!--a@b>, <?a@b> and <![CDATA[a@b> through. An unterminated block like that hides later messages in the visitor's own rendered transcript. The raw file is intact, and nobody writes this by accident. Verified fix: require the email local part to start with an alphanumeric in HTML_BLOCK_START.
  2. legacyCopy's finally could in theory still throw past its guard, in a document where remove() or focus() throws.
  3. stripAttachmentData would throw on a null attachment in useChat's persistence path. Pre-existing, outside this feature.
  4. docs/src/content/docs/api/widget.md lists a ChatWidgetProps that was already missing plugins, streaming, attachments, and voice. Left alone here rather than half-fixed.
  5. Natural next steps: flip the default to on once this has run in production, and a "Clear conversation" item, now that a menu exists and useChat already exposes clearMessages.

Notes for merging

  • Changelog: e001999 is a fix: for a bug that never shipped (it was found and fixed during review on this branch). With a merge commit, release-please will list it under Bug Fixes. Squash-merging, or editing the release notes, avoids that.
  • The implementation plan (docs/plans/2026-09-19-conversation-export-implementation.md) is about 3,500 lines because every step carries its code. docs/plans/ already holds implementation plans, but it is one git rm away if you would rather it not ride along.

🤖 Generated with Claude Code

PAMulligan and others added 28 commits September 19, 2026 11:42
Design for #55: an opt-in header menu that copies the conversation as
Markdown or downloads it as Markdown or JSON, entirely in the browser.

Records the decisions that depart from or go beyond the issue text:
opt-in rather than on by default (embeds load the floating @1 CDN tag),
a new optional ChatMessage.createdAt so timestamps exist to export, and
the protections that stop one message damaging the rest of a rendered
transcript (unclosed code fences, single-newline line breaks, HTML block
openers).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fourteen test-first tasks for #55, each with its own code, commands,
and expected results. The code for the four self-contained units (text
protections, serializers, clipboard and download helpers, header menu)
was extracted from the plan and run before committing: 53 tests, ESLint,
and Prettier, which turned up a missing focus() in the copy fallback and
two jsx-a11y violations that CI would otherwise have caught later.

The design follows one refinement made along the way: Tab is left to
the browser and the menu closes as focus leaves it, instead of closing
on the keystroke and unmounting the focused item under it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A docs link pointed at /api/, which has no index page and nothing validates links. The bundle task wrote scratch files to /tmp. One hook test set state outside act().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conversation export (#55) needs timestamps, and messages had none. Stamped where useChat creates a message, kept from the streaming placeholder to the settled reply, and restored when a plugin returns a fresh object. Workers discard unknown message fields, so nothing changes on the wire that matters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes unclosed code fences, turns single newlines into hard breaks outside code, and escapes a line-leading < so an unterminated HTML block cannot hide later messages. Code inside a fence is never touched.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Markdown: one heading per message with role and localized time, attachments by name only, tool names, and citations as escaped links. JSON: the ChatMessage[] the widget persists, never with inline bytes. Dates are normalized because Node 20 and some browsers emit U+202F before AM/PM.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The whole-text trim (replacing /\s+$/ before fence tracking) was stripping trailing spaces and blank lines even when they fell inside an unclosed code block. This violated the invariant that content inside a fence is never modified.

Fixed by: normalizing line endings only during initial split, handling fence-closing content after the line loop (distinguish between still-open fences, which keep all interior content intact, vs closed fences, which trim trailing blank lines and spaces only outside code).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Task review found that the plan's protectMessageText trimmed the whole text before it knew where the fences were, so a reply stopped inside a code block lost trailing spaces and blank lines from that code. The design's invariant that code in a fence is never modified wins. The design now says so explicitly, and the plan's code and tests match the fix in e001999.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
copyText calls the async clipboard API before yielding so the click's user activation still holds, and falls back to execCommand on insecure origins and in iframes without clipboard-write. It resolves to a boolean and never rejects.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
A generic WAI-ARIA menu button for the chat header, rendered inline so it stays inside the focus trap and the theme scope. Escape closes only the menu. Disabled items use aria-disabled so a focused control never vanishes from under the trap.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Turns the conversation into the three header-menu actions and a transient copy status. Items are disabled while the conversation is empty or a reply is in flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opt-in and fails closed: only the literal true renders the menu, and nothing at all renders when it is off. The chat's Escape listener now skips events that are already defaultPrevented, so Escape closes an open menu without closing the window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…, and snippets

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
A real download for both formats, the real clipboard in Chromium, and Escape closing the menu before the chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
locator.all() does not wait for elements to match, so the disabled-state loop could run zero times and pass without checking anything if the menu items rendered late. Assert the exact item count first, and tighten the JSON download filename assertion to match the Markdown test's rigor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task review found the plan's last e2e test iterated locator.all() with no wait, so it could loop zero times and pass vacuously. The plan now matches the fix in 6e3c207.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Enabling it in each embed style, what each format contains, and a privacy posture that says plainly what an export can carry and that a transcript is not evidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured against main with size-limit: claudius.iife.js +10531 B raw, +3810 B gzip (3.8 KB), +2882 B brotli; claudius.js +14434 B raw, +4030 B gzip (4.0 KB), +3361 B brotli; claudius.css +509 B raw, +95 B gzip (0.1 KB), +71 B brotli. The cost is the header menu, the transcript serializer, and twelve strings in four locales. Limits are the measured sizes plus 5%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fence scanner accepted a closing fence at any indentation. A message
that shows Markdown inside Markdown, with a fenced block indented under a
list item, had its outer block ended at the inner fence: the code after it
was escaped and given trailing spaces, breaking "code inside a fence is
never modified", and the leftover fence opened a block that swallowed the
next message. A closer now has to sit within three columns of its opener,
as CommonMark requires, with both indents measured in columns and tabs
expanded to four-column stops. Openers still accept any indentation.

Alongside it, six smaller defects in the same module:

- a source title escaped \, [ and ] but not <, so a title could emit its
  own autolink or a raw tag in a viewer that does not sanitize;
- a URL ending in \ escaped the paren that closes the link;
- a line-leading autolink such as <https://example.com/docs> was escaped
  as an HTML block start, which an autolink can never be;
- serialization threw on history that is not what the types promise.
  sessionStorage is restored unvalidated, so a non-string content, a null
  message or a malformed source, attachment or tool use reached the host
  page as an uncaught error with the menu already closed. Values are now
  coerced, lists guarded, and a time zone the engine rejects falls back to
  an ISO date instead of throwing out of the retry;
- trailing whitespace was trimmed with /[ \t]+$/, which backtracks
  quadratically: 50,000 spaces mid-line took seven seconds. A short loop
  does it in linear time;
- the no-break space normalization was applied twice, once per formatter
  branch, and only the injected branch had a test. Both now go through one
  wrapper, so that test covers the production path too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…menu

Pressing the trigger while the menu is open reopened it in WebKit, so the
menu could not be closed that way. WebKit does not focus a button when it
is clicked, so the press blurs the focused item with relatedTarget null;
onMenuBlur read that as focus leaving the menu and closed it, React
re-rendered, and the click then saw open === false and opened it again.
The handler now returns on a null relatedTarget and only judges a real
next target. Outside presses were already the pointerdown listener's job,
so the only behaviour given up is closing when focus leaves the page.

The test replays that event order by hand, because jsdom and user-event
both focus a button on click, the way Chrome does, and the Playwright spec
runs only in chromium-desktop.

Also rewords the comment beside the menu's tabIndex -1. It said the focus
trap skips tabindex -1, which is true of the role="menu" div but reads as
if it were about the item buttons, and the trap's selector does match
those. What makes them safe is that they are never the dialog's first or
last focusable control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only execCommand was inside legacyCopy's try. The DOM calls before it were
not, and copyText's `return legacyCopy(text)` sits outside copyText's own
try, so a document that refuses createElement, appendChild, focus or select
turned a copy into an unhandled rejection on the host page and left the
hidden textarea in the DOM with the visitor's focus inside it. The whole
body is now in the try, so the function always answers with a boolean, and
the textarea is removed and focus restored in a finally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test named "does not set state after unmount" could not fail: React
18.3.1 has no warning for a state update on an unmounted component, and
removing the guard left the test green. What the guard really prevents is
a four-second status timer armed on a page whose chat window has already
closed. The test now resolves the copy after unmount under fake timers and
asserts vi.getTimerCount() is 0, which does fail with the guard removed,
and its name says so.

Also records why the effect re-arms mounted.current instead of relying on
the ref's initial value: React 18 Strict Mode mounts, cleans up, and mounts
again, and without the reassignment the flag would stay false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The attribute enabled the feature on any value but the exact string
"false", copying `attachments`. That is wrong for a privacy switch: a
Python or Jinja template renders a false value as "False", and "0" and
"no" are just as plausible, and all three switched export on. It now
enables only when the attribute is present and its value, trimmed and
lowercased, is "" or "true". `attachments` and `voice` are unchanged.

The embed tests could not see any of this. Mutating embed.tsx so an
ABSENT attribute enabled the feature left all ten of them green, although
"off means nothing changes" is the feature's first requirement. There are
now cases for the attribute absent and for the key absent from
ClaudiusConfig, plus every accepted and rejected value, and the test whose
title claimed "and when absent" while mounting only "false" is split.

Each negative assertion is now anchored by the open dialog, in the embed
tests and in ChatWidget.export.test.tsx: without it they would have passed
just as happily if the chat had never opened. ChatWindow's "puts the menu
in the header" checked indexOf("More options") === indexOf("Close chat")
- 1, which -1 === 0 - 1 satisfies when neither button is there, so it now
asserts both are present first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Kopieren nicht moeglich. Laden Sie die Unterhaltung stattdessen
herunter." wrapped to two lines inside the status pill, which is fully
rounded and sized for one. The shorter wording says the same thing in the
same formal register and fits. Nothing asserted the old text; the locale
parity test only requires the key to be non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design said "any amount of leading whitespace is accepted" for fences.
That is right for an opener and wrong for a closer: without the CommonMark
limit of three columns past the opener, a message showing Markdown inside
Markdown has its outer block ended by the example's indented fence. The
protection now spells out which end is lenient and why.

The Public API bullet said the web component attribute followed the same
rule as `attachments`, enabling on any value but "false". It no longer
does, and the bullet and the user-facing "Enabling it" section now state
the accepted values exactly.

Out of scope gains the three residual divergences between the scanner and
a real parser: a list-item fence closed at column 0, which CommonMark
reads as a new opener; fences inside blockquotes, which are not recognised;
and a fence whose opener is itself indented four columns, which CommonMark
reads as code. Re-indenting under-indented closers was measured against a
differential fuzz and helped as often as it hurt, so it is not attempted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
claudius.iife.js (raw) 217.69 KB (+5.33% 🔺)
claudius.iife.js (gzip) 70.24 KB (+6.01% 🔺)
claudius.iife.js (brotli) 61.35 KB (+5.45% 🔺)
claudius.js (raw) 112.62 KB (+15.48% 🔺)
claudius.js (gzip) 31.11 KB (+16.06% 🔺)
claudius.js (brotli) 26.55 KB (+15.55% 🔺)
claudius.css (raw) 24.03 KB (+2.12% 🔺)
claudius.css (gzip) 5.06 KB (+1.87% 🔺)
claudius.css (brotli) 4.45 KB (+1.59% 🔺)

@PAMulligan
PAMulligan marked this pull request as ready for review September 20, 2026 15:04
@PAMulligan
PAMulligan merged commit 35e85b9 into main Sep 20, 2026
10 checks passed
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.

Conversation export (Markdown, JSON, copy-to-clipboard)

1 participant