Repository navigation
feat(widget): conversation export (Markdown, JSON, copy to clipboard) - #157
Merged
Merged
Conversation
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>
Contributor
size-limit report 📦
|
PAMulligan
marked this pull request as ready for review
September 20, 2026 15:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ChatMessagegains an optionalcreatedAt. 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:
Decisions worth a look
@1CDN 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 matchesattachmentsandvoice, and can be flipped later without breaking anyone. Only the literaltrueenables it. With it off, nothing renders and the header is unchanged.conversation-exportattribute is deliberately stricter thanattachments. 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.attachmentsandvoiceare unchanged.createdAtrides in the request body, likeiddoes. Workers have discarded unknown message fields since the first commit (b53d64a), so this is safe even for a worker that is never redeployed.<is escaped. Code inside a fence is never modified.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
ChatMessage[]the widget stores, which means without inline attachment byteswindow.ClaudiusConfig,<claudius-chat conversation-export>, andwidget.conversationExportin client configs (schema, CLI validation, both snippet generators)How it was verified
At 40308b4, run locally with the tools directly:
tsc, TypeDocreact-refreshwarnings onChatWidget.tsxexist on main too)publint,attwchromium-desktopThe 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
Intlpatched 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:
export.spec.ts. Themobile-safariproject only matchesmobile.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.
claudius.iife.jsclaudius.jsclaudius.cssFor 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.
<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 inHTML_BLOCK_START.legacyCopy'sfinallycould in theory still throw past its guard, in a document whereremove()orfocus()throws.stripAttachmentDatawould throw on a null attachment inuseChat's persistence path. Pre-existing, outside this feature.docs/src/content/docs/api/widget.mdlists aChatWidgetPropsthat was already missingplugins,streaming,attachments, andvoice. Left alone here rather than half-fixed.useChatalready exposesclearMessages.Notes for merging
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.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 onegit rmaway if you would rather it not ride along.🤖 Generated with Claude Code