Skip to content

Commit bee5f1c

Browse files
davidrimshnick-pplxPSI Bot
authored andcommitted
Fix web chat keyboard scroll direction
1 parent 3f161de commit bee5f1c

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

packages/happy-app/sources/components/ChatList.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
1717
import { Modal } from '@/modal';
1818
import { useSessionQuickActions } from '@/hooks/useSessionQuickActions';
1919
import { resolveControlMode } from '@/sync/controlHandoff';
20+
import { getInvertedChatListKeyboardScrollDelta } from './chatListKeyboardScroll';
2021

2122
const SCROLL_THRESHOLD = 300;
2223

@@ -322,6 +323,24 @@ const ChatListInternal = React.memo((props: {
322323
return () => node.removeEventListener('wheel', handler);
323324
}, []);
324325

326+
// Web applies keyboard scrolling to the transformed DOM node underneath the
327+
// inverted FlatList, so native key behavior reveals content backward.
328+
React.useEffect(() => {
329+
if (Platform.OS !== 'web') return;
330+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
331+
const node = (flatListRef.current as any)?.getScrollableNode?.() as HTMLElement | undefined;
332+
if (!node) return;
333+
const handler = (e: KeyboardEvent) => {
334+
if (!shouldHandleChatListKeyboardEvent(node, e.target)) return;
335+
const delta = getInvertedChatListKeyboardScrollDelta(e, node.clientHeight);
336+
if (delta === null) return;
337+
node.scrollTop += delta;
338+
e.preventDefault();
339+
};
340+
window.addEventListener('keydown', handler, true);
341+
return () => window.removeEventListener('keydown', handler, true);
342+
}, []);
343+
325344
return (
326345
<View style={{ flex: 1 }}>
327346
<FlatList
@@ -378,6 +397,12 @@ function isCollapsibleDisplayItem(item: DisplayItem): item is ToolGroupItem | Ex
378397
return item.type === 'tool-group' || item.type === 'agent-work-group';
379398
}
380399

400+
function shouldHandleChatListKeyboardEvent(node: HTMLElement, target: EventTarget | null): boolean {
401+
if (!(target instanceof HTMLElement)) return true;
402+
if (target.isContentEditable || target.closest('input, textarea, select')) return false;
403+
return target === document.body || target === document.documentElement || node.contains(target);
404+
}
405+
381406
const styles = StyleSheet.create((theme) => ({
382407
scrollButtonContainer: {
383408
position: 'absolute',
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
CHAT_LIST_KEYBOARD_LINE_SCROLL,
4+
getInvertedChatListKeyboardScrollDelta,
5+
} from './chatListKeyboardScroll';
6+
7+
const VIEWPORT_HEIGHT = 720;
8+
9+
function keyboardEvent(
10+
key: string,
11+
overrides: Partial<Parameters<typeof getInvertedChatListKeyboardScrollDelta>[0]> = {},
12+
) {
13+
return {
14+
key,
15+
defaultPrevented: false,
16+
altKey: false,
17+
ctrlKey: false,
18+
metaKey: false,
19+
shiftKey: false,
20+
...overrides,
21+
};
22+
}
23+
24+
describe('inverted chat list keyboard scrolling', () => {
25+
it('maps standard reading-navigation keys to the inverse DOM scroll delta', () => {
26+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('ArrowDown'), VIEWPORT_HEIGHT))
27+
.toBe(-CHAT_LIST_KEYBOARD_LINE_SCROLL);
28+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('ArrowUp'), VIEWPORT_HEIGHT))
29+
.toBe(CHAT_LIST_KEYBOARD_LINE_SCROLL);
30+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('PageDown'), VIEWPORT_HEIGHT))
31+
.toBe(-VIEWPORT_HEIGHT);
32+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('PageUp'), VIEWPORT_HEIGHT))
33+
.toBe(VIEWPORT_HEIGHT);
34+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent(' '), VIEWPORT_HEIGHT))
35+
.toBe(-VIEWPORT_HEIGHT);
36+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('Spacebar'), VIEWPORT_HEIGHT))
37+
.toBe(-VIEWPORT_HEIGHT);
38+
});
39+
40+
it('leaves handled, modified, and unrelated key events alone', () => {
41+
expect(getInvertedChatListKeyboardScrollDelta(
42+
keyboardEvent('ArrowDown', { defaultPrevented: true }),
43+
VIEWPORT_HEIGHT,
44+
)).toBeNull();
45+
expect(getInvertedChatListKeyboardScrollDelta(
46+
keyboardEvent('ArrowDown', { metaKey: true }),
47+
VIEWPORT_HEIGHT,
48+
)).toBeNull();
49+
expect(getInvertedChatListKeyboardScrollDelta(
50+
keyboardEvent('ArrowDown', { ctrlKey: true }),
51+
VIEWPORT_HEIGHT,
52+
)).toBeNull();
53+
expect(getInvertedChatListKeyboardScrollDelta(
54+
keyboardEvent('ArrowDown', { altKey: true }),
55+
VIEWPORT_HEIGHT,
56+
)).toBeNull();
57+
expect(getInvertedChatListKeyboardScrollDelta(
58+
keyboardEvent('ArrowDown', { shiftKey: true }),
59+
VIEWPORT_HEIGHT,
60+
)).toBeNull();
61+
expect(getInvertedChatListKeyboardScrollDelta(keyboardEvent('Home'), VIEWPORT_HEIGHT))
62+
.toBeNull();
63+
});
64+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
interface KeyboardScrollEvent {
2+
key: string;
3+
defaultPrevented: boolean;
4+
altKey: boolean;
5+
ctrlKey: boolean;
6+
metaKey: boolean;
7+
shiftKey: boolean;
8+
}
9+
10+
export const CHAT_LIST_KEYBOARD_LINE_SCROLL = 48;
11+
12+
export function getInvertedChatListKeyboardScrollDelta(
13+
event: KeyboardScrollEvent,
14+
viewportHeight: number,
15+
): number | null {
16+
if (event.defaultPrevented) return null;
17+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null;
18+
19+
switch (event.key) {
20+
case 'ArrowDown':
21+
return -CHAT_LIST_KEYBOARD_LINE_SCROLL;
22+
case 'ArrowUp':
23+
return CHAT_LIST_KEYBOARD_LINE_SCROLL;
24+
case 'PageDown':
25+
case ' ':
26+
case 'Spacebar':
27+
return -viewportHeight;
28+
case 'PageUp':
29+
return viewportHeight;
30+
default:
31+
return null;
32+
}
33+
}

0 commit comments

Comments
 (0)