From 75d1e21446cb626c2559d913fc8ccbe199a242e2 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 12 Jun 2026 19:38:50 -0500 Subject: [PATCH] =?UTF-8?q?Revert=20"feat(desktop):=20follow=20streaming?= =?UTF-8?q?=20output=20at=20bottom=20+=20jump-to-bottom=20but=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bbf020e709eca4571c488ebb7cc65b1202bf5dab. --- apps/desktop/src/app/chat/index.tsx | 2 - .../src/app/chat/scroll-to-bottom-button.tsx | 58 --------------- .../assistant-ui/thread-virtualizer.tsx | 72 ++++++------------- apps/desktop/src/i18n/en.ts | 1 - apps/desktop/src/i18n/types.ts | 1 - apps/desktop/src/i18n/zh.ts | 1 - apps/desktop/src/store/thread-scroll.ts | 36 ++-------- apps/desktop/src/styles.css | 57 +-------------- 8 files changed, 30 insertions(+), 198 deletions(-) delete mode 100644 apps/desktop/src/app/chat/scroll-to-bottom-button.tsx 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; } } +