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
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 20:08:58 +03:00
parent e00ebea262
commit 59fa91309a
5 changed files with 122 additions and 0 deletions
+3
View File
@@ -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(() => {
@@ -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 (
<ErrorBoundary>
+2
View File
@@ -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');
@@ -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);
});
});
});
@@ -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 });
}, []);
};