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:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}, []);
|
||||
};
|
||||
Reference in New Issue
Block a user