diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 89c4209b96d..e58e45ae9c2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false) + const [firstVisibleIndex, setFirstVisibleIndex] = useState(0) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -1113,6 +1115,46 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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 -
+
+ { + setFirstVisibleIndex(range.startIndex) + }} />
{areButtonsVisible && ( diff --git a/webview-ui/src/components/chat/StickyUserMessageNav.tsx b/webview-ui/src/components/chat/StickyUserMessageNav.tsx new file mode 100644 index 00000000000..baf5948909a --- /dev/null +++ b/webview-ui/src/components/chat/StickyUserMessageNav.tsx @@ -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 ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onNavigate() + } + }} + aria-label={t("chat:stickyNav.scrollToMessage")}> + + + {truncatedText} + + {t("chat:stickyNav.clickToJump")} + +
+ ) +}) + +StickyUserMessageNav.displayName = "StickyUserMessageNav" + +export default StickyUserMessageNav diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 9ce1ea34318..bf69695870f 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -477,5 +477,9 @@ "updated": "Updated the to-do list", "completed": "Completed", "started": "Started" + }, + "stickyNav": { + "scrollToMessage": "Scroll to your message", + "clickToJump": "Click to jump" } }