Skip to content

feat: smooth selection bar, select across a reply's paragraphs, copy as plain text - #165

Merged
chenweigao merged 2 commits into
mainfrom
feat/reply-selection-20261015
Oct 15, 2026
Merged

chenweigao merged 2 commits into
mainfrom
feat/reply-selection-20261015

Conversation

@chenweigao

Copy link
Copy Markdown
Owner

Summary

  • Selection bar no longer janks.
    • Before, it was rebuilt on every selection step: a new NSHostingView, a synchronous layout, and a forced redisplay of a shadowed glass panel. It also appeared mid-drag and trailed scrolling a frame late.
    • Now it appears once the selection settles (mouse up, or 0.12 s after a keyboard change) and fades in; Reduce Motion skips the fade.
    • One hosting view is reused and only moved.
    • While the containing transcript scrolls, it hides and comes back after it stops.
  • Select across a reply's paragraphs. A reply's paragraphs, headings, list items and code blocks are separate text views, so a drag used to stop at the first edge. ReplySelection treats one transcript entry as one selection:
    • A drag continues into the views above or below. The anchor keeps the real selection; the other views show their part with a temporary highlight in the selection color.
    • ⌘C, the context menu Copy, the bar's 复制文本 and 引用 read the whole span in reading order.
    • ⌘A selects the whole entry.
    • A click elsewhere ends the previous selection. This also removes the stale grey selection seen before.
  • 复制整条回复(纯文本) in a reply's context menu. It copies the whole reply without Markdown syntax (ReplyPlainText): list markers, quotes, tab-separated tables and code source.

Implementation notes

  • NSTextView's own drag loop still runs the drag. ReplyTextView hooks mouseDown, setSelectedRanges, copy, selectAll and the first-responder changes.
  • NSTextView stops asking for ranges once the pointer leaves the anchor, so a timer in the event-tracking run loop mode follows the pointer during a drag.
  • The span arithmetic (TextSelectionSpan) and the plain text are in WorkbenchCore and have tests.
  • check-preview-sources.py now also reads Python file lists such as check-scroll-following.py, which missed new sources twice yesterday. It caught this one.

Verification

  • Linux portable suite on clean head d2e60b7: 8/8.
  • Linux WorkbenchCore + WorkbenchChecks: 57 PASS, including the new reply plain text and cross-view span checks.
  • New host check reply-selection:
    • It drags with queued events across a native reply's heading, paragraph and two list items.
    • It asserts the anchor runs to its end, the extension is highlighted, and copy holds the whole span.
    • It also asserts that a click elsewhere clears everything, that Select All selects the whole reply, and that the menu offers the plain-text copy.
    • It runs in the macOS job.
  • Not run locally: no macOS SDK here, so the macOS job is the first compile.
  • Mac check needed: how the extension highlight and the bar look and feel in the real app.
  • Known limits:
    • Shift-arrow keyboard extension stays within one view.
    • A span does not cross from a user prompt into the reply.
    • List markers are not part of the copied span; the plain-text menu keeps them.

🤖 Generated with Claude Code

chenweigao and others added 2 commits October 15, 2026 16:56
A reply's paragraphs, headings, list items and code blocks are separate
text views, and AppKit keeps one selection per view. A drag stopped at
the edge of the paragraph it started in, and a selection made in another
view stayed behind in grey.

ReplySelection treats one transcript entry (ConversationEntryContainer,
or a user bubble that is a row's root) as one selection:
- A drag continues into the views above or below. The anchor keeps a real
  selection and stays first responder. The other views show their part
  with a temporary background in the selection color, because AppKit
  draws an inactive view's selection grey. A timer in the event-tracking
  run loop mode follows the pointer, since NSTextView's drag loop stops
  asking for ranges once the pointer has left the anchor.
- Copy (⌘C, the context menu) reads the whole span in reading order. ⌘A
  selects the whole entry. A click in another view ends the previous
  selection, including a double-clicked word.
- The reply's context menu adds 复制整条回复(纯文本), using
  ReplyPlainText: markers, quotes, tab-separated tables and code source,
  without Markdown syntax.

The range arithmetic (TextSelectionSpan) and the plain text are Core
code with WorkbenchChecks coverage. The new host check `reply-selection`
drags across a native reply with queued events and checks the span,
copy, clearing, Select All and the menu. Previews, the scroll-following
check and the performance export test compile the new source;
check-preview-sources.py now also reads Python file lists like the
scroll-following one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bar felt janky because it was rebuilt on every selection change.
During a drag that means every pointer step: a new NSHostingView, a
synchronous layout, and a forced redisplay of a shadowed glass panel.
It also appeared mid-drag and jumped with the selection's first line,
and while the transcript scrolled it chased each step a frame late.

- It appears after the mouse button is released, or 0.12 s after a
  keyboard change, and fades in (skipped with Reduce Motion).
- One hosting view is reused and only moved. It is rebuilt only when
  quoting availability or the language changes; actions read the
  selection when they run.
- While a clip view that contains the selection scrolls, or the window
  resizes, the bar hides and returns 0.15 s after it stops. Bounds
  changes elsewhere are ignored.
- Copy and Quote read a span across text views (ReplySelection), and the
  bar anchors to its first line.

The design note covers selection and the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T09:02:04.528045Z d2e60b7 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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

Copy link
Copy Markdown

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: d2e60b79df

ℹ️ 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 +1088 to +1090
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
ReplySelection.shared.refreshHighlight()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear cross-view selection when focus leaves the reply

After making a cross-view selection, clicking the composer or any other non-ReplyTextView only causes the anchor to resign first responder. This handler merely recolors the temporary extension, while AppKit retains the anchor's inactive selection, so the newly added multi-paragraph highlight remains visibly stale until another reply text view is clicked. Add a click/focus transition that clears the span when focus leaves the reply, while excluding interactions with the selection bar itself.

Useful? React with 👍 / 👎.

Comment on lines 119 to +121
let visible = window.convertToScreen(textView.convert(textView.visibleRect, to: nil))
guard rect.width > 0 || rect.height > 0, visible.intersects(rect) else { return nil }
let transcript = textView.enclosingScrollView.map { window.convertToScreen($0.convert($0.bounds, to: nil)) } ?? visible
guard rect.width > 0 || rect.height > 0, transcript.intersects(rect) || visible.intersects(rect) else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the outer transcript viewport for selection anchors

When a cross-view drag starts in a code block and extends upward into an earlier paragraph, firstLineRect returns a rectangle in that paragraph, but textView.enclosingScrollView is the code card's nested horizontal scroll view rather than the transcript. Neither that small viewport nor the code text's visibleRect intersects the earlier paragraph, so anchor(for:) returns nil and the selection bar never appears for this valid selection direction. The visibility check needs to locate the outer transcript scroll view or test the returned candidate view's own visibility.

Useful? React with 👍 / 👎.

@chenweigao
chenweigao merged commit 84f3f6d into main Oct 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant