Skip to content

fix(console): exclude toggle labels from trace message copy#464

Open
heiwen wants to merge 3 commits into
mainfrom
claude-issue-460-20260511-0724
Open

fix(console): exclude toggle labels from trace message copy#464
heiwen wants to merge 3 commits into
mainfrom
claude-issue-460-20260511-0724

Conversation

@heiwen
Copy link
Copy Markdown
Contributor

@heiwen heiwen commented May 11, 2026

Summary

  • Teach CopyButton to accept a DOM subtree via its value prop (now string | () => string | RefObject<HTMLElement>) and walk the tree itself, skipping any subtree marked with data-copy-ignore.
  • Annotate the trace message toggles, the Reasoning disclosure trigger, and the tool-call / other-part label elements with data-copy-ignore.
  • Drop getNodeText from Code.tsx and point CopyButton at the <pre> ref so there's one copy of the text in the DOM and the clipboard string is only built on click.
  • Walker uses textContent semantics so collapsed / hidden text is still copied in full (no line-clamp truncation, Reasoning panel uses keepMounted).

Fixes #460

Test plan

  • Long message still collapsed: copy → full text, no "More" / "Less".
  • Long message expanded: copy → full text, no "More" / "Less".
  • Message with reasoning (collapsed): copy → reasoning text is present, "Reasoning" trigger label is not.
  • Message with tool calls: copy → arguments present, tool-call badge name absent.
  • CodeBlock copy → pasted code matches rendered content (title and untitled variants).
  • Other CopyButton sites (Raw JSON, metadata rows, alias paths, Input) still copy expected string.

Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Copy-to-clipboard now ignores UI controls (badges, expand/collapse triggers) so copied content is cleaner
    • Copy behavior expanded to derive text from rendered elements for more accurate results when copying code or message blocks
    • Expandable content now keeps children mounted after collapse to preserve state and improve responsiveness

Review Change Stack

github-actions Bot and others added 2 commits May 11, 2026 07:27
Teach CopyButton to accept a sourceRef and walk the DOM itself, skipping
any subtree marked with the data-copy-ignore attribute. Trace message
toggles, the Reasoning disclosure trigger, and tool-call/other-part
label elements are annotated so their text no longer leaks into the
clipboard. The walker uses textContent semantics so collapsed/hidden
content is still copied in full.

Fixes #460

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
- Collapse `value` and `sourceRef` into a single `value: string | (() => string) | RefObject<HTMLElement | null>` prop on CopyButton.
- Delete `getNodeText` from Code.tsx; pass a ref on the rendered `<pre>` to CopyButton instead, so the text lives once in the DOM and the copy string is only built on click.
- Update trace details.tsx to use `value={contentRef}` in place of `sourceRef`.

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

CopyButton now accepts element refs and extracts clipboard text by walking the referenced DOM (skipping nodes marked data-copy-ignore). CodeBlock and MessageBlock pass refs to CopyButton; UI badges and expand/collapse triggers are marked to be excluded from copied output, and collapsed content remains mounted.

Changes

Copy Button Ref-Based Text Extraction

Layer / File(s) Summary
CopyButton type and DOM text utilities
packages/shared-ui/src/components/CopyButton.tsx
Introduces CopyValue (`string
CopyButton resolution & copy implementation
packages/shared-ui/src/components/CopyButton.tsx
Adds resolveText() which computes clipboard payload for all CopyValue variants and updates the copy handler to always use resolveText() before writing to the clipboard.
CodeBlock ref wiring
packages/shared-ui/src/components/Code.tsx
CodeBlock allocates a preRef and passes it to CopyButton; the rendered <pre> attaches ref={preRef} so copy extraction happens from the DOM node instead of React node text extraction.
MessageBlock copy integration & ignore markers
apps/console/app/routes/_shell.agent.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx
MessageBlock uses contentRef directly for CopyButton. Adds data-copy-ignore to tool-call Badge, "other parts" type label, and expand/collapse triggers (CollapsibleText, CollapsibleCode, ExpandableContent). ExpandableContent’s CollapsibleContent uses keepMounted so children remain mounted when collapsed.

Sequence Diagram

sequenceDiagram
  participant User
  participant CopyButton
  participant contentRef
  participant collectCopyText
  participant Clipboard

  User->>CopyButton: clicks copy
  CopyButton->>contentRef: read ref
  CopyButton->>collectCopyText: traverse DOM subtree
  collectCopyText->>collectCopyText: skip nodes with data-copy-ignore
  collectCopyText-->>CopyButton: resolved text
  CopyButton->>Clipboard: writeText(resolved text)
  Clipboard-->>User: content copied
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Suggested reviewers

  • turisanapo

Poem

🐰 I nibble through the DOM so neat,
Skipping badges, toggles, and feet —
data-copy-ignore marks the trail,
Clean text hops out without the tail,
Clipboard fills — a carrot treat! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: excluding toggle labels from trace message copy, which directly addresses the core problem in the changeset.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from issue #460: preventing toggle labels from being copied [#460], capturing full message text including collapsed content [#460], and implementing DOM-subtree-based copying with data-copy-ignore support [#460].
Out of Scope Changes check ✅ Passed All changes are directly related to the copy functionality fix: CopyButton enhancement to support refs, MessageBlock and Code.tsx updates to use refs, and ExpandableContent modifications to support the copying mechanism.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude-issue-460-20260511-0724

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26fa2ddde8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +53 to +54
.replaceAll(/\n{3,}/g, "\n\n")
.trim();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve exact whitespace when copying DOM-backed content

The new DOM walker normalizes output with .replaceAll(/\n{3,}/g, "\n\n").trim(), which mutates copied text by removing leading/trailing whitespace and collapsing intentional blank lines. This now affects CodeBlock because it was switched to CopyButton value={preRef} in this commit, so pasted code can differ from the rendered snippet (for example, snippets with meaningful leading indentation at the start, trailing newline, or 3+ consecutive blank lines). Copy actions should preserve exact text rather than rewriting whitespace.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/console/app/routes/_shell.agent.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx (1)

370-390: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove data-copy-ignore from tool-call names and part-type labels; they are semantic content, not UI affordances.

When copying message content, the tool call name and part type are essential context that originates from part.name and part.type in the message data structure. Currently, data-copy-ignore strips these labels, leaving only the JSON payload with no indication of which tool was invoked or what type of part it represents.

The toggle buttons (More/Less in CollapsibleText and CollapsibleCode, and the Reasoning disclosure trigger) correctly use data-copy-ignore since they are UI affordances. Tool names and part types, however, are data content and should remain in the clipboard text.

Proposed change
             {toolCalls.map((tc, index) => (
               <div key={`tool-call:${index}`} className="space-y-2">
-                <Badge variant="outline" data-copy-ignore>
+                <Badge variant="outline">
                   <Wrench className="size-3" />
                   {tc.name}
                 </Badge>
                 <CollapsibleCode code={tc.arguments} maxLength={300} />
               </div>
             ))}

             {otherParts.map((part, index) => (
               <div key={`${part.type}:${index}`} className="space-y-2">
-                <div
-                  data-copy-ignore
-                  className="text-xs font-medium text-muted-foreground uppercase"
-                >
+                <div className="text-xs font-medium text-muted-foreground uppercase">
                   {part.type}
                 </div>
                 <CollapsibleCode code={part.value} maxLength={300} />
               </div>
             ))}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/console/app/routes/_shell.agent`.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx
around lines 370 - 390, Remove the data-copy-ignore attribute from the tool call
name Badge and the part-type label div so semantic content is included when
copying; locate the JSX rendering toolCalls (Badge with {tc.name}) and
otherParts (the div showing {part.type}) and delete the data-copy-ignore prop
there, keeping data-copy-ignore only on UI affordance elements like the
CollapsibleCode/CollapsibleText "More/Less" toggles and the Reasoning disclosure
trigger.
🧹 Nitpick comments (2)
apps/console/app/routes/_shell.agent.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx (1)

347-349: 💤 Low value

Copy on the empty-message branch silently writes "".

When this branch renders, contentRef is never attached and resolveText() returns "", so clicking copy succeeds with an empty clipboard. Minor UX issue; consider hiding CopyButton (or disabling it) when there is no content to copy.

♻️ Optional — gate the copy button on having content
-        <div className="flex items-center justify-between">
+        {const hasContent = reasoning || texts.length > 0 || toolCalls.length > 0 || otherParts.length > 0;}
+        <div className="flex items-center justify-between">
           <div className="flex items-center gap-2">
             ...
           </div>

-          <CopyButton value={contentRef} />
+          {hasContent && <CopyButton value={contentRef} />}
         </div>

(Alternatively, lift the hasContent boolean to replace the inline boolean expression on line 347.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/console/app/routes/_shell.agent`.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx
around lines 347 - 349, The empty-message branch renders "(no message)" but
still allows CopyButton to copy an empty string because contentRef is not
attached and resolveText() returns ""; update the conditional that renders the
CopyButton (or lift/create a hasContent boolean) to check for actual content
before rendering/enabling CopyButton — use contentRef, resolveText(), texts,
toolCalls, otherParts and reasoning to compute hasContent and either hide or
disable the CopyButton in the branch that currently renders the "(no message)"
paragraph so copy is impossible when there is nothing to copy.
packages/shared-ui/src/components/Code.tsx (1)

13-36: 💤 Low value

LGTM — clean migration from getNodeText to ref-based copy.

Passing preRef directly to CopyButton removes the separate React-node-to-text traversal in favor of the rendered <pre> subtree, which is exactly what the new CopyButton API expects. Code content is preserved verbatim (text nodes' \ns are kept; <pre> is recognized as a block but the wrapping newlines are stripped by the final .trim()).

Minor nit: className="" at Line 26 is a no-op and can be omitted for symmetry with the consumers that pass real classes.

✨ Tiny cleanup
-          <CopyButton value={preRef} className="" />
+          <CopyButton value={preRef} />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared-ui/src/components/Code.tsx` around lines 13 - 36, Remove the
no-op className="" prop passed to CopyButton in the title branch: locate the
conditional that renders CopyButton alongside the title (uses title ? ... :
...), and update the CopyButton call that currently includes className="" to
omit the empty prop so it matches the other branch that passes a real class; the
CopyButton and preRef identifiers are the references to update.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@apps/console/app/routes/_shell.agent`.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx:
- Around line 370-390: Remove the data-copy-ignore attribute from the tool call
name Badge and the part-type label div so semantic content is included when
copying; locate the JSX rendering toolCalls (Badge with {tc.name}) and
otherParts (the div showing {part.type}) and delete the data-copy-ignore prop
there, keeping data-copy-ignore only on UI affordance elements like the
CollapsibleCode/CollapsibleText "More/Less" toggles and the Reasoning disclosure
trigger.

---

Nitpick comments:
In
`@apps/console/app/routes/_shell.agent`.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx:
- Around line 347-349: The empty-message branch renders "(no message)" but still
allows CopyButton to copy an empty string because contentRef is not attached and
resolveText() returns ""; update the conditional that renders the CopyButton (or
lift/create a hasContent boolean) to check for actual content before
rendering/enabling CopyButton — use contentRef, resolveText(), texts, toolCalls,
otherParts and reasoning to compute hasContent and either hide or disable the
CopyButton in the branch that currently renders the "(no message)" paragraph so
copy is impossible when there is nothing to copy.

In `@packages/shared-ui/src/components/Code.tsx`:
- Around line 13-36: Remove the no-op className="" prop passed to CopyButton in
the title branch: locate the conditional that renders CopyButton alongside the
title (uses title ? ... : ...), and update the CopyButton call that currently
includes className="" to omit the empty prop so it matches the other branch that
passes a real class; the CopyButton and preRef identifiers are the references to
update.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4aa71b8b-3289-4a08-ae48-b23df2efd70b

📥 Commits

Reviewing files that changed from the base of the PR and between cf004f4 and 26fa2dd.

📒 Files selected for processing (3)
  • apps/console/app/routes/_shell.agent.$agentSlug.branch.$branchSlug.traces.($traceId)/details.tsx
  • packages/shared-ui/src/components/Code.tsx
  • packages/shared-ui/src/components/CopyButton.tsx

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 11, 2026

@claude what do you think about the codereview comments?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 11, 2026

Claude finished @heiwen's task in 1m 48s —— View job


My take on the review comments

I read through both reviewer comments and the code. Here's my honest assessment, ranked by what I'd actually act on:


1. Codex P1 — whitespace normalization clobbers code ✅ Legit, worth fixing

collectCopyText ends with .replaceAll(/\n{3,}/g, "\n\n").trim()

This is a real bug now that CodeBlock pipes preRef through CopyButton:

  • .trim() will strip a leading indent (e.g. function foo()… loses its two spaces) and any trailing newline. Code files conventionally end with \n, so every paste from a CodeBlock drops it.
  • \n{3,}\n\n collapses intentional blank-line groupings (e.g. 3 blank lines between sections of a file).

The whitespace normalization makes sense for prose/message copies where block-level walking produces spurious double-newlines, but it's wrong for a <pre>. Two reasonable fixes:

  • Targeted: detect <pre> at the root and return root.textContent ?? "" verbatim (bypass the walker entirely). Matches the rendered pre-formatted text exactly.
  • General: split the concerns — have the walker not trim/collapse, and push that policy to the caller via an option, or only apply it when crossing block boundaries rather than globally.

I'd lean toward the targeted fix — it's smaller and the <pre> case really is different from the message-tree case.


2. CodeRabbit "Major" — data-copy-ignore on tool-call names / part-type labels ⚠️ Disagree, but worth acknowledging

CodeRabbit's claim is that tc.name and part.type are semantic content, not UI chrome, so they shouldn't be stripped.

It's a reasonable design argument, but it's explicitly contradicted by your own test plan:

Message with tool calls: copy → arguments present, tool-call badge name absent.

So this was a deliberate call, not an oversight. I think your call is defensible: the badge is a visual label, the canonical representation of "which tool" lives in the JSON arguments anyway, and pasting Wrench get_weather\n{"city":"Paris"} into another tool isn't obviously better than pasting the JSON alone. I'd leave it as-is and maybe drop a one-liner on the CodeRabbit thread explaining the intent. If you want a middle ground, you could include the tool name inline in the copy (e.g. as a prepended // tool: get_weather comment) rather than relying on the badge text — but that's scope creep.


3. CodeRabbit nits

  • Empty-message branch copies "" (details.tsx:344): true but low impact. Clicking copy shows a "Copied" tooltip and nothing happens. Easy to hide the button when there's no content, but not a blocker.
  • className="" on CodeBlock's titled variant (Code.tsx:26): harmless no-op. Drop it for tidiness if you're touching the file anyway.

Summary

Comment Verdict
Codex: whitespace normalization breaks code copies Fix — real bug for CodeBlock
CodeRabbit: include tool names / part types in copy Skip — intentional per your test plan
CodeRabbit: empty-message copy button Skip / cosmetic
CodeRabbit: empty className="" Optional cleanup

Want me to push the fix for the Codex whitespace issue? I'd special-case <pre> root to preserve verbatim text.
· branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude how was the whitespace normalization handled before?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 1m 25s —— View job


Whitespace normalization — before vs. after

There was no whitespace normalization before. The two old call sites each got it "for free" in different ways, neither of which collapsed blank lines or trimmed:

1. CodeBlock (old packages/shared-ui/src/components/Code.tsx)

<CopyButton value={getNodeText(children)} />

getNodeText was a React-tree walker:

const getNodeText = (node: React.ReactNode): string => {
  if (typeof node === "string" || typeof node === "number") return String(node);
  if (Array.isArray(node)) return React.Children.toArray(node).map(getNodeText).join("");
  if (React.isValidElement(node)) return getNodeText(node.props.children);
  return "";
};

It just concatenated the string/number leaves of the React tree, verbatim — leading indent, trailing \n, runs of blank lines, all preserved. No .trim(), no \n{3,} collapse. That's why piping preRef through the new walker is a regression for code: the walker now mutates content the old path left alone.

2. MessageBlock (old details.tsx, line 344 on main)

<CopyButton value={() => contentRef.current?.innerText ?? ""} />

This delegated to the browser's innerText algorithm, which:

  • Inserts \n between block-level elements (similar in spirit to the new walker's BLOCK_TAG_PATTERN handling).
  • Respects CSS visibility — display:none / visibility:hidden text is not included. That's actually the bug the PR exists to fix: collapsed line-clamp text and unmounted Reasoning content were being dropped on copy.
  • Does not collapse \n{3,} and does not .trim().

So in both old paths, blank-line groupings and leading/trailing whitespace were preserved. The new .replaceAll(/\n{3,}/g, "\n\n").trim() is brand new behavior introduced in 26fa2dd to paper over double-newlines that the new block-tag walker generates around nested blocks (e.g. a <div> inside a <section> inside a <div>).

Why it matters for the Codex finding

For <pre> content the walker isn't really needed — <pre> already has a single text node (or close to it) with the exact rendered whitespace. The cleanest fix is to detect a <pre> root and bypass the walker:

function collectCopyText(root: HTMLElement): string {
  if (root.tagName === "PRE") return root.textContent ?? "";
  // …existing walker + normalize
}

That restores the old getNodeText behavior for CodeBlock (verbatim text, no trim/collapse) while keeping the message-tree normalization where it's actually wanted.

Want me to push that fix?
· branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude why do we need the BLOCK_TAG_PATTERN in the first place, can't we simplify that?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 2m 5s —— View job


Why BLOCK_TAG_PATTERN, and can it be simpler?

Why it exists

The walker uses textContent semantics (so collapsed / keepMounted / line-clamp text is still captured — that's the whole reason this PR exists; innerText was dropping hidden content). But raw textContent mashes block siblings together: <div>Hello</div><div>World</div> becomes HelloWorld instead of Hello\nWorld. BLOCK_TAG_PATTERN is the manual replacement for the block-boundary newlines that innerText would have inserted for us.

So the constraint is: we need block-boundary \ns and we need to include hidden text. No single browser API gives both, hence the walker.

Can it be simpler? Yes. A few options, ordered by how aggressive:

1. Tighten the set to the tags we actually emit (smallest, lowest risk)

In the message subtree, after [data-copy-ignore] strips the buttons/badges/icons, the only block tags rendered are DIV, P, and PRE. The 25-tag regex is dead weight here:

const BLOCK_TAGS = new Set(["DIV", "P", "PRE"]);
// ...
const isBlock = BLOCK_TAGS.has(el.tagName);

A Set lookup also reads better than a megamorphic regex. Cheap and clearly correct for this codebase.

2. Use getComputedStyle().display (more robust, slightly more expensive)

Lets the rendered layout decide what's a block, so it works for anything (custom elements, components that render as flex/grid/block via CSS regardless of tag):

const isBlock = (el: Element) => {
  const d = getComputedStyle(el).display;
  return d !== "inline" && d !== "inline-block" && d !== "contents" && d !== "none";
};

Forces style resolution, but copies happen on click — totally fine. This is the version I'd reach for if we expect the walker to be reused in places we don't control the markup.

3. Lean on the structure of contentRef (biggest simplification, most coupled)

contentRef's immediate children are already a flat list of blocks (space-y-3). After filtering [data-copy-ignore], each child has at most one text-bearing block inside (<p>, <pre>, etc.). So:

function collectCopyText(root: HTMLElement): string {
  const clone = root.cloneNode(true) as HTMLElement;
  clone.querySelectorAll(`[${COPY_IGNORE_ATTR}]`).forEach((el) => el.remove());
  return Array.from(clone.children)
    .map((c) => (c.textContent ?? "").trim())
    .filter(Boolean)
    .join("\n\n");
}

No walker, no tag list, no whitespace post-processing. Trade-off: assumes a flat block-list shape at the root, which is true for MessageBlock but breaks for the CodeBlock case (where root is <pre> with a single text child — Array.from(children) is empty). Would need a <pre> short-circuit, which we want anyway to fix the Codex whitespace finding.

My recommendation

Go with (1): replace BLOCK_TAG_PATTERN with a 3-element set, plus the <pre> short-circuit from the earlier Codex thread:

function collectCopyText(root: HTMLElement): string {
  if (root.tagName === "PRE") return root.textContent ?? "";
  // ...walker with BLOCK_TAGS = new Set(["DIV", "P", "PRE"])
}

That kills both the over-broad pattern and the code-copy regression in one go, without coupling the walker to MessageBlock's exact shape. (3) is tempting but I'd hold off until we have a second consumer pulling the walker in a different direction.

Want me to push that?
· branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude it feels more complicated than it needs to be. why not simply walk the whole HTML tree recursively, like it was done previously in getNodeText()?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 2m 55s —— View job


Why not just getNodeText-style?

Short answer: because getNodeText only ever ran against a <pre> subtree, where there's a single text leaf and no sibling structure. The new walker runs against MessageBlock's content tree, which has multiple sibling block-level children whose visual separation comes from CSS (space-y-3), not from \n characters in the DOM. A pure recursive concatenation works fine for the first case and produces run-on garbage for the second.

The strict translation

getNodeText was just "concatenate string/number leaves of the React tree, no separators". The DOM equivalent is essentially node.textContent — or, written longhand, ~10 lines:

function collectCopyText(node: Node): string {
  if (node.nodeType === Node.TEXT_NODE) return node.nodeValue ?? "";
  if (node.nodeType !== Node.ELEMENT_NODE) return "";
  const el = node as HTMLElement;
  if (el.hasAttribute(COPY_IGNORE_ATTR)) return "";
  if (el.tagName === "BR") return "\n";
  return Array.from(el.childNodes).map(collectCopyText).join("");
}

That's truly equivalent to the old behavior, plus the [data-copy-ignore] skip. For CodeBlock it's perfect — <pre> has one text node and the rendered whitespace is preserved verbatim (incidentally fixing the Codex whitespace issue for free).

Why it's not enough for MessageBlock

Apply that walker to a real message subtree with reasoning, two text parts, and a tool call:

<div class="space-y-3">          
  <div>                          
    <button data-copy-ignore>Reasoning</button>
    <div><p>I will check the weather</p></div>
  </div>
  <p>Here's the result</p>       
  <div>                          
    <pre>{"city":"Paris"}</pre>
  </div>
</div>

The naive walker returns:

I will check the weatherHere's the result{"city":"Paris"}

Run-on. There's no \n in the DOM between those siblings — the visual gap is margin-top on each child of space-y-3. getNodeText never had to deal with that because it was only ever pointed at a single <pre> blob.

So the new walker needs something that says "insert a newline at block boundaries". That's all BLOCK_TAG_PATTERN is — it's the manual replacement for the block-boundary newlines that browsers insert for us when computing innerText. We can't just use innerText itself because it's layout-aware and drops collapsed / keepMounted content, which is the entire bug we're fixing.

So how can we simplify?

The complexity that's actually unnecessary:

  • The 25-tag regex (we only render DIV / P / PRE in the message subtree).
  • The chunks array + pushBlockBreak bookkeeping.
  • The \n{3,} collapse + .trim() (only exists to paper over double-newlines from the regex over-firing, and it's the cause of the Codex regression for <pre>).

A tighter version that keeps the block-boundary semantics but drops everything else:

const BLOCK_TAGS = new Set(["DIV", "P", "PRE"]);

function collectCopyText(root: HTMLElement): string {
  if (root.tagName === "PRE") return root.textContent ?? "";

  const walk = (node: Node): string => {
    if (node.nodeType === Node.TEXT_NODE) return node.nodeValue ?? "";
    if (node.nodeType !== Node.ELEMENT_NODE) return "";
    const el = node as HTMLElement;
    if (el.hasAttribute(COPY_IGNORE_ATTR)) return "";
    if (el.tagName === "BR") return "\n";
    const inner = Array.from(el.childNodes).map(walk).join("");
    return BLOCK_TAGS.has(el.tagName) ? `\n${inner}\n` : inner;
  };

  return walk(root).replaceAll(/\n+/g, "\n").trim();
}

That's the "as simple as it can get without regressing MessageBlock". Maybe 15 lines, no regex, no chunks list, no over-broad whitespace pass.

If we want to go further, we can lean on the fact that contentRef's immediate children are always a flat list of blocks:

function collectCopyText(root: HTMLElement): string {
  if (root.tagName === "PRE") return root.textContent ?? "";
  const clone = root.cloneNode(true) as HTMLElement;
  clone.querySelectorAll(`[${COPY_IGNORE_ATTR}]`).forEach((el) => el.remove());
  return Array.from(clone.children)
    .map((c) => (c.textContent ?? "").trim())
    .filter(Boolean)
    .join("\n\n");
}

No walker at all. Trade-off: it assumes the "flat list of block children" shape that MessageBlock happens to have, so the walker is no longer reusable somewhere with nested content. Fine if we don't expect a second consumer.

My take

The strict getNodeText equivalent really is ~10 lines, but only because the old code never had to handle sibling blocks. The current implementation is over-built for what we actually render — the first version above (small set, <pre> short-circuit, single regex pass) gets us most of the way to "as simple as getNodeText" without losing the block separation we now need.

Want me to push that?
· branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude ok, go with this version:

const BLOCK_TAGS = new Set(["DIV", "P", "PRE"]);

function collectCopyText(root: HTMLElement): string {
  if (root.tagName === "PRE") return root.textContent ?? "";

  const walk = (node: Node): string => {
    if (node.nodeType === Node.TEXT_NODE) return node.nodeValue ?? "";
    if (node.nodeType !== Node.ELEMENT_NODE) return "";
    const el = node as HTMLElement;
    if (el.hasAttribute(COPY_IGNORE_ATTR)) return "";
    if (el.tagName === "BR") return "\n";
    const inner = Array.from(el.childNodes).map(walk).join("");
    return BLOCK_TAGS.has(el.tagName) ? `\n${inner}\n` : inner;
  };

  return walk(root).replaceAll(/\n+/g, "\n").trim();
}

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 2m 11s —— View job


Simplified collectCopyText — pushed in 610c986

  • Replace collectCopyText with the simpler walker (set-based block tags, <pre> short-circuit, single regex pass)
  • Typecheck passes (bun run -F @hebo/shared-ui typecheck)
  • Commit and push

Net change: +12 / −36 in CopyButton.tsx. Gone are the 25-tag regex, the chunks array + pushBlockBreak helper, and the \n{3,} collapse. The <pre> short-circuit also resolves the Codex whitespace finding — code copies now preserve leading indent and trailing newline verbatim.

One small lint accommodation: oxlint's no-array-callback-reference rule rejects .map(walk) for recursive references, so the call is wrapped as .map((child) => walk(child)). Same behavior, slightly more verbose.
· branch claude-issue-460-20260511-0724

Drop the over-broad block-tag regex and the chunks/pushBlockBreak
bookkeeping. Pre-formatted roots short-circuit through textContent so
CodeBlock copies preserve indentation and trailing newlines verbatim
instead of being mangled by the trim/collapse pass.

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/shared-ui/src/components/CopyButton.tsx (1)

15-31: 💤 Low value

Nested <pre> loses verbatim formatting.

The PRE short-circuit only fires when PRE is the root. When a <pre> is nested inside a non-PRE root (e.g., a message that embeds a code block), the walker recurses into it and the outer replaceAll(/\n+/g, "\n") collapses the code block's blank/indent-preserving newlines. For the current call sites (CodeBlock passes the <pre> directly; MessageBlock content typically lacks nested <pre>) this isn't user‑visible today, but it's a latent foot‑gun if message rendering ever embeds rendered code blocks.

Consider short‑circuiting nested PREs to textContent as well, and emitting block separators around the result so surrounding newlines are preserved:

♻️ Proposed adjustment
   const walk = (node: Node): string => {
     if (node.nodeType === Node.TEXT_NODE) return node.nodeValue ?? "";
     if (node.nodeType !== Node.ELEMENT_NODE) return "";
     const el = node as HTMLElement;
     if (el.hasAttribute(COPY_IGNORE_ATTR)) return "";
     if (el.tagName === "BR") return "\n";
+    if (el.tagName === "PRE") return `\n${el.textContent ?? ""}\n`;
     const inner = Array.from(el.childNodes)
       .map((child) => walk(child))
       .join("");
     return BLOCK_TAGS.has(el.tagName) ? `\n${inner}\n` : inner;
   };

Note: preserving internal newlines of a nested <pre> also requires changing the final normalization to not collapse them (e.g., only trim, or only collapse 3+ newlines into 2). Worth deciding based on whether nested <pre> is actually expected in MessageBlock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared-ui/src/components/CopyButton.tsx` around lines 15 - 31, The
walker in collectCopyText currently only short-circuits when the root is PRE, so
nested <pre> elements lose verbatim newlines when the final replaceAll collapses
runs of newlines; update the walker to treat nested PREs specially by returning
the pre's full textContent wrapped with block separators (e.g., return
`\n${el.textContent ?? ""}\n` when el.tagName === "PRE") and keep respecting
COPY_IGNORE_ATTR and BR handling, and then change the final normalization from
replaceAll(/\n+/g, "\n") to one that preserves internal pre spacing (for example
only trim() or collapse 3+ newlines to two using replaceAll(/\n{3,}/g, "\n\n")).
Ensure references: collectCopyText, COPY_IGNORE_ATTR, BLOCK_TAGS, and the walk
inner function are updated accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/shared-ui/src/components/CopyButton.tsx`:
- Around line 15-31: The walker in collectCopyText currently only short-circuits
when the root is PRE, so nested <pre> elements lose verbatim newlines when the
final replaceAll collapses runs of newlines; update the walker to treat nested
PREs specially by returning the pre's full textContent wrapped with block
separators (e.g., return `\n${el.textContent ?? ""}\n` when el.tagName ===
"PRE") and keep respecting COPY_IGNORE_ATTR and BR handling, and then change the
final normalization from replaceAll(/\n+/g, "\n") to one that preserves internal
pre spacing (for example only trim() or collapse 3+ newlines to two using
replaceAll(/\n{3,}/g, "\n\n")). Ensure references: collectCopyText,
COPY_IGNORE_ATTR, BLOCK_TAGS, and the walk inner function are updated
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a630a56f-b577-48ec-a692-e4516128162e

📥 Commits

Reviewing files that changed from the base of the PR and between 26fa2dd and 610c986.

📒 Files selected for processing (1)
  • packages/shared-ui/src/components/CopyButton.tsx

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude ok, so we are using the DOM representation now. Would the alternative be to use the virtual dom and if so how to do that without creating duplicate representations of everything just for the copy button?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 3m 12s —— View job


Virtual DOM as the copy source — would it work, and at what cost?

Short answer: there isn't really a "virtual DOM" you can walk from outside React. React's fiber tree is internal; the only public surfaces are the JSX you pass in (children/props of components you author) and what React renders out (the DOM). So "walk the vDOM" practically means one of three concrete things — and only one of them genuinely avoids duplicate representations.

Option A — walk React children of a wrapper component

Put the content inside a wrapper:

<Copyable>
  <ReasoningPanel ... />
  <CollapsibleText ... />
  <CollapsibleCode ... />
</Copyable>

Copyable runs React.Children.toArray(children) recursively, like the old getNodeText. It doesn't actually solve our problem. getNodeText worked for CodeBlock because the children passed were the text — <pre>{rawString}</pre>. But in MessageBlock, the children are components: <CollapsibleText text="..." />, <ExpandableContent>...</ExpandableContent>. Walking React children sees props.text, not what the component renders. To get the actual text you have to either:

  • Drill into specific component props (couples Copyable to every component shape), or
  • Render to know — which means a DOM walk again, or…

Option B — renderToStaticMarkup on the React tree

Pull in react-dom/server, serialize the JSX to an HTML string, then parse that string for text. This does give you a virtual-DOM-ish snapshot without touching the live DOM, but:

  • It re-renders the whole subtree on click. Cheap-ish, but it's literally building "a duplicate representation of everything just for the copy button" — exactly what you wanted to avoid.
  • Server renderer doesn't run effects, so any state-dependent rendering (e.g. expanded in CollapsibleText at details.tsx:399) renders from defaults, not the user's current view. For our case this happens to be fine because we capture all text regardless of expansion, but it's a footgun.
  • Adds a dependency we currently don't need on the client.

So this is "virtual DOM" in spirit, but it's strictly more duplication than the current DOM walk, not less.

Option C — derive copy text from the source data

This is the only path that genuinely avoids any extra tree. The data the MessageBlock renders from is the canonical representation; the rendered DOM is just a projection of it. So is the copy text. Don't re-derive copy text from a projection — derive it from the source alongside the render:

function MessageBlock({ message }: { message: TraceMessage }) {
  const { texts, reasoning, toolCalls, otherParts } = extractMessageParts(message);

  const copyText = useMemo(
    () =>
      [
        reasoning,
        ...texts,
        ...toolCalls.map((tc) => tc.arguments),
        ...otherParts.map((p) => p.value),
      ]
        .filter(Boolean)
        .join("\n\n"),
    [reasoning, texts, toolCalls, otherParts],
  );

  return (
    // ...
    <CopyButton value={() => copyText} />
    // ...
  );
}

For CodeBlock it's even simpler — children already is a string in every existing call site, so CopyButton value={children} (or a typed code: string prop) works directly.

This is the no-duplicate version. Source → render. Source → copy. Two projections, one source. No data-copy-ignore, no walker, no whitespace normalization, no keepMounted workaround for collapsed content (the bug from #460 only existed because we were reading from the DOM, where collapsed nodes were hidden from innerText).

Why didn't we go with C in the first place?

Two reasons, both real:

  1. Genericity. A DOM-ref-based CopyButton is a one-size-fits-all primitive: any site that has visible text just points the button at the subtree and is done. Source-derived copy text is per-site code — every consumer has to build its own string. We have ~6 CopyButton call sites currently and most of them already pass strings (jsonStr, identifier values, etc.), so the cost is concentrated in MessageBlock and CodeBlock.
  2. Drift. Visual rendering and copy text can disagree. A DOM walker guarantees "what you see is what you copy" (modulo data-copy-ignore). Source-derived copy text can drift if rendering does any transformation — e.g., trimming, prefix indicators, or formatting differences.

For our actual workload, (1) is small (two sites) and (2) is a non-issue for MessageBlock specifically (the rendering is text<p>{text}</p>, no transforms; arguments → JSON in a <pre>; etc.). So if we wanted to refactor toward C, the refactor is small and the trade-offs are mild.

My take

The current DOM walker is fine for what it is, but if the discomfort is "we're using the DOM as a string source," the right alternative is C, not B. C is the only one that actually drops a representation rather than adding one. Concretely:

  • CodeBlock: switch back to value={children} (or accept code: string). children is already a string in every existing call.
  • MessageBlock: build copyText from extractMessageParts output and pass it as value={() => copyText}. Drop contentRef, data-copy-ignore, keepMounted, and collectCopyText entirely.

The PR as it stands fixes #460 and the regression has been narrow; we don't have to refactor now. But if we want to do it, that's the shape — happy to put up a follow-up.
· branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude I won't the CopyButton to be generic, so that the client doesn't have to provide their own mapping logic every single time. but I also like to avoid the keepMounted, since that can slow down the frontend significantly. how?

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 3m 55s —— View job


Generic CopyButton without keepMounted — what's possible

The constraint chain is straightforward:

  1. Generic CopyButton ⇒ walker reads from DOM.
  2. Walker reads from DOM ⇒ content has to be in the DOM when copied.
  3. Collapsible from base-ui defaults to unmounting its content when closed.
  4. ⇒ Either keepMounted, or a different "keep in DOM" mechanism.

So the right framing isn't "drop keepMounted and somehow still copy hidden text" — that's contradictory. It's "use a different way to keep collapsed content in the DOM that doesn't pay the cost you're worried about." A few options, ordered by how much I'd actually reach for them.

What's actually paying cost?

Worth confirming first: for ExpandableContent we're talking about one call site (the Reasoning panel) wrapping one <p> of text. keepMounted keeps that <p> in the DOM and runs React's lifecycle for it. The cost should be negligible. If you've measured it as significant, I'd bet it's the wrapper component (animation/measurement work in Collapsible), not the children.

If that's right, the cheap fix is changing the wrapper, not changing the architecture.

Option 1 — native <details> / <summary>

function ExpandableContent({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <details>
      <summary data-copy-ignore className={...}>
        <ChevronRight className="size-3 transition-transform group-open:rotate-90" />
        <span>{label}</span>
      </summary>
      {children}
    </details>
  );
}
  • Browser keeps content in the DOM at all times — closed <details> just hides children, doesn't unmount them.
  • No React lifecycle thrash, no keepMounted flag, no Collapsible runtime.
  • textContent picks up the inner text → walker captures it.
  • data-copy-ignore on <summary> keeps the label out.

Trade-off: CSS work to match the existing chevron/hover/focus styling. Pseudo-selectors are different (details[open] instead of data-panel-open), so the existing class is mostly portable but needs a quick pass.

This is the option I'd reach for first.

Option 2 — keep Collapsible, replace keepMounted with CSS hide

If you want to stay on base-ui for visual consistency, write or use a "force keep DOM" version that hides via CSS instead of React's animation/measurement path:

<Collapsible>
  <CollapsibleTrigger data-copy-ignore .../>
  <div hidden={!open}>{children}</div>
</Collapsible>

You'd hoist open into local state to drive the hidden attribute. Effectively the same as keepMounted from a "children are always mounted" perspective, but skips whatever measurement / animation CollapsibleContent does internally. Worth doing only if you've actually measured keepMounted as the hot spot.

Option 3 — copy-text registration via context

Keep Collapsible unmounting children, and let components contribute their text to a CopyButton via React context. CopyButton becomes:

const CopyContext = createContext<{ register(get: () => string): () => void } | null>(null);

function CopyProvider({ children, value }: ...) {
  const sources = useRef<Set<() => string>>(new Set());
  // register/unregister via context
  // walker uses contentRef + concatenates sources.current.values()
}

Components that may unmount their content (like ExpandableContent) call useCopyText(() => textRepresentation) to register. CopyButton walks the DOM as before and concatenates registered sources. Unmounted-but-registered text is still captured because the registration lives in a parent's ref.

Pros: no keepMounted, no per-site mapping at the call sites — only the Collapsible-style components need the hook once.

Cons: it's a real piece of API surface, registration ordering can drift from visual order, and you've effectively reinvented keepMounted at a different layer (the registered closure captures the text). I'd only build this if Options 1/2 don't work.

Option 4 — accept that MessageBlock is the exception

Keep the generic walker for the easy 90% (CodeBlock, Raw JSON, identifiers, metadata rows — all fine), and have MessageBlock build its copy string from extractMessageParts directly:

const copyText = useMemo(
  () => [reasoning, ...texts, ...toolCalls.map((tc) => tc.arguments), ...otherParts.map((p) => p.value)]
    .filter(Boolean).join("\n\n"),
  [reasoning, texts, toolCalls, otherParts],
);
<CopyButton value={() => copyText} />

Drop keepMounted, data-copy-ignore, and contentRef from this one site. Generic API everywhere else. That's the "every blanket rule has one exception" version.

My recommendation

Option 1. It's the smallest change, keeps the API generic, and lines up with the actual nature of the problem: we want collapsed content in the DOM, and that's literally what <details> does without any React-level cost. Want me to push that change?


  • Read trigger and current code
  • Explain trade-offs and lay out options
  • (Optional) Implement chosen option — awaiting confirmation
    · branch claude-issue-460-20260511-0724

@heiwen
Copy link
Copy Markdown
Contributor Author

heiwen commented May 14, 2026

@claude there must be another way. my constraints are clear (i) keep using the collapsible component, we don't have access to it's API (ii) don;t use keepMounted (iii) a generic way to pass the content into the copybutton [whether that's dom or virtual dom or whatever I don't know). (iv) no expensive duplication so it doesn't blow up memory or the like. this should be possible, since the content is already there somewhere. What's that way? (don't implement anything)

@hebo-agent
Copy link
Copy Markdown

hebo-agent Bot commented May 14, 2026

Claude finished @heiwen's task in 4m 1s —— View job


The way: register the JSX, don't render it

You named the constraint correctly — "the content is already there somewhere." It is. We just need to look at where, per case, and stop conflating "in memory" with "in the DOM."

Where the text actually lives, per component

Component Text lives in Always in DOM? Captured by textContent?
CollapsibleText text prop → rendered as <p>{text}</p> Yesline-clamp-6 is CSS-only, doesn't remove nodes ✅ Yes (CSS hiding doesn't affect textContent)
CollapsibleCode code prop → rendered as <pre>{code}</pre> Yesmax-h-40 overflow-hidden is CSS-only ✅ Yes
ExpandableContent children JSX → rendered conditionally by Collapsible No — when closed, base-ui unmounts ❌ No

So actually only one of these three is the problem: ExpandableContent. The other two never needed keepMounted in the first place — line-clamp and overflow:hidden are CSS-only and textContent ignores CSS. The current PR's data-copy-ignore on the trigger is sufficient for those two; remove keepMounted from ExpandableContent only and we have one specific gap to fill.

Where ExpandableContent's text lives when closed

When Collapsible unmounts the inner DOM, the children prop is still alive — it's a JSX object held by ExpandableContent's render closure. JSX is just a plain JS object with { type, props, children } fields. It's referenced once, by the parent. No duplication, no mount, no effects, no refs, no lifecycle. It's exactly the "the content is already there somewhere" that you're pointing at.

So the question reduces to: how does CopyButton reach into ExpandableContent's children JSX?

The mechanism: Context + slot placeholder + JSX walk

A CopyScope context with a registry. ExpandableContent (the only consumer that unmounts) participates:

function ExpandableContent({ label, children }) {
  const slotId = useCopySource(children);   // registers () => walkJSX(children) at slotId

  return (
    <Collapsible>
      <CollapsibleTrigger data-copy-ignore>...</CollapsibleTrigger>
      <span hidden />   {/* zero-content marker, always in DOM */}
      <CollapsibleContent>{children}</CollapsibleContent>   {/* no keepMounted */}
    </Collapsible>
  );
}

CopyButton resolves text by walking the DOM as it does today, with one extra rule: when it hits an element with data-copy-slot="<id>", it pulls that slot's registered text from context and inlines it at that position. The text is produced by the same getNodeText-style JSX walker we discussed — string/number leaves of the React element tree, concatenated. Cheap object traversal, no rendering.

Checking it against your constraints

Constraint Met? Why
(i) Keep using Collapsible, no API access needed We don't touch base-ui. We just don't pass keepMounted and add a sibling marker.
(ii) No keepMounted Collapsible unmounts freely. The text source is the JSX, not the DOM.
(iii) Generic CopyButton API Call sites unchanged: <CopyButton value={contentRef} />. The participation lives inside the unmounting component, not at consumer sites.
(iv) No expensive duplication children JSX is held once by ExpandableContent's props. The registry stores a closure reference (function + JSX pointer, no copy). The text string is built once, on click. No second mount, no second DOM tree, no effects re-run.

Why this is not a reinvention of keepMounted

  • keepMounted keeps a React subtree mounted: components run, effects fire, refs are taken, DOM nodes exist for every child. For a Reasoning panel with rich content, that means measurement, animation, motion observers, etc., all live in parallel with the visible copy.
  • The registry holds a JSX object reference — a static description of the tree, the same { type, props, children } data the React reconciler would consume if it mounted. Nothing runs. There's no DOM. There's no second instance. It's the same JS object the parent already holds.

That's the whole point: React always has two representations of any subtree — the JSX you write (cheap, declarative) and the rendered fiber/DOM tree (expensive, live). keepMounted keeps the second one alive when you don't need it. The slot trick lets us read from the first one instead.

Caveats worth naming

  • The JSX walker only sees text passed as children. If a child component computes text from props (<Greeting name="Bob" /> → "Hello, Bob"), the walker sees <Greeting name="Bob"/> and gets nothing. For our ExpandableContent usage today, children is <p>{reasoning}</p> — pure text — so this is a non-issue. For future containers that wrap arbitrary components and need this, those components would either pass strings as children, or themselves participate via useCopySource. Same one-time cost, isolated to authors.
  • Slot ordering is preserved because the placeholder <span data-copy-slot> sits at the JSX position where the content would render, so the DOM walker encounters it in correct order.
  • Three call sites for useCopySource would feel like API surface; one call site does not.

TL;DR

CollapsibleText and CollapsibleCode don't actually need keepMounted — drop it from the PR's mental model, they were never the problem. ExpandableContent is the one container whose DOM disappears, but its children JSX lives in memory anyway. Read from that via a Context + slot-placeholder, and you get the generic CopyButton API, no keepMounted, and zero expensive duplication.
· branch claude-issue-460-20260511-0724

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.

Chat history message copy includes 'less'/'more' toggle labels

1 participant