From d7212a19fe38794a148fb3e7b02f47fcb70752ae Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:56:07 +0000 Subject: [PATCH 1/3] fix: use >= comparison in image height cap to prevent layout oscillation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The > operator causes an infinite feedback loop for images exactly 300pt tall: GeometryReader reports 300 → 300 > 300 is false → frame(height: nil) → image expands to natural height → GeometryReader reports >300 → frame(height: 300) → GeometryReader reports 300 → loop repeats. Using >= stabilizes the loop: once capped at 300, 300 >= 300 stays true, so the frame stays at 300 and GeometryReader reports no change. Co-Authored-By: Jason Zhou --- .../shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift index 77d4845f1fb..614d65c8fd0 100644 --- a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift +++ b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift @@ -63,7 +63,7 @@ public struct InlineImageEmbedView: View { } ) .onPreferenceChange(ImageHeightKey.self) { imageIntrinsicHeight = $0 } - .frame(height: imageIntrinsicHeight > 300 ? 300 : nil) + .frame(height: imageIntrinsicHeight >= 300 ? 300 : nil) .clipped() .clipShape(RoundedRectangle(cornerRadius: 8)) .onAppear { isVisible = true } From 407d014cae35b2623b2463be423521393adc9d44 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:05:58 +0000 Subject: [PATCH 2/3] fix: replace GeometryReader height cap with HeightCapLayout to eliminate flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GeometryReader + PreferenceKey pattern requires a state round-trip: first render has imageIntrinsicHeight=0, so the image renders at full natural height, then GeometryReader measures and triggers a second render with the cap applied — visible as a single-frame flicker. HeightCapLayout uses the Layout protocol to measure the child once, cap the reported height, and place within capped bounds — all in a single layout pass. No @State, no PreferenceKey, no flicker. This also removes the >= vs > oscillation bug entirely since there is no feedback loop to oscillate. Co-Authored-By: Jason Zhou --- .../MediaEmbeds/InlineImageEmbedView.swift | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift index 614d65c8fd0..e8a9d6fc6e5 100644 --- a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift +++ b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift @@ -15,10 +15,29 @@ import AppKit /// image at once. /// /// Tapping the image opens the URL in the user's default browser. -private struct ImageHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() + +/// Caps content height at a maximum using the Layout protocol (O(1)). +/// Unlike `.frame(maxHeight:)` which creates `_FlexFrameLayout` with its +/// O(n × depth) alignment cascade, this measures the child once, caps the +/// reported height, and places the child within the capped bounds. +/// Unlike GeometryReader + PreferenceKey, this resolves in a single layout +/// pass — no @State round-trip, so no first-render flicker. +private struct HeightCapLayout: Layout { + let cap: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let child = subviews.first else { return .zero } + let childSize = child.sizeThatFits(proposal) + return CGSize(width: childSize.width, height: min(cap, childSize.height)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let child = subviews.first else { return } + child.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) } } @@ -32,38 +51,31 @@ public struct InlineImageEmbedView: View { /// Flipped to `true` by `onAppear`; prevents eager network fetches /// for images that are off-screen in long chat histories. @State private var isVisible = false - /// Tracks intrinsic image height for capping without `_FlexFrameLayout`. - @State private var imageIntrinsicHeight: CGFloat = 0 public var body: some View { - Group { - if isVisible { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - placeholderSkeleton - .overlay(ProgressView()) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - case .failure: - EmptyView() - @unknown default: - EmptyView() + HeightCapLayout(cap: 300) { + Group { + if isVisible { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + placeholderSkeleton + .overlay(ProgressView()) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + EmptyView() + @unknown default: + EmptyView() + } } + } else { + placeholderSkeleton } - } else { - placeholderSkeleton } } - .background( - GeometryReader { geo in - Color.clear.preference(key: ImageHeightKey.self, value: geo.size.height) - } - ) - .onPreferenceChange(ImageHeightKey.self) { imageIntrinsicHeight = $0 } - .frame(height: imageIntrinsicHeight >= 300 ? 300 : nil) .clipped() .clipShape(RoundedRectangle(cornerRadius: 8)) .onAppear { isVisible = true } From 1327ae9d0903d7ea3021bdc8d8e50ea4d424cf31 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:19:23 +0000 Subject: [PATCH 3/3] fix: re-measure child with capped height to eliminate horizontal flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When HeightCapLayout caps a tall image, the width from the uncapped measurement doesn't account for aspect ratio at the capped height. A resizable image with .fit needs to narrow when height is reduced. Now sizeThatFits re-measures the child with the capped height as the proposal, so the child can report the correct width for its aspect ratio in the same layout pass — no horizontal settling. Co-Authored-By: Jason Zhou --- .../Chat/MediaEmbeds/InlineImageEmbedView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift index e8a9d6fc6e5..c5c80e9ca59 100644 --- a/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift +++ b/clients/shared/Features/Chat/MediaEmbeds/InlineImageEmbedView.swift @@ -18,8 +18,9 @@ import AppKit /// Caps content height at a maximum using the Layout protocol (O(1)). /// Unlike `.frame(maxHeight:)` which creates `_FlexFrameLayout` with its -/// O(n × depth) alignment cascade, this measures the child once, caps the -/// reported height, and places the child within the capped bounds. +/// O(n × depth) alignment cascade, this measures the child, caps the +/// reported height, and re-measures with the capped height so the child +/// can adjust its width (e.g. to preserve aspect ratio). /// Unlike GeometryReader + PreferenceKey, this resolves in a single layout /// pass — no @State round-trip, so no first-render flicker. private struct HeightCapLayout: Layout { @@ -28,7 +29,11 @@ private struct HeightCapLayout: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { guard let child = subviews.first else { return .zero } let childSize = child.sizeThatFits(proposal) - return CGSize(width: childSize.width, height: min(cap, childSize.height)) + guard childSize.height > cap else { return childSize } + // Re-measure with capped height so the child can adjust its width + // (e.g. a resizable image with .fit will narrow to preserve aspect ratio). + let cappedSize = child.sizeThatFits(ProposedViewSize(width: proposal.width, height: cap)) + return CGSize(width: cappedSize.width, height: min(cap, cappedSize.height)) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {