feat: add sticky user message navigation for easier scrolling

Implements a sticky navigation banner that appears at the top of the chat
when users scroll up past their messages. Clicking the banner scrolls to
the most recent out-of-view user message, making it easier to navigate
long conversations.

Addresses Issue #10690
This commit is contained in:
Roo Code 2026-01-13 14:21:41 +00:00
parent 78821a3951
commit 98ad090a77
3 changed files with 118 additions and 1 deletions

View file

@ -44,6 +44,7 @@ import SystemPromptWarning from "./SystemPromptWarning"
import ProfileViolationWarning from "./ProfileViolationWarning"
import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
import StickyUserMessageNav from "./StickyUserMessageNav"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { Cloud } from "lucide-react"
@ -150,6 +151,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const stickyFollowRef = useRef<boolean>(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [firstVisibleIndex, setFirstVisibleIndex] = useState(0)
const lastTtsRef = useRef<string>("")
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [checkpointWarning, setCheckpointWarning] = useState<
@ -1113,6 +1115,46 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return result
}, [isCondensing, visibleMessages, isBrowserSessionMessage])
// Compute user message indices from groupedMessages for sticky navigation
const userMessageIndices = useMemo(() => {
const indices: number[] = []
groupedMessages.forEach((msg, index) => {
if (msg.say === "user_feedback") {
indices.push(index)
}
})
return indices
}, [groupedMessages])
// Find the last user message that is scrolled out of view (before firstVisibleIndex)
const stickyNavMessage = useMemo(() => {
// Find the last user message index that is before the first visible index
const lastUserIndexBeforeVisible = userMessageIndices.findLast((idx) => idx < firstVisibleIndex)
if (lastUserIndexBeforeVisible !== undefined) {
return groupedMessages[lastUserIndexBeforeVisible]
}
return null
}, [userMessageIndices, firstVisibleIndex, groupedMessages])
// Show sticky nav when user has scrolled up past a user message and not at top
const showStickyNav = useMemo(() => {
return stickyNavMessage !== null && firstVisibleIndex > 0 && !isAtBottom
}, [stickyNavMessage, firstVisibleIndex, isAtBottom])
// Handler to scroll to the sticky nav message
const handleStickyNavClick = useCallback(() => {
if (stickyNavMessage) {
const messageIndex = groupedMessages.findIndex((msg) => msg.ts === stickyNavMessage.ts)
if (messageIndex >= 0) {
virtuosoRef.current?.scrollToIndex({
index: messageIndex,
behavior: "smooth",
align: "start",
})
}
}
}, [stickyNavMessage, groupedMessages])
// scrolling
const scrollToBottomSmooth = useMemo(
@ -1490,7 +1532,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{task && (
<>
<div className="grow flex" ref={scrollContainerRef}>
<div className="grow flex flex-col relative" ref={scrollContainerRef}>
<StickyUserMessageNav
message={stickyNavMessage}
onNavigate={handleStickyNavClick}
isVisible={showStickyNav}
/>
<Virtuoso
ref={virtuosoRef}
key={task.ts}
@ -1506,6 +1553,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
rangeChanged={(range) => {
setFirstVisibleIndex(range.startIndex)
}}
/>
</div>
{areButtonsVisible && (

View file

@ -0,0 +1,63 @@
import React, { memo } from "react"
import { useTranslation } from "react-i18next"
import { ChevronUp, User } from "lucide-react"
import type { ClineMessage } from "@roo-code/types"
import { cn } from "@/lib/utils"
interface StickyUserMessageNavProps {
/** The user message to display in the sticky nav */
message: ClineMessage | null
/** Callback when the sticky nav is clicked to scroll to the message */
onNavigate: () => void
/** Whether the sticky nav should be visible */
isVisible: boolean
}
/**
* A sticky navigation component that appears at the top of the chat when the user
* scrolls up past their messages. Clicking it scrolls back to the most recent
* user message that is out of view.
*/
const StickyUserMessageNav = memo(({ message, onNavigate, isVisible }: StickyUserMessageNavProps) => {
const { t } = useTranslation()
if (!isVisible || !message) {
return null
}
// Truncate message text for display
const displayText = message.text || ""
const truncatedText = displayText.length > 80 ? displayText.substring(0, 80) + "..." : displayText
return (
<div
className={cn(
"sticky top-0 z-10 flex items-center gap-2 px-3 py-2 cursor-pointer",
"bg-vscode-editor-background/95 backdrop-blur-sm",
"border-b border-vscode-editorGroup-border",
"transition-all duration-200 ease-in-out",
"hover:bg-vscode-list-hoverBackground",
)}
onClick={onNavigate}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onNavigate()
}
}}
aria-label={t("chat:stickyNav.scrollToMessage")}>
<ChevronUp className="w-4 h-4 text-vscode-descriptionForeground shrink-0" />
<User className="w-4 h-4 text-vscode-descriptionForeground shrink-0" />
<span className="text-sm text-vscode-foreground truncate flex-1">{truncatedText}</span>
<span className="text-xs text-vscode-descriptionForeground shrink-0">
{t("chat:stickyNav.clickToJump")}
</span>
</div>
)
})
StickyUserMessageNav.displayName = "StickyUserMessageNav"
export default StickyUserMessageNav

View file

@ -477,5 +477,9 @@
"updated": "Updated the to-do list",
"completed": "Completed",
"started": "Started"
},
"stickyNav": {
"scrollToMessage": "Scroll to your message",
"clickToJump": "Click to jump"
}
}