From 8623b9f6bba0923b4b5ec1ed45c91a8a6abbe96d Mon Sep 17 00:00:00 2001 From: Ferris Eanfar Date: Tue, 17 Feb 2026 15:56:38 -0600 Subject: [PATCH 1/2] fix: add scroll anchoring to prevent content jitter when scrolling up through message stream --- .../src/cli/cmd/tui/routes/session/index.tsx | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 55ab4d54dd4c..49d587de517f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, Show, Switch, useContext, @@ -222,6 +223,43 @@ export function Session() { let prompt: PromptRef const keybind = useKeybind() + // Scroll anchoring: prevents content shift/jitter when the user has scrolled + // up to read older messages. When content resizes (tree-sitter highlights, + // tool results expanding, etc.), yoga recalculates child positions. Without + // anchoring, the same scrollTop value shows different content because + // children's _y values shifted. We save a reference child's position and + // compensate after the layout tree walk completes. + const SCROLL_BOTTOM_THRESHOLD = 5 + let userAtBottom = true + let anchorChildId: string | undefined + let anchorChildY = 0 + + function updateUserAtBottom() { + if (!scroll || scroll.isDestroyed) return + const max = Math.max(0, scroll.scrollHeight - scroll.viewport.height) + userAtBottom = max <= 0 || scroll.scrollTop >= max - SCROLL_BOTTOM_THRESHOLD + } + + function saveAnchor() { + if (!scroll || scroll.isDestroyed) return + const top = scroll.scrollTop + for (const child of scroll.getChildren()) { + if (child.id && (child as any)._y + child.height > top) { + anchorChildId = child.id + anchorChildY = (child as any)._y + return + } + } + anchorChildId = undefined + } + + // Poll to track user scroll position and save anchor state. + const pollId = setInterval(() => { + updateUserAtBottom() + if (!userAtBottom) saveAnchor() + }, 50) + onCleanup(() => clearInterval(pollId)) + // Allow exit when in child session (prompt is hidden) const exit = useExit() @@ -985,7 +1023,33 @@ export function Session() {
(scroll = r)} + ref={(r) => { + scroll = r + // Scroll anchoring: after content resizes, yoga recalculates + // child positions during the tree walk. recalculateBarProps + // fires mid-walk (children not yet updated). We schedule a + // process.nextTick to run AFTER the tree walk finishes, then + // compare the anchor child's new _y with its saved position + // and adjust scrollTop to keep the same content in view. + const original = (r as any).recalculateBarProps.bind(r) + ;(r as any).recalculateBarProps = () => { + const savedId = anchorChildId + const savedY = anchorChildY + const wasAtBottom = userAtBottom + original() + if (wasAtBottom || !savedId) return + process.nextTick(() => { + if (r.isDestroyed) return + const anchor = r.getChildren().find((c: any) => c.id === savedId) + if (!anchor) return + const delta = (anchor as any)._y - savedY + if (delta !== 0) { + r.scrollTop = r.scrollTop + delta + } + saveAnchor() + }) + } + }} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} From 2f611dc9ea0e25a80d73cddb299cc19679826c84 Mon Sep 17 00:00:00 2001 From: Ferris Eanfar Date: Thu, 19 Feb 2026 09:47:44 -0600 Subject: [PATCH 2/2] Update index.tsx --- .../src/cli/cmd/tui/routes/session/index.tsx | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 49d587de517f..924b3fdad881 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1025,12 +1025,12 @@ export function Session() { { scroll = r - // Scroll anchoring: after content resizes, yoga recalculates - // child positions during the tree walk. recalculateBarProps - // fires mid-walk (children not yet updated). We schedule a - // process.nextTick to run AFTER the tree walk finishes, then - // compare the anchor child's new _y with its saved position - // and adjust scrollTop to keep the same content in view. + // Scroll anchoring: when content resizes, yoga recalculates + // child positions. recalculateBarProps fires mid-tree-walk + // (children's _y not yet updated), but yoga's computed layout + // is already finalized. We read the anchor child's new + // position directly from yoga (getComputedLayout().top) and + // adjust scrollTop synchronously to avoid any flicker. const original = (r as any).recalculateBarProps.bind(r) ;(r as any).recalculateBarProps = () => { const savedId = anchorChildId @@ -1038,16 +1038,14 @@ export function Session() { const wasAtBottom = userAtBottom original() if (wasAtBottom || !savedId) return - process.nextTick(() => { - if (r.isDestroyed) return - const anchor = r.getChildren().find((c: any) => c.id === savedId) - if (!anchor) return - const delta = (anchor as any)._y - savedY - if (delta !== 0) { - r.scrollTop = r.scrollTop + delta - } - saveAnchor() - }) + const anchor = r.getChildren().find((c: any) => c.id === savedId) + if (!anchor) return + const newY = anchor.getLayoutNode().getComputedLayout().top + const delta = newY - savedY + if (delta !== 0) { + r.scrollTop = r.scrollTop + delta + anchorChildY = newY + } } }} viewportOptions={{