Skip to content

desktop: fast scroll up from bottom snaps back down, older history loads one chunk at a time #56285

Open

Description

@Railander

Description

In a long desktop chat session sitting idle at the latest message (bottom), scrolling up quickly snaps the viewport back down toward the bottom, so it's impossible to scroll up in one gesture. Older history lazy-loads one chunk at a time: mid-scroll loading doesn't happen — you must stop, wait, then resume. Reproducible, not intermittent.

Code pointers (verified against dev, may drift): the scroll handler in packages/app/src/session/timeline/virtualizer.tsx (handleListScroll, ~L753-769) can re-pin for any input via the atEnd && arrived heuristic (~L760-764, endEpsilon 0.5px), but only unpins for pointer-held/touch scrolls (~L765) — wheel unpin comes only from the wheel handler (~L652-657). Measurement-compensation writes can satisfy arrived and cause a spurious re-pin mid-gesture, after which the pinned scrollToEnd() forcers (~L354-359, end-anchoring ~L279-284) yank the viewport down. Separately, history loading is scroll-event-driven only (~L768) with top-edge/single-flight gates (interaction.ts ~L217-251, 20-message pages), so fast scrolling outruns it. Related but distinct: #56130 (row drift during wheel-up).

Plugins

None found in config

OpenCode version

2.0.29 (install/channel: latest)

Steps to reproduce

  1. Open a long session in opencode v2 desktop (Windows build + WSL server), idle/completed, viewport at the bottom.
  2. Scroll up fast (mouse wheel) in one continuous gesture.
  3. Observe the viewport snaps back down; older history appears only after stopping and waiting, one chunk at a time.

Screenshot and/or share link

None

Operating System

Windows desktop app + WSL server; WSL: Linux 6.18.54.1-microsoft-standard-WSL2 x64

Terminal

opencode v2 desktop app (not terminal); shell in WSL: /bin/bash

Expected / Actual

Expected: fast upward scroll travels up through history, loading older chunks mid-scroll. Actual: viewport snaps back down; chunks load only after stopping and waiting.

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the code pointers. I tried to reproduce this with the app's timeline in Chromium. I used a 150-exchange idle session sitting at the bottom, with paged history loading and a simulated 150 ms server delay. Then I did one fast upward mouse-wheel gesture (60 steps of 150 px, about 1 s), with and without CPU slowdown and with replies of uneven height. I tried both the latest dev (65932e8) and the timeline code from 2.0.29.

    In those runs the view never jumped back toward the bottom; the most it moved back down was about 8 px, which is the row shift tracked in #56130. Older history also loaded during the gesture: each time the view reached the top it paused briefly, then continued once the next page arrived.

    I couldn't test the Windows desktop app itself, real smooth scrolling, or a server running in WSL. Could you help narrow it down?

    • Do you scroll with a mouse wheel (notched or free-spinning) or a touchpad? Is Windows smooth scrolling on?
    • Does it also happen in the browser? To check, run opencode web in WSL and open it in Chrome or Edge on Windows.
    • About how long is the session, and what kind of content is near the bottom (long tool outputs, diffs, images)?
    • When it snaps back, does it go all the way to the bottom or only part of the way? A short screen recording would help a lot.

    You can reply here, or edit the issue and leave a comment once you've updated it.

    Related: #56130 (rows shift while wheeling up), #40322 (snapping back to the bottom while a response streams, closed).

  2. Railander commented on Oct 17, 2026

    @Railander
    Author

    Additional repro details from my side (desktop app only, haven't tried opencode web in a browser):

    • I can reproduce it two ways, neither involving the mouse wheel: dragging the scrollbar thumb quickly to the top, or middle-clicking inside the session (autoscroll) and moving the cursor to the top of the window. That may explain the non-repro: both paths produce scrolling with no wheel events, so the wheel-handler unpin never fires.
    • Session length unknown, but there is a transient aspect: after I manage to fight it all the way to the top once, I can then scroll down/up repeatedly with no snapping — until it goes idle for 'a while', after which the snapping comes back. So something seems to settle/cache after a full traversal and then lapse again when idle.
    • The snap target is not the bottom: it snaps back to (approximately) where the viewport was before the gesture, oscillating at seemingly 1-frame intervals between that position and a little above it for as long as I hold the scroll toward the top. It never progresses upward while held.

    No screen recording yet; can provide one if the above isn't enough to narrow it down.

  3. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks, those details were the missing piece: I can reproduce this now. It happens on the latest dev (65932e8) and with the timeline code from 2.0.29. I ran the app's frontend in Chromium with a long idle session sitting at the bottom; I couldn't run the Windows desktop app itself.

    • Dragging the scrollbar thumb up: the view is pulled back to the bottom on every frame for as long as the thumb is held, and stays at the bottom after release.
    • Autoscroll-style scrolling (scroll position moved up each frame, with no wheel): the view keeps jumping back toward the bottom and ends only a few hundred pixels up.

    Mouse-wheel scrolling did not show this, which explains my earlier failed attempt. The test below reproduces both cases.

    A likely cause, not yet confirmed by a fix: the custom scrollbar thumb stops its pointer-down event from reaching the timeline. The timeline therefore never registers a held pointer, so it keeps following the bottom and its size updates scroll the view back down. Autoscroll has no wheel or pointer event at all, so it hits the same problem.

    Reproduction script (scroll-no-wheel.spec.ts)
    // Reproduction for anomalyco/opencode#56285: scrolling up from the bottom of a long idle session WITHOUT wheel
    // events (dragging the custom scrollbar thumb, or middle-click autoscroll) is pulled back to the bottom.
    // Run from packages/app:
    //   bunx playwright test --config e2e/performance/timeline-stability/playwright.config.ts scroll-no-wheel
    import { expect, test, type Page } from "@playwright/test"
    import { assistantMessage, setupTimeline, textPart, userMessage, type TimelineMessage } from "../../utils/timeline"
    
    function history(count: number): TimelineMessage[] {
      return Array.from({ length: count }, (_, index) => {
        const prefix = `msg_${String(index).padStart(4, "0")}_nowheel`
        const userID = `${prefix}_a_user`
        return [
          userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
          assistantMessage(
            [textPart(`prt_${String(index).padStart(4, "0")}_nowheel`, `History ${index}. ${"content ".repeat(20 + ((index * 37) % 120))}\n\n\`\`\`ts\n${"const x = 1\n".repeat(3 + (index % 15))}\`\`\`\n`)],
            { id: `${prefix}_b_assistant`, parentID: userID, created: 1690000001000 + index * 10_000 },
          ),
        ]
      }).flat()
    }
    
    const VIEWPORT = ".scroll-view__viewport:has([data-timeline-row])"
    
    async function open(page: Page) {
      await setupTimeline(page, { messages: history(150), messageDelay: 150, viewport: { width: 1400, height: 800 }, reducedMotion: true })
      await expect(page.getByText("History 149.", { exact: false })).toBeVisible()
      await page.waitForTimeout(1500)
    }
    
    async function startSampling(page: Page) {
      await page.evaluate((sel) => {
        const el = document.querySelector(sel) as HTMLElement
        const samples: { t: number; fromEnd: number }[] = []
        ;(window as any).__samples = samples
        const start = performance.now()
        const tick = () => {
          samples.push({ t: Math.round(performance.now() - start), fromEnd: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop) })
          if (performance.now() - start < 4000) requestAnimationFrame(tick)
        }
        requestAnimationFrame(tick)
      }, VIEWPORT)
    }
    
    async function summarize(page: Page, label: string) {
      const samples: { t: number; fromEnd: number }[] = await page.evaluate(() => (window as any).__samples)
      let snaps = 0
      let maxSnap = 0
      for (let i = 1; i < samples.length; i++) {
        const d = samples[i - 1]!.fromEnd - samples[i]!.fromEnd
        if (d > 50) snaps++
        maxSnap = Math.max(maxSnap, d)
      }
      const peak = Math.max(...samples.map((s) => s.fromEnd))
      console.log(label, JSON.stringify({ snapsTowardBottom: snaps, maxSnapPx: maxSnap, peakFromEnd: peak, finalFromEnd: samples.at(-1)!.fromEnd }))
      console.log(label, "trace", JSON.stringify(samples.filter((_, i) => i % 5 === 0).map((s) => s.fromEnd)))
      return { snaps, peak, final: samples.at(-1)!.fromEnd }
    }
    
    test("dragging the scrollbar thumb up from the bottom travels up", async ({ page }) => {
      await open(page)
      const viewport = page.locator(VIEWPORT)
      const box = (await viewport.boundingBox())!
      await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) // hover reveals the thumb
      const thumb = page.locator('.scroll-view__thumb[data-orientation="vertical"]').first()
      await expect(thumb).toBeVisible()
      const t = (await thumb.boundingBox())!
      await startSampling(page)
      await page.mouse.move(t.x + t.width / 2, t.y + t.height / 2)
      await page.mouse.down()
      // Drag the thumb to the top over ~1s and hold it there.
      for (let i = 1; i <= 30; i++) {
        await page.mouse.move(t.x + t.width / 2, t.y + t.height / 2 - ((t.y - box.y) * i) / 30)
        await page.waitForTimeout(30)
      }
      await page.waitForTimeout(1500)
      await page.mouse.up()
      await page.waitForTimeout(1500)
      const r = await summarize(page, "THUMB_DRAG")
      expect(r.snaps, "viewport snapped back toward the bottom while dragging the thumb up").toBe(0)
      expect(r.final).toBeGreaterThan(2000)
    })
    
    test("autoscroll-style upward scrolling (no wheel/pointer) travels up", async ({ page }) => {
      await open(page)
      await startSampling(page)
      // Middle-click autoscroll moves scrollTop every frame without wheel or pointerdown on the list.
      await page.evaluate(
        (sel) =>
          new Promise<void>((resolve) => {
            const el = document.querySelector(sel) as HTMLElement
            const start = performance.now()
            const step = () => {
              el.scrollTop = el.scrollTop - 60
              if (performance.now() - start < 2000) requestAnimationFrame(step)
              else resolve()
            }
            requestAnimationFrame(step)
          }),
        VIEWPORT,
      )
      await page.waitForTimeout(2000)
      const r = await summarize(page, "AUTOSCROLL")
      expect(r.snaps, "viewport snapped back toward the bottom during upward autoscroll").toBe(0)
      expect(r.final).toBeGreaterThan(2000)
    })

    Run it: bun scroll-no-wheel.spec.ts

  4. argszero commented on Oct 17, 2026

    @argszero
    Contributor

    I'd like to work on this.

    The two reproduction paths you described (dragging the scrollbar thumb, and autoscroll-style position changes with no wheel or pointer events) point at the same place, and I think the cause is slightly wider than the thumb swallowing the pointer-down:

    • handleListScroll in packages/app/src/session/timeline/virtualizer.tsx only unpins for pointer-held/touch scrolls; wheel gets its own unpin in the wheel handler, and scrolls with neither (thumb drag that is stopped, autoscroll, keyboard paging) never unpin at all. While pinned, the endEpsilon atEnd && arrived heuristic can re-pin on any input, and the scrollToEnd() forcers then pull the viewport back down — which is the 1-frame oscillation the reporter sees while holding the scroll.
    • History loading is scroll-event-driven only, with a top-edge gate and single-flight, so a fast gesture outruns it: pages arrive only after the gesture stops.

    Plan in packages/app/src/session/timeline/:

    1. Unpin on any scroll that moves meaningfully away from the end, whatever produced it — keep the existing pointer/touch and wheel paths, and add the "distance from end grew" case rather than trying to enumerate every input device. Keep the end-anchoring behaviour for content that grows while genuinely pinned.
    2. Make the arrived check not treat measurement-compensation writes as an arrival, so a mid-gesture re-pin cannot happen.
    3. Let history paging proceed during a gesture instead of only on the trailing scroll event, so older chunks load while the view is still moving.

    I'll add a regression test using your scroll-no-wheel.spec.ts as the base (thumb drag and autoscroll), and validate it fails before the fix and passes after. Scope stays inside the timeline virtualizer and its loader — no changes to unrelated scroll behaviour.

  5. argszero commented on Oct 17, 2026

    @argszero
    Contributor

    Opened #56308 for this.

    The cause turned out to be the unpin rule rather than the thumb on its own. handleListScroll only released the pin for a held pointer or a native touch scroll, and the wheel handler covered wheel, so a thumb drag (whose pointer-down the thumb swallows) and autoscroll-style scrolling (no pointer, wheel or key event at all) never unpinned. While pinned, the size updates scrolled the viewport back to the end, which is the 1-frame oscillation you described.

    The fix decides from the distance to the end instead of from the input device: a measurement adjustment moves the offset and the scroll range together and leaves that distance alone, while any move away from the end grows it. Two regression cases cover the thumb drag and the autoscroll path; both end fully back at the end without the fix and stay more than 200 px up with it.

    On the second half of your report, older history now loads while you keep dragging, because the view can stay near the top long enough for the paged loader to chain. I left the paging thresholds themselves unchanged — say so if you would rather prefetch earlier.

  6. dajiaohuang commented on Oct 22, 2026

    @dajiaohuang

    Thanks for reproducing and validating the no-wheel scroll fix in #56308. Its close note says it was closed only for the contributor open-PR cap and the branch remains available; this issue is still open. Could you reopen the PR or confirm the branch is no longer current? I'll avoid duplicating the tested change.

  7. Railander commented on Oct 22, 2026

    @Railander
    Author

    Checked #57831 (merged) against this report. It fixes one of the two repro paths here, not both.

    Scrollbar thumb drag — fixed. The capture-phase pointerdown on the ScrollView root makes a thumb drag register as a user scroll, so the timeline unpins. Verified on dev at ac68d6a (fix present in the built bundle) with the repro from above: the drag now reaches the top instead of being pulled back to the bottom.

    Autoscroll-style scrolling — still reproduces. Same build, same fixture, moving the scroll position up each frame with no pointer/wheel/key event: 8 pull-backs of up to ~1600 px and the view ends only ~360 px from the bottom, so it never gets away from the end. The reason is unchanged: handleListScroll still releases the pin only for pointerHeld || touchScrolling, and the captured pointerdown only sets pointerHeld while the button is held — nothing in the scroll path unpins a no-input scroll.

    Older history still loads one chunk at a time. #57831 doesn't touch the loader; the trigger is still scroll-event-driven with the top-edge gate and single-flight, so a fast gesture outruns it.

    So the remaining scope is what #56308 covered (geometry-based unpin, i.e. "distance to end grew" regardless of input device). That PR was closed for the contributor open-PR cap rather than for the change itself — if its branch is still current, that looks like the piece that would close the rest of this.

    Environment for the check: dev @ ac68d6a, production build served from packages/app, headless Chromium, 150-turn idle session with paged history loading and uneven row heights.

  8. trustworthyHamster commented on Oct 31, 2026

    @trustworthyHamster

    Windows desktop 2.2.0, middle-click autoscroll — same cause, plus a user-side workaround that isolates it

    Windows 11, OpenCode desktop 2.2.0 (Electron build).

    1. In a long session sitting at the end, middle-click inside the timeline and pan up: the viewport twitches in place and never leaves the end. Same as the autoscroll path reported above.
    2. One wheel notch up first, then middle-click autoscroll: panning works normally and the view stays where I put it.

    So the pin is what blocks the gesture, not the panning itself: the wheel handler unpins, and the autoscroll path produces nothing the current rule can name. Chromium's middle-click autoscroll works fine in the same Electron shell on a plain scrollable page, so the browser side is not involved.

    Request: the geometry rule from #56308 (movedAway = maxScroll - scrollTop > previousMaxScroll - previousTop + endEpsilon) covers this path without naming an input device, and the branch is still on the author's fork. Re-applying that two-line change would close the remaining half of this report — #57831 only covered the thumb drag.

  9. AndysTMC commented on Nov 3, 2026

    @AndysTMC

    Scrolling up through long conversations using the scrollbar is still pretty frustrating. I'm running into two issues:

    1. When I open a long conversation and drag the scrollbar upward, it only goes so far before older messages start loading. Sometimes, the viewport even jumps back down by roughly 20% before loading the next chunk. I then have to drag the scrollbar up slowly, wait for each chunk to load, and repeat. It makes navigating older messages unnecessarily difficult.

    2. After reaching the beginning of the conversation and scrolling back down, dragging the scrollbar upward again becomes even more problematic. Instead of moving up normally, the viewport keeps jumping back down, sometimes before I've even made it 30% of the way up.

    Not sure if both are caused by the same underlying issue, but the scrollbar behavior still seems inconsistent, especially in longer conversations. Hopefully this can be improved so navigating chat history feels smoother and more predictable.

    Environment: Ubuntu 26.04.1 LTS (kernel 7.0.0-42-generic), OpenCode v2.2.5 (desktop app).

  10. qtapioca commented on Nov 6, 2026

    @qtapioca

    It is a simple Windows scroll bar issue. Why can't it not be fixed quickly? This is very frustrating using a tiny scroll bar and have it snap right back down when simply trying to scroll up from the bottom.

    Is there a real tech support reading this and fix it besides the bot?

  11. qtapioca commented on Nov 11, 2026

    @qtapioca

    I am using Windows 10, OpenCode Version 2.2.7.

    This is very frustrating when one simply wants to grab the side scroll bar and scroll to the top while working. This is a basic function that should work. I just can't believe why it takes so long and it is still not fixed. It affects people's work.

    A tiny scroll bar is also unacceptable. It should be at least the width like the ChatGpt app, twice the width of what is there in OpenCode. Actually your website opencode.ai has exactly the same problem. I have been working as a system and UI interface designer, and Business Analyst before. This is actually a bad UI. Small is NOT better, if it does not improve the user's experience and functions. I hope someone in Opencode can figure it out soon enough.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions