diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 99adf73559fc..b296072d131f 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -53,7 +53,6 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
-import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -392,7 +391,6 @@ export function ChatView({
)}
- {showChatBar && }
diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
deleted file mode 100644
index b5d947ac18cf..000000000000
--- a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useStore } from '@nanostores/react'
-import { useRef } from 'react'
-
-import { Codicon } from '@/components/ui/codicon'
-import { useI18n } from '@/i18n'
-import { triggerHaptic } from '@/lib/haptics'
-import { cn } from '@/lib/utils'
-import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
-
-/**
- * Floating "jump to bottom" control. Sits centered just above the composer,
- * clearing the out-of-flow status stack via the same measured-height CSS vars
- * the thread's bottom clearance uses (`--composer-measured-height` +
- * `--status-stack-measured-height`), so it never overlaps the queue / subagent
- * / background cards. Visible only while the user has scrolled meaningfully
- * away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
- *
- * Enter/exit motion lives in styles.css under `.thread-jump-button` — a
- * directional scale (contract in from 1.1, contract out to 0.9) keyed off
- * `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
- * `in`/`out` only swap once it has actually appeared.
- */
-export function ScrollToBottomButton() {
- const { t } = useI18n()
- const visible = useStore($threadJumpButtonVisible)
- const hasShownRef = useRef(false)
-
- if (visible) {
- hasShownRef.current = true
- }
-
- const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
-
- return (
-
- )
-}
diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
index c42084138493..0b3a5c824afe 100644
--- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
@@ -14,21 +14,13 @@ import {
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
-import {
- onScrollToBottomRequest,
- resetThreadScroll,
- setThreadJumpButtonVisible,
- setThreadScrolledUp
-} from '@/store/thread-scroll'
+import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
-// Reveal the floating jump button only once scrolled meaningfully away — above
-// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it.
-const JUMP_BUTTON_THRESHOLD = 10
type ThreadMessageComponents = ComponentProps['components']
@@ -317,7 +309,7 @@ function useThreadScrollAnchor({
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
- useEffect(() => () => resetThreadScroll(), [])
+ useEffect(() => () => setThreadScrolledUp(false), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
@@ -333,13 +325,6 @@ function useThreadScrollAnchor({
programmaticScrollPendingRef.current = 0
}
- // Dim the composer the instant we leave the bottom; reveal the jump button
- // only once scrolled meaningfully away.
- const publishScrollDistance = (dist: number) => {
- setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD)
- setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD)
- }
-
const onScroll = () => {
const top = el.scrollTop
@@ -357,19 +342,22 @@ function useThreadScrollAnchor({
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
- publishScrollDistance(el.scrollHeight - (top + el.clientHeight))
+ const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
+ setThreadScrolledUp(!atBottom)
return
}
- // Disarm on ANY upward movement (even 1px), but only while content +
- // viewport height are stable — virtualizer measurement, streaming
- // markdown, and composer/window resize all shift scrollTop as a layout
- // side effect. Wheel-up and touchmove disarm immediately too (below).
+ // Disarm only when `scrollTop` decreases while both content height and
+ // viewport height are stable. A bare `top < lastTopRef.current` check is
+ // unsafe: virtualizer measurement, streaming markdown, composer resizing,
+ // window resizing, and toolbar/status updates can all move scrollTop as a
+ // layout side effect. Wheel-up and touchmove still disarm immediately via
+ // their own listeners below, so real user intent remains covered.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
- if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) {
+ if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
}
@@ -377,14 +365,13 @@ function useThreadScrollAnchor({
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
- const distFromBottom = el.scrollHeight - (top + el.clientHeight)
+ const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
- // Re-arm follow only once genuinely back at the bottom.
- if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
+ if (atBottom) {
setMutableRef(stickyBottomRef, true)
}
- publishScrollDistance(distFromBottom)
+ setThreadScrolledUp(!atBottom)
}
const onWheel = (event: WheelEvent) => {
@@ -404,28 +391,15 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
- // Streaming auto-follow: while — and ONLY while — parked at the bottom, chase
- // content growth (streaming tokens, late measurement, Shiki re-highlight) so
- // the tail stays in view. One upward pixel (scroll/wheel/touch above) flips
- // the gate false and following stops until the user returns to the bottom.
- // Keyed on the virtualizer's own size signal and pinned in useLayoutEffect —
- // the virtualizer's scrollToFn runs in the same pre-paint pass, so the two
- // don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid
- // growth is cheap.
- const totalSize = virtualizer.getTotalSize()
- const prevTotalSizeRef = useRef(null)
- useLayoutEffect(() => {
- const prev = prevTotalSizeRef.current
- prevTotalSizeRef.current = totalSize
-
- if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) {
- pinToBottom()
- }
- }, [enabled, pinToBottom, stickyBottomRef, totalSize])
-
- // The floating jump button asks us to return to the bottom; same re-arm + pin
- // path as a new turn.
- useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom])
+ // Intentionally NO streaming auto-follow. Earlier builds ran a
+ // ResizeObserver here that re-pinned the viewport to the bottom on every
+ // content growth while a turn was running, so the chat tracked tokens as
+ // they streamed. That behavior is removed by request: once a turn is in
+ // flight the viewport stays exactly where the user left it. The viewport
+ // is still moved to the bottom ONCE per user submit / new turn / session
+ // change (see the layout effect and the session-change effect below) so a
+ // freshly submitted message lands in view — but it does not chase the
+ // stream afterward.
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 3e9671e21979..4542b0d02f42 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1666,7 +1666,6 @@ export const en: Translations = {
stopReading: 'Stop reading',
readAloud: 'Read aloud',
editMessage: 'Edit message',
- scrollToBottom: 'Scroll to bottom',
stop: 'Stop',
restorePrevious: 'Restore previous checkpoint',
restoreCheckpoint: 'Restore checkpoint',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 7b75afa57453..52a9a1692db5 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1325,7 +1325,6 @@ export interface Translations {
stopReading: string
readAloud: string
editMessage: string
- scrollToBottom: string
stop: string
restorePrevious: string
restoreCheckpoint: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 9dbe863714af..67c86c52cba7 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1846,7 +1846,6 @@ export const zh: Translations = {
stopReading: '停止朗读',
readAloud: '朗读',
editMessage: '编辑消息',
- scrollToBottom: '滚动到底部',
stop: '停止',
restorePrevious: '恢复上一个检查点',
restoreCheckpoint: '恢复检查点',
diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts
index c0a9afd741fd..b577f50404de 100644
--- a/apps/desktop/src/store/thread-scroll.ts
+++ b/apps/desktop/src/store/thread-scroll.ts
@@ -1,35 +1,11 @@
-import { atom, type WritableAtom } from 'nanostores'
+import { atom } from 'nanostores'
-// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the
-// composer / status stack). `$threadJumpButtonVisible` trips a little further up
-// (~10px) so the floating jump control only shows once meaningfully away.
export const $threadScrolledUp = atom(false)
-export const $threadJumpButtonVisible = atom(false)
-// Skip no-op writes so subscribers don't churn on every scroll tick.
-const setter = (target: WritableAtom) => (value: boolean) => {
- if (target.get() !== value) {
- target.set(value)
+export function setThreadScrolledUp(value: boolean) {
+ if ($threadScrolledUp.get() === value) {
+ return
}
+
+ $threadScrolledUp.set(value)
}
-
-export const setThreadScrolledUp = setter($threadScrolledUp)
-export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible)
-
-export const resetThreadScroll = () => {
- setThreadScrolledUp(false)
- setThreadJumpButtonVisible(false)
-}
-
-// Cross-component bridge: the jump button lives by the composer, the re-arm +
-// pin machinery lives in the virtualizer. The virtualizer registers a handler;
-// the button fires it. Mirrors the composer focus/insert emitter pattern.
-const handlers = new Set<() => void>()
-
-export const onScrollToBottomRequest = (handler: () => void) => {
- handlers.add(handler)
-
- return () => void handlers.delete(handler)
-}
-
-export const requestScrollToBottom = () => handlers.forEach(handler => handler())
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index b4a4d33f03f1..42b781eb3cf5 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1255,62 +1255,6 @@ canvas {
}
}
-/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx).
- Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps
- contracting to 0.9 as it leaves — always shrinking in the direction of
- travel, so the motion reads as a soft settle / recede rather than a pop. The
- X half-offset stays baked into every transform so `left-1/2` centering holds
- through the animation. */
-.thread-jump-button {
- opacity: 0;
- transform: translateX(-50%) translateY(0.3rem) scale(0.9);
-}
-
-.thread-jump-button[data-state='in'] {
- animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
-}
-
-.thread-jump-button[data-state='out'] {
- animation: thread-jump-out 180ms ease-in forwards;
-}
-
-@keyframes thread-jump-in {
- from {
- opacity: 0;
- transform: translateX(-50%) translateY(0.3rem) scale(1.1);
- }
-
- to {
- opacity: 1;
- transform: translateX(-50%) translateY(0) scale(1);
- }
-}
-
-@keyframes thread-jump-out {
- from {
- opacity: 1;
- transform: translateX(-50%) translateY(0) scale(1);
- }
-
- to {
- opacity: 0;
- transform: translateX(-50%) translateY(0.3rem) scale(0.9);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .thread-jump-button[data-state='in'],
- .thread-jump-button[data-state='out'] {
- animation: none;
- transition: opacity 120ms linear;
- }
-
- .thread-jump-button[data-state='in'] {
- opacity: 1;
- transform: translateX(-50%) translateY(0) scale(1);
- }
-}
-
@keyframes code-card-stream-glow {
from {
border-color: color-mix(in srgb, var(--dt-ring) 18%, var(--ui-stroke-tertiary));
@@ -1332,3 +1276,4 @@ canvas {
animation: none;
}
}
+