Eliminate explicitAlignment recursion in message list layout (LUM-800)#24446
Conversation
…yout (LUM-800) Replace all .frame(maxWidth:) and .frame(maxWidth:, alignment:) modifiers in the message cell hierarchy with _FrameLayout (.frame(width:)) and HStack+Spacer patterns. These _FlexFrameLayout modifiers triggered recursive explicitAlignment queries during SwiftUI layout passes, causing O(depth x children) cascades that hung the main thread for 58+ seconds on conversations with many visible messages. Changes across 3 files, 7 sites: - MessageListView.swift: Replace double .frame(maxWidth:) with single .frame(width:) using already-available containerWidth property - ChatBubble.swift: Wrap bubble body in HStack+Spacer for alignment instead of .frame(maxWidth: .infinity, alignment:); remove all .frame(maxWidth:) from bubbleChrome branches (error, assistant, user) - MessageCellView.swift: Replace .frame(maxWidth:, alignment:) on thinking indicator and subagent rows with HStack+Spacer Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
… warnings Add AGENTS.md rule documenting why .frame(maxWidth:, alignment:) must not be used inside LazyVStack cell hierarchies (FlexFrame alignment recursion). Add inline warning comments at each fix site referencing the AGENTS.md rule, matching the pattern used for the motionVectors fix. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
The HStack+Spacer pattern for the error branch in bubbleChrome couldn't expand to full width because the parent VStack is content-sized (the outer body HStack+Spacer gives the VStack only its ideal width). Replace with .containerRelativeFrame(.horizontal) which resolves against the ScrollView container, ensuring the error background spans the full column width without creating a FlexFrame. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…iews (LUM-800) Remove .frame(maxWidth:, alignment:) from all inner cell content views that were initiating explicitAlignment recursion cascades: P0 — hot path (every message with tool calls or text): - ChatBubbleInterleavedContent: HStack+Spacer for tool progress - AssistantProgressView: HStack+Spacer for header, output block, output text - ChatMarkdownParser: HStack+Spacer for table cells, .frame(width:) for table root - MarkdownSegmentView: optionalMaxWidth now uses .frame(width:) (_FrameLayout) P1 — medium frequency: - ChatBubbleToolStatusView: remove FlexFrame from progress + permission chips P2 — indicators: - ThinkingBlockView: remove FlexFrame, parent VStack provides alignment - MessageListContentView: HStack+Spacer for orphan subagents, remove redundant FlexFrames from indicator rows that already have internal Spacers Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…Stack+Spacer, padding consolidation Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
ashleeradka
left a comment
There was a problem hiding this comment.
Ship it ✦
Reviewed all 6 commits across 10 source files + AGENTS.md. Every .frame(maxWidth:, alignment:) in the cell layout hierarchy is eliminated — zero remaining in code (only
What this fixes
The first commit (c2fd37b) fixed the 3 outer wrapper files but missed 16 .frame(maxWidth:, alignment:) sites inside the cell content views. Spindump on that build showed 3730 explicitAlignment hits — identical to pre-fix. The second commit (30d1feb) converts the remaining 16 sites in 7 inner content files (AssistantProgressView, ChatBubbleInterleavedContent, ChatMarkdownParser, ChatBubbleToolStatusView, MarkdownSegmentView, ThinkingBlockView, MessageListContentView).
QA notes
- Must verify: Build, open a long conversation with tool calls, resize window. Should be zero perceptible lag. Spindump should show near-zero
explicitAlignmenthits. - Minor visual check: Some rows (AssistantProgressView, indicator rows) lost their explicit
chatBubbleMaxWidthconstraint and may be ~48px wider (fillingchatColumnMaxWidthinstead). Should be barely visible since it's in the padding zone, but worth a glance.
Nitpicks (non-blocking)
ChatMarkdownParser:396uses.frame(width: maxWidth.isFinite ? maxWidth : nil, alignment: .leading)— thealignment: .leadingon_FrameLayoutstill propagates alignment queries, but at O(depth) not O(n × depth). Won't cause hangs.MarkdownSegmentView:912same pattern, same verdict.- Both could drop the
alignment:parameter since their content already uses HStack+Spacer for alignment, but not worth a follow-up.
|
Thanks for the thorough review! Agreed on both nitpicks — the |
…nto LazyVStack (LUM-800) AlignmentBarrierLayout is a custom SwiftUI Layout that returns nil for explicitAlignment queries while passing through sizing and placement unchanged. Wrapping the ScrollView content in MessageListView with this barrier prevents _FlexFrameLayout alignment queries from cascading into the LazyVStack — eliminating the O(depth × children) recursive measureEstimates that caused 50+ second hangs. This is a single-point architectural fix that protects everything below the barrier, making it future-proof against new .frame(maxWidth:, alignment:) modifiers added inside cell content views. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…nt already constrains content The .frame(width:) on the ScrollView forced a fixed 808pt width that caused overflow on narrow windows (one-frame flash at containerWidth=0) and prevented the scroll area from expanding to fill the parent naturally. Content width is already capped at 760pt via the bubbleMaxWidth environment set in MessageListContentView. The parent VStack centers children by default, so no explicit width constraint is needed on the ScrollView itself. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Removing .frame(width:) entirely let the ScrollView expand to the full window width. Use .containerRelativeFrame(.horizontal) instead — it resolves against the nearest scroll container and caps width at 808pt without creating a _FlexFrameLayout node. This avoids both the FlexFrame alignment cascade AND the fixed-width overflow on narrow windows. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…broke layout Revert the containerRelativeFrame experiment. The original .frame(width:) is safe (_FrameLayout, no alignment queries) and correctly caps the ScrollView at chatColumnMaxWidth. The AlignmentBarrierLayout inside the ScrollView is the primary hang fix — the outer frame modifier type is no longer critical for preventing the alignment cascade. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
The AlignmentBarrierLayout inside the ScrollView blocks the alignment cascade, making the outer .frame(maxWidth:) safe. The maxWidth modifier allows the ScrollView to shrink below 808pt on narrow windows, preventing sidebar squeeze. The fixed-width and containerRelativeFrame approaches both caused layout regressions. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
🚩 Remaining .frame(maxWidth:) usages in non-modified files
The PR removes .frame(maxWidth:) from LazyVStack cell hierarchies in the modified files, but several unmodified files still use the pattern within the same LazyVStack: ChatBubbleAttachmentContent.swift:144,269,408, ChatLoadingSkeleton.swift:37,45, ChatEmptyStateView.swift:124,154,167,400. If the AGENTS.md rule about no .frame(maxWidth:) in LazyVStack cells is to be enforced consistently, these would need follow-up. However, attachment views with fixed max widths (not alignment: parameter) may produce _FlexFrameLayout without the problematic alignment queries, so the performance impact may vary.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
These are all outside the scope of this PR and protected by the AlignmentBarrierLayout barrier — alignment queries from the outer .frame(maxWidth:) chain stop at the barrier and never reach the LazyVStack. The remaining sites in ChatBubbleAttachmentContent, ChatLoadingSkeleton, and ChatEmptyStateView can be addressed in a follow-up if spindump shows any residual hits after the barrier is verified.
| .padding(EdgeInsets(top: VSpacing.md, leading: VSpacing.lg, | ||
| bottom: VSpacing.md, trailing: VSpacing.lg)) | ||
| .frame(maxWidth: .infinity) | ||
| .containerRelativeFrame(.horizontal) |
There was a problem hiding this comment.
🔴 .containerRelativeFrame(.horizontal) on error bubbles resolves against ScrollView, overflowing LazyVStack padding
The error bubble branch in bubbleChrome replaced .frame(maxWidth: .infinity) with .containerRelativeFrame(.horizontal). .containerRelativeFrame resolves against the nearest scroll container (the ScrollView in MessageListView.swift:113), returning the full ScrollView visible width. However, the LazyVStack content has horizontal padding of VSpacing.xl (24pt) per side applied at MessageListContentView.swift:272-273. The old .frame(maxWidth: .infinity) expanded within the parent's proposed width (already accounting for padding), while .containerRelativeFrame(.horizontal) ignores the padding and returns the raw ScrollView width. This makes error bubbles ~48pt wider than the content area, causing horizontal overflow.
Layout chain showing the mismatch
ScrollView (width W) → AlignmentBarrierLayout → LazyVStack.padding(.horizontal, VSpacing.xl) → cell proposed (W − 48pt) → HStack → VStack → bubbleChrome → .containerRelativeFrame(.horizontal) resolves to W (not W − 48pt)
Prompt for agents
The error bubble in bubbleChrome uses .containerRelativeFrame(.horizontal) which resolves against the ScrollView, ignoring the LazyVStack's horizontal padding (VSpacing.xl per side, applied in MessageListContentView). This causes error bubbles to be ~48pt wider than the content area.
The fix needs to replace .containerRelativeFrame(.horizontal) with a pattern that expands to the parent's proposed width (which already accounts for padding) without using .frame(maxWidth:) (which is banned by AGENTS.md for LazyVStack cells). Options:
1. Use an HStack + Spacer pattern like other views in this PR:
HStack(spacing: 0) { content(); Spacer(minLength: 0) }
This provides leading-aligned full-width expansion without FlexFrame.
2. Use .containerRelativeFrame with explicit size computation that subtracts the known padding.
3. Use a GeometryReader in .background to measure and constrain, though this is more complex.
Option 1 (HStack+Spacer) is most consistent with the rest of this PR's approach and should be the preferred fix. The error background fill (bubbleChromeBackground) is already applied via .background so it will naturally extend to the HStack's width.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is a known tradeoff already documented in the PR's testing checklist. The alternatives were evaluated:
HStack+Spacer— already tried and rejected. The Codex bot correctly identified (comment ci: add terraform apply workflow on platform changes #3) that the error bubble sits inside a content-sized VStack, so the Spacer collapses to zero and the error background shrinks to content width instead of spanning full-width..frame(maxWidth: .infinity)— the original code, banned by AGENTS.md for LazyVStack cells (creates_FlexFrameLayoutwith alignment queries)..containerRelativeFrame(.horizontal)— resolves against the ScrollView, which is ~48pt wider than the padded content area. The error background may extend beyond the normal content bounds.
Option 3 is the least-bad choice. The AlignmentBarrierLayout is the primary hang fix — this .containerRelativeFrame is defense-in-depth. The user flagged this for visual verification during local Xcode testing.
| func optionalMaxWidth(_ width: CGFloat?) -> some View { | ||
| if let width { | ||
| self.frame(maxWidth: width, alignment: .leading) | ||
| self.frame(width: width, alignment: .leading) | ||
| } else { | ||
| self | ||
| } |
There was a problem hiding this comment.
🚩 optionalMaxWidth semantic change from .frame(maxWidth:) to .frame(width:) forces exact widths
The optionalMaxWidth helper at clients/macos/vellum-assistant/Features/Chat/MarkdownSegmentView.swift:914-920 changed from .frame(maxWidth: width, alignment: .leading) to .frame(width: width, alignment: .leading). This is a meaningful behavioral change: maxWidth allows the view to be narrower than the cap if the parent proposes less, while width forces the view to report exactly that width regardless of the parent's proposal.
This affects code blocks (line 869) and horizontal rules (line 110). For code blocks, this means they will always be exactly maxContentWidth wide instead of shrinking to fit. Since maxContentWidth flows from bubbleMaxWidth which is already computed as min(chatBubbleMaxWidth, containerWidth - 2*xl) at MessageListContentView.swift:274-276, overflow is unlikely in normal usage. However, during transient states (sidebar resize, window narrowing before the environment updates), the fixed width could briefly exceed the available space. The function name optionalMaxWidth is now misleading since it applies an exact width, not a maximum.
(Refers to lines 914-920)
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Acknowledged — the function name optionalMaxWidth is now semantically inaccurate since it applies .frame(width:) not .frame(maxWidth:). However, on macOS (the target platform), text goes through SelectableRunView which uses a pre-measured .frame(width: measurement.size.width) — it never hits optionalMaxWidth. The only callers post-PR are code blocks (MarkdownSegmentView.swift:869) and horizontal rules (MarkdownSegmentView.swift:110), both of which should fill to exact width. The transient-state overflow risk is theoretical since bubbleMaxWidth is already computed from containerWidth which updates via onGeometryChange.
A rename to optionalWidth would be more accurate, but keeping the existing name to minimize diff churn in this performance PR.
ashleeradka
left a comment
There was a problem hiding this comment.
Re-approving after final 5 commits (AlignmentBarrierLayout + width fix iterations). Reviewed the full 12-file diff — architecturally sound. AlignmentBarrierLayout is the right fix. Ship it ✦
Adds CI-enforced guard tests that scan Swift source files for three known LazyVStack performance anti-patterns: 1. FlexFrameLayout (.frame(maxWidth:) / .frame(maxHeight:)) in cell hierarchy 2. motionVectors transitions (.transition(.move(edge:))) in cell hierarchy 3. withAnimation in scroll handlers (motionVectors cascade) Prevents regression of fixes from PRs #24321, #24375, #24411, #24446, #24530, #24570, #24589. Part of #24613. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add SwiftUI performance guard tests for LazyVStack anti-patterns Adds CI-enforced guard tests that scan Swift source files for three known LazyVStack performance anti-patterns: 1. FlexFrameLayout (.frame(maxWidth:) / .frame(maxHeight:)) in cell hierarchy 2. motionVectors transitions (.transition(.move(edge:))) in cell hierarchy 3. withAnimation in scroll handlers (motionVectors cascade) Prevents regression of fixes from PRs #24321, #24375, #24411, #24446, #24530, #24570, #24589. Part of #24613. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing cell files to LAZY_VSTACK_CELL_FILES and remove PR references Adds the 10 allowlisted file basenames to the cell hierarchy list so the allowlist is actually consulted. Removes historical PR numbers from the file header comment per AGENTS.md guidance. Co-Authored-By: Claude <noreply@anthropic.com> * fix: use grep -E for portable ERE alternation in FlexFrame guard BSD grep (macOS default) doesn't support \| in BRE mode. Switch to grep -E with | for alternation so the guard works for local dev too. Co-Authored-By: Claude <noreply@anthropic.com> * fix: add SubagentEventsReader to cell files and escape dots in transition grep Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Vellum Assistant <assistant@vellum.ai> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Adds
AlignmentBarrierLayout— a custom SwiftUILayoutthat returnsnilforexplicitAlignmentqueries while passing sizing and placement through unchanged. Wrapping theScrollViewcontent inMessageListViewwith this barrier prevents_FlexFrameLayoutalignment queries from cascading into theLazyVStack, eliminating the O(depth × children) recursivemeasureEstimatesthat caused 50+ second main-thread hangs during hover and window resize.The original
.frame(maxWidth:).frame(maxWidth: .infinity)on the ScrollView is preserved as-is — the barrier sits between those outer FlexFrames and theLazyVStack, so the alignment cascade stops at the barrier and never reaches the lazy container.As defense-in-depth, also converts 23
.frame(maxWidth:, alignment:)sites across 10 cell-hierarchy files toHStack+Spaceror.frame(width:)— none of which create FlexFrame nodes or trigger alignment cascades. Documents both the anti-pattern rule and theAlignmentBarrierLayoutconvention inAGENTS.md. See WWDC23: Demystify SwiftUI performance.Why this is safe:
AlignmentBarrierLayoutonly overridesexplicitAlignment→nil; sizing and placement pass through unchanged. The outer ScrollView frame pattern is identical tomain. Removed.frame(maxWidth: chatBubbleMaxWidth)caps are redundant — the parent layout chain (chatColumnMaxWidth808pt − 2 ×VSpacing.xl48pt = 760pt =chatBubbleMaxWidth) already constrains content.Alternatives not taken:
.frame(maxWidth:)sites individually — higher risk (15+ files), not future-proof.AlignmentBarrierLayoutis 1 file + 1 wrapper..frame(maxWidth:)with.frame(width:)— caused sidebar squeeze on narrow windows..containerRelativeFrame— caused layout regressions.Review & Testing Checklist for Human
CI skips macOS builds — local Xcode is the only compilation and runtime check. The
AlignmentBarrierLayouthas not been verified at runtime.explicitAlignmenthits in the LazyVStack subtree (was 3730 pre-fix). This is the critical validation — if hits persist, the barrier placement may be wrong.AlignmentBarrierLayout.swiftis inclients/shared/DesignSystem/Layout/(SPM auto-pickup), used fromclients/macos/.containerRelativeFrame(.horizontal)inChatBubble.swiftresolves against the ScrollView, not the padded content area. Error background may extend ~24pt beyond normal content on each side. Verify this looks acceptable. (HStack+Spacerwas tried first but the parent VStack is content-sized, causing Spacer to collapse to zero;.frame(maxWidth:)is banned.).frame(maxWidth: chatBubbleMaxWidth)caps don't cause overflow.Notes
optionalMaxWidthchanged from.frame(maxWidth:)to.frame(width:)— on macOS, text usesSelectableRunViewwith pre-measured width and never hits this modifier. Post-PR callers are only code blocks and horizontal rules, which should fill to exact width. The function name is now slightly misleading but was kept to minimize diff churn.ChatMarkdownParser:394andMarkdownSegmentView:915retainalignment: .leadingon_FrameLayout— O(depth) not O(n × depth), no hang risk.ChatBubbleAttachmentContent,ChatLoadingSkeleton,ChatEmptyStateView) still use.frame(maxWidth:)— these are protected by theAlignmentBarrierLayoutbarrier and can be addressed in a follow-up if spindump shows residual hits.Link to Devin session: https://app.devin.ai/sessions/ef66aadc67724413b3b5ede76cf20ca9
Requested by: @ashleeradka