perf(macos/chat): drop redundant pinned-turn viewport probe#29950
Conversation
…w env PinnedLatestTurnSection sized its topAlignedMinHeight floor against the scroll container by running its own containerRelativeFrame + onGeometryChange probe and mirroring the resolved height into a local @State. MessageListView already tracks the same value via OnScrollGeometryChange to drive bottomAlignedMinHeight. The probe's @State round-trip was redundant and added a third full layout pass through MessageTranscriptStack's eager VStack on every viewport change (composer Enter resize, splitter drag, window resize, conversation switch), surfacing as the LUM-1353 hang. Publish viewportHeight from MessageListView via a chat-local ScrollViewportHeightKey environment value (mirroring the BubbleMaxWidthKey pattern in ChatBubble.swift) and have PinnedLatestTurnSection read it through @Environment. Going through EnvironmentValues bypasses the MessageListContentView Equatable barrier without widening it: only descendants that read \.scrollViewportHeight re-evaluate on viewport changes. The VSpacing.md * 2 subtraction matches the outer MessageListContentView.body padding the probe was accounting for, and topAlignedMinHeight(nil) is already a no-op for the initial frame before OnScrollGeometryChange lands its first measurement.
🤖 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:
|
…stable topAlignedMinHeight(_:) is a @ViewBuilder modifier that wraps content in TopAlignedMinHeightLayout when the value is non-nil and returns the content unwrapped when nil. Driving it from Optional<CGFloat> meant the section rendered without the wrapper before the first OnScrollGeometryChange measurement landed, then SwiftUI flipped its structural identity to add the wrapper on the first non-nil value — rebuilding every transcript row inside the pinned turn on the initial frame. Fall back to 0 when scrollViewportHeight is nil so topAlignedMinHeight always wraps in TopAlignedMinHeightLayout (matching the old `@State viewportMinHeight: CGFloat = 0` default). Identity stays stable across the first measurement; minHeight: 0 has no sizing effect on content that's already non-negative.
There was a problem hiding this comment.
✦ Vex Approval
Value: Closes the LUM-1353 ~2s app hang. Removes a redundant viewport probe in PinnedLatestTurnSection that was triggering a full eager-VStack layout pass through MessageTranscriptStack on every viewport change (composer multi-line resize, splitter drag, window resize, conversation switch). Routes the same filtered viewport height that MessageListView already publishes via OnScrollGeometryChange through a file-local EnvironmentKey, so descendants size against a single source of truth without taking their own measurement.
Boss's three explicit asks — all verified:
-
Environment injected outside the descendant tree, propagates to
PinnedLatestTurnSection. ✅
MessageListView.swift:230applies.environment(\.scrollViewportHeight, viewportHeight.isFinite ? viewportHeight : nil)on the same chain as.scrollContentBackground/.scrollPosition/.scrollIndicators/.fixedWidth/.id(conversationId)/.flipped()— i.e. on theScrollViewitself. SwiftUI propagates env values to descendants, andPinnedLatestTurnSection(rendered inside viaMessageListContentView → MessageTranscriptStack) reads it via@Environment(\.scrollViewportHeight). -
VSpacing.md * 2offset matches the old probe exactly. ✅- Old (
MessageListContentView.swift:511-518pre-PR):containerRelativeFrame(.vertical, alignment: .top) { length, _ in max(0, length - VSpacing.md * 2) }— closure subtractsVSpacing.md * 2and clamps to non-negative. - New:
private var viewportMinHeight: CGFloat { scrollViewportHeight.map { max(0, $0 - VSpacing.md * 2) } ?? 0 }— identical subtraction, identical clamp, default 0 instead of nil (deliberate; see #3).
- Old (
-
EnvironmentKeyis file-local in the same shape asBubbleMaxWidthKey. ✅
Cross-checked againstChatBubble.swift:11-20—private struct BubbleMaxWidthKey: EnvironmentKey { static let defaultValue: CGFloat = ... }+ internalextension EnvironmentValues. NewScrollViewportHeightKeyatMessageListView.swift:22-31follows the exact same pattern: private struct, defaultValueCGFloat? = nil, internal extension property. The struct is file-private; the extension property is internal-by-default (required soMessageListContentView.swiftin a sibling file can read it). Same scope envelope as the established pattern.
Devin's self-caught structural identity flip — verified and correctly resolved. ✅
The first push exposed viewportMinHeight: CGFloat? (nil before first measurement), which would have flipped PinnedLatestTurnSection's structural identity on the first measurement landing because topAlignedMinHeight(_:) is a @ViewBuilder with if let minHeight (verified at clients/shared/DesignSystem/Modifiers/TopAlignedMinHeightLayout.swift:73-83). The if let branch creates a TopAlignedMinHeightLayout wrapper; the else branch returns self. A nil → CGFloat transition appears the wrapper for the first time → SwiftUI rebuilds the entire pinned-section subtree (anchor row, response cluster, sentinel) on the first viewport measurement. Devin caught this and fixed it at 444807d by defaulting the computed property to 0 instead of nil. With 0, the wrapper is present from frame 1 and minHeight: 0 is a no-op against any non-negative content size — so the TopAlignedMinHeightLayout.sizeThatFits returns max(childSize.height, 0) = childSize.height and placeSubviews re-measures with the same proposal, no behavior change. The PR description also explicitly walks through this — good.
Cross-check vs the existing bottomAlignedMinHeight consumer:
Both env publish (line 230) and existing bottomAlignedMinHeight (line 191) use the identical filter viewportHeight.isFinite ? viewportHeight : nil. So the two consumers can never drift — when viewportHeight = .infinity (init or post-conversation-switch reset at line 233), both see nil simultaneously. Single source of truth confirmed.
Anti-pattern sweep — clean.
- No new
.frame(width:/maxWidth:/maxHeight:)introduced in either file at HEADcd70cb2f. - The removed
containerRelativeFrame+onGeometryChange+@Stateround-trip is itself an anti-pattern under the new "Don't double-track scroll viewport geometry" rule this PR adds. TopAlignedMinHeightLayoutcorrectly opts out of the default explicit-alignment cascade (explicitAlignmentreturnsnil) — verified at the modifier source..equatable()barrier onMessageListContentViewis preserved because the env channel only invalidates descendants that read\.scrollViewportHeight, not the parent — env propagation does not widen==. Boss's PR description correctly identifies this as the reason env > prop-drill here.
Solid additions to the codebase:
- New AGENTS.md rule under "View Bodies and Rendering" — codifies the pattern so the next contributor doesn't rebuild a viewport probe inside a descendant of
MessageListView. CitesonScrollGeometryChange/EnvironmentKey/onGeometryChangeApple docs. Excellent. - The PR description's "Alternatives considered" section is exemplary — explains why simpler shapes (revert pinned-turn, Equatable-shield section,
Equatableprop onMessageListContentView) don't work or trade off the wrong way. Future me will appreciate this.
Bot status:
- Devin Review found 1 potential issue (the structural identity flip) → resolved at
444807dwith verification. - Codex 👍 reaction on PR description ✅.
- Devin Review UI shows "2 additional findings" not visible via the GitHub API — recommend a quick scan via the Devin Review link before merge if anything looks worth addressing.
CI: Socket Security ×2 ✅, FlexFrame Lint ✅. macOS Build / macOS Tests / Lint Unused Code: SKIPPED — expected for clients/macos/ PRs that don't trigger the heavy macOS workflow. Boss QA on a local build is the gating factor before merge:
cd ~/Development/vellum-assistant/clients/macos && ./build.sh clean && ./build.sh run- Visual smoke test: open a chat with one short user message + short assistant reply (the section that uses
PinnedLatestTurnSection), drag the window splitter, switch conversations, resize the composer multiline → confirm the latest turn stays anchored, no visible regression in pinning UX, no jump on first measurement. - Sanity profile: trigger the same scenarios while running Instruments / spindump → confirm no
BottomAlignedMinHeightLayout / FixedWidthLayout2s hang.
Approving.
— Vex ✦
Summary
Replace
PinnedLatestTurnSection's local viewport-feedback loop with a chat-localEnvironmentValueschannel sourced from the sameviewportHeightMessageListViewalready filters viaOnScrollGeometryChange.MessageListViewdeclares a file-localScrollViewportHeightKey: EnvironmentKey(mirroringBubbleMaxWidthKeyinChatBubble.swift) and publishes its filtered viewport height via.environment(\.scrollViewportHeight, …)on theScrollView.PinnedLatestTurnSectionreads@Environment(\.scrollViewportHeight), derivesviewportMinHeight: CGFloat(defaulting to0before the first measurement lands), and applies it via the existingtopAlignedMinHeightmodifier. ThecontainerRelativeFrame+onGeometryChangeprobe and its@State viewportMinHeightround-trip are removed.Why this is needed
The pinned-turn section was sizing its
topAlignedMinHeightfloor against the scroll container with its owncontainerRelativeFrame+onGeometryChangeprobe, mirroring the resolved height into a local@State.MessageListViewalready tracks the same value viaOnScrollGeometryChangeto drivebottomAlignedMinHeighton the outer transcript stack — so the probe's@Stateround-trip was redundant and added another full layout pass through the eagerMessageTranscriptStackVStackon every viewport change (composer multi-line resize, splitter drag, window resize, conversation switch). This surfaced as the LUM-1353 ~2s app hang.Benefits
.equatable()barrier onMessageListContentViewintact: viewport changes only re-run views that read\.scrollViewportHeight, not the whole transcript.MessageListView'sOnScrollGeometryChange— instead of two independent measurements that could drift.Why this is safe
bottomAlignedMinHeighton the outer transcript stack — no new measurement, no new@State.VSpacing.md * 2subtraction matches the outerMessageListContentView.bodypadding (EdgeInsets(top: VSpacing.md, …, bottom: VSpacing.md, …)).viewportMinHeightdefaults to0(notnil) before the first scroll-geometry measurement lands.topAlignedMinHeight(_:)is a@ViewBuildermodifier (if let minHeight { TopAlignedMinHeightLayout … } else { self }), so anil → CGFloattransition would flip the section's structural identity and rebuild every transcript row inside on the first measurement. Defaulting to0keeps theTopAlignedMinHeightLayoutwrapper present from frame 1, andminHeight: 0has no sizing effect on non-negative content.EnvironmentKeyand theEnvironmentValuesextension are file-local (private struct + internal extension), matchingBubbleMaxWidthKey— no design system surface area added.NotificationCenterpayload contract is touched, so it's compatible with both old-platform/new-macOS and new-platform/old-macOS skew.Alternatives considered (and why not)
PinnedLatestTurnSectionto the pre-pinned-turn rendering path. Rejected — the section exists to keep the user's latest message anchored to the visual top while a short response streams; reverting would regress the pinning UX.Equatable-shield the section's body. Rejected —@Statemutation from.backgroundis a SwiftUI anti-pattern (state-mutation triggered by layout) and would still measure the viewport twice. Removing the redundant probe is strictly better.viewportHeightas anEquatableprop onMessageListContentView. Rejected — would either widen the==(re-evaluating the transcript on viewport changes) or require a separate non-equatable wrapper view.EnvironmentValuespropagates orthogonally to==so the existing.equatable()barrier keeps doing its job.ScrollViewportHeightKeyto the design system. Rejected — only one consumer today; file-local matches the existingBubbleMaxWidthKeyprecedent. Promote later if a second consumer appears.Optional<CGFloat>andtopAlignedMinHeight(nil)for the pre-measurement frame. Rejected — flagged by Devin Review:topAlignedMinHeight(_:)is a@ViewBuildermodifier withif let, sonil → valueflips structural identity and rebuilds every transcript row on the first measurement. Defaulting to0keeps the wrapper stable.BottomAlignedMinHeightLayout.placeSubviews. Out of scope. The hot path is the redundant feedback loop, not the layout itself; once the loop is removed the layout runs once per viewport change.MessageListContentViewshort of expanding into the deferredLazyVStack+MessageHeightCacherewrite. The viewport-feedback loop is a tractable, lower-risk fix landing this regression now.Root cause analysis
PinnedLatestTurnSectiona viewport-sized floor so the anchor row pins to the visual top while a short response streams. At the time the section was a localized leaf, socontainerRelativeFrameinside.backgroundlooked self-contained.MessageListViewwas already tracking the same value. By the time it shipped,OnScrollGeometryChangewas already feedingbottomAlignedMinHeightfrom the same source, so the probe duplicated work and added another layout pass through the eager transcript stack on every viewport change.@Statefrom a.backgroundview — state mutation triggered by layout, a known SwiftUI anti-pattern. Once the section grew to wrap the whole pinned-turn render, the@Stateround-trip was guaranteed to invalidate the section and re-measure the transcript on every viewport change. The Sentry signature onBottomAlignedMinHeightLayout.placeSubviewswas the surfaced symptom.onGeometryChange→@Stateviewport feedback inside transcript-adjacent views as suspect. If a viewport-derived value already exists on an ancestor scroll container, propagate it throughEnvironmentValuesrather than re-measuring. Also: when swapping a non-optional@Statefor an optional environment value, check whether downstream@ViewBuildermodifiers branch onnil— default to the old initial value to keep view identity stable across the first read.clients/AGENTS.md"Performance and Resource Management → View Bodies and Rendering": "Don't double-track scroll viewport geometry." with links toonScrollGeometryChange,EnvironmentKey, andonGeometryChange. Concise, link-heavy, no PR number — follows the AGENTS.md philosophy.References
EnvironmentValuesEnvironmentKeyonScrollGeometryChange(for:of:_:)onGeometryChange(for:of:action:)ViewBuilder(if/elsebranches produce distinct structural identities)Test plan
clients/scripts/check-flexframe.shpasses with no new entries.Suggested local verification (Xcode):
MACOS-RWevent count forBottomAlignedMinHeightLayout.placeSubviewsdrops post-deploy.Link to Devin session: https://app.devin.ai/sessions/4b829e012f5643a6bf6f8a49fec541e2
Requested by: @ashleeradka