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 { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
|
||||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
|
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
|
||||||
import {
|
import {
|
||||||
@@ -717,6 +718,8 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
|
||||||
|
useRootScrollLock();
|
||||||
|
|
||||||
useRouter();
|
useRouter();
|
||||||
|
|
||||||
const handleToggleMemoryDebug = React.useCallback(() => {
|
const handleToggleMemoryDebug = React.useCallback(() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
|||||||
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
|
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
@@ -318,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
|||||||
useMiniChatKeyboardShortcuts();
|
useMiniChatKeyboardShortcuts();
|
||||||
usePushVisibilityBeacon({ enabled: true });
|
usePushVisibilityBeacon({ enabled: true });
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
useRootScrollLock();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
|||||||
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
|
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
@@ -57,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
|||||||
useAppFontEffects();
|
useAppFontEffects();
|
||||||
usePushVisibilityBeacon({ enabled: true });
|
usePushVisibilityBeacon({ enabled: true });
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
|
useRootScrollLock();
|
||||||
useRouter();
|
useRouter();
|
||||||
useGlobalSessionsPolling(panelType !== 'agentManager');
|
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