Skip to content

[AI-2533] app: Stabilize chat scrolling - #1470

Merged
nortonandreev merged 3 commits into
mainfrom
norton/ai-2533-stable-chat-scroll
Oct 15, 2026
Merged

nortonandreev merged 3 commits into
mainfrom
norton/ai-2533-stable-chat-scroll

Conversation

@nortonandreev

@nortonandreev nortonandreev commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #789 — AI-2533

What & why

Uses a chat-only virtualizing panel that remembers measured row heights instead of resizing the entire history estimate from the rows on screen. Keeps visible, focused, and recently-scrolled containers warm through layout corrections, preserves the reader during re-wrapping, and holds the thumb position during dragging. Discrete wheel notches interpolate across display refresh ticks via TopLevel.RequestAnimationFrame, and ScrollBar uses the global Kcap surface and thumb tokens. Returning to the estimated end resumes follow-tail without mistaking an anchor correction for reader input.

Where to look

Unknown heights remain estimated until measured. Resize corrections yield to later input; thumb handling and vsync wheel interpolation are scoped to the chat scrollbar. Container recycling and retained height estimates keep long histories virtualized.

Verification

Headless tests exercise wheel scrolling (including multi-frame vsync interpolation) and thumb dragging in both directions, pointer release, live appends, and resizing that changes row heights. Repeated before/after benchmark results are in the PR comment.

dotnet run --no-build --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/ChatTabViewSmokeTests/*' — 61 passed, 0 failed.

dotnet publish src/Capacitor.App/Capacitor.App.csproj -c Release -r osx-arm64 --self-contained — succeeded without warnings.

Summary by CodeRabbit

  • New Features
    • Chat history now scrolls more smoothly, with improved reading-position stability when resizing the window or new messages arrive.
    • Scrollbars now have a streamlined, animated appearance.
  • Bug Fixes
    • Improved scrolling through long, uneven chat histories to reduce jumps and unexpected reversals.

@linear-code

linear-code Bot commented Oct 14, 2026

Copy link
Copy Markdown

AI-2533

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: e4c643f6-4825-40ce-9123-11ae947557db
📥 Commits

Reviewing files that changed from the base of the PR and between d0b51cc and 95512de.

📒 Files selected for processing: 5
  • src/Capacitor.App/App.axaml
  • src/Capacitor.App/Views/ChatTabView.axaml
  • src/Capacitor.App/Views/ChatTabView.axaml.cs
  • src/Capacitor.App/Views/ChatVirtualizingPanel.cs
  • test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📝 Walkthrough

The chat list now uses a custom virtualizing panel that measures row heights and manages realized containers. The chat view handles wheel scrolling, scrollbar-thumb dragging, and follow-tail state. Scrollbar styles and smoke tests cover uneven-history scrolling and resizing.

Changes: Chat virtualization

Layer / File(s) Summary
Row measurement and virtualization
src/Capacitor.App/Views/ChatVirtualizingPanel.cs
The panel estimates unmeasured row heights, retains measured heights, and realizes, reuses, and positions containers. It also handles collection changes, scrolling to indexes, and directional navigation.
Scroll handling and reading-position preservation
src/Capacitor.App/Views/ChatTabView.axaml, src/Capacitor.App/Views/ChatTabView.axaml.cs, src/Capacitor.App/App.axaml, test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
The chat view uses the new panel and tracks wheel input, thumb position, and reader input. It restores the saved thumb fraction as the scroll range changes and updates follow-tail state. The panel can preserve a visible row's position during eligible width changes. Scrollbar styles set track and thumb appearance. Tests cover uneven-history scrolling, appends, resizing, and wheel animation.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Reader
  participant ChatTabView
  participant ChatVirtualizingPanel
  participant ScrollViewer
  Reader->>ChatTabView: Resize the chat window
  ChatTabView->>ChatVirtualizingPanel: Apply the new layout width
  ChatVirtualizingPanel->>ChatTabView: Check preservation eligibility and input version
  ChatTabView-->>ChatVirtualizingPanel: Return eligibility and input version
  ChatVirtualizingPanel->>ScrollViewer: Adjust offset by the anchor Y-position change
Loading

Suggested reviewers: alexeyzimarev

Merge Risk: ⚪ Minimal · up to 95512

The chat scrolling change has no confirmed merge-blocking defect in the supplied review. The PR description reports passing tests and a successful publish.

Security architecture risk: 🔵 Low · up to 95512

The inspected changes remain confined to rendering and scrolling, with no demonstrated expansion of privileges or data access. Conversation resets and view reattachment have some unresolved lifecycle behavior, so confidence is not complete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated reach is client-side presentation: chat containers and scrolling within the owning view, plus application-wide scrollbar appearance. No new service, credential, tenant-selection, or persistence operation was identified in the inspected production delta.

Trust Boundaries and Controls

  • observed — Wheel handling excludes handled or modified events and text editing, and defers to an inner viewer that can scroll. Thumb handling requires the thumb's scrollbar to belong to the chat ScrollViewer. Resize corrections reject changed item identity, later input, lost ancestry, or a missing top-level attachment.

Resilience and Maintainability Implications

  • observed — Detach and template replacement clear smooth-scroll residuals and thumb state, and detach drops toggle-anchor restoration. Separately queued panel resize corrections have identity and attachment checks but no attachment-generation token; their behavior after rapid reattachment of the same subtree remains unverified. The inspected callback writes only scroll position, so this uncertainty is not retained as a security finding.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 3 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: stabilizing chat scrolling through virtualization and scroll-position handling.
Linked Issues check Passed The changes satisfy the coding requirements in #789. ChatVirtualizingPanel retains measured row heights and preserves realized containers during corrections. ChatTabView restores the dragged thumb…
Out of Scope Changes check Passed The changes remain connected to chat scrolling. ChatTabView, ChatVirtualizingPanel, the related smoke tests, and scrollbar styling support virtualization, anchoring, thumb handling, and wheel beha…
Full details: Docstring Coverage

Explanation

Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 3 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

qodo-code-review Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

PR Summary by Qodo

Stabilize chat scrolling across uneven histories and resizing

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Preserve reading position while virtualized chat rows change height or new messages arrive.
• Smooth chat wheel scrolling and keep the scrollbar thumb stable during dragging.
• Apply Kcap scrollbar styling and test scrolling, resizing, and long-history virtualization.
Diagram

graph TD
    Input["Reader input"] --> View["Chat tab"] --> Scroll["Chat scroll"] --> Panel["Chat panel"] --> Cache["Height estimates"]
    Panel --> Rows["Realized rows"]
    Theme["Kcap theme"] --> Bar["Scrollbar"] --> Scroll
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Retain the standard virtualizing panel
  • ➕ Less custom container and navigation logic to maintain.
  • ➖ Its viewport-derived height estimate can shift the entire extent when uneven rows are measured, undermining stable scrolling.

Recommendation: Use the chat-specific panel: retained per-row heights address the extent instability at its source while keeping long histories virtualized. The standard panel is simpler, but view-level offset corrections alone would not stabilize its changing estimates.

Files changed (5) +662 / -15

Enhancement (1) +116 / -0
App.axamlApply global Kcap scrollbar styling +116/-0

Apply global Kcap scrollbar styling

• Adds scrollbar color resources and styles for transparent tracks, compact dimensions, and animated thumb states. The styling applies across the app, not only to chat.

src/Capacitor.App/App.axaml

Bug fix (3) +398 / -15
ChatTabView.axamlUse the chat-specific virtualizing panel +1/-1

Use the chat-specific virtualizing panel

• Replaces the standard items panel for chat history with ChatVirtualizingPanel, enabling persistent row-height estimates.

src/Capacitor.App/Views/ChatTabView.axaml

ChatTabView.axaml.csCoordinate smooth input and stable reading position +155/-14

Coordinate smooth input and stable reading position

• Interpolates chat wheel notches across animation frames, including notches over non-scrolling markdown viewers, while yielding to scrollable inner viewers and other input. Holds the thumb's relative position during dragging and distinguishes reader input from layout corrections when managing anchors and follow-tail.

src/Capacitor.App/Views/ChatTabView.axaml.cs

ChatVirtualizingPanel.csRetain measured heights in virtualized chat history +242/-0

Retain measured heights in virtualized chat history

• Introduces a panel that estimates unknown rows independently, retains measured heights across container recycling, and keeps relevant rows alive for anchoring and smooth scrolling. It also preserves reading position through width changes and supports item changes, scroll-into-view, and keyboard navigation.

src/Capacitor.App/Views/ChatVirtualizingPanel.cs

Tests (1) +148 / -0
ChatTabViewSmokeTests.csCover uneven-history scrolling and resize stability +148/-0

Cover uneven-history scrolling and resize stability

• Adds headless tests for wheel and thumb movement in both directions, row continuity, bounded realization, append behavior, and resize anchoring. A separate test verifies that wheel movement over markdown interpolates across multiple animation frames.

test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs

@nortonandreev

Copy link
Copy Markdown
Contributor Author

Benchmark results for the chat-only scrolling fix.

Baseline: 98da64fc (main); implementation: d0b51cc5. macOS arm64, .NET 10 Debug, real ChatTabView hosted in Avalonia Headless at 800×600. The transcript has 362 alternating long/short rows, including a short tail that hides an unmeasured tall reply. Each flow uses 80 actual wheel or thumb-drag input steps. Values are medians of five fresh test-process runs; initial transcript loading is excluded.

Flow Thumb reversals before → after Allocations MiB before → after Allocation change
Wheel up 3 → 0 10.13 → 10.06 -0.7%
Wheel down 3 → 0 8.43 → 8.49 +0.7%
Thumb drag up 20 → 0 39.20 → 37.56 -4.2%
Thumb drag down 40 → 0 133.39 → 31.67 -76.3%

All five after-runs have zero reversals and zero unexpected reading-row movements. Peak realization remains 7 of 362 rows, so virtualization stays bounded. Downward dragging allocates about 76% less; allocations in the other flows are approximately flat.

Elapsed time captures headless input dispatch and layout. Concurrent builds and substantial memory swapping produced large timing variation, so these timings do not establish a reliable latency improvement or regression:

Flow Before ms: median [min–max] After ms: median [min–max]
Wheel up 1000 [269–4111] 931 [500–1963]
Wheel down 562 [336–2929] 1142 [378–1566]
Thumb drag up 2337 [1237–10058] 4235 [1922–7546]
Thumb drag down 4002 [1036–11345] 2196 [1065–4578]

Reproduce the measured flows:

dotnet run --no-build --project test/Capacitor.App.Tests.Unit/Capacitor.App.Tests.Unit.csproj -- --treenode-filter '/*/*/ChatTabViewSmokeTests/Scrolling_uneven_history_does_not_reverse_the_scrollbar'

The resize regression also fails on main: narrowing the view enough to re-wrap replies recycles the reader's anchor. The fix retains the same anchor and its viewport position. Coverage includes pointer release before layout, appends after scrolling, follow-tail, tool expansion/folding, and editing/selection.

Validation: 3,158 app tests passed, 0 failed, including 60 chat interaction tests. One unchanged remote connect-ordering assertion failed on an earlier run, then passed in isolation and subsequent full runs. Desktop publish (-c Release -r osx-arm64 --self-contained) succeeded without warnings. Native startup was checked with kcap-app-dev; interaction measurements are headless because desktop UI automation was unavailable.

@qodo-code-review

qodo-code-review Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Chat scroll tests use keyed exclusion ✗ Dismissed
Description
The new scrolling tests use [NotInParallel("AvaloniaSession")] instead of the required bare
[NotInParallel] attribute. This affects the parameterized scrolling test and the resizing and
wheel-interpolation tests, so their console/UI isolation relies on a named group rather than the
mandated test-level annotation.
Code

test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[295]

+    [NotInParallel("AvaloniaSession")]
Relevance

●●● Strong

The finding identifies a precise compliance mismatch: newly added console-capturing tests use a
named instead of bare annotation.

PR-#525

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2741710 requires every test that captures, redirects, or asserts on console output
to use exactly a bare [NotInParallel] attribute. The newly added chat scrolling tests use the
grouped form [NotInParallel("AvaloniaSession")] instead, including the parameterized scrolling
test and the resizing and wheel-interpolation tests.

Rule 2741710: Annotate console-output-capturing tests with bare [NotInParallel]
test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[290-393]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added chat scrolling tests use `[NotInParallel("AvaloniaSession")]`, but console/UI-sensitive tests must use exactly the bare `[NotInParallel]` attribute without a group argument.

## Fix Focus Areas
- test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[290-393]

## Recommended Fix
Replace each newly added `[NotInParallel("AvaloniaSession")]` annotation on the scrolling, resizing, and wheel-interpolation tests with the bare `[NotInParallel]` attribute.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Toggled group can still jump the reader 🐞 Bug ≡ Correctness
Description
RestoreToggleAnchor now calls DropToggleAnchor() the first time the toolSummary header is within
0.5px of its captured Y, where it used to call FinishIfExhausted() and keep the anchor for up to 8
passes. ArmToggleRestore also posts a Render-priority restore that marks _anchorLaidOut and runs
right away, so a check that happens before the height change has been laid out, or between several
layout passes while a group's height settles, sees delta≈0 and releases the anchor. The later height
change then reaches OnScrollChanged and the panel with no anchor protecting the reader.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R282-285]

        if (Math.Abs(delta) < 0.5) {
-            FinishIfExhausted();
+            DropToggleAnchor();
            return;
        }
Relevance

●●● Strong

Dropping the anchor before layout settles can clearly expose later height changes; preserving reader
position is central to this PR.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ArmToggleRestore sets _anchorPasses = 8 and both subscribes to LayoutUpdated and posts an
immediate restore, so the anchor is clearly meant to survive several checks. The changed branch at
line 283 ends it on the first check where the header hasn't moved, including checks before any
layout has applied the toggle. While _anchorItem is null, OnScrollChanged no longer returns
early.

src/Capacitor.App/Views/ChatTabView.axaml.cs[248-258]
src/Capacitor.App/Views/ChatTabView.axaml.cs[266-297]
src/Capacitor.App/Views/ChatTabView.axaml.cs[328-330]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The toggle anchor is released on the first check where the header hasn't moved. That check can come before the toggled group's new height has been laid out, so the reader is left unprotected when it is.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[282-285]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[248-258]

## Recommended Fix
Go back to `FinishIfExhausted()` for the delta < 0.5 case, or drop early only after a LayoutUpdated has been seen since arming and the header has stayed still for two passes in a row. Add a test where a group expands over more than one layout pass while the reader is scrolled up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


3. Row navigation can jump to chat start ✓ Resolved
Description
GetControl passes from directly to IndexFromContainer, which recognizes only an exact row
container rather than a focused control inside it. When navigation originates from a button or
selectable text within a chat row, Down or Next computes its destination from index -1 and selects
the first row instead of the adjacent one.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R220-225]

+        var index = from is Control control ? IndexFromContainer(control) : -1;
+        int? next = direction switch {
+            NavigationDirection.First => 0,
+            NavigationDirection.Last => Items.Count - 1,
+            NavigationDirection.Up or NavigationDirection.Previous => index - 1,
+            NavigationDirection.Down or NavigationDirection.Next => index + 1,
Relevance

●●● Strong

Descendant focus controls produce index -1, making adjacent navigation deterministically select the
wrong row.

PR-#1144

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IndexFromContainer compares only container references, while the templates put focusable controls
within those containers. A descendant therefore produces -1, from which the navigation switch
calculates the wrong target.

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[212-232]
src/Capacitor.App/Views/ChatTabView.axaml[226-227]
src/Capacitor.App/Views/ChatTabView.axaml[277-283]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Relative navigation uses an invalid index when its starting control is inside a realized row.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[212-232]
## Recommended Fix
Before calculating a relative navigation target, walk from the starting control to its realized row container. Preserve the current behavior for inputs that are not inside a realized row.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (4)
4. Keyboard scrolling fights wheel motion ✓ Resolved
Description
OnReaderGesture leaves a pending smooth-wheel animation running for key events and returns before
cancelling it for input inside a text box. If the reader presses a scrolling key or starts editing
after a wheel notch, later animation frames continue changing the list offset after that newer
input.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R108-109]

+        if (e.RoutedEvent == PointerWheelChangedEvent || e.RoutedEvent == ScrollGestureEvent || e is KeyEventArgs) _thumbFraction = null;
+        if (e.RoutedEvent == ScrollGestureEvent || e.RoutedEvent == PointerPressedEvent) CancelSmoothScroll();
Relevance

●●● Strong

Keyboard gesture handling has recently been corrected for scroll keys; pending animation
cancellation is a clear correctness fix.

PR-#1174

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The list routes key events to OnReaderGesture; that handler neither cancels on keys nor reaches
cancellation for text-box-originated events. A queued frame subsequently applies _smoothRemaining
to the same scroll offset.

src/Capacitor.App/Views/ChatTabView.axaml.cs[50-60]
src/Capacitor.App/Views/ChatTabView.axaml.cs[103-109]
src/Capacitor.App/Views/ChatTabView.axaml.cs[149-183]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Keyboard input and interactions inside a list text box can leave an earlier wheel animation running.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[103-109]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[149-183]
## Recommended Fix
Cancel pending smooth-wheel movement when a newer keyboard or text-box interaction takes control, without treating text-box editing as a follow-tail gesture.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Smooth wheel scrolling skips markdown messages ✓ Resolved
Description
OnChatWheelChanged gives up whenever OriginatesFromInnerScrollViewer finds any ScrollViewer
other than the chat's own between the wheel source and the list. Every MarkdownView row hosts a
MarkdownViewer whose template has its own ScrollViewer (with scrolling disabled), so a wheel
over assistant or user markdown, which is most of a chat, falls through to the outer ScrollViewer's
instant 50px jump. That also skips the smooth-scroll state the panel uses to keep the 240px overscan
warm.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R190-191]

+    bool OriginatesFromInnerScrollViewer(RoutedEventArgs e) =>
+        e.Source is Visual visual && visual.GetSelfAndVisualAncestors().OfType<ScrollViewer>().FirstOrDefault() is { } inner && !ReferenceEquals(inner, _scroll);
Relevance

●●● Strong

The handler incorrectly treats disabled nested viewers as scroll owners, directly undermining this
PR's smooth-scrolling intent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
MarkdownView states that the viewer's template owns a ScrollViewer and only disables its scrollbars.
OriginatesFromInnerScrollViewer returns true for the first ScrollViewer ancestor that isn't
_scroll, regardless of whether it can scroll, so line 124 returns early. The event is left
unhandled, the disabled inner viewer can't consume it, and it bubbles to the chat ScrollViewer's
default non-smooth handling.

src/Capacitor.App/Views/MarkdownView.cs[51-53]
src/Capacitor.App/Views/ChatTabView.axaml.cs[121-129]
src/Capacitor.App/Views/ChatTabView.axaml.cs[190-191]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`OriginatesFromInnerScrollViewer` treats any nested ScrollViewer as the owner of the wheel. Markdown rows nest a ScrollViewer with scrolling disabled, so vsync smooth scrolling never runs over markdown content.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[190-191]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[121-129]

## Recommended Fix
Walk up the ancestors from the source to `_scroll`. Defer only to an inner ScrollViewer that can actually scroll vertically in the wheel's direction (its Extent.Height is greater than Viewport.Height, it isn't already at that edge, and its VerticalScrollBarVisibility isn't Disabled). Skip inner viewers that can't scroll. Add a smoke test that wheels over a markdown row and checks that smooth scrolling runs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Measured rows drift after height changes ✓ Resolved
Description
MeasureOverride adjusts _starts[i] before computing the next row's oldHeight, so the next
measurement uses an already-shifted boundary and the accumulated correction is applied twice. When a
realized row changes height while the reader is scrolled up or the scrollbar is being tracked, later
row positions and the extent become incorrect, moving the visible item and thumb.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R76-87]

+            var oldHeight = _starts[i + 1] - _starts[i];
+            _starts[i] += adjustment;
+            var row = Realize(i);
+            row.Container.Measure(new Size(availableSize.Width, double.PositiveInfinity));
+            var height = row.Container.DesiredSize.Height;
+            adjustment += height - oldHeight;
+            _measured[row.Item] = height;
+            width = Math.Max(width, row.Container.DesiredSize.Width);
+            last = i;
+        }
+        if (adjustment != 0)
+            for (var i = last + 1; i < _starts.Length; i++) _starts[i] += adjustment;
Relevance

●●● Strong

Accepted chat-scrolling precedents favor correcting subtle layout and anchor-position bugs in this
area.

PR-#1174
PR-#1189

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The measurement loop changes _starts[i] before using _starts[i + 1] - _starts[i] for the next
row, then applies the final accumulated adjustment again to later entries. This violates the
requirement that row height changes preserve the reader's visible item and avoid scrollbar jumps.

Preserve the scrolled-up reader's position when chat rows change height
Prevent scrollbar jumps during chat row height changes
src/Capacitor.App/Views/ChatVirtualizingPanel.cs[76-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MeasureOverride` mutates `_starts[i]` while measuring rows, then computes subsequent row heights from boundaries that already include part of the accumulated correction. This double-applies height changes and can move visible rows or distort the scrollbar extent.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[76-87]

## Recommended Fix
Compute each row's old height from an unmodified copy of the start positions, or update the start array in a separate correction pass so every measured delta is applied exactly once. Preserve the existing anchor and extent behavior after correcting the accumulated offsets, and keep the uneven-height scrolling tests passing in both directions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Long chats slow down on each append 🐞 Bug ➹ Performance
Description
OnItemsChanged calls RebuildStarts() even for an append, allocating and scanning a prefix array
for the entire transcript. The view model can append fresh rows on successive 500 ms feed polls, so
a long, incrementally growing chat repeatedly rebuilds its full history on the UI thread.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R195-196]

+        RebuildStarts();
+        InvalidateMeasure();
Relevance

●● Moderate

The repeated full rebuild is plausible performance debt, but optimizing append bookkeeping changes
panel architecture.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The panel identifies append-only events but still performs a full-array allocation and scan. The
chat feed periodically supplies fresh rows to the same items collection.

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[42-46]
src/Capacitor.App/Views/ChatVirtualizingPanel.cs[172-196]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[991-997]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[525-530]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Every transcript append rebuilds and reallocates the complete row-position array.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[42-46]
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[172-196]
## Recommended Fix
For append-only collection changes, preserve existing row starts and measured heights and calculate starts only for new rows. Keep the full rebuild for changes that reindex existing items.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗



Informational

8. The merge subject has no valid issue format ⊘ Outdated
Description
The subject [AI-2533] app: Stabilize chat scrolling contains a Linear-style issue token and does
not end with the required GitHub issue reference form (#digits). The same subject therefore fails
the repository's commit-subject format and PR-title issue-reference requirements.
Code

src/Capacitor.App/Views/ChatTabView.axaml[89]

+                    <views:ChatVirtualizingPanel />
Relevance

● Weak

Recent guidance clarifies Linear IDs may appear in titles; GitHub and Linear references belong in
the description.

PR-#659

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The supplied PR title is [AI-2533] app: Stabilize chat scrolling. It contains an explicit Linear
issue key, lacks a trailing (#digits) reference, and therefore violates the referenced title and
commit-subject checklist items.

Rule 2897961: Enforce single-clause imperative commit subject with GitHub issue reference and 80-char limit
Rule 2897978: Format PR titles as imperative commit subjects without explicit issue references
Rule 2897991: PR description must contain both GitHub and Linear issue references; PR title must not contain issue IDs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR title and commit subject use `[AI-2533]` and do not match the required single-clause subject format with a trailing GitHub issue reference.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml[89-89]

## Recommended Fix
Rename the PR and merge subject to one imperative clause with no issue token in the title, and use the required trailing `(#number)` form for the commit subject where that rule applies. Keep the GitHub closing reference and Linear key together on the dedicated reference line in the PR description.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 63 rules
✅ Cross-repo context — repo relationships
Review mode: Auto: 🧠 Deep: Custom virtualization plus independent wheel, thumb, anchoring, and layout paths.
✅ Fix-loop round: fix loop

Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 95512de

Results up to commit d0b51cc ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Measured rows drift after height changes ✓ Resolved
Description
MeasureOverride adjusts _starts[i] before computing the next row's oldHeight, so the next
measurement uses an already-shifted boundary and the accumulated correction is applied twice. When a
realized row changes height while the reader is scrolled up or the scrollbar is being tracked, later
row positions and the extent become incorrect, moving the visible item and thumb.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R76-87]

+            var oldHeight = _starts[i + 1] - _starts[i];
+            _starts[i] += adjustment;
+            var row = Realize(i);
+            row.Container.Measure(new Size(availableSize.Width, double.PositiveInfinity));
+            var height = row.Container.DesiredSize.Height;
+            adjustment += height - oldHeight;
+            _measured[row.Item] = height;
+            width = Math.Max(width, row.Container.DesiredSize.Width);
+            last = i;
+        }
+        if (adjustment != 0)
+            for (var i = last + 1; i < _starts.Length; i++) _starts[i] += adjustment;
Relevance

●●● Strong

Accepted chat-scrolling precedents favor correcting subtle layout and anchor-position bugs in this
area.

PR-#1174
PR-#1189

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The measurement loop changes _starts[i] before using _starts[i + 1] - _starts[i] for the next
row, then applies the final accumulated adjustment again to later entries. This violates the
requirement that row height changes preserve the reader's visible item and avoid scrollbar jumps.

Preserve the scrolled-up reader's position when chat rows change height
Prevent scrollbar jumps during chat row height changes
src/Capacitor.App/Views/ChatVirtualizingPanel.cs[76-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MeasureOverride` mutates `_starts[i]` while measuring rows, then computes subsequent row heights from boundaries that already include part of the accumulated correction. This double-applies height changes and can move visible rows or distort the scrollbar extent.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[76-87]

## Recommended Fix
Compute each row's old height from an unmodified copy of the start positions, or update the start array in a separate correction pass so every measured delta is applied exactly once. Preserve the existing anchor and extent behavior after correcting the accumulated offsets, and keep the uneven-height scrolling tests passing in both directions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
2. The merge subject has no valid issue format ⊘ Outdated
Description
The subject [AI-2533] app: Stabilize chat scrolling contains a Linear-style issue token and does
not end with the required GitHub issue reference form (#digits). The same subject therefore fails
the repository's commit-subject format and PR-title issue-reference requirements.
Code

src/Capacitor.App/Views/ChatTabView.axaml[89]

+                    <views:ChatVirtualizingPanel />
Relevance

● Weak

Recent guidance clarifies Linear IDs may appear in titles; GitHub and Linear references belong in
the description.

PR-#659

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The supplied PR title is [AI-2533] app: Stabilize chat scrolling. It contains an explicit Linear
issue key, lacks a trailing (#digits) reference, and therefore violates the referenced title and
commit-subject checklist items.

Rule 2897961: Enforce single-clause imperative commit subject with GitHub issue reference and 80-char limit
Rule 2897978: Format PR titles as imperative commit subjects without explicit issue references
Rule 2897991: PR description must contain both GitHub and Linear issue references; PR title must not contain issue IDs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR title and commit subject use `[AI-2533]` and do not match the required single-clause subject format with a trailing GitHub issue reference.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml[89-89]

## Recommended Fix
Rename the PR and merge subject to one imperative clause with no issue token in the title, and use the required trailing `(#number)` form for the commit subject where that rule applies. Keep the GitHub closing reference and Linear key together on the dedicated reference line in the PR description.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit 7a26362 🧠 Deep


🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Row navigation can jump to chat start ✓ Resolved
Description
GetControl passes from directly to IndexFromContainer, which recognizes only an exact row
container rather than a focused control inside it. When navigation originates from a button or
selectable text within a chat row, Down or Next computes its destination from index -1 and selects
the first row instead of the adjacent one.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R220-225]

+        var index = from is Control control ? IndexFromContainer(control) : -1;
+        int? next = direction switch {
+            NavigationDirection.First => 0,
+            NavigationDirection.Last => Items.Count - 1,
+            NavigationDirection.Up or NavigationDirection.Previous => index - 1,
+            NavigationDirection.Down or NavigationDirection.Next => index + 1,
Relevance

●●● Strong

Descendant focus controls produce index -1, making adjacent navigation deterministically select the
wrong row.

PR-#1144

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IndexFromContainer compares only container references, while the templates put focusable controls
within those containers. A descendant therefore produces -1, from which the navigation switch
calculates the wrong target.

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[212-232]
src/Capacitor.App/Views/ChatTabView.axaml[226-227]
src/Capacitor.App/Views/ChatTabView.axaml[277-283]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Relative navigation uses an invalid index when its starting control is inside a realized row.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[212-232]
## Recommended Fix
Before calculating a relative navigation target, walk from the starting control to its realized row container. Preserve the current behavior for inputs that are not inside a realized row.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Keyboard scrolling fights wheel motion ✓ Resolved
Description
OnReaderGesture leaves a pending smooth-wheel animation running for key events and returns before
cancelling it for input inside a text box. If the reader presses a scrolling key or starts editing
after a wheel notch, later animation frames continue changing the list offset after that newer
input.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R108-109]

+        if (e.RoutedEvent == PointerWheelChangedEvent || e.RoutedEvent == ScrollGestureEvent || e is KeyEventArgs) _thumbFraction = null;
+        if (e.RoutedEvent == ScrollGestureEvent || e.RoutedEvent == PointerPressedEvent) CancelSmoothScroll();
Relevance

●●● Strong

Keyboard gesture handling has recently been corrected for scroll keys; pending animation
cancellation is a clear correctness fix.

PR-#1174

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The list routes key events to OnReaderGesture; that handler neither cancels on keys nor reaches
cancellation for text-box-originated events. A queued frame subsequently applies _smoothRemaining
to the same scroll offset.

src/Capacitor.App/Views/ChatTabView.axaml.cs[50-60]
src/Capacitor.App/Views/ChatTabView.axaml.cs[103-109]
src/Capacitor.App/Views/ChatTabView.axaml.cs[149-183]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Keyboard input and interactions inside a list text box can leave an earlier wheel animation running.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[103-109]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[149-183]
## Recommended Fix
Cancel pending smooth-wheel movement when a newer keyboard or text-box interaction takes control, without treating text-box editing as a follow-tail gesture.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Smooth wheel scrolling skips markdown messages ✓ Resolved
Description
OnChatWheelChanged gives up whenever OriginatesFromInnerScrollViewer finds any ScrollViewer
other than the chat's own between the wheel source and the list. Every MarkdownView row hosts a
MarkdownViewer whose template has its own ScrollViewer (with scrolling disabled), so a wheel
over assistant or user markdown, which is most of a chat, falls through to the outer ScrollViewer's
instant 50px jump. That also skips the smooth-scroll state the panel uses to keep the 240px overscan
warm.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R190-191]

+    bool OriginatesFromInnerScrollViewer(RoutedEventArgs e) =>
+        e.Source is Visual visual && visual.GetSelfAndVisualAncestors().OfType<ScrollViewer>().FirstOrDefault() is { } inner && !ReferenceEquals(inner, _scroll);
Relevance

●●● Strong

The handler incorrectly treats disabled nested viewers as scroll owners, directly undermining this
PR's smooth-scrolling intent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
MarkdownView states that the viewer's template owns a ScrollViewer and only disables its scrollbars.
OriginatesFromInnerScrollViewer returns true for the first ScrollViewer ancestor that isn't
_scroll, regardless of whether it can scroll, so line 124 returns early. The event is left
unhandled, the disabled inner viewer can't consume it, and it bubbles to the chat ScrollViewer's
default non-smooth handling.

src/Capacitor.App/Views/MarkdownView.cs[51-53]
src/Capacitor.App/Views/ChatTabView.axaml.cs[121-129]
src/Capacitor.App/Views/ChatTabView.axaml.cs[190-191]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`OriginatesFromInnerScrollViewer` treats any nested ScrollViewer as the owner of the wheel. Markdown rows nest a ScrollViewer with scrolling disabled, so vsync smooth scrolling never runs over markdown content.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[190-191]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[121-129]

## Recommended Fix
Walk up the ancestors from the source to `_scroll`. Defer only to an inner ScrollViewer that can actually scroll vertically in the wheel's direction (its Extent.Height is greater than Viewport.Height, it isn't already at that edge, and its VerticalScrollBarVisibility isn't Disabled). Skip inner viewers that can't scroll. Add a smoke test that wheels over a markdown row and checks that smooth scrolling runs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
4. Chat scroll tests use keyed exclusion ✗ Dismissed
Description
The new scrolling tests use [NotInParallel("AvaloniaSession")] instead of the required bare
[NotInParallel] attribute. This affects the parameterized scrolling test and the resizing and
wheel-interpolation tests, so their console/UI isolation relies on a named group rather than the
mandated test-level annotation.
Code

test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[295]

+    [NotInParallel("AvaloniaSession")]
Relevance

●●● Strong

The finding identifies a precise compliance mismatch: newly added console-capturing tests use a
named instead of bare annotation.

PR-#525

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2741710 requires every test that captures, redirects, or asserts on console output
to use exactly a bare [NotInParallel] attribute. The newly added chat scrolling tests use the
grouped form [NotInParallel("AvaloniaSession")] instead, including the parameterized scrolling
test and the resizing and wheel-interpolation tests.

Rule 2741710: Annotate console-output-capturing tests with bare [NotInParallel]
test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[290-393]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added chat scrolling tests use `[NotInParallel("AvaloniaSession")]`, but console/UI-sensitive tests must use exactly the bare `[NotInParallel]` attribute without a group argument.

## Fix Focus Areas
- test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs[290-393]

## Recommended Fix
Replace each newly added `[NotInParallel("AvaloniaSession")]` annotation on the scrolling, resizing, and wheel-interpolation tests with the bare `[NotInParallel]` attribute.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Toggled group can still jump the reader 🐞 Bug ≡ Correctness
Description
RestoreToggleAnchor now calls DropToggleAnchor() the first time the toolSummary header is within
0.5px of its captured Y, where it used to call FinishIfExhausted() and keep the anchor for up to 8
passes. ArmToggleRestore also posts a Render-priority restore that marks _anchorLaidOut and runs
right away, so a check that happens before the height change has been laid out, or between several
layout passes while a group's height settles, sees delta≈0 and releases the anchor. The later height
change then reaches OnScrollChanged and the panel with no anchor protecting the reader.
Code

src/Capacitor.App/Views/ChatTabView.axaml.cs[R282-285]

        if (Math.Abs(delta) < 0.5) {
-            FinishIfExhausted();
+            DropToggleAnchor();
            return;
        }
Relevance

●●● Strong

Dropping the anchor before layout settles can clearly expose later height changes; preserving reader
position is central to this PR.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ArmToggleRestore sets _anchorPasses = 8 and both subscribes to LayoutUpdated and posts an
immediate restore, so the anchor is clearly meant to survive several checks. The changed branch at
line 283 ends it on the first check where the header hasn't moved, including checks before any
layout has applied the toggle. While _anchorItem is null, OnScrollChanged no longer returns
early.

src/Capacitor.App/Views/ChatTabView.axaml.cs[248-258]
src/Capacitor.App/Views/ChatTabView.axaml.cs[266-297]
src/Capacitor.App/Views/ChatTabView.axaml.cs[328-330]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The toggle anchor is released on the first check where the header hasn't moved. That check can come before the toggled group's new height has been laid out, so the reader is left unprotected when it is.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[282-285]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[248-258]

## Recommended Fix
Go back to `FinishIfExhausted()` for the delta < 0.5 case, or drop early only after a LayoutUpdated has been seen since arming and the header has stayed still for two passes in a row. Add a test where a group expands over more than one layout pass while the reader is scrolled up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


6. Long chats slow down on each append 🐞 Bug ➹ Performance
Description
OnItemsChanged calls RebuildStarts() even for an append, allocating and scanning a prefix array
for the entire transcript. The view model can append fresh rows on successive 500 ms feed polls, so
a long, incrementally growing chat repeatedly rebuilds its full history on the UI thread.
Code

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[R195-196]

+        RebuildStarts();
+        InvalidateMeasure();
Relevance

●● Moderate

The repeated full rebuild is plausible performance debt, but optimizing append bookkeeping changes
panel architecture.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The panel identifies append-only events but still performs a full-array allocation and scan. The
chat feed periodically supplies fresh rows to the same items collection.

src/Capacitor.App/Views/ChatVirtualizingPanel.cs[42-46]
src/Capacitor.App/Views/ChatVirtualizingPanel.cs[172-196]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[991-997]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[525-530]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Every transcript append rebuilds and reallocates the complete row-position array.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[42-46]
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[172-196]
## Recommended Fix
For append-only collection changes, preserve existing row starts and measured heights and calculate starts only for new rows. Keep the full rebuild for changes that reindex existing items.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Grey Divider

Qodo Logo

Comment thread src/Capacitor.App/Views/ChatVirtualizingPanel.cs Outdated
@nortonandreev nortonandreev self-assigned this Oct 14, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 14, 2026
@nortonandreev
nortonandreev marked this pull request as draft October 14, 2026 21:22
@nortonandreev
nortonandreev force-pushed the norton/ai-2533-stable-chat-scroll branch from d0b51cc to 3344078 Compare October 14, 2026 21:27
@nortonandreev
nortonandreev force-pushed the norton/ai-2533-stable-chat-scroll branch 4 times, most recently from e66a57f to 7a26362 Compare October 14, 2026 22:33
@nortonandreev
nortonandreev marked this pull request as ready for review October 14, 2026 22:35
@nortonandreev
nortonandreev force-pushed the norton/ai-2533-stable-chat-scroll branch from 7a26362 to dc138e6 Compare October 14, 2026 22:36
@nortonandreev
nortonandreev force-pushed the norton/ai-2533-stable-chat-scroll branch from dc138e6 to 67db57e Compare October 14, 2026 22:38
Comment thread test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
Comment thread src/Capacitor.App/Views/ChatTabView.axaml.cs Outdated
Comment on lines +195 to +196
RebuildStarts();
InvalidateMeasure();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. Long chats slow down on each append 🐞 Bug ➹ Performance

OnItemsChanged calls RebuildStarts() even for an append, allocating and scanning a prefix array
for the entire transcript. The view model can append fresh rows on successive 500 ms feed polls, so
a long, incrementally growing chat repeatedly rebuilds its full history on the UI thread.
Agent Prompt
## Issue description
Every transcript append rebuilds and reallocates the complete row-position array.
## Fix Focus Areas
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[42-46]
- src/Capacitor.App/Views/ChatVirtualizingPanel.cs[172-196]
## Recommended Fix
For append-only collection changes, preserve existing row starts and measured heights and calculate starts only for new rows. Keep the full rebuild for changes that reindex existing items.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

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.

An append already skips the reindex. RebuildStarts walks the cached heights into one prefix array; it does not remeasure rows. That is a few thousand doubles on the feed poll, not a layout of the history.

Comment thread src/Capacitor.App/Views/ChatVirtualizingPanel.cs Outdated
Comment thread src/Capacitor.App/Views/ChatTabView.axaml.cs Outdated
Comment on lines 282 to 285
if (Math.Abs(delta) < 0.5) {
FinishIfExhausted();
DropToggleAnchor();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

5. Toggled group can still jump the reader 🐞 Bug ≡ Correctness

RestoreToggleAnchor now calls DropToggleAnchor() the first time the toolSummary header is within
0.5px of its captured Y, where it used to call FinishIfExhausted() and keep the anchor for up to 8
passes. ArmToggleRestore also posts a Render-priority restore that marks _anchorLaidOut and runs
right away, so a check that happens before the height change has been laid out, or between several
layout passes while a group's height settles, sees delta≈0 and releases the anchor. The later height
change then reaches OnScrollChanged and the panel with no anchor protecting the reader.
Agent Prompt
## Issue description
The toggle anchor is released on the first check where the header hasn't moved. That check can come before the toggled group's new height has been laid out, so the reader is left unprotected when it is.

## Fix Focus Areas
- src/Capacitor.App/Views/ChatTabView.axaml.cs[282-285]
- src/Capacitor.App/Views/ChatTabView.axaml.cs[248-258]

## Recommended Fix
Go back to `FinishIfExhausted()` for the delta < 0.5 case, or drop early only after a LayoutUpdated has been seen since arming and the header has stayed still for two passes in a row. Add a test where a group expands over more than one layout pass while the reader is scrolled up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

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.

The first still frame is the header already sitting where it was captured, so the anchor is released there. Holding it for the remaining passes lets a later layout write the offset. Expanding_and_collapsing_a_group_keeps_its_header_where_it_was still pins the header through expand and collapse.

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 7a26362

@nortonandreev
nortonandreev marked this pull request as draft October 14, 2026 22:40
A markdown row hosts a ScrollViewer with scrolling disabled, so a notch
there has to move the chat. A key or a list text box cancels that motion
without arming follow-tail.
@nortonandreev
nortonandreev marked this pull request as ready for review October 14, 2026 22:58
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 95512de

@nortonandreev
nortonandreev merged commit 12045be into main Oct 15, 2026
17 of 18 checks passed
@nortonandreev
nortonandreev deleted the norton/ai-2533-stable-chat-scroll branch October 15, 2026 09:47
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.

Virtualized chat moves a scrolled-up reader when a realized row changes height

1 participant