diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index f06c2a61..70d893e5 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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) => { + if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { + return; + } + + if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') { + return; + } + + scrollRef.current?.focus({ preventScroll: true }); + }, [scrollRef]); + return (
@@ -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; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 8110db07..7e621494 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1474,21 +1474,22 @@ const MessageList = React.forwardRef(({ return false; } - const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length; - if (targetIsTail) { - return false; - } - const container = resolveScrollContainer(); if (!container) { return false; } const turnElement = container.querySelector(`[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(({ 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: () => { diff --git a/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts b/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts index 8990ebe4..c69bf340 100644 --- a/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts +++ b/packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts @@ -80,7 +80,7 @@ interface UseChatTurnNavigationOptions { export interface ChatTurnNavigation { scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise; scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise; - scrollByTurnOffset: (offset: number) => Promise; + scrollByTurnOffset: (offset: number, options?: { resumePastEnd?: boolean }) => Promise; resumeToLatest: () => void; } @@ -133,14 +133,23 @@ export const useChatTurnNavigation = ({ return scrollToMessage(messageId, { behavior: options?.behavior }); }, [scrollToMessage]); - const scrollByTurnOffset = React.useCallback(async (offset: number): Promise => { - const target = resolveTurnOffsetTarget(turnIdsRef.current, activeTurnIdRef.current, offset); + const scrollByTurnOffset = React.useCallback(async ( + offset: number, + options?: { resumePastEnd?: boolean }, + ): Promise => { + 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;