Agent chat: rich copy out of the transcript, and rich paste into the composer

Merged
TE
1d ago
+969-10

Leo, 2026-11-05: "Okay, we need to be doing this where you can copy and it's a rich copy of the transcript, the agent chat stuff. Like it needs to be copying the formatting, because Claude does it, ChatGPT does it, we don't, and we don't even render bullet points properly in our thing when we're pasting that stuff in. So we need to just do that. Like, I mean, we have it when we manually type it, you know, with the space or whatever, but we need to be able to just have that on paste as well."

What changes

Rich copy out of the agent transcript

  • A message's Copy button (replies, prompts, queued prompts) now writes the message's Markdown as text/plain, as before, and adds the same message as text/html, the way Claude and ChatGPT copy. When you paste into Notes, Slack, Docs or Mail, the headings, bullets, numbered lists, bold, links, code blocks and tables come through (richClipboard.ts copyMarkdownResult, rendered with marked, which the pane already uses). Raw HTML in a reply is copied as escaped text, not markup.
  • Select text, then Cmd-C inside one message: the HTML on the clipboard is the reply's own formatting with the transcript's classes, inline colors, buttons and icons removed, so a paste into Notes does not pick up the pane's dark-theme text color. text/plain is the selection as Markdown, so pasting into the composer or a terminal keeps the bullets (conversation/selectionCopy.ts). Some selections still use the page's default copy: one that contains math (mathCopy.ts handles it), one that contains highlighted code (its shadow root can't be cloned), and one that spans more than one message.

The composer renders rich paste

  • A paste whose HTML has lists, headings, quotes or tables (Notes, Docs, Slack, a web page, or a copy from our own transcript) is converted to Markdown. Plain text that is Markdown with a list or a fenced code block (Claude's or ChatGPT's Copy) is used as it is. Either way the composer parses it into the same blocks that typing - already makes: bullets, numbered lists, headings, code blocks, bold and links. Line breaks inside a paragraph stay line breaks, as they do in a plain paste. The text's own * and _ stay literal characters.
  • Everything else pastes as plain lines, the same as before (#23840): prose, logs, and a script with a # comment (a lone heading or quote doesn't trigger Markdown), VS Code pastes, pastes into a code block, images and file names.
  • Paste as Plain Text (Cmd-Shift-V, or the prompt menu) still pastes plain lines. The field marks the next paste as plain.
  • A huge paste still turns into a Pasted text chip first (onBeforeInput).

Typing behavior is unchanged, and the plain-text copy is unchanged.

What the pasteboard gets (copy side)

When you click Copy on the bulleted reply from Leo's screenshots, the pasteboard gets two types:

text/plain (unchanged: the Markdown source)

Continuing the landing pass:

- **#21620** merged; no red of its own
- **#21621** typecheck fixed where `vp check` failed
- **#21200** waiting on review

text/html (new; Notes, Slack, Docs and Mail paste this)

<p>Continuing the landing pass:</p>
<ul>
<li><strong>#21620</strong> merged; no red of its own</li>
<li><strong>#21621</strong> typecheck fixed where <code>vp check</code> failed</li>
<li><strong>#21200</strong> waiting on review</li>
</ul>

A Cmd-C selection inside a reply gets the same kind of HTML, cloned from the reply with every class, style and data- attribute removed, and buttons and icons dropped, with Markdown as its text/plain (selectionCopy.test.ts).

Gallery (composer paste)

The PR's gallery advisory run captures these composer.gallery.ts states in Linux Chromium, light and dark:

  • paste-bulleted-reply-plain: before, and still the result of Paste as Plain Text. The reply goes in as literal - / ** lines.
  • paste-bulleted-reply: after. The same paste shows an intro line and three bullets, with bold and inline code.
  • paste-heading-and-code: HTML with a heading, a list and a code block shows those blocks.

Prior art

  • #23840 (paste stays lines, never <br />): kept. Only a paste with Markdown blocks takes the new path, and its paragraphs keep their lines.
  • #26660 (sent user Markdown renders; Copy keeps the Markdown source): consistent with this PR. Copy's plain text is still the source, and HTML is added beside it.
  • #12479 (inline rich Markdown editing in Markdown panels): a different surface. No shared code.
  • #34299 (composer menus on shared primitives): no overlap. The menu's Paste as Plain Text goes through ComposerBox.plainPaste, which now also tells the field.

Changelog

  • Added: Copying a message in an agent chat carries its formatting (bullets, headings, bold, links, code) into Notes, Slack, Docs and Mail, and pasting formatted text or Markdown into the composer shows it as lists, headings and code blocks.

Tests

  • bun test src/agent-session/acpmux/richClipboard.test.ts src/agent-session/acpmux/conversation/selectionCopy.test.ts: the Markdown→HTML conversion and back, Notes- and Docs-shaped HTML, escaping, the paste trigger rules, and selection-copy cleaning.
  • webviews/test/composer-rich-paste.test.ts (Chromium and WebKit): a Markdown list pastes as ul/ol with the intro's lines kept; Notes/web HTML pastes as a heading, a list and a code block; Cmd-Shift-V keeps - one as text and the next paste is rich again; a # comment script stays as lines; a message's Copy writes Markdown plus <ul> HTML, and pasting that back gives the list.
  • webviews/test/composer-line-breaks.test.ts: still covers #23840's lines. Its clipboard mock gained write, because Copy now writes both formats in one call.

Observed / Not run

Observed:

  • Fleet run of 138bee4a (run 41994387325): 25/25 browser tests (composer-rich-paste, pane-english, composer-line-breaks, composer-html-paste-edit) in Chromium and WebKit, 42/42 unit tests (richClipboard, selectionCopy, agent-pane-math, chat-icon-tooltips, card-corner-copy, user-prompt-collapse), lint 0 warnings, no type errors.
  • Review BLOCK at 0a37de19 (gpt-6.1-sol): copy lost line breaks inside a paragraph, a selection inside a table copied bare cells, and an ordered-list selection copied the drawn numbers twice. Fixed in 138bee4a, each covered in richClipboard.test.ts / selectionCopy.test.ts.
  • Fleet run of f7b86bc3 (run 41966234063): 8/8 unit tests and 30/30 browser tests in Chromium and WebKit. That covers composer-rich-paste, plus composer-line-breaks (#23840), composer-html-paste-edit, composer-paste-end and gallery-composer-paste, all unchanged and passing. The full bun run check passed: lint 0 warnings, no type errors.
  • The first fleet run (70323e4a) caught a real bug: in WebKit, a list pasted after text on the same line lost its first bullet, because that item was merged into the line. Fixed in f7b86bc3; the pasted slice now stays open only at an end that is a paragraph.
  • Review BLOCK at 70323e4a (gpt-6-astra): a selection entirely inside one equation was taken from mathCopy. Fixed in f1ff596b. selectionCopy.test.ts fails without the fix and passes with it.

Not run: pasting into Notes or Slack in a DEV build. Gallery images come from this PR's advisory run.

🤖 Generated with Claude Code

Agent session: agent session