Skip to content

feat(chat): implement message navigation and fix scroll-to-previous functionality#460

Open
menny wants to merge 1 commit intositeboon:mainfrom
menny:scroll-to-previous
Open

feat(chat): implement message navigation and fix scroll-to-previous functionality#460
menny wants to merge 1 commit intositeboon:mainfrom
menny:scroll-to-previous

Conversation

@menny
Copy link
Contributor

@menny menny commented Mar 1, 2026

Summary

This PR isolates and implements the message navigation features in the chat interface, specifically focusing on "Scroll to Previous User Message" and "Scroll to Bottom" functionality. It also resolves several build and runtime issues encountered after rebasing onto the latest main.

Key Changes

Feature Isolation

  • Chat Interface Cleanup: Isolated and restored message navigation props in ChatInterface.tsx.

Message Navigation Fixes

  • DOM Attribute Support: Added data-message-index to MessageComponent.tsx root elements to enable accurate Targeting by the scroll logic.
  • Global Index Mapping: Updated ChatMessagesPane.tsx to calculate and pass global message indices (from chatMessages) instead of relative view indices, ensuring "Scroll to previous" correctly identifies targets across the entire history.

Summary by CodeRabbit

  • New Features

    • Added ability to navigate to previous user messages in chat history with a dedicated button when scrolled up.
    • Enhanced pagination system for large chat histories with improved scroll restoration.
  • Improvements

    • Better handling of scroll position when loading and displaying older messages.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

📝 Walkthrough

Walkthrough

Enhances the chat interface with reverse-chronological message navigation by introducing a scroll-to-previous-user-message feature and improved pagination logic. Updates the useChatSessionState hook with scroll restoration, message loading refinements, and new scroll-control callbacks threaded through ChatInterface components to ChatInputControls.

Changes

Cohort / File(s) Summary
Chat Session State Hook
src/components/chat/hooks/useChatSessionState.ts
Added pagination/scroll restoration with pendingScrollToMessageRef, convertedMessages memoization, loadEarlierMessages, isNearBottom utility, and scrollToPreviousUserMessage. Enhanced loadOlderMessages and overhauled loadAllMessages for chunk processing and scroll position restoration.
Chat Interface Wiring
src/components/chat/view/ChatInterface.tsx, src/components/chat/view/subcomponents/ChatComposer.tsx, src/components/chat/view/subcomponents/ChatInputControls.tsx
Threaded scrollToPreviousUserMessage callback from useChatSessionState through component hierarchy. Added UI button to scroll to previous user message when user is scrolled up, with distinct SVG and click action.
Message Rendering
src/components/chat/view/subcomponents/ChatMessagesPane.tsx, src/components/chat/view/subcomponents/MessageComponent.tsx
Added global index calculation for visible messages using chatMessages.indexOf() and exposed message index via data-message-index attribute for scroll targeting.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant ChatInputControls
    participant useChatSessionState
    participant MessageLoading
    participant ScrollSystem
    
    User->>ChatInputControls: Clicks "scroll to previous user message"
    ChatInputControls->>useChatSessionState: onScrollToPreviousUserMessage()
    
    alt Message visible in current window
        useChatSessionState->>ScrollSystem: Scroll to message index
    else Message not visible
        useChatSessionState->>MessageLoading: loadOlderMessages()
        MessageLoading->>useChatSessionState: Update visibleMessageCount
        useChatSessionState->>useChatSessionState: Set pendingScrollToMessageRef
        useChatSessionState->>ScrollSystem: Adjust scroll position & restore
        ScrollSystem->>ChatInputControls: Render updated messages
    end
Loading

Possibly related PRs

Suggested reviewers

  • viper151
  • blackmammoth

Poem

🐰 Hop-hop through history we go,
Scroll up, up to messages below,
Previous words now in our sight,
Pagination makes scrolling light!
A rabbit's delight, smooth and precise.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: implementing message navigation features and fixing scroll-to-previous functionality, which aligns with the primary objectives of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/components/chat/hooks/useChatSessionState.ts (1)

305-309: Remove debug logging from scroll paths before merge.

console.log('[ScrollDebug]', ...) in frequent navigation/layout paths adds noise and avoidable overhead.

🧹 Cleanup diff
-    console.log('[ScrollDebug] scrollToPreviousUserMessage', {
-      viewportTop,
-      firstVisibleGlobalIndex,
-      totalChatMessages: chatMessages.length
-    });
...
-      console.log('[ScrollDebug] No earlier user message found in loaded history');
       if (hasMoreMessages) {
-        console.log('[ScrollDebug] Triggering server load');
         loadOlderMessages(container);
       }
       return;
     }
-
-    console.log('[ScrollDebug] Target user message found at global index', targetGlobalIndex);
...
     if (targetEl) {
-      console.log('[ScrollDebug] Scrolling to rendered message element');
       container.scrollTop = Math.max(0, targetEl.offsetTop - 20);
     } else {
-      console.log('[ScrollDebug] Message not rendered, increasing visible count');
       pendingScrollToMessageRef.current = targetGlobalIndex;
...
-        console.log('[ScrollDebug] Scrolling to pending message element via data-index', { targetGlobalIndex, offsetTop: targetEl.offsetTop });
         container.scrollTop = Math.max(0, targetEl.offsetTop - 20);

Also applies to: 321-324, 329-329, 334-334, 338-338, 397-397

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/chat/hooks/useChatSessionState.ts` around lines 305 - 309,
Remove the debug console.log calls for scroll tracing from the
useChatSessionState hook: delete the console.log('[ScrollDebug] ...') in
scrollToPreviousUserMessage and all similar '[ScrollDebug]' logs in the same
file (e.g., in scrollToNextUserMessage, scrollToMessageAtIndex,
ensureMessageVisible/other scroll handlers) so navigation/layout code no longer
emits debug output or adds overhead; keep any necessary error handling or real
logging mechanisms intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/chat/hooks/useChatSessionState.ts`:
- Around line 747-751: After completing the full-history load, synchronize
pagination state to avoid later duplicate fetches: set messagesOffsetRef.current
to allLoadedMessages.length, set the hasMoreMessages flag/ref (hasMoreMessages
or hasMoreMessagesRef) to false, and update totalMessages (or totalMessagesRef /
setTotalMessages) to allLoadedMessages.length; do this right after calling
setSessionMessages and before/unconditionally with
setAllMessagesLoaded/setLoadAllJustFinished so pagination and refs reflect the
“all loaded” state.
- Around line 720-741: The loop in useChatSessionState.ts currently prepends
each chunk with allLoadedMessages = [...chunk, ...allLoadedMessages], causing
quadratic reallocations; change the accumulation to an append-friendly strategy
(e.g., push each chunk into allLoadedMessages via
allLoadedMessages.push(...chunk) or collect chunks in an array-of-chunks and do
a single concat at the end) and if required restore the original message order
after the loop (reverse or single concat) so behavior of selectedProject/session
loading remains the same; update references to allLoadedMessages, currentOffset
and the while loop accordingly.

In `@src/components/chat/view/subcomponents/ChatInputControls.tsx`:
- Around line 127-131: In ChatInputControls.tsx update the new navigation
<button> (the one with onClick={onScrollToPreviousUserMessage}) to use the i18n
translation key instead of hardcoded tooltip text, add an aria-label that uses
the same localized string, and set type="button" for safety; locate the button
in the ChatInputControls component and replace the hardcoded title with t('...')
(or the project’s translation helper) and add aria-label={t('...')} plus
type="button".

In `@src/components/chat/view/subcomponents/ChatMessagesPane.tsx`:
- Around line 246-247: The render loop in ChatMessagesPane.tsx uses
chatMessages.indexOf(message) inside visibleMessages.map (assigning
globalIndex), causing O(n²) behavior; instead, compute a lookup map once when
chatMessages changes (e.g., build a Map from message unique id or object
reference to its global index in chatMessages) and then read globalIndex =
indexMap.get(message.id || message) inside the visibleMessages.map. Update both
occurrences that use chatMessages.indexOf(message) (the one creating globalIndex
and the similar usage around the later occurrence) to use the precomputed
indexMap to avoid repeated linear scans.

---

Nitpick comments:
In `@src/components/chat/hooks/useChatSessionState.ts`:
- Around line 305-309: Remove the debug console.log calls for scroll tracing
from the useChatSessionState hook: delete the console.log('[ScrollDebug] ...')
in scrollToPreviousUserMessage and all similar '[ScrollDebug]' logs in the same
file (e.g., in scrollToNextUserMessage, scrollToMessageAtIndex,
ensureMessageVisible/other scroll handlers) so navigation/layout code no longer
emits debug output or adds overhead; keep any necessary error handling or real
logging mechanisms intact.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e22f42 and 088d1b8.

📒 Files selected for processing (6)
  • src/components/chat/hooks/useChatSessionState.ts
  • src/components/chat/view/ChatInterface.tsx
  • src/components/chat/view/subcomponents/ChatComposer.tsx
  • src/components/chat/view/subcomponents/ChatInputControls.tsx
  • src/components/chat/view/subcomponents/ChatMessagesPane.tsx
  • src/components/chat/view/subcomponents/MessageComponent.tsx

Comment on lines +720 to +741
let allLoadedMessages: any[] = [];
let currentOffset = 0;
let hasMore = true;

if (currentSessionId !== requestSessionId) return;

if (response.ok) {
const data = await response.json();
const allMessages = data.messages || data;
while (hasMore) {
const response = await (api.sessionMessages as any)(
selectedProject.name,
selectedSession.id,
100, // Load in larger chunks
currentOffset,
sessionProvider,
);

if (container) {
pendingScrollRestoreRef.current = {
height: previousScrollHeight,
top: previousScrollTop,
};
if (!response.ok) {
throw new Error('Failed to load messages');
}

setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
setHasMoreMessages(false);
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;

setVisibleMessageCount(Infinity);
setAllMessagesLoaded(true);
const data = await response.json();
const chunk = data.messages || [];
allLoadedMessages = [...chunk, ...allLoadedMessages];
currentOffset += chunk.length;
hasMore = Boolean(data.hasMore);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Load-all accumulation is quadratic and will degrade on big histories.

allLoadedMessages = [...chunk, ...allLoadedMessages] inside the loop repeatedly reallocates growing arrays.

⚡ Proposed fix
-      let allLoadedMessages: any[] = [];
+      const messageChunks: any[][] = [];
       let currentOffset = 0;
       let hasMore = true;
...
         const data = await response.json();
         const chunk = data.messages || [];
-        allLoadedMessages = [...chunk, ...allLoadedMessages];
+        messageChunks.push(chunk);
         currentOffset += chunk.length;
         hasMore = Boolean(data.hasMore);
...
-      setSessionMessages(allLoadedMessages);
+      const allLoadedMessages = messageChunks.reverse().flat();
+      setSessionMessages(allLoadedMessages);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/chat/hooks/useChatSessionState.ts` around lines 720 - 741, The
loop in useChatSessionState.ts currently prepends each chunk with
allLoadedMessages = [...chunk, ...allLoadedMessages], causing quadratic
reallocations; change the accumulation to an append-friendly strategy (e.g.,
push each chunk into allLoadedMessages via allLoadedMessages.push(...chunk) or
collect chunks in an array-of-chunks and do a single concat at the end) and if
required restore the original message order after the loop (reverse or single
concat) so behavior of selectedProject/session loading remains the same; update
references to allLoadedMessages, currentOffset and the while loop accordingly.

Comment on lines +747 to +751
setSessionMessages(allLoadedMessages);
setAllMessagesLoaded(true);
allMessagesLoadedRef.current = true;
setVisibleMessageCount(allLoadedMessages.length);
setLoadAllJustFinished(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Synchronize pagination state after loading full history.

After full fetch, pagination refs/state remain stale (messagesOffsetRef, hasMoreMessages, totalMessages). If all-loaded mode is later reset, top-pagination can issue redundant/duplicate fetches.

🧭 Proposed fix
       const allLoadedMessages = messageChunks.reverse().flat();
       setSessionMessages(allLoadedMessages);
+      messagesOffsetRef.current = allLoadedMessages.length;
+      setHasMoreMessages(false);
+      setTotalMessages(allLoadedMessages.length);
       setAllMessagesLoaded(true);
       allMessagesLoadedRef.current = true;
       setVisibleMessageCount(allLoadedMessages.length);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/chat/hooks/useChatSessionState.ts` around lines 747 - 751,
After completing the full-history load, synchronize pagination state to avoid
later duplicate fetches: set messagesOffsetRef.current to
allLoadedMessages.length, set the hasMoreMessages flag/ref (hasMoreMessages or
hasMoreMessagesRef) to false, and update totalMessages (or totalMessagesRef /
setTotalMessages) to allLoadedMessages.length; do this right after calling
setSessionMessages and before/unconditionally with
setAllMessagesLoaded/setLoadAllJustFinished so pagination and refs reflect the
“all loaded” state.

Comment on lines +127 to +131
<button
onClick={onScrollToPreviousUserMessage}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
title="Scroll to previous user message"
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize and label the new navigation button.

The new tooltip text is hardcoded and not localized. Add i18n key usage and an aria-label (and type="button" for safety).

🌐 Suggested fix
-          <button
+          <button
+            type="button"
             onClick={onScrollToPreviousUserMessage}
             className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
-            title="Scroll to previous user message"
+            title={t('input.scrollToPreviousUserMessage', { defaultValue: 'Scroll to previous user message' })}
+            aria-label={t('input.scrollToPreviousUserMessage', { defaultValue: 'Scroll to previous user message' })}
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={onScrollToPreviousUserMessage}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
title="Scroll to previous user message"
>
<button
type="button"
onClick={onScrollToPreviousUserMessage}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
title={t('input.scrollToPreviousUserMessage', { defaultValue: 'Scroll to previous user message' })}
aria-label={t('input.scrollToPreviousUserMessage', { defaultValue: 'Scroll to previous user message' })}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/chat/view/subcomponents/ChatInputControls.tsx` around lines
127 - 131, In ChatInputControls.tsx update the new navigation <button> (the one
with onClick={onScrollToPreviousUserMessage}) to use the i18n translation key
instead of hardcoded tooltip text, add an aria-label that uses the same
localized string, and set type="button" for safety; locate the button in the
ChatInputControls component and replace the hardcoded title with t('...') (or
the project’s translation helper) and add aria-label={t('...')} plus
type="button".

Comment on lines +246 to +247
// index here is relative to visibleMessages. We need the global index in chatMessages.
const globalIndex = chatMessages.indexOf(message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid O(n²) global-index lookup in render loop.

chatMessages.indexOf(message) inside visibleMessages.map scales poorly and can slow scrolling/rendering on large sessions. Precompute an index map once per chatMessages change.

⚡ Proposed fix
-import { useCallback, useRef } from 'react';
+import { useCallback, useMemo, useRef } from 'react';
...
+  const globalIndexMap = useMemo(() => {
+    const map = new WeakMap<ChatMessage, number>();
+    chatMessages.forEach((msg, idx) => map.set(msg, idx));
+    return map;
+  }, [chatMessages]);
...
-            const globalIndex = chatMessages.indexOf(message);
+            const globalIndex = globalIndexMap.get(message);
...
-                index={globalIndex !== -1 ? globalIndex : index}
+                index={globalIndex ?? index}

Also applies to: 253-253

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/chat/view/subcomponents/ChatMessagesPane.tsx` around lines 246
- 247, The render loop in ChatMessagesPane.tsx uses
chatMessages.indexOf(message) inside visibleMessages.map (assigning
globalIndex), causing O(n²) behavior; instead, compute a lookup map once when
chatMessages changes (e.g., build a Map from message unique id or object
reference to its global index in chatMessages) and then read globalIndex =
indexMap.get(message.id || message) inside the visibleMessages.map. Update both
occurrences that use chatMessages.indexOf(message) (the one creating globalIndex
and the similar usage around the later occurrence) to use the precomputed
indexMap to avoid repeated linear scans.

@blackmammoth
Copy link
Collaborator

Hey @menny, thanks for the PR! Can you clarify what issues it solves in more detail and why the changes were necessary?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants