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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user