Skip to content

fix(macos): decouple VSelectableTextView text container from view frame#28617

Merged
ashleeradka merged 3 commits into
mainfrom
devin/1777386256-lum-1170-selectable-textview-container
Apr 28, 2026
Merged

fix(macos): decouple VSelectableTextView text container from view frame#28617
ashleeradka merged 3 commits into
mainfrom
devin/1777386256-lum-1170-selectable-textview-container

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 28, 2026

VSelectableTextView previously let NSTextContainer.widthTracksTextView = true and autoresizingMask = [.width] couple the container width to the NSTextView frame. On the useExternalSizing: true path — where SwiftUI applies a .frame(width:height:) precomputed by the static measureSize helper — every SwiftUI-driven setFrameSize (window resize, eager VStack measurement) forwarded onto the layout manager and triggered _fillLayoutHoleForCharacterRange: a synchronous, O(glyph-count) main-thread relayout. This change decouples the container from the frame on that path and aligns VSelectableTextView with the established peers (VCodeView, HighlightedTextView).

Why this is needed

The useExternalSizing: true callers (every chat message in MarkdownSegmentView) hand SwiftUI an exact frame derived from measureSize(...). AppKit's response to setFrameSize only mattered to legacy auto-layout flows, and on this path it is purely harmful: it re-runs glyph layout on the entire message every time SwiftUI resizes the host. With many message rows visible (eager VStack, window resize), the work compounds into multi-second main-thread hangs.

Benefits

  • Removes an O(glyph-count) per-frame-resize cost from the chat surface — eliminates the dominant setFrameSize_fillLayoutHoleForCharacterRange path observed under load.
  • Brings VSelectableTextView in line with peer NSTextView bridges in the design system (VCodeView, HighlightedTextView), which already configure their containers this way; one less divergent pattern across clients/shared/DesignSystem.
  • Defense in depth alongside the fix to the eager-measurement trigger: even if a future code path resizes the frame of a large VSelectableTextView (window resize, split-view drag, layout cascade), the container no longer gets dragged along.

Why this is safe

  • The useExternalSizing contract already provides .frame(width:height:) from measureSize(...), which itself uses an NSTextContainer of (maxWidth, .greatestFiniteMagnitude). Sizing the live container with the same dimensions reproduces the same wrap geometry the measurement assumed, so visible layout is unchanged.
  • updateNSView propagates maxWidth changes onto containerSize so window-resize and split-view-drag still re-wrap text correctly.
  • The useExternalSizing: false branch (used only by the design system gallery) is byte-for-byte the previous configuration; it still calls sizeThatFits and lets the container track the frame.
  • Backwards compatible: no API change, default value of useExternalSizing is unchanged.

References

Alternatives not taken

  • Enable allowsNonContiguousLayout on the VSelectableTextView layout manager. Rejected. The render stack here is paired with a sibling measurement stack that lays out every glyph; non-contiguous render layout produces streaming gap/overlap (text paints into a smaller region than the measured frame, then the next sibling is positioned via the measured frame and the lazy glyphs paint outside it). clients/AGENTS.md already documents this exception and VCodeView / HighlightedTextView only opt into non-contiguous layout because they don't share the same measurement model.
  • Drop the change and rely solely on the message-height-cache flag fix (LUM-1167). Insufficient. That change removes the eager-measurement trigger but leaves the frame-coupling vulnerability in place — any future caller that resizes a VSelectableTextView frame would re-introduce the same hang. Fixing the underlying configuration is the durable repair.
  • Disable widthTracksTextView unconditionally (drop useExternalSizing entirely). Rejected for now to keep the change scoped. The gallery preview path still relies on sizeThatFits-driven layout and the existing tracking behavior. Consolidating to a single mode is reasonable follow-up work but is not blocking this fix.

Root cause analysis

  1. How did the code get into this state? The view originally landed with NSTextView's defaults (widthTracksTextView = true, autoresizingMask = [.width]) — the configuration AppKit recommends when the text view is the source of truth for sizing. useExternalSizing was added later as a perf optimization to skip sizeThatFits in lazy containers, but the AppKit-default configuration in makeNSView was not revisited to match the new contract (caller-supplied frame via measureSize).
  2. What mistakes or decisions led to it? The optimization assumed that returning nil from sizeThatFits was sufficient to bypass measurement work. It missed that setFrameSize itself — which SwiftUI invokes when applying .frame(width:height:) — has its own AppKit-internal layout cost when the container is configured to track the frame.
  3. Were there warning signs we missed? The peer components (VCodeView, HighlightedTextView) had already converged on widthTracksTextView = false plus an explicit containerSize. The divergence was visible but never flagged as a perf concern because the symptom only surfaces under combined eager-measurement + large-text load. Sentry MACOS-D9 grouped this hang with the older LUM-712 path, masking that a second, distinct bug existed.
  4. What can we do to prevent this pattern from recurring? The structural fix is the one shipping in this PR: align all three NSTextView-bridging design system components on the same configuration. The procedural fix is the AGENTS.md addition below — a checklist item that makes this configuration explicit for future NSViewRepresentable authors.
  5. AGENTS.md update. Added one bullet to clients/AGENTS.md § "SwiftUI performance practices" documenting the rule with Apple references. Kept it lightweight per the existing tone (rule + links, no PR-specific narrative). The rule is durable: it describes a configuration invariant, not a transient migration.

Prompt / plan

LUM-1170. Investigated the v0.6.5 Sentry hang on NSTextView.setFrameSize_fillLayoutHoleForCharacterRange, identified the frame/container coupling on the useExternalSizing path, and aligned the configuration with the peer NSTextView bridges in the design system.

Test plan

  • Pre-push hooks (FlexFrame guard, design token guard, secret/generic-examples checks, assistant typecheck/lint, related test suite) green locally.
  • CI: PR pipeline.
  • Manual chat-surface verification deferred to user (requires Xcode + a live app); peer behavior in VCodeView / HighlightedTextView provides reference for visual correctness.

Closes LUM-1170

Link to Devin session: https://app.devin.ai/sessions/b98a97a718fe4330a8ef08ddf3ba8645
Requested by: @ashleeradka


Open in Devin Review

@linear
Copy link
Copy Markdown

linear Bot commented Apr 28, 2026

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

// and the container tracks the view frame as NSTextView's
// default behavior intends. Used by the Gallery preview, where
// there is only a single instance and no resize storm.
let initialContainerWidth = useExternalSizing ? (maxWidth ?? CGFloat.greatestFiniteMagnitude) : 0
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Container width / measurement width mismatch when maxWidth is nil on external-sizing path

When useExternalSizing is true and maxWidth is nil, the text container width is set to CGFloat.greatestFiniteMagnitude (line 229: maxWidth ?? CGFloat.greatestFiniteMagnitude), but the measurement in resolveSelectableRunMeasurementResult (clients/macos/vellum-assistant/Features/Chat/MarkdownSegmentView.swift:438) uses effectiveMaxWidth = maxContentWidth ?? VSpacing.chatBubbleMaxWidth. Before this PR, widthTracksTextView was always true, so the container followed the view frame (set by the caller's .frame(width:height:) from the measurement), keeping wrapping consistent. Now, with widthTracksTextView = false on the external-sizing path, the container stays at greatestFiniteMagnitude — text won't wrap at the measured width, causing a rendering/measurement mismatch (text clipped horizontally, incorrect height). This affects SubagentDetailPanel (clients/macos/vellum-assistant/Features/MainWindow/Panels/SubagentDetailPanel.swift:203-205) during its initial layout pass when panelContentWidth == 0markdownWidth = nil. The issue self-corrects when onGeometryChange fires a valid width.

Prompt for agents
The root cause is the nil fallback to CGFloat.greatestFiniteMagnitude for the container width on the external-sizing path. The measurement path in resolveSelectableRunMeasurementResult (MarkdownSegmentView.swift:438) falls back to VSpacing.chatBubbleMaxWidth when maxContentWidth is nil, but the container fallback uses greatestFiniteMagnitude. These two fallbacks need to agree.

Two possible approaches:
1. In SelectableTextView.swift, change the nil fallback for container width to match the measurement fallback. However, VSelectableTextView does not (and should not) know about VSpacing.chatBubbleMaxWidth.
2. In MarkdownSegmentView.SelectableRunView (line 197), pass the effectiveMaxWidth (maxContentWidth ?? VSpacing.chatBubbleMaxWidth) instead of the raw maxContentWidth to VSelectableTextView's maxWidth parameter. This ensures the container always gets the same concrete width that was used for measurement.

Approach 2 is cleaner because it keeps the contract between measurement and container explicit at the call site.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 843fa36 — same approach as the codex thread: SelectableRunMeasurementResult now exposes effectiveMaxWidth, and the useExternalSizing: true call site passes it directly so live container width and measurement width can't drift. The SubagentDetailPanel initial-layout case (panelContentWidth == 0markdownWidth = nil) is covered because MarkdownSegmentView.maxContentWidth defaults to VSpacing.chatBubbleMaxWidth and the measurement resolver applies the same fallback.

@ashleeradka ashleeradka self-assigned this Apr 28, 2026
devin-ai-integration Bot and others added 2 commits April 28, 2026 15:16
…rnal-sizing path (LUM-1170)

Closes LUM-1170

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…SelectableTextView comments

Closes LUM-1170

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1777386256-lum-1170-selectable-textview-container branch from 952418f to 774dfed Compare April 28, 2026 15:18
@devin-ai-integration devin-ai-integration Bot changed the title fix(macos): decouple VSelectableTextView container from frame on external-sizing path (LUM-1170) fix(macos): decouple VSelectableTextView text container from view frame Apr 28, 2026
…TextView

Both reviewers flagged that VSelectableTextView's container width fell back
to .greatestFiniteMagnitude on the useExternalSizing path when maxWidth was
nil, while the measurement path fell back to VSpacing.chatBubbleMaxWidth.
The two fallbacks must agree or the live container will not wrap at the
measured width, producing horizontal clipping until a real width arrives.

Carry the resolved effectiveMaxWidth on SelectableRunMeasurementResult and
pass it directly to VSelectableTextView so the call site cannot drift from
the measurement value. Document the contract on VSelectableTextView so
future external-sizing callers do the same.

Closes LUM-1170

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

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

APPROVE

Value: Eliminates the O(glyph-count) _fillLayoutHoleForCharacterRange main-thread hang in VSelectableTextView during window resize and eager VStack measurement by decoupling the NSTextContainer width from the NSTextView frame on the useExternalSizing path.

What this does: When useExternalSizing: true, the container is now sized explicitly from maxWidth at init time (widthTracksTextView = false, isHorizontallyResizable = true). setFrameSize can no longer forward a width change onto the layout manager. updateNSView propagates maxWidth changes onto the container directly. A new effectiveMaxWidth field on SelectableRunMeasurementResult ensures the single call site passes the same resolved width to both measurement and the live container, eliminating the nil-width divergence flagged by Codex and Devin.


Root cause fix is correct. The pre-PR behavior (widthTracksTextView = true + autoresizingMask = [.width]) caused every SwiftUI-driven setFrameSize — window resize, eager VStack measurement from message-height-cache flag ON — to call NSTextView._setFrameSize:forceScroll:_glyphRangeForBoundingRect_fillLayoutHoleForCharacterRange. The fix matches the VCodeView/HighlightedTextView pattern and is exactly what Devin's investigation identified.

The Codex/Devin maxWidth nil concern is resolved. The original PR had makeNSView initialize container width to CGFloat.greatestFiniteMagnitude when maxWidth is nil, while measurement used effectiveMaxWidth = maxContentWidth ?? VSpacing.chatBubbleMaxWidth. The fix introduces SelectableRunMeasurementResult.effectiveMaxWidth and SelectableRunView now passes measurement.effectiveMaxWidth directly to VSelectableTextView.maxWidth, making live container and measurement always agree. Verified on the PR branch.

useExternalSizing: false path is untouched. Gallery-mode behavior (widthTracksTextView = true, autoresizingMask = [.width], sizeThatFits drives layout) is preserved exactly. The only call site with useExternalSizing: true is SelectableRunView in MarkdownSegmentView.

AGENTS.md guidance added. New entry correctly documents the pattern contract for future callers of useExternalSizing: true. Good hygiene.

One non-blocking observation: makeNSView still falls back to CGFloat.greatestFiniteMagnitude if maxWidth is nil on the external-sizing path (line let initialContainerWidth = useExternalSizing ? (maxWidth ?? CGFloat.greatestFiniteMagnitude) : 0). With the effectiveMaxWidth fix this nil case can't be reached from the current call site, but the defensive fallback in updateNSView uses the same maxWidth ?? CGFloat.greatestFiniteMagnitude, keeping behavior consistent. The contract doc on VSelectableTextView now explicitly states maxWidth MUST be non-nil on the external-sizing path, which is the right enforcement layer.

Merge criteria check:

  • ✅ Root cause matches MACOS-D9 / LUM-1170 Devin investigation findings
  • ✅ Bot concern (maxWidth nil mismatch) resolved by effectiveMaxWidth
  • ✅ AGENTS.md updated with pattern contract
  • ✅ Only one useExternalSizing: true call site, fully updated
  • ⚠️ CI macOS build/tests skipped — needs a clean build confirmation before merge
  • ⚠️ Devin and Codex both COMMENTED, not APPROVED (their findings are addressed but reviews not re-triggered)

Suggest: trigger @devin review this PR + @codex review to get formal approvals on the updated commit, then merge.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +253 to +263
textView.isHorizontallyResizable = useExternalSizing
if useExternalSizing {
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
} else {
// Match NSTextView's default autoresizing so the container
// width tracks the view frame in the Gallery preview path.
textView.autoresizingMask = [.width]
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🚩 isHorizontallyResizable = true with maxSize may cause unexpected text view behavior

Setting isHorizontallyResizable = true (SelectableTextView.swift:253) combined with maxSize = (.greatestFiniteMagnitude, .greatestFiniteMagnitude) (lines 255-258) allows the NSTextView to grow beyond its frame. This is intentional — it prevents setFrameSize (triggered by SwiftUI layout) from forwarding width changes onto the layout manager and causing O(glyph-count) relayouts. Since widthTracksTextView = false and the container is sized explicitly, the text view's frame can diverge from the container without affecting rendering. The .frame(width:height:) applied by SelectableRunView constrains the visible area. Worth monitoring if any AppKit drawing artifacts appear with this configuration.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ashleeradka ashleeradka merged commit 1878e39 into main Apr 28, 2026
8 checks passed
@ashleeradka ashleeradka deleted the devin/1777386256-lum-1170-selectable-textview-container branch April 28, 2026 16:15
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

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