Skip to content

fix(desktop): stabilize native transcript scrolling / 修复聊天区原生滚动跳页与卡顿 - #8264

Merged
SivanCola merged 6 commits into
esengine:main-v2from
SivanCola:fix/desktop-transcript-trackpad-scroll
Aug 11, 2026
Merged

fix(desktop): stabilize native transcript scrolling / 修复聊天区原生滚动跳页与卡顿#8264
SivanCola merged 6 commits into
esengine:main-v2from
SivanCola:fix/desktop-transcript-trackpad-scroll

Conversation

@SivanCola

@SivanCola SivanCola commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Long variable-height transcripts could feel sticky, reverse direction, jump by a virtual page, or stop following the final answer when native scrolling overlapped TanStack measurement compensation, streaming tail-follow, or resize repins. This PR makes native input a first-class scroll owner, keeps compensating writers silent until the gesture has actually settled, and replays a still-valid tail repin once after idle instead of dropping it.

It also preserves the original large-table and nested-scroll improvements while integrating the latest main-v2 transcript selection and virtualization work.

User-visible changes

  • macOS trackpad inertia is no longer fought by virtualizer, stream, row-size, footer, or container scroll writes.
  • Mouse wheels, native scrollbar drags, touch scrolling, keyboard scrolling, pen/middle-button auto-scroll, and nested table/code scrolling use the same ownership rules across macOS, Windows, and Linux webviews.
  • A downward wheel over a table or code block at the physical bottom no longer accidentally disables streaming tail-follow.
  • If the final token, async Markdown render, image, or measured row growth lands during a harmless downward gesture, the viewport stays still during the gesture and catches up exactly once after idle.
  • A deliberate upward move remains detached from streaming tail-follow even when it stops inside the former 80px near-bottom band; queued passive repins are discarded.
  • Tail-follow resumes only after the reader reaches the physical bottom or uses an explicit bottom/reset action; the same rule applies to Creation's custom scrollbar.
  • Stale scrollend, RAF, timer, resize, row-measurement, and deferred-repin work cannot affect a newer tab generation.
  • Wheel deltaMode values are normalized for pixel, line, and page devices before nested-scroll handoff.
  • Tables and code blocks that only need horizontal overflow hand vertical scrolling back to the transcript.
  • Large tables default to a bounded in-flow preview with Expand all / Collapse and virtualize their expanded body.
  • Creation's custom scrollbar freezes its drag geometry at pointerdown, so virtual row mounts cannot change sensitivity under the pointer.
  • Selection, rewind, jump, history prepend, stream follow, and custom-scrollbar semantics remain compatible.

Implementation

  • Extends transcriptScrollSession with explicit wheel, touch, keyboard, native-scroll, nested-scroll, and middle-button ownership.
  • Propagates normalized nested-wheel direction into useScrollManager; only upward or already-detached intent releases auto-scroll.
  • Adds a generation-aware deferred tail repin for stream, container-resize, footer-resize, and row-size owners. Rejected passive writes replay once at gesture idle using the current scrollHeight only if tail-follow is still valid.
  • Clears deferred work on upward/manual intent, selection, explicit jump/rewind/custom-scrollbar actions, and tab/reveal generation changes.
  • Distinguishes controller-owned scroll events from unowned native scroll events in useScrollManager.
  • Latches explicit user detachment independently from geometric bottom proximity, preventing stream and resize repins after gesture settlement.
  • Uses scrollend as a guarded hint with a fallback quiet window; stale events are rejected.
  • Adds a row-size owner, disables anchor compensation while pinned, and frame-batches measured row growth into the tail-follow path.
  • Adds a passive live-to-settled fallback for Markdown layout growth without overriding a reader who scrolled away.
  • Removes unconditional full virtualizer measurement after every gesture. Measurements now run only for real width or typography invalidations, capture a stable viewport anchor, and reconcile once after layout.
  • Extracts Creation scrollbar and transcript interaction wiring from Transcript.tsx into focused hooks.
  • Adds deterministic state-machine tests and a production-build Chromium gate for variable-height scrolling, delayed jumps, short near-bottom upward gestures, harmless bottom-down growth, and Windows middle-button auto-scroll.

Consolidation and repository contributions

Compatibility and security

  • Frontend-only DOM, scrolling, and test changes.
  • No new dependency, network access, persistence, Wails API, backend protocol, provider serialization, prompt, MCP, or credential behavior.
  • No transcript text, selected text, clipboard content, or deferred-repin payload is logged.
  • Cache-impact: none - provider-visible prompt and tool payloads are unchanged.
  • Cache-guard: existing cache-impact path guard confirms no cache-sensitive files are touched.
  • System-prompt-review: N/A - no system-prompt or provider-visible prefix changes.

Verification

  • pnpm --dir desktop/frontend test:transcript
  • pnpm --dir desktop/frontend test:transcript-browser
  • pnpm --dir desktop/frontend test:typecheck
  • pnpm --dir desktop/frontend test:motion
  • pnpm --dir desktop/frontend build
  • go run ./tools/repolint
  • git diff --check
  • pnpm --dir desktop/frontend test:bench — scroll-path gates pass; two repository-level baseline gates remain red as described below.

Real Chromium assertions include:

  • no virtualizer, stream, row-size, footer, or container writes during active wheel inertia;
  • no delayed scrollTop jump after scrollend;
  • harmless wheel-down plus tail-row growth remains tail-follow, then replays once to bottom distance 0 after idle;
  • a 24px upward wheel remains in manual mode after the quiet window, and a wheel back to the physical bottom restores tail-follow;
  • middle-button auto-scroll retains ownership without wheel samples;
  • 20+ turn logical selection remains bounded and copies correctly;
  • selection cleanup retains less than 2 MiB (latest observed 0.74 MiB).

Production bundle results:

  • initial JavaScript gzip: 395.2 KiB / 400.0 KiB;
  • largest initial JavaScript chunk gzip: 222.9 KiB / 280.0 KiB;
  • initial raw JavaScript and CSS: 2244.7 KiB / 2246.0 KiB.

Repository-wide benchmark note: the latest branch run reports 303.8ms switch P95 (3.8ms above the 300ms gate) and 46.7 MiB retained-heap growth. The latest same-machine main-v2 comparison reported 305ms and 46.4 MiB, respectively, so these existing benchmark failures are not attributed to this scroll change. Scroll-path evidence remains bounded: 24ms input P95, 0% DOM growth, and no observed long tasks. Native macOS Wails/WebKit and Windows Wails/WebView2 manual shell verification is still recommended before merge.

Documentation impact

Documentation-impact: none - this repairs existing transcript scrolling behavior without changing the documented user workflow or configuration.

Problem: On macOS, two-finger scrolling the chat content area felt sticky
and jumpy—especially over markdown tables—because nested overflow
containers latched the trackpad and virtualizer/stream rewrites fought
native inertia mid-gesture.

Root cause: CSS overflow-x:auto promotes overflow-y to auto, so ordinary
tables/code blocks became vertical scroll ports; virtualizer height
compensation and stream tail-follow could still write scrollTop while the
user was gesturing.

Fix: Add a short user-gesture lock that blocks compensating scroll writes,
promote edge/non-scrollable nested wheels to the transcript with latching
handoff, keep small tables and code blocks out of nested vertical scroll,
and mark large virtual tables for handoff.

Verification: npx tsx scroll-manager, transcript-scroll-session,
nested-scroll-handoff, markdown-table-virtual, typography-overflow-contract,
and transcript-virtualization tests.
Problem: Large markdown tables still introduced nested vertical scroll
ports by default, and virtualizer remeasure could jump after a trackpad
gesture settled.

Root cause: Oversized tables mounted a max-height virtual scroller
immediately, and height compensation was deferred only mid-gesture, not
batched once after the hold window.

Fix: Collapse tables above the virtual threshold to a 12-row document-flow
preview with Expand all / Collapse; expanded tables keep the nested
virtual scroller with handoff. After gesture idle, measure once and
reconcile the reading anchor.

Verification: npx tsx markdown-table-virtual, scroll-manager,
transcript-scroll-session, and nested-scroll-handoff tests.
@SivanCola
SivanCola requested a review from esengine as a code owner August 11, 2026 00:08
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) labels Aug 11, 2026
Problem:
PR esengine#8264 was based on an older main-v2 transcript implementation.

Root cause:
Selection and transcript virtualization work merged after the contributor branch diverged.

Fix:
Merge the current base before completing scroll-session hardening so all fixes target the live integration surface.

Verification:
Conflicts were resolved semantically and both the existing selection behavior and the new scroll-session contracts were verified by the follow-up test commit.
Problem:
Long variable-height transcripts could stall or jump while trackpad inertia, native scrollbar input, or Windows middle-button auto-scroll overlapped virtualizer and resize compensation. Creation scrollbar sensitivity could also change while rows mounted.

Root cause:
Scroll ownership only covered wheel, touch, and keyboard intent; treated unowned native scroll events ambiguously; remeasured the entire virtualizer after every idle gesture; and recomputed custom-scrollbar geometry during a drag. A stale scrollend could also settle a newer gesture.

Fix:
Model native input sessions explicitly, arbitrate controller-owned scroll events, normalize wheel delta modes, guard stale scrollend, freeze custom-scrollbar drag geometry, and measure only real layout invalidations with anchor reconciliation. Split interaction and scrollbar concerns out of Transcript and add real-browser stability coverage.

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- go run ./tools/repolint
@SivanCola SivanCola changed the title fix(desktop): stop trackpad scroll stalls in chat transcript / 修复聊天区触摸板滚动卡顿粘住 fix(desktop): stabilize native transcript scrolling / 修复聊天区原生滚动跳页与卡顿 Aug 11, 2026
SivanCola and others added 2 commits August 11, 2026 09:51
Problem:
A short upward wheel, trackpad, native scrollbar, or Creation scrollbar movement could remain inside the 80px near-bottom band. After the gesture quiet window, streaming output could therefore re-enable tail-follow and pull the viewport back down.

Root cause:
The scroll manager treated geometric proximity as user intent and recomputed stick mode from the near-bottom threshold after every native scroll event.

Fix:
Latch explicit user detachment across gesture settlement, keep resize and stream repins disabled while detached, and restore tail-follow only at the physical bottom or through an explicit bottom/reset action. Apply the same contract to the Creation scrollbar.

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- go run ./tools/repolint
Problem:
- Nested downward handoff at the physical bottom detached streaming tail-follow.
- Passive tail writes rejected during an active gesture were dropped, so final token and row-size growth could leave the viewport behind.

Root cause:
- Nested handoff discarded wheel direction and always released auto-scroll.
- Gesture arbitration had no generation-aware replay for blocked passive owners.

Fix:
- Propagate normalized nested deltas and release only for upward or detached reading intent.
- Replay blocked stream, resize, footer, and row-size repins once after gesture idle using the current scrollHeight.
- Cancel deferred work on manual intent, selection, explicit navigation, and tab generation changes.
- Adapt esengine#8291's row-size ownership, pinned measurement rule, and stream-end fallback into the unified gesture controller.
- Add deterministic and real Chromium regression coverage.

Verification:
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm test:motion
- pnpm build
- go run ./tools/repolint

Co-authored-by: LI Xinyu <Lxy1962@bupt.edu.cn>
@SivanCola
SivanCola merged commit 2760c09 into esengine:main-v2 Aug 11, 2026
25 checks passed
@DUCK-Stop

Copy link
Copy Markdown

确实经常遇到这个问题,修复的很快,赞!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants