@@ -6,7 +6,7 @@ import { useChatScrolling } from "@/hooks/useChatScrolling";
66import { useSettingsValue } from "@/settings/model" ;
77import { ChatMessage } from "@/types/message" ;
88import { App } from "obsidian" ;
9- import React , { memo , useEffect , useState } from "react" ;
9+ import React , { memo , useCallback , useEffect , useMemo , useState } from "react" ;
1010
1111interface ChatMessagesProps {
1212 chatHistory : ChatMessage [ ] ;
@@ -21,6 +21,12 @@ interface ChatMessagesProps {
2121 onDelete : ( messageIndex : number ) => void ;
2222 onReplaceChat : ( prompt : string ) => void ;
2323 showHelperComponents : boolean ;
24+ messagesRef ?: React . MutableRefObject < HTMLDivElement | null > ;
25+ /** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */
26+ vimNavigationEnabled ?: boolean ;
27+ onKeyDown ?: React . KeyboardEventHandler < HTMLDivElement > ;
28+ onBlur ?: React . FocusEventHandler < HTMLDivElement > ;
29+ onClick ?: React . MouseEventHandler < HTMLDivElement > ;
2430}
2531
2632const ChatMessages = memo (
@@ -36,6 +42,11 @@ const ChatMessages = memo(
3642 onDelete,
3743 onReplaceChat,
3844 showHelperComponents = true ,
45+ messagesRef,
46+ vimNavigationEnabled = false ,
47+ onKeyDown,
48+ onBlur,
49+ onClick,
3950 } : ChatMessagesProps ) => {
4051 const [ loadingDots , setLoadingDots ] = useState ( "" ) ;
4152
@@ -46,6 +57,17 @@ const ChatMessages = memo(
4657 chatHistory,
4758 } ) ;
4859
60+ // Combine scroll container ref with external messagesRef for vim navigation
61+ const combinedScrollContainerRef = useCallback (
62+ ( node : HTMLDivElement | null ) => {
63+ scrollContainerCallbackRef ( node ) ;
64+ if ( messagesRef ) {
65+ messagesRef . current = node ;
66+ }
67+ } ,
68+ [ scrollContainerCallbackRef , messagesRef ]
69+ ) ;
70+
4971 useEffect ( ( ) => {
5072 let intervalId : NodeJS . Timeout ;
5173 if ( loading ) {
@@ -58,9 +80,29 @@ const ChatMessages = memo(
5880 return ( ) => clearInterval ( intervalId ) ;
5981 } , [ loading ] ) ;
6082
61- if ( ! chatHistory . filter ( ( message ) => message . isVisible ) . length && ! currentAiMessage ) {
83+ // Find last visible message index with single reverse scan (O(n) with early exit)
84+ const { lastVisibleMessageIndex, hasVisibleMessages } = useMemo ( ( ) => {
85+ for ( let i = chatHistory . length - 1 ; i >= 0 ; i -- ) {
86+ if ( chatHistory [ i ] . isVisible ) {
87+ return { lastVisibleMessageIndex : i , hasVisibleMessages : true } ;
88+ }
89+ }
90+ return { lastVisibleMessageIndex : - 1 , hasVisibleMessages : false } ;
91+ } , [ chatHistory ] ) ;
92+
93+ if ( ! hasVisibleMessages && ! currentAiMessage ) {
6294 return (
63- < div className = "tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto" >
95+ < div
96+ ref = { messagesRef }
97+ tabIndex = { vimNavigationEnabled ? 0 : undefined }
98+ role = { vimNavigationEnabled ? "region" : undefined }
99+ aria-label = { vimNavigationEnabled ? "Chat messages" : undefined }
100+ onKeyDown = { vimNavigationEnabled ? onKeyDown : undefined }
101+ onBlur = { vimNavigationEnabled ? onBlur : undefined }
102+ onClick = { vimNavigationEnabled ? onClick : undefined }
103+ data-testid = "chat-messages"
104+ className = "copilot-messages-focusable tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto"
105+ >
64106 { showHelperComponents && settings . showRelevantNotes && (
65107 < RelevantNotes defaultOpen = { true } key = "relevant-notes-before-chat" />
66108 ) }
@@ -81,13 +123,18 @@ const ChatMessages = memo(
81123 < RelevantNotes className = "tw-mb-4" defaultOpen = { false } key = "relevant-notes-in-chat" />
82124 ) }
83125 < div
84- ref = { scrollContainerCallbackRef }
126+ ref = { combinedScrollContainerRef }
127+ tabIndex = { vimNavigationEnabled ? 0 : undefined }
128+ role = { vimNavigationEnabled ? "region" : undefined }
129+ aria-label = { vimNavigationEnabled ? "Chat messages" : undefined }
130+ onKeyDown = { vimNavigationEnabled ? onKeyDown : undefined }
131+ onBlur = { vimNavigationEnabled ? onBlur : undefined }
132+ onClick = { vimNavigationEnabled ? onClick : undefined }
85133 data-testid = "chat-messages"
86- className = "tw-relative tw-flex tw-w-full tw-flex-1 tw-select-text tw-flex-col tw-items-start tw-justify-start tw-overflow-y-auto tw-scroll-smooth tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)]"
134+ className = "copilot-messages-focusable tw-relative tw-flex tw-w-full tw-flex-1 tw-select-text tw-flex-col tw-items-start tw-justify-start tw-overflow-y-auto tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)]"
87135 >
88136 { chatHistory . map ( ( message , index ) => {
89- const visibleMessages = chatHistory . filter ( ( m ) => m . isVisible ) ;
90- const isLastMessage = index === visibleMessages . length - 1 ;
137+ const isLastMessage = index === lastVisibleMessageIndex ;
91138 // Only apply min-height to AI messages that are last
92139 const shouldApplyMinHeight = isLastMessage && message . sender !== USER_SENDER ;
93140
0 commit comments