From 59fa91309a7060c77b02e84b26350746595cab8b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 28 Aug 2026 19:15:50 +0300 Subject: [PATCH] fix(ui): keep the app root pinned when the caret scrolls it Chromium scrolls overflow:hidden ancestors when a textarea caret moves out of view (PageUp/PageDown in the prompt box, long prompts), shifting the whole app up and hiding the title bar with no way to scroll back. Snap html/body/#root back to zero on any root scroll event in the web, desktop, VS Code and mini-chat apps. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- packages/ui/src/App.tsx | 3 + packages/ui/src/apps/ElectronMiniChatApp.tsx | 2 + packages/ui/src/apps/VSCodeApp.tsx | 2 + .../ui/src/hooks/useRootScrollLock.test.ts | 64 +++++++++++++++++++ packages/ui/src/hooks/useRootScrollLock.ts | 51 +++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 packages/ui/src/hooks/useRootScrollLock.test.ts create mode 100644 packages/ui/src/hooks/useRootScrollLock.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cf81988b..daad92af 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,6 +20,7 @@ import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { useConfigStore } from '@/stores/useConfigStore'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { @@ -717,6 +718,8 @@ function App({ apis }: AppProps) { useWindowTitle(); + useRootScrollLock(); + useRouter(); const handleToggleMemoryDebug = React.useCallback(() => { diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 7aed5ba0..4b59a36e 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -8,6 +8,7 @@ import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { opencodeClient } from '@/lib/opencode/client'; import type { RuntimeAPIs } from '@/lib/api/types'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -318,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) { useMiniChatKeyboardShortcuts(); usePushVisibilityBeacon({ enabled: true }); useWindowTitle(); + useRootScrollLock(); return ( diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 737a0239..43e3f6f4 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -14,6 +14,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling'; import { useRouter } from '@/hooks/useRouter'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { opencodeClient } from '@/lib/opencode/client'; import type { RuntimeAPIs } from '@/lib/api/types'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -57,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) { useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useWindowTitle(); + useRootScrollLock(); useRouter(); useGlobalSessionsPolling(panelType !== 'agentManager'); diff --git a/packages/ui/src/hooks/useRootScrollLock.test.ts b/packages/ui/src/hooks/useRootScrollLock.test.ts new file mode 100644 index 00000000..7a194d11 --- /dev/null +++ b/packages/ui/src/hooks/useRootScrollLock.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; + +import { isRootScrollTarget, resetRootScroll } from './useRootScrollLock'; + +type FakeElement = EventTarget & { id: string; scrollTop: number; scrollLeft: number }; + +const element = (id: string): FakeElement => Object.assign(new EventTarget(), { id, scrollTop: 0, scrollLeft: 0 }); + +/** Installs a minimal stand-in for `document` for the duration of `run`. */ +const withDocument = (setup: { root?: FakeElement }, run: () => void) => { + const fakeDocument = { + documentElement: element('html'), + body: element('body'), + getElementById: (id: string) => (setup.root && setup.root.id === id ? setup.root : null), + }; + // The hook only reads documentElement/body/getElementById from `document`; + // this stand-in provides exactly those members for a DOM-less test process. + const hadDocument = 'document' in globalThis; + const previous = hadDocument ? globalThis.document : undefined; + Reflect.set(globalThis, 'document', fakeDocument); + try { + run(); + } finally { + if (hadDocument) Reflect.set(globalThis, 'document', previous); + else Reflect.deleteProperty(globalThis, 'document'); + } +}; + +describe('resetRootScroll', () => { + test('snaps every root scroll offset back to zero and reports the reset', () => { + const root = element('root'); + withDocument({ root }, () => { + document.documentElement.scrollTop = 48; + document.body.scrollLeft = 12; + root.scrollTop = 200; + expect(resetRootScroll()).toBe(true); + expect(document.documentElement.scrollTop).toBe(0); + expect(document.body.scrollLeft).toBe(0); + expect(root.scrollTop).toBe(0); + }); + }); + + test('reports nothing to do when the root is already at zero', () => { + withDocument({}, () => { + expect(resetRootScroll()).toBe(false); + }); + }); +}); + +describe('isRootScrollTarget', () => { + test('recognises the document, html and body as root scroll sources', () => { + withDocument({}, () => { + expect(isRootScrollTarget(document)).toBe(true); + expect(isRootScrollTarget(document.documentElement)).toBe(true); + expect(isRootScrollTarget(document.body)).toBe(true); + }); + }); + + test('ignores scroll events from inner containers', () => { + withDocument({}, () => { + expect(isRootScrollTarget(element('chat-timeline'))).toBe(false); + }); + }); +}); diff --git a/packages/ui/src/hooks/useRootScrollLock.ts b/packages/ui/src/hooks/useRootScrollLock.ts new file mode 100644 index 00000000..6e90acee --- /dev/null +++ b/packages/ui/src/hooks/useRootScrollLock.ts @@ -0,0 +1,51 @@ +import React from 'react'; + +/** + * The document root (`html`, `body`, `#root`) is `overflow: hidden` and must + * never scroll — every scrollable area lives in a dedicated container. Chromium + * still scrolls hidden-overflow ancestors programmatically, most visibly when + * a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a + * long prompt being typed) and the browser scrolls it into view. Once that + * happens the whole app shifts up, hides the title bar, and nothing the user + * does with the wheel or keyboard can scroll it back. + * + * Snap every root scroll straight back to zero. + */ + +const rootScrollTargets = (): HTMLElement[] => { + const targets = [document.documentElement, document.body]; + const appRoot = document.getElementById('root'); + if (appRoot) targets.push(appRoot); + return targets; +}; + +export const resetRootScroll = (): boolean => { + let reset = false; + for (const target of rootScrollTargets()) { + if (target.scrollTop !== 0) { + target.scrollTop = 0; + reset = true; + } + if (target.scrollLeft !== 0) { + target.scrollLeft = 0; + reset = true; + } + } + return reset; +}; + +export const isRootScrollTarget = (target: EventTarget | null): boolean => + target === document || rootScrollTargets().some((element) => element === target); + +export const useRootScrollLock = (): void => { + React.useEffect(() => { + const handleScroll = (event: Event) => { + if (isRootScrollTarget(event.target)) resetRootScroll(); + }; + // Capture: the root's own scroll events don't bubble to inner listeners, + // and scroll events from inner containers are filtered out above. + document.addEventListener('scroll', handleScroll, { capture: true, passive: true }); + resetRootScroll(); + return () => document.removeEventListener('scroll', handleScroll, { capture: true }); + }, []); +};