Skip to content

fix(macos/chat): read chat widths from bubbleMaxWidth env, not static token (LUM-1117)#27557

Merged
ashleeradka merged 1 commit into
mainfrom
claude/vibrant-ritchie-5fd63f
Apr 22, 2026
Merged

fix(macos/chat): read chat widths from bubbleMaxWidth env, not static token (LUM-1117)#27557
ashleeradka merged 1 commit into
mainfrom
claude/vibrant-ritchie-5fd63f

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented Apr 22, 2026

What

Chat images, the inline document-preview card, and the thinking block all stayed at a fixed 760pt width at non-fullscreen window sizes and overflowed the chat column. This wires those five sites onto the existing container-aware \.bubbleMaxWidth environment value that ChatBubble's main markdown path already uses. Adds a durable note to clients/macos/AGENTS.md describing the env-vs-token contract.

Fixes LUM-1117.

Why it's safe

  • Shape stays .frame(width: computed) throughout. No .frame(maxWidth:) is introduced — so the _FlexFrameLayout hang patterns fixed in #25844, #26053, #26092 don't return. See clients/macos/AGENTS.md § LazyVStack width/height rules and WWDC23: Demystify SwiftUI performance.
  • The 0-fallback matches the first-layout contract. MessageListLayoutMetrics deliberately reports bubbleMaxWidth = 0 until GeometryReader resolves (see MessageListLayoutMetrics.swift:15-23); the cache-poisoning guards added in c7eefb41b5 already handle this for text, and images degrade to zero-size on the first pass (invisible, not oversized — same philosophy as the metrics comment).
  • The env default outside MessageListContentView is the static 760 (ChatBubble.swift:11-13). Non-chat callers (e.g. SubagentDetailPanel) are unaffected.
  • AttachmentImageGrid's decode targetSize remains static. Keying the decode .task(id:) on a resize-varying value would re-fire ImageIO downsampling every resize frame — the display frame uses the env, the decode does not. Trade: small memory overhead at narrow widths; avoided: resize-thrash.

What I chose NOT to do

  • Not changing MarkdownSegmentView's default (from VSpacing.chatBubbleMaxWidth to an env read). SubagentDetailPanel renders MarkdownSegmentView outside the message list where \.bubbleMaxWidth is the static default; changing the internal fallback would silently reshape that panel's layout. Per-call-site env reads keep each context explicit.
  • Not using .widthCap(bubbleMaxWidth). WidthCapLayout is the right tool for content that proposes a natural width and needs to be bounded (WidthCapLayout.swift, blessed pattern per AGENTS.md:278). For these views the width feeds into a downstream computation (aspect-fit for images, definite .frame(width:) for measured text) — a cap modifier wouldn't reach those. Kept the existing .frame(width: computed) shape and swapped the input.
  • Not touching ChatEmptyStateView / ChatLoadingSkeleton. Both reference the static cap but use .frame(maxWidth:) and live outside LazyVStack, so they already shrink to container and don't hit the FlexFrame perf trap.
  • Not touching SubagentDetailPanel. Separate surface, separate env context. Its maxContentWidth: nil is a different symptom of the same misleading API — worth a follow-up.

Root cause analysis

How did the code get into this state?

  1. PR #25844 (LUM-835) replaced .frame(maxWidth:, maxHeight:) with computed definite .frame(width:, height:) in image and file-preview paths to eliminate 2s+ FlexFrame hangs. The computation used the static VSpacing.chatBubbleMaxWidth — correct for the perf problem at hand.
  2. PR #24446 (LUM-800) later introduced MessageListLayoutMetrics + \.bubbleMaxWidth env to make text content container-aware.
  3. Only ChatBubble's main markdown path was migrated to read the env. Peer views (images, document preview, thinking block) kept their static cap. There was no sweep.
  4. Subsequent alignment tweaks (e.g. #26130 subtracting 2 * VSpacing.sm in the thinking block) patched symptoms within the static framework instead of migrating to the env.

Decisions that led to it

  • No architectural seam for "current chat column width". \.bubbleMaxWidth exists but isn't discoverable from a peer view — you'd have to grep MessageListContentView to find it.
  • Misleading token name. VSpacing.chatBubbleMaxWidth = 760 reads like "the" chat bubble width. Nothing signals that it's a fallback and the env is the source of truth.
  • Foot-gun API on MarkdownSegmentView.maxContentWidth. It reads like a max, but SelectableRunView applies it as a definite .frame(width:) — callers passing a static 760 or nil (which falls back to 760) are silently buggy at narrow widths. The comment at MarkdownSegmentView.swift:1041-1044 documents nil = use default as intentional, which institutionalized the wrong pattern.

Warning signs we missed

  • The thinking block code already carried a paragraph-long comment explaining the definite-width behavior and doing - 2 * VSpacing.sm math to compensate — a clear smell that the API was confusing.
  • Prior overflow tickets (LUM-822, LUM-639, LUM-608) each fixed a single surface rather than sweeping. The pattern "user reports cutoff, we fix one view" repeated without a root-cause pass.
  • The comment at MessageListLayoutMetrics.swift:15-23 explicitly explains the first-pass 0 behavior and why using the static token would overflow narrow containers — but only for the outer container, not the children.

Prevention

  • AGENTS.md bullet added describing the env-vs-token contract for chat cells. Short and durable — doesn't name specific files.
  • Follow-up candidates (intentionally not in this PR):
    • Consider renaming MarkdownSegmentView.maxContentWidthcontentWidth or similar to signal "definite," or change the API to make it a real max.
    • Sweep other consumers of VSpacing.chatBubbleMaxWidth in chat-cell hierarchy to confirm no other latent overflow.
    • Consider a @ViewBuilder helper that wraps an env read + fallback so every site doesn't repeat the pattern.

Prompt / plan

Enriched Linear ticket LUM-1117 (Garrison-reported cutoff at non-fullscreen widths) plus a user screenshot showing the document preview also overflowing. Investigation traced the static-760 pattern through every rich UI site in the chat feed, cross-checked the three FlexFrame-hang PRs to confirm the fix shape doesn't reintroduce them, and verified the first-layout 0-case handling before applying the env read at each site.

Test plan

  • ./build.sh — green
  • ./build.sh lint — green (strict concurrency)
  • MarkdownSegmentViewTests — 27/27, including the zero-width regression test from c7eefb41b5
  • Manual, window narrower than ~810pt:
    • Assistant message with wide image
    • Tool-generated image (e.g. image-gen)
    • Single-image attachment
    • Multi-image attachment grid (still uses 160pt cells)
    • Expanded inline document preview (the reporter's scenario)
    • Expanded thinking block
  • Manual, window wider than 810pt: content still caps at 760pt

🤖 Generated with Claude Code

@linear
Copy link
Copy Markdown

linear Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

@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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

… (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 ashleeradka force-pushed the claude/vibrant-ritchie-5fd63f branch from 52debaf to b78ccb7 Compare April 22, 2026 21:02
@ashleeradka ashleeradka changed the title fix(macos/chat): cap rich-content widths to container (LUM-1117) fix(macos/chat): read chat widths from bubbleMaxWidth env, not static token (LUM-1117) Apr 22, 2026
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.

APPROVE

Value: Fixes visible content overflow at non-fullscreen widths for images, inline file previews, and thinking blocks — all of which were stuck at static 760pt while the text path was already container-aware.

What this does: Migrates five chat-cell views from reading VSpacing.chatBubbleMaxWidth (static 760pt) to @Environment(\.bubbleMaxWidth) (container-aware value set by MessageListContentView). Adds an AGENTS.md rule documenting the env-vs-token contract so future contributors don't repeat the pattern.

Analysis — this is clean:

  1. No anti-pattern violations. All .frame() calls remain definite .frame(width:, height:) — no .frame(maxWidth:) introduced. FlexFrame Lint passes. The _FrameLayout_FlexFrameLayout boundary is preserved everywhere.

  2. Zero-width first-pass contract is correct. Every site follows the same bubbleMaxWidth > 0 ? bubbleMaxWidth : VSpacing.chatBubbleMaxWidth guard pattern. When MessageListLayoutMetrics reports 0 before GeometryReader resolves, the static fallback kicks in. This matches the existing text-path behavior, and the cache-poisoning guards from #26316 prevent degenerate measurements from persisting.

  3. Decode targetSize correctly left static. The .task(id:) for ImageIO downsampling intentionally keeps VSpacing.chatBubbleMaxWidth — keying on a resize-varying env value would re-fire expensive downsampling every resize frame. The comment documents this trade explicitly.

  4. ThinkingBlockView simplification is a nice cleanup. Removed the paragraph-long comment explaining the old static math and replaced with a short, accurate comment about maxContentWidth becoming a definite .frame(width:). Less code, same behavior, now responsive.

  5. InlineFilePreviewView fix follows the same - 2 * VSpacing.sm pattern as ThinkingBlockView for the card padding subtraction. Consistent.

  6. Root cause analysis in the PR description is excellent. The "warning signs we missed" section identifying the pattern of one-off fixes (LUM-822, LUM-639, LUM-608) rather than sweeping is the real lesson here.

One observation (non-blocking): AnimatedImageView.gifSize(maxDimension:) is called twice in the GIF frame builder (once for width, once for height). It's trivial math so no perf concern — just noting if you ever wanted to hoist it into a local let.

Ship it.

@ashleeradka ashleeradka merged commit 8fc3f1e into main Apr 22, 2026
7 checks passed
@ashleeradka ashleeradka deleted the claude/vibrant-ritchie-5fd63f branch April 22, 2026 21:13
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