feat: add keyboard turn navigation

Navigate chat turns with ArrowUp and ArrowDown
Only triggers when the chat area is focused
Supports scrolling to the latest visible turn
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 18:17:13 +03:00
parent c8eb9a6c5f
commit a3b300f7a9
3 changed files with 149 additions and 18 deletions
@@ -49,8 +49,73 @@ const IDLE_SESSION_STATUS = { type: 'idle' as const };
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const;
const CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[contenteditable="true"]',
'[role="button"]',
'[role="combobox"]',
'[role="dialog"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[role="textbox"]',
'[data-radix-popper-content-wrapper]',
].join(',');
type SessionMessageRecord = { info: Message; parts: Part[] };
const isHTMLElement = (target: EventTarget | null): target is HTMLElement => {
return target instanceof HTMLElement;
};
const shouldIgnoreChatNavigationTarget = (target: EventTarget | null): boolean => {
if (!isHTMLElement(target)) {
return false;
}
return Boolean(target.closest(CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR));
};
const shouldIgnoreChatNavigationForFocus = (activeElement: Element | null, scrollContainer: HTMLElement | null): boolean => {
if (typeof document === 'undefined') {
return true;
}
if (!activeElement || activeElement === document.body || activeElement === document.documentElement) {
return true;
}
if (shouldIgnoreChatNavigationTarget(activeElement)) {
return true;
}
return !scrollContainer?.contains(activeElement);
};
const hasBlockingChatOverlay = (): boolean => {
const {
isAboutDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isImagePreviewOpen,
isMultiRunLauncherOpen,
isSessionSwitcherOpen,
isSettingsDialogOpen,
} = useUIStore.getState();
return isAboutDialogOpen
|| isCommandPaletteOpen
|| isHelpDialogOpen
|| isImagePreviewOpen
|| isMultiRunLauncherOpen
|| isSessionSwitcherOpen
|| isSettingsDialogOpen;
};
type HydratingToolSkeletonRow = {
id: string;
titleWidth: string;
@@ -111,6 +176,18 @@ const ChatViewport = React.memo(({
sessionPermissions,
isProgrammaticFollowActive,
}: ChatViewportProps) => {
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
return;
}
if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') {
return;
}
scrollRef.current?.focus({ preventScroll: true });
}, [scrollRef]);
return (
<div
className={cn(
@@ -128,6 +205,8 @@ const ChatViewport = React.memo(({
style={CHAT_SCROLL_STYLE}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
tabIndex={0}
onClick={focusScrollContainer}
data-scroll-shadow="true"
data-scrollbar="chat"
>
@@ -533,6 +612,49 @@ export const ChatContainer: React.FC = () => {
resumeToBottom: timelineController.resumeToBottomInstant,
});
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) {
return;
}
const handleChatTurnKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) {
return;
}
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
const { activeMainTab } = useUIStore.getState();
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
return;
}
const scrollContainer = scrollRef.current;
if (shouldIgnoreChatNavigationForFocus(document.activeElement, scrollContainer)) {
return;
}
if (shouldIgnoreChatNavigationTarget(event.target)) {
return;
}
event.preventDefault();
const offset = event.key === 'ArrowUp' ? -1 : 1;
void navigation.scrollByTurnOffset(offset, { resumePastEnd: false });
};
window.addEventListener('keydown', handleChatTurnKeyDown);
return () => {
window.removeEventListener('keydown', handleChatTurnKeyDown);
};
}, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]);
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) return;
+15 -15
View File
@@ -1474,21 +1474,22 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false;
}
const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
if (targetIsTail) {
return false;
}
const container = resolveScrollContainer();
if (!container) {
return false;
}
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
if (!turnElement) {
return scrollHistoryIndexIntoView(index, behavior);
if (turnElement) {
turnElement.scrollIntoView({ behavior, block: 'start' });
return true;
}
turnElement.scrollIntoView({ behavior, block: 'start' });
return true;
const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
if (targetIsTail) {
return false;
}
return scrollHistoryIndexIntoView(index, behavior);
},
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
@@ -1498,13 +1499,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
return false;
}
const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
if (targetIsTail) {
return false;
}
return scrollMessageElementIntoView(messageId, behavior)
|| scrollHistoryIndexIntoView(index, behavior);
|| (
trailingStreamingEntry !== undefined && index >= historyEntries.length
? false
: scrollHistoryIndexIntoView(index, behavior)
);
},
captureViewportAnchor: () => {
@@ -80,7 +80,7 @@ interface UseChatTurnNavigationOptions {
export interface ChatTurnNavigation {
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollByTurnOffset: (offset: number) => Promise<boolean>;
scrollByTurnOffset: (offset: number, options?: { resumePastEnd?: boolean }) => Promise<boolean>;
resumeToLatest: () => void;
}
@@ -133,14 +133,23 @@ export const useChatTurnNavigation = ({
return scrollToMessage(messageId, { behavior: options?.behavior });
}, [scrollToMessage]);
const scrollByTurnOffset = React.useCallback(async (offset: number): Promise<boolean> => {
const target = resolveTurnOffsetTarget(turnIdsRef.current, activeTurnIdRef.current, offset);
const scrollByTurnOffset = React.useCallback(async (
offset: number,
options?: { resumePastEnd?: boolean },
): Promise<boolean> => {
const turnIds = turnIdsRef.current;
const target = resolveTurnOffsetTarget(turnIds, activeTurnIdRef.current, offset);
if (target.kind === 'noop') {
return offset === 0;
}
if (target.kind === 'resume') {
if (options?.resumePastEnd === false) {
const lastTurnId = turnIds[turnIds.length - 1];
return lastTurnId ? scrollToTurnId(lastTurnId, { behavior: 'auto' }) : false;
}
setHash(null);
resumeToBottom();
return true;