feat(web): copy a finished answer for Claude, Codex, or Gemini - #80
Merged
Conversation
The copy button under an answer hands over the raw markdown, which is the right thing when you are pasting into a document. It is not quite the right thing when you are pasting into another assistant: the answer arrives looking like your own words, so the assistant agrees with it rather than checks it, and it has no boundary, so whatever you type after it reads as part of it. So there is a second control beside it, "Copy for", holding one entry per destination. It is a clipboard write and not a link. A deep link carrying the answer in a query string would hand the answer to a third party the moment the button was pressed, and the person who pressed it asked to copy. The three payloads share a body and a sentence of provenance, and differ for two stated reasons and no others: - Claude gets tag delimiters, because Anthropic documents XML style tags as the way to mark a quoted document for Claude. - Codex is told the answer is reference material and not a work order, because Codex is an agent pointed at a repository and can act on what it reads. Claude and Gemini, pasted into a chat, have nothing to act on. Gemini is the baseline neither of those applies to. It is not the other two with a name swapped, it is the frame they each depart from. A test asserts the three never come out as the same bytes, because if they ever do then one entry is wearing three hats and should go back to being one. Nothing is ever fenced in backticks. Answers about code contain fences, and a fence around a fence ends early and leaves the rest of the answer reading as an instruction. The entries are a disclosure, not an ARIA menu: role="menu" promises arrow key navigation and a roving tabstop, and claiming the role without providing them is worse than plain buttons. They are revealed in the row rather than in a popup, because the scroller clips and a scrollable box only extends its overflow down and right, so a popup opening upwards from the first answer is cut off with no way to reach it. The plain copy button is untouched and still hands over the raw answer. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
This was referenced Aug 22, 2026
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.
The copy button under a finished answer hands over the raw markdown. That is
right when you are pasting into a document. It is not quite right when you
are pasting into another assistant, for two reasons: the answer arrives
looking like your own words, so the assistant agrees with it rather than
checks it, and it has no boundary, so whatever you type after it reads as
part of it.
This adds a second control beside the existing one, "Copy for", holding one
entry per destination: Claude, Codex, Gemini.
Clipboard, not deep links
Every path here is a clipboard write. There is no link that carries the
answer in a query string, and I did not build one and do not think one should
be built. A deep link would send the answer to a third party as a side effect
of a click, before the person had decided that is where it should go. The ask
was "copy to". The clipboard leaves them holding it.
Do the three payloads actually differ?
This is the interesting part of the change, so here is the reasoning rather
than the conclusion.
I started by trying to enumerate what genuinely differs between the three as
paste targets, and most of the candidate differences did not survive:
not another.
everywhere, so its fix belongs in all three and is not a difference.
fenced code. Also identical, also not a difference.
Two candidates did survive:
for Claude, and says Claude attends to them. That is the vendor's own
guidance rather than a taste of mine, so Claude gets
<zorp-answer>…</zorp-answer>.be read as a work order and acted on. Claude and Gemini, pasted into a
chat, have nothing to act on. So the Codex payload adds one sentence:
"Reference material, not a work order. Do not change a file because of it
unless I ask." This is the difference I am most confident is real, because
it is behavioural and not stylistic.
Gemini is what is left when neither applies. I want to be plain about this
rather than dress it up: I found no Gemini-specific reason to depart from
the plain frame. The honest way to describe the three is not "three bespoke
formats". It is one baseline plus two departures, each with a stated reason.
Gemini is the baseline. It is not the other two with a name swapped, it is the
frame they each depart from, so the entry is not redundant, but if a reviewer
wants to argue the Gemini entry should just be called "Plain" I would not
fight hard.
What I deliberately did not do is invent a third variant so that three
buttons would look justified. Formatting differences with no reason behind
them are noise that a reader has to learn, and they rot: the next person
cannot tell which differences are load bearing.
A test asserts the three never come out as the same bytes. If they ever do,
that is the signal that one entry is wearing three hats and should go back to
being one.
Also, in all three: no backtick fence anywhere. A fence wrapped around an
answer that itself contains a fence ends early, and the remainder of the
answer then reads as an instruction to the receiving assistant. There is a
test for that too.
The UI
A disclosure behind one control, not three more buttons in the row. Every
answer in the transcript carries this row, and four controls under each one is
exactly the column of buttons that the copy button's existing styling already
goes out of its way to avoid. Resting state stays at two controls.
A disclosure and not an ARIA menu, on purpose.
role="menu"promises arrowkey navigation and a roving tabstop, and a control that claims the role
without providing them is worse for a screen reader than plain buttons. So
these are plain buttons: Tab reaches them, Escape shuts the list and hands
focus back to the toggle, and focus leaving the group shuts it too.
The entries are revealed in the row, not in a popup. I built the popup
first and it was wrong:
.scrollerclips, and a scrollable box only everextends its overflow down and right, so a list opening upwards from the first
answer is cut off with no way to scroll to it, and one opening downwards from
the last answer is cut off at the other end. Opening in the row has nothing to
be clipped by, and because the entries are the same height as the button the
row grows sideways and nothing below it moves. It wraps on a narrow screen.
Accessibility matches the existing standard: the toggle and each entry have a
visible label of one or two words, so each carries an
aria-labelwith thelonger form, and the toggle carries
aria-expandedandaria-controls.Everything reaches the page through
textContentand DOM construction. NoHTML string is assembled anywhere, and there is a test that a hostile answer
puts no element into the controls.
The plain copy button is untouched, still exported, still tested, and still
hands over the raw answer with nothing wrapped around it.
Tests
TDD, red first: the new imports were added to the test file and watched fail
with "does not provide an export named 'SHARE_TARGETS'" before a line of the
implementation existed.
19 new cases in
web/test/copy-response.test.ts, taking that file from 6 to25, with all of the existing ones kept. They cover the payload for each
destination, that the
three are never byte-identical, that the answer is quoted verbatim and never
fenced, that the answer thunk is read on click and not at build time, that a
refused clipboard surfaces a failure, the open/shut and Escape and focus-out
behaviour, the
aria-labelon every control, and that a hostile answer neverbecomes markup.
Ran from
web/:npm run checkcleannpm test168 passing, 0 failing, across the wholeweb/testsuitenpm run buildandnpm run build:siteboth fineAlso driven in a real browser against a scratch page, which is how the popup
clipping problem was found. No Rust was touched, so no cargo commands were
run.
Conflicts to expect
Based on
origin/main. Two other branches are in flight that touchweb/src/main.ts,web/index.html, andzorp-web/src/api.rs(the web searchindicator, and Zorp mode). This change touches none of
index.html,zorp-web/, ordocs/, and its footprint inmain.tsis 13 lines: theimport, two call sites, and the doc comment on the local helper, which was
renamed from
answerCopyButtontoanswerControlsbecause it now returns arow rather than a button. Expect a small textual conflict on the import block
in
main.tsand nothing else.https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4