fix(macos): drive user message collapse from the model, not observed geometry (LUM-1108)#27498
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
…UM-1108) Thread content visibly twitches/shifts left-to-right on initial load when switching to a thread containing a collapsible user message. The cause is a geometry-observation feedback loop in userMessageHeightWrapper: - .frame(height: 150) hard-proposes 150pt to its child (_FrameLayout). - onGeometryChange on the same subtree writes the clamped height back into @State userMessageIntrinsicHeight. - On thread switch, .id(conversationId) destroys the ScrollView, wiping @State. The first frame uses the NSString.boundingRect estimate as a fallback; subsequent frames re-observe the clamped height. The disagreement between the two drives the isCollapsible decision across two renders, flipping whether RoundedRectangle(.fill(surfaceLift)) background is applied. That background appearing/disappearing as container width resolves is the visible twitch. This is the SwiftUI anti-pattern Apple warns about in the onGeometryChange docs: geometry observations should not drive state that changes the observed layout. Fix: drive isCollapsible from a deterministic estimate of text + attachment heights, computed from the model. No geometry observation, no @State, no feedback loop. .frame(height:) stays (needed to avoid the _FlexFrameLayout hang from PR #24589). Worst case if the estimate under-shoots: no Show more button and content renders at natural height — identical to existing non-collapsible user messages. No clipping regression. Estimate covers text height (VFont.nsChat = 16pt DM Sans, matching MarkdownSegmentView), per-attachment heights (single image ~200pt, grid tiles 120pt, video ~200pt, audio ~80pt, file chip ~40pt), bubble chrome vertical padding (2 * VSpacing.md), and inter-section VStack spacing. Also fixes the Show less button disappearing on click (same root cause, originally diagnosed in closed PR #26131). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
366e911 to
edaa3e5
Compare
There was a problem hiding this comment.
✦ APPROVE
Value: Fixes the geometry-observation feedback loop in user message collapse — the root cause of the thread-switch twitch (LUM-1108) and the "Show less" button teardown. Good for Thursday's release.
The feedback loop that was killed:
.frame(height: 150)clamps the childonGeometryChangeobserves the clamped 150ptisCollapsibleevaluates150 > 150 → false- Frame removed → child re-measures larger →
isCollapsibleflips back → oscillation
Classic anti-pattern Apple documents in the onGeometryChange docs.
What replaced it:
estimatedContentExceedsCollapseThreshold — purely model-driven, deterministic. Combines:
NSString.boundingRectwithVFont.nsChat(correctness fix over the old hardcodedsystemFont(ofSize: 14)— now tracks actual rendered font)- Conservative per-attachment heights mirroring
ChatBubbleAttachmentContent.swiftrenderers - Bubble chrome padding + inter-section spacing
Failure mode is safe: Underestimate → no "Show more" button → content renders at natural height. No clipping. Identical to a non-collapsible message.
Verified:
@State userMessageIntrinsicHeightremoved ✅onGeometryChangeobserver removed ✅- Load-bearing
.frame(height:)preserved (not.frame(maxHeight:)which causes the FlexFrame cascade) ✅ partitionedAttachmentsalready computed elsewhere in ChatBubble — no new work ✅stripHeavyContent/heuristicUserMessageCollapseWrapperfallback path untouched ✅- AGENTS.md rule is well-worded and cites Apple docs ✅
- SCROLL_STRATEGY.md accurately updated ✅
Anti-patterns: None. This PR removes one (the geometry observation) and documents it as a rule to prevent reintroduction.
Clean fix, well-motivated, ships safely for Thursday.
What changes
userMessageHeightWrapperinChatBubble.swiftno longer writes observed geometry into@State. The collapsibility decision is now derived from a conservative content estimate (text size + per-attachment heights) computed from the message model..frame(height:)still clamps collapsed bubbles to 150pt.A new rule in
clients/macos/AGENTS.mdcodifies the underlying anti-pattern so it isn't reintroduced.Why
The old code had a measurement feedback loop:
.frame(height: 150)on the bubble subtree is a_FrameLayout— it hard-proposes 150pt to its child.onGeometryChangeinside that subtree observed the clamped height and wrote it to@State userMessageIntrinsicHeight.isCollapsibleevaluates that state against 150pt and flips tofalse, removing the frame and the Show less button together.This is exactly the anti-pattern Apple documents in
onGeometryChange: geometry observations should not drive state that changes the observed layout. WWDC22's Compose custom layouts with SwiftUI and WWDC23: Demystify SwiftUI performance make the same point about layout/state coupling.User-visible effects of the loop:
.id(conversationId)destroys the ScrollView, wiping@State. First frame falls back to theNSString.boundingRectestimate; subsequent frames re-observe the clamped height. The disagreement flipsisCollapsible, which gates theRoundedRectangle(.fill(surfaceLift))background wrapper — that background appearing/disappearing as container width resolves is the twitch.isCollapsiblebecomes false, the whole wrapper (button + frame) tears down in one render.Benefits
@Stateand oneonGeometryChangefrom a hot chat path — less churn on every layout pass.AGENTS.mdso the pattern is caught in review before it ships again.Why it's safe
.frame(height:)is preserved..frame(maxHeight:)would regress the 35s+ LazyVStack hang fixed in apps#24589.ChatBubbleAttachmentContent.swift; intentionally err toward overestimate so borderline content is classified collapsible rather than miscategorized.heuristicUserMessageCollapseWrapper.Considered and rejected
contentWidth == 0guard in the estimator. My first instinct. Rejected because it addresses one symptom of the feedback loop, not the loop itself. Any future edit that re-introduces geometry observation would reopen the bug.hideScrollIndicatorsBriefly()inMessageListScrollState. Initially hypothesized as the cause because it toggles.scrollIndicatorson every thread switch. Rejected after the reporter confirmed the twitch is per-thread — only threads with a long user message reproduce. Indicator toggling can't explain content-dependent behavior. Left untouched here; it may still be worth removing as a separate architectural question (it's a sibling of theswitchRestoreTaskpattern the inverted-scroll migration explicitly retired), but mixing it into this PR would confuse the diff.sizeThatFitson a customLayout. Would give us measured size before the clamp, not after. Rejected as overkill: the deterministic model-driven estimate is simpler, doesn't add a new layout node, and the attachment counts give us the information we need without measuring.onGeometryChangefires once (without a clamp during that first pass). Rejected because it re-introduces the full-height flash the originalNSString.boundingRectestimator was added to prevent (LUM-626, apps#24626).Root cause analysis
How did the code get into this state?
The feedback loop was introduced in apps#24589 (Apr 8) when
.frame(maxHeight:)was swapped for.frame(height:)as an emergency fix for a 35s+ LazyVStack hang caused by_FlexFrameLayout's O(n × depth) alignment cascade. The height-clamp changed from soft (maxHeight) to hard (height), but theonGeometryChangethat had previously observed a soft-clamped subtree was kept unchanged. From that point on, the observed value was the clamped value — the loop was latent. Symptoms surfaced gradually: the Show less teardown bug was reported weeks later, and LUM-1108's twitch only reproduces when.id(conversationId)wipes the estimator's fallback, which requires thread switching on a thread with a collapsible message.What decisions led to it?
maxHeight→height) was correct and necessary, but the review didn't trace downstream consumers of the clamped subtree's geometry. Making a soft clamp hard changes whatonGeometryChangereports.NSString.boundingRectfallback +onGeometryChangeprimary) looked defensive but was actually masking the bug: most of the time the fallback was correct, so the loop's incorrect value was overwritten before the user noticed. The feedback loop only became visible when state-wipe (via.id()) made the fallback the rendered value.VFont.nsChatfix, the bubble-padding fix) all treated symptoms — miscategorized messages, wrong font, missing padding — without questioning whether the geometry observation itself should exist.Warning signs we missed
onGeometryChangedocumentation directly warns against this pattern.// MARK: - User Message Collapse / Expand(pre-change) described.frame(height:)as load-bearing for perf but didn't mention the measurement invariant it imposed on descendants.Prevention
This PR adds a rule to
clients/macos/AGENTS.md:Secondary process suggestion (not blocking this PR): when a closed PR contains a root-cause analysis that wasn't acted on for non-technical reasons (product decisions, scope), the analysis should still be captured in the code review policy or AGENTS.md — closing a PR shouldn't close the learning.
Test plan
CI only covers lint + socket scans; macOS builds are skipped. Local Xcode verification:
heuristicUserMessageCollapseWrapperpath triggers (unchanged)🤖 Generated with Claude Code