Skip to content

perf: cap ForEach item count and eliminate cell-level FlexFrames (LUM-945)#26092

Merged
ashleeradka merged 4 commits into
mainfrom
devin/1776368364-lum-945-uncapped-foreach-fix
Apr 16, 2026
Merged

perf: cap ForEach item count and eliminate cell-level FlexFrames (LUM-945)#26092
ashleeradka merged 4 commits into
mainfrom
devin/1776368364-lum-945-uncapped-foreach-fix

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

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

Summary

Addresses the remaining main-thread hang after PR #26053 eliminated the ancestor-level _FlexFrameLayout cascade. Spindump analysis of a local build with #26053 applied showed a ~29s residual hang from two independent layers: (1) unbounded ForEach item count when displayedMessageCount escalated to Int.max, and (2) cell-level _FlexFrameLayout alignment queries on video/audio embeds.

Three changes, ordered by impact:

1. Replace .frame(maxWidth: 360).widthCap(360) on media embeds (HIGHEST IMPACT)

InlineVideoAttachmentView and InlineAudioAttachmentView used .frame(maxWidth: 360), which creates _FlexFrameLayout — triggering explicitAlignment queries 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 "bounded maxWidth: 360 is NOT the problematic .infinity pattern" — but any _FlexFrameLayout with a maxWidth parameter still triggers alignment cascade queries through the cell hierarchy. The difference is magnitude, not kind.

Also made WidthCapLayout (struct, init, methods, and widthCap extension) public for cross-module access — the media embed views are in VellumAssistantLib while WidthCapLayout is in VellumAssistantShared.

2. Replace Int.max sentinel with isShowAllMode: Bool

displayedMessageCount = Int.max was used as a sentinel meaning "show all messages." This conflated two concerns:

  • Behavioral: "don't shrink the window when new messages arrive"
  • Sizing: "how many items go into the ForEach"

When active, all messages entered the ForEach — making LazyVStack measure every cell. The new isShowAllMode flag decouples these. Updated across all sites in ChatPaginationState, ChatViewModel, iOS ChatContentView, ChatVisibleMessageFilter, and test call sites in ConversationForkNavigationIOSTests.

All 4 trim/reset paths in ChatViewModel (trimOldMessagesIfNeeded, trimForBackground, memory pressure handler, populateFromHistory initial load) now also reset isShowAllMode = false alongside displayedMessageCount = messagePageSize to keep pagination state consistent.

3. Cap paginatedVisibleMessages at 400 items (macOS)

New maxPaginatedWindowSize = 400 constant. In show-all mode, recomputePaginatedSuffix() applies min(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.md FlexFrame guidance

  • Expanded the FlexFrame rule to explicitly cover all maxWidth/maxHeight variants (not just maxWidth:alignment:), and clarified that bounded values like .frame(maxWidth: 360) are equally problematic — the difference is magnitude, not kind.
  • Added .widthCap(N) as the first-listed safe alternative (it was missing entirely — agents could only discover it by reading source).
  • Clarified the "Do NOT use a custom Layout barrier" caveat to specify it applies between the outer modifier chain and the ScrollView — not to Layout-protocol modifiers used inside cells (like 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.max sentinel was introduced in PR #7852 (Feb 2026, Timur + Claude). The original pagination (PR #7770) capped displayedMessageCount at displayedMessages.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.max was 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 bounded maxWidth wasn't problematic.

2. What mistakes or decisions led to it?

  1. Using a magic sentinel value instead of a separate flag. Int.max overloaded the meaning of displayedMessageCount — it simultaneously meant "don't shrink" and "show everything." A boolean flag would have been the correct abstraction from the start.

  2. Incomplete understanding of _FlexFrameLayout during PR fix(chat): Remove remaining FlexFrame anti-patterns from LazyVStack cell views #25947 audit. The review concluded bounded maxWidth was safe because it's "not the .infinity pattern." But _FlexFrameLayout is created for ANY .frame(maxWidth:) call — it's the modifier type, not the parameter, that determines alignment cascade behavior.

  3. No upper bound on ForEach item count. The pagination system had no defensive cap. The implicit assumption was that displayedMessageCount would always be "reasonable," but Int.max violated that by definition.

3. Were there warning signs we missed?

4. What can we do to prevent this pattern from recurring?

  • Never use 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.
  • AGENTS.md updated in this PR — the FlexFrame guidance in clients/macos/AGENTS.md now explicitly lists .widthCap() as the preferred safe alternative, clarifies that bounded maxWidth values are equally dangerous, and disambiguates the custom Layout caveat so agents don't avoid WidthCapLayout.

Test plan

  • No Swift linter configured in repo; TypeScript lint is N/A for these changes
  • CI runs security checks (macOS Build/Tests/Lint are skipped — no Xcode runner)
  • Requires local macOS build verification — the function signature changes to resolvePendingChatAnchor and nextPendingChatAnchorSearchStep will fail at compile time if any call site was missed. The WidthCapLayout access level was already caught and fixed via local build.
  • iOS test file (ConversationForkNavigationIOSTests) updated with new isShowAllMode parameter at all 6 call sites

Human review checklist

  • iOS has no 400-item cap in show-all mode: The maxPaginatedWindowSize = 400 cap is applied in recomputePaginatedSuffix() (macOS path). iOS computes its own visibleMessages inline and returns ALL messages when isShowAllMode is true — unbounded. Decide whether iOS needs a matching cap or if iOS conversations are small enough in practice.
  • 400-item cap interaction with active pagination (macOS): When the user has explicitly paginated back and isShowAllMode is true, recomputePaginatedSuffix caps at suffix(400). Older loaded messages outside that window are invisible and unreachable (LUM-952 tracks sliding window fix). Is this acceptable for now?
  • populateFromHistory when isShowAllMode && hasMore: The new code skips incrementing displayedMessageCount entirely in this branch. The intent is that the Combine pipeline will re-derive paginatedVisibleMessages from the updated messages array. Verify the prepended (older) messages actually appear — they'd be at the start of the array, and suffix(400) takes from the end, so they may be outside the window.
  • Double-mutation in iOS ChatContentView:829-830: Setting isShowAllMode = true then displayedMessageCount = ... triggers recomputeVisibleMessages twice via the ChatViewModel setter. Verify no problematic intermediate state.
  • No remaining Int.max in pagination paths: grep confirms remaining Int.max usages are in unrelated files (emoji tests, conversation display order, queue position, hash function).
  • AGENTS.md update: Review the updated FlexFrame guidance in clients/macos/AGENTS.md for accuracy and completeness.

Related PRs

PR Description Relationship
#26053 BottomAlignedMinHeightLayout — ancestor-level FlexFrame fix (LUM-944) Prerequisite; exposed this residual hang
#25947 Cell-level FlexFrame audit Incorrectly preserved .frame(maxWidth: 360) — this PR fixes that
#24589 Original WidthCapLayout introduction Created the Layout protocol struct we use here
#26007 FlexFrame batch 3 — expanded WidthCapLayout usage Same pattern adopted here
#7852 Original Int.max show-all mode Introduced the sentinel this PR replaces

Follow-up issues

  • LUM-952 — Sliding window pagination so cap-hidden messages (beyond 400) are reachable

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


Open with Devin

…-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-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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +146 to +150
let effectiveCount = isShowAllMode
? min(visible.count, Self.maxPaginatedWindowSize)
: displayedMessageCount
if effectiveCount < visible.count {
paginatedVisibleMessages = Array(visible.suffix(effectiveCount))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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.

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.

Comment on lines +177 to +179
if next >= total {
isShowAllMode = true
displayedMessageCount = total
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

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.

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 3 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/ios/Views/ChatContentView.swift
Comment thread clients/shared/Features/Chat/ChatViewModel.swift
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot Apr 16, 2026

Choose a reason for hiding this comment

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

🚩 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.

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.

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>
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 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +129 to 133
// 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))
}
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot Apr 16, 2026

Choose a reason for hiding this comment

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

🟡 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.

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.

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 = 200 cap is applied in recomputePaginatedSuffix() (macOS path). iOS computes its own visibleMessages inline and returns ALL messages when isShowAllMode is 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.

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.

✦ Approved — solid design, clean execution

What this does

Three changes addressing the residual ~29s hang after PR #26053 removed the ancestor-level FlexFrame cascade:

  1. .frame(maxWidth: 360).widthCap(360) on video/audio embeds — eliminates cell-level _FlexFrameLayout alignment queries. WidthCapLayout is already proven across 10+ sites in the codebase. Access level bump to public is the correct fix for cross-module usage (VellumAssistantLibVellumAssistantShared).

  2. Int.max sentinel → 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.

  3. maxPaginatedWindowSize = 200 cap — 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 _FlexFrameLayout regardless 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.
  • WidthCapLayout is the established pattern — used in InlineSurfaceRouter (5 sites), ModelListBubble, CommandListBubble. Consistent adoption here.
  • No circular fix riskInt.max was 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:

  1. iOS visibleMessages not checking isShowAllMode — would hide new messages
  2. 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:

  1. iOS has no 200-cap in show-all mode. The macOS path caps at 200 via recomputePaginatedSuffix, but iOS computes its own visibleMessages inline and returns ALL messages when isShowAllMode is true. If iOS ever gets conversations with 500+ messages, same hang potential. Worth a follow-up ticket.

  2. populateFromHistory with isShowAllMode && hasMore — when prepending older pages in show-all mode, displayedMessageCount isn't incremented (the if !isShowAllMode guard skips it). The prepended messages land at the start of the array, and suffix(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 ✦

devin-ai-integration Bot and others added 2 commits April 16, 2026 20:13
…rify custom Layout caveat

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit 6106157 into main Apr 16, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1776368364-lum-945-uncapped-foreach-fix branch April 16, 2026 20:17
devin-ai-integration Bot added a commit that referenced this pull request Apr 16, 2026
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>
ashleeradka added a commit that referenced this pull request Apr 22, 2026
…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.
ashleeradka added a commit that referenced this pull request Apr 22, 2026
… (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>
ashleeradka added a commit that referenced this pull request Apr 22, 2026
… (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>
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