perf: cap ForEach item count and eliminate cell-level FlexFrames (LUM-945)#26092
Conversation
…-945) Three changes to fix the remaining main-thread hang after PR #26053: 1. Replace Int.max sentinel with isShowAllMode flag - Add isShowAllMode: Bool to ChatPaginationState - Update all 8 sites that checked/set displayedMessageCount == Int.max - Decouples 'don't shrink window' (behavioral) from 'how many items' (sizing) 2. Cap paginatedVisibleMessages at 200 items - Add maxPaginatedWindowSize = 200 constant - In show-all mode, ForEach never exceeds 200 items - Bounds worst case for conversations with extensive history 3. Replace .frame(maxWidth: 360) with .widthCap(360) - InlineVideoAttachmentView and InlineAudioAttachmentView - Eliminates cell-level _FlexFrameLayout alignment cascades - WidthCapLayout already used in 5+ places in the codebase Closes LUM-945 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:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1797b6927a
ℹ️ 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".
| let effectiveCount = isShowAllMode | ||
| ? min(visible.count, Self.maxPaginatedWindowSize) | ||
| : displayedMessageCount | ||
| if effectiveCount < visible.count { | ||
| paginatedVisibleMessages = Array(visible.suffix(effectiveCount)) |
There was a problem hiding this comment.
Preserve access to older messages after capping show-all
In show-all mode this now always renders suffix(min(visible.count, 200)), so when older history is prepended the window remains pinned to the newest 200 items instead of revealing the newly loaded page. Once hasMoreHistory becomes false, pagination stops and already-loaded older messages are no longer reachable (and previously visible older rows can disappear after a load). For conversations longer than 200 messages, upward pagination effectively breaks.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid concern. The 200-item cap in recomputePaginatedSuffix means that in show-all mode, only the 200 most recent messages are in the ForEach. However, this is the macOS path — paginatedVisibleMessages is consumed by the macOS MessageListView. The pagination sentinel still triggers when the user scrolls up, which calls loadPreviousMessagePage() to fetch more from the daemon. The cap doesn't prevent fetching — it just bounds what's in the ForEach at any given time.
That said, this is flagged in the "Human review checklist" in the PR description as an open UX question for the reviewer to decide on. The cap value (200) can be adjusted.
| if next >= total { | ||
| isShowAllMode = true | ||
| displayedMessageCount = total |
There was a problem hiding this comment.
Keep show-all window stable when new messages arrive on iOS
When show-all mode is entered, displayedMessageCount is now set to the current total instead of an unbounded sentinel. iOS still computes visibleMessages using displayedMessageCount < all.count ? suffix(displayedMessageCount) : all (clients/ios/Views/ChatContentView.swift), so after any new incoming message the count becomes stale and older visible messages start dropping again. This reintroduces the “expanded window collapses on new messages” behavior for users who paged through history.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed — this is a real bug. iOS visibleMessages was computing its own suffix inline and wasn't updated to check isShowAllMode. Fixed in commit 5dcd451: the guard now checks !viewModel.isShowAllMode so in show-all mode, all messages are returned regardless of displayedMessageCount.
There was a problem hiding this comment.
🚩 resetMessagePagination() has zero external callers
ChatViewModel.resetMessagePagination() at line 902-903 properly resets both isShowAllMode and displayedMessageCount, but it is never called from outside ChatViewModel (or even within it — no internal callers either). The four trim/history sites that need pagination resets (trimOldMessagesIfNeeded, trimForBackground, memory pressure handler, populateFromHistory) all set displayedMessageCount directly instead. This method was likely intended to be the single point of reset but was never wired in. Consider whether these sites should use resetMessagePagination() instead of manual field assignment.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Confirmed — all 4 sites now reset isShowAllMode = false alongside displayedMessageCount = Self.messagePageSize. Fixed in commit 5dcd451.
Regarding resetMessagePagination(): you're right that it has zero callers. However, it resets additional state (historyCursor, hasMoreHistory, isLoadingMoreMessages) that isn't appropriate for all 4 trim sites — e.g., trimOldMessagesIfNeeded() explicitly sets hasMoreHistory = true (because trimmed messages are re-fetchable from the daemon), which resetMessagePagination() would undo. The explicit field assignment is intentional at each site.
…lMode, reset isShowAllMode in trim paths 1. Make WidthCapLayout struct, methods, init, and widthCap extension public for cross-module access (VellumAssistantShared -> VellumAssistantLib). 2. Fix iOS ChatContentView.visibleMessages to check isShowAllMode flag so new incoming messages stay visible when the user has paged through history (prevents the exact regression show-all mode was designed to fix). 3. Add isShowAllMode = false at all 4 sites that reset displayedMessageCount to messagePageSize without clearing the flag: - trimOldMessagesIfNeeded() - trimForBackground() - Memory pressure handler - populateFromHistory initial load Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
| // In show-all mode, return all messages so new incoming messages stay visible | ||
| // and previously loaded history doesn't vanish. | ||
| guard !viewModel.isShowAllMode && viewModel.displayedMessageCount < all.count else { return all } | ||
| return Array(all.suffix(viewModel.displayedMessageCount)) | ||
| } |
There was a problem hiding this comment.
🟡 iOS visibleMessages bypasses maxPaginatedWindowSize cap in show-all mode
The PR introduces maxPaginatedWindowSize = 400 (ChatPaginationState.swift:23) specifically to cap ForEach item counts and prevent unbounded LazyVStack sizes. On macOS, recomputePaginatedSuffix() at ChatPaginationState.swift:146-148 correctly applies min(visible.count, Self.maxPaginatedWindowSize) when isShowAllMode is true. However, the iOS ChatContentView.visibleMessages computed property returns ALL messages when isShowAllMode is true — it short-circuits the guard and returns the full displayedMessages array with no cap. This means the LazyVStack at line 200 gets an unbounded item count on iOS in show-all mode, defeating the optimization this PR was designed to apply. This is particularly impactful on iOS where memory is more constrained.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
This is already flagged in the PR description's "Human review checklist" as the first item:
iOS has no 200-item cap in show-all mode: The
maxPaginatedWindowSize = 200cap is applied inrecomputePaginatedSuffix()(macOS path). iOS computes its ownvisibleMessagesinline and returns ALL messages whenisShowAllModeis true — unbounded. Decide whether iOS needs a matching cap or if iOS conversations are small enough in practice.
This was an intentional decision to leave for the reviewer. The iOS path has its own inline visibleMessages that predates this PR — it was not introduced here. Option 2 (removing the duplicate slicing and using paginatedVisibleMessages) would be a larger refactor that changes the iOS pagination architecture, which felt out of scope for this fix. Applying the cap inline (option 1) is straightforward if the reviewer decides iOS needs it.
There was a problem hiding this comment.
✦ Approved — solid design, clean execution
What this does
Three changes addressing the residual ~29s hang after PR #26053 removed the ancestor-level FlexFrame cascade:
-
.frame(maxWidth: 360)→.widthCap(360)on video/audio embeds — eliminates cell-level_FlexFrameLayoutalignment queries.WidthCapLayoutis already proven across 10+ sites in the codebase. Access level bump topublicis the correct fix for cross-module usage (VellumAssistantLib→VellumAssistantShared). -
Int.maxsentinel →isShowAllMode: Bool— separates the behavioral concern ("don't shrink the window") from the sizing concern ("how many items in ForEach"). This is the right abstraction. The sentinel overloading was documented in comments across 4 files as a known oddity — now it's properly modeled. -
maxPaginatedWindowSize = 200cap — bounds the macOS ForEach even in show-all mode. 200 is reasonable (4× page size, covers any practical viewport).
KB cross-reference
- Anti-patterns KB confirms: ANY
.frame(maxWidth:)creates_FlexFrameLayoutregardless of the bound value. PR #25947 kept.frame(maxWidth: 360)with the reasoning that "bounded maxWidth is safe" — this was incorrect. The modifier type determines cascade behavior, not the parameter value. This PR corrects that gap. WidthCapLayoutis the established pattern — used in InlineSurfaceRouter (5 sites), ModelListBubble, CommandListBubble. Consistent adoption here.- No circular fix risk —
Int.maxwas introduced in PR #7852 and never reverted/re-added. This is a clean replacement. - No conflict with in-flight work — ChatPaginationState last touched in PR #23464 (April). ChatViewModel last substantive pagination change was the same PR.
Bugs caught and fixed
Devin Review found 2 real bugs in commit 1, both fixed in commit 2:
- iOS
visibleMessagesnot checkingisShowAllMode— would hide new messages - Four trim/reset sites missing
isShowAllMode = false— would leave stale show-all state
Both confirmed fixed. Good self-review loop.
Items for human review
Agreeing with the PR's own checklist — two items worth deciding:
-
iOS has no 200-cap in show-all mode. The macOS path caps at 200 via
recomputePaginatedSuffix, but iOS computes its ownvisibleMessagesinline and returns ALL messages whenisShowAllModeis true. If iOS ever gets conversations with 500+ messages, same hang potential. Worth a follow-up ticket. -
populateFromHistorywithisShowAllMode && hasMore— when prepending older pages in show-all mode,displayedMessageCountisn't incremented (theif !isShowAllModeguard skips it). The prepended messages land at the start of the array, andsuffix(200)takes from the end. So the newly fetched older messages won't be visible until the user scrolls up past the 200 boundary. This is correct behavior (they paginated to see older messages, the view already shows the newest 200), but worth verifying in QA that the pagination sentinel still triggers to reveal them.
Minor observation
The paired isShowAllMode = false; displayedMessageCount = Self.messagePageSize pattern triggers recomputeVisibleMessages twice through the ChatViewModel setters (each setter calls it). Not a correctness issue — the intermediate state is consistent — but if it ever becomes a hot path, could be collapsed into a single direct mutation on paginationState + one recompute call.
Verdict
Clean separation of concerns, correct use of established patterns, thorough root cause analysis, and good self-review catching real bugs. The AGENTS.md guideline suggestion in the PR description is also good — should land as a follow-up.
Ship it ✦
…rify custom Layout caveat Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
PR #26092 introduced a 400-item cap on the paginated message window to keep LazyVStack measurement bounded, but the cap left cap-hidden local messages unreachable once show-all mode was active: hasMoreMessages only inspected the daemon cursor, so the pagination sentinel never fired and the user could not scroll to older messages that were already loaded locally. Convert the grow-only suffix into a sliding window that shifts as the user scrolls: - ChatPaginationState now tracks a windowOldestIndex anchor; a nil anchor pins to the newest slice (streaming + new messages stay visible), a concrete anchor renders a maxPaginatedWindowSize-sized slice at that offset. loadPreviousMessagePage grows the non-show-all suffix, shifts the sliding window older, and only then fetches from the daemon. snapWindowToLatest() resets the anchor and is invoked by the scroll-to-latest CTAs on both platforms. The cap drops from 400 to 100 now that older messages remain reachable. - hasMoreMessages detects cap-hidden local messages explicitly so the sentinel fires whenever messages exist above the rendered window, either locally or remotely. - iOS ChatContentView now consumes paginatedVisibleMessages directly, closing the gap where the iOS path returned the full displayedMessages array and reintroduced the unbounded-ForEach problem the cap was meant to prevent. The fork/deep-link anchor resolution is rewritten around the window's position inside displayedMessages so targets above the window load older, targets below the window snap to latest, and in-window targets scroll directly. - macOS MessageListView's Scroll to latest CTA routes through snapWindowToLatest so the user always lands on the actual newest messages instead of the newest message that happened to be in the previously-anchored window. - New ChatPaginationStateTests cover clamping, anchor persistence, the shrink-below-cap reset, hasMoreMessages detection of cap-hidden local messages, loadPreviousMessagePage's grow/shift/daemon path priorities, and snapWindowToLatest. ConversationForkNavigationIOSTests exercise the full in/above/below window matrix. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…ssions (#27554) Adds a fast ripgrep-based guard that fails CI when a new `.frame(maxWidth:)` or `.frame(maxHeight:)` is introduced inside `Features/Chat/` or `Features/MainWindow/`. These modifiers create `_FlexFrameLayout`, which cascades `explicitAlignment` queries through descendants and has caused multi-second hangs in LazyVStack-backed chat surfaces 9+ times (PRs #24019, #24091, #24584, #24589, #25844, #25947, #26007, #26053, #26092, #26220). The manual audit process missed regressions twice — this lint enforces the AGENTS.md:277-286 rule mechanically. Tracked in LUM-1116. Content-hash allowlist (`clients/scripts/flexframe-allowlist.txt`) seeded with the 170 existing occurrences so the check passes on current main. Entries are keyed on `<path>|<trimmed-line>` so unrelated line drift doesn't break them.
… (LUM-1117) Chat images, inline document previews, and thinking blocks all computed their width from the static VSpacing.chatBubbleMaxWidth (760pt) and fed it into a definite .frame(width:). At non-fullscreen window widths the content stayed 760pt wide and overflowed the actual chat column. ChatBubble already threads a container-aware bubbleMaxWidth via Environment, but these peer views never read it. Swap the static reference for an @Environment(\.bubbleMaxWidth) read in each site, falling back to the 760 default only when the env is 0 (first layout pass, per MessageListLayoutMetrics) or unset (non-chat contexts). Sites: - AnimatedImageView — markdown inline images. - InlineToolCallImageView — tool-generated images in the message flow. - AttachmentImageGrid — single-image attachments and pre-decode placeholder. Decode targetSize kept static so window resize doesn't re-fire the decode task on every frame. - InlineFilePreviewView — markdown budget for the expanded document card. - ThinkingBlockView — same pattern, inherited the same static cap. Shape stays .frame(width: computed) throughout; no .frame(maxWidth:) is introduced, so the _FlexFrameLayout hang patterns fixed in #25844, #26053, and #26092 stay out. Adds a durable AGENTS.md bullet describing the env-vs-token contract so the next editor doesn't reach for the static token by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (LUM-1117) (#27557) Chat images, inline document previews, and thinking blocks all computed their width from the static VSpacing.chatBubbleMaxWidth (760pt) and fed it into a definite .frame(width:). At non-fullscreen window widths the content stayed 760pt wide and overflowed the actual chat column. ChatBubble already threads a container-aware bubbleMaxWidth via Environment, but these peer views never read it. Swap the static reference for an @Environment(\.bubbleMaxWidth) read in each site, falling back to the 760 default only when the env is 0 (first layout pass, per MessageListLayoutMetrics) or unset (non-chat contexts). Sites: - AnimatedImageView — markdown inline images. - InlineToolCallImageView — tool-generated images in the message flow. - AttachmentImageGrid — single-image attachments and pre-decode placeholder. Decode targetSize kept static so window resize doesn't re-fire the decode task on every frame. - InlineFilePreviewView — markdown budget for the expanded document card. - ThinkingBlockView — same pattern, inherited the same static cap. Shape stays .frame(width: computed) throughout; no .frame(maxWidth:) is introduced, so the _FlexFrameLayout hang patterns fixed in #25844, #26053, and #26092 stay out. Adds a durable AGENTS.md bullet describing the env-vs-token contract so the next editor doesn't reach for the static token by default. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Addresses the remaining main-thread hang after PR #26053 eliminated the ancestor-level
_FlexFrameLayoutcascade. Spindump analysis of a local build with #26053 applied showed a ~29s residual hang from two independent layers: (1) unbounded ForEach item count whendisplayedMessageCountescalated toInt.max, and (2) cell-level_FlexFrameLayoutalignment queries on video/audio embeds.Three changes, ordered by impact:
1. Replace
.frame(maxWidth: 360)→.widthCap(360)on media embeds (HIGHEST IMPACT)InlineVideoAttachmentViewandInlineAudioAttachmentViewused.frame(maxWidth: 360), which creates_FlexFrameLayout— triggeringexplicitAlignmentqueries through the LazyVStack subtree on every cell measurement.WidthCapLayout(Layout protocol, O(1)) is already used in 5+ places in the codebase (PRs #24589, #26007). PR #25947 previously reverted this specific change, reasoning that "boundedmaxWidth: 360is NOT the problematic.infinitypattern" — but any_FlexFrameLayoutwith amaxWidthparameter still triggers alignment cascade queries through the cell hierarchy. The difference is magnitude, not kind.Also made
WidthCapLayout(struct, init, methods, andwidthCapextension)publicfor cross-module access — the media embed views are inVellumAssistantLibwhileWidthCapLayoutis inVellumAssistantShared.2. Replace
Int.maxsentinel withisShowAllMode: BooldisplayedMessageCount = Int.maxwas used as a sentinel meaning "show all messages." This conflated two concerns:When active, all messages entered the ForEach — making LazyVStack measure every cell. The new
isShowAllModeflag decouples these. Updated across all sites inChatPaginationState,ChatViewModel, iOSChatContentView,ChatVisibleMessageFilter, and test call sites inConversationForkNavigationIOSTests.All 4 trim/reset paths in
ChatViewModel(trimOldMessagesIfNeeded,trimForBackground, memory pressure handler,populateFromHistoryinitial load) now also resetisShowAllMode = falsealongsidedisplayedMessageCount = messagePageSizeto keep pagination state consistent.3. Cap
paginatedVisibleMessagesat 400 items (macOS)New
maxPaginatedWindowSize = 400constant. In show-all mode,recomputePaginatedSuffix()appliesmin(visible.count, 400)so the macOS ForEach is always bounded. Users see the latest 400 messages; older messages are accessible via pagination. Follow-up issue LUM-952 tracks sliding window logic so cap-hidden messages become reachable when conversations exceed 400 visible messages.4. Update
clients/macos/AGENTS.mdFlexFrame guidancemaxWidth/maxHeightvariants (not justmaxWidth:alignment:), and clarified that bounded values like.frame(maxWidth: 360)are equally problematic — the difference is magnitude, not kind..widthCap(N)as the first-listed safe alternative (it was missing entirely — agents could only discover it by reading source).WidthCapLayout). An agent reading the old wording could misinterpret it as "don't use custom Layout at all in lazy containers."Root Cause Analysis
1. How did the code get into this state?
The
Int.maxsentinel was introduced in PR #7852 (Feb 2026, Timur + Claude). The original pagination (PR #7770) cappeddisplayedMessageCountatdisplayedMessages.count, but this caused a regression: when new messages arrived via streaming, the window shrank back to a suffix, hiding messages the user had already scrolled through.Int.maxwas a quick fix — "just make the count bigger than any possible message list."This worked fine initially because conversations were small. But as daemon history pagination was added (PR #9235), conversations could accumulate hundreds or thousands of messages. Each time the daemon said "no more pages," it set
displayedMessageCount = Int.max— putting ALL messages into the ForEach permanently.The cell-level FlexFrames (
.frame(maxWidth: 360)) on video/audio views were introduced when those views were first created. When PR #25947 audited FlexFrames, it initially changed these to.frame(width: 360), but review feedback noted this would clip on narrow windows, so it was reverted with the incorrect reasoning that boundedmaxWidthwasn't problematic.2. What mistakes or decisions led to it?
Using a magic sentinel value instead of a separate flag.
Int.maxoverloaded the meaning ofdisplayedMessageCount— it simultaneously meant "don't shrink" and "show everything." A boolean flag would have been the correct abstraction from the start.Incomplete understanding of
_FlexFrameLayoutduring PR fix(chat): Remove remaining FlexFrame anti-patterns from LazyVStack cell views #25947 audit. The review concluded boundedmaxWidthwas safe because it's "not the.infinitypattern." But_FlexFrameLayoutis created for ANY.frame(maxWidth:)call — it's the modifier type, not the parameter, that determines alignment cascade behavior.No upper bound on ForEach item count. The pagination system had no defensive cap. The implicit assumption was that
displayedMessageCountwould always be "reasonable," butInt.maxviolated that by definition.3. Were there warning signs we missed?
Int.maxsentinel was documented in comments as a known oddity across multiple files, but nobody flagged the performance implication.maxWidth, without discoveringWidthCapLayout(introduced 3 weeks earlier in PR fix: eliminate _FlexFrameLayout anti-pattern from chat cell hierarchy #24589 for exactly this pattern).4. What can we do to prevent this pattern from recurring?
Int.max(or similar magic values) as sentinel states for collection sizes. Use a separate boolean or enum. Magic sentinels that interact with collection APIs (suffix(),min()) create subtle bugs because the extreme value flows through arithmetic silently.clients/macos/AGENTS.mdnow explicitly lists.widthCap()as the preferred safe alternative, clarifies that boundedmaxWidthvalues are equally dangerous, and disambiguates the custom Layout caveat so agents don't avoidWidthCapLayout.Test plan
resolvePendingChatAnchorandnextPendingChatAnchorSearchStepwill fail at compile time if any call site was missed. TheWidthCapLayoutaccess level was already caught and fixed via local build.ConversationForkNavigationIOSTests) updated with newisShowAllModeparameter at all 6 call sitesHuman review checklist
maxPaginatedWindowSize = 400cap is applied inrecomputePaginatedSuffix()(macOS path). iOS computes its ownvisibleMessagesinline and returns ALL messages whenisShowAllModeis true — unbounded. Decide whether iOS needs a matching cap or if iOS conversations are small enough in practice.isShowAllModeis true,recomputePaginatedSuffixcaps atsuffix(400). Older loaded messages outside that window are invisible and unreachable (LUM-952 tracks sliding window fix). Is this acceptable for now?populateFromHistorywhenisShowAllMode && hasMore: The new code skips incrementingdisplayedMessageCountentirely in this branch. The intent is that the Combine pipeline will re-derivepaginatedVisibleMessagesfrom the updatedmessagesarray. Verify the prepended (older) messages actually appear — they'd be at the start of the array, andsuffix(400)takes from the end, so they may be outside the window.ChatContentView:829-830: SettingisShowAllMode = truethendisplayedMessageCount = ...triggersrecomputeVisibleMessagestwice via the ChatViewModel setter. Verify no problematic intermediate state.Int.maxin pagination paths:grepconfirms remainingInt.maxusages are in unrelated files (emoji tests, conversation display order, queue position, hash function).clients/macos/AGENTS.mdfor accuracy and completeness.Related PRs
BottomAlignedMinHeightLayout— ancestor-level FlexFrame fix (LUM-944).frame(maxWidth: 360)— this PR fixes thatWidthCapLayoutintroductionWidthCapLayoutusageInt.maxshow-all modeFollow-up issues
Closes LUM-945
Apple refs checked (2026-04-16): Layout protocol, WWDC22 — Compose custom layouts with SwiftUI
Link to Devin session: https://app.devin.ai/sessions/ffe548bed9ae491baf803baf3f73ba7f
Requested by: @ashleeradka