feat: add jump-to-user-prompts navigation feature

- Add UserPromptNavigation component with up/down navigation buttons
- Integrate navigation buttons near scroll-to-bottom button
- Add keyboard shortcuts (Alt+Up/Down) for quick navigation
- Add visual highlight animation when jumping to prompts
- Add position indicator showing current prompt (e.g., "2 of 5")
- Add comprehensive tests for the navigation feature
- Add translation keys for new UI elements

Closes #9825
This commit is contained in:
Roo Code 2025-12-04 16:42:25 +00:00
parent 29385e01d7
commit 57caeaa940
5 changed files with 565 additions and 43 deletions

View file

@ -49,6 +49,7 @@ import { QueuedMessages } from "./QueuedMessages"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { Cloud } from "lucide-react"
import { UserPromptNavigation } from "./UserPromptNavigation"
export interface ChatViewProps {
isHidden: boolean
@ -1244,40 +1245,44 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return <BrowserSessionStatusRow key={messageOrGroup.ts} message={messageOrGroup} />
}
// regular message
// regular message - add data attribute for navigation
return (
<ChatRow
key={messageOrGroup.ts}
message={messageOrGroup}
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={toggleRowExpansion} // This was already stabilized
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
isLast={index === groupedMessages.length - 1} // Original direct access
onHeightChange={handleRowHeightChange}
isStreaming={isStreaming}
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
(() => {
let tool: any = {}
try {
tool = JSON.parse(messageOrGroup.text || "{}")
} catch (_) {
if (messageOrGroup.text?.includes("updateTodoList")) {
tool = { tool: "updateTodoList" }
<div data-message-index={index}>
<ChatRow
key={messageOrGroup.ts}
message={messageOrGroup}
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={toggleRowExpansion} // This was already stabilized
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
isLast={index === groupedMessages.length - 1} // Original direct access
onHeightChange={handleRowHeightChange}
isStreaming={isStreaming}
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
isFollowUpAnswered={
messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs
}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
(() => {
let tool: any = {}
try {
tool = JSON.parse(messageOrGroup.text || "{}")
} catch (_) {
if (messageOrGroup.text?.includes("updateTodoList")) {
tool = { tool: "updateTodoList" }
}
}
}
if (tool.tool === "updateTodoList" && alwaysAllowUpdateTodoList) {
return false
}
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
hasCheckpoint={hasCheckpoint}
/>
if (tool.tool === "updateTodoList" && alwaysAllowUpdateTodoList) {
return false
}
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
hasCheckpoint={hasCheckpoint}
/>
</div>
)
},
[
@ -1330,6 +1335,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
switchToNextMode()
}
}
// Check for Alt + Arrow keys for prompt navigation
if (event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
event.preventDefault() // Prevent default scrolling
// Find the UserPromptNavigation component and trigger navigation
const promptNavButtons = document.querySelectorAll("[data-prompt-nav]")
if (promptNavButtons.length > 0) {
if (event.key === "ArrowUp") {
// Trigger previous prompt navigation
const prevButton = document.querySelector('[data-prompt-nav="prev"]') as HTMLButtonElement
prevButton?.click()
} else if (event.key === "ArrowDown") {
// Trigger next prompt navigation
const nextButton = document.querySelector('[data-prompt-nav="next"]') as HTMLButtonElement
nextButton?.click()
}
}
}
},
[switchToNextMode, switchToPreviousMode],
)
@ -1474,17 +1498,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
: "opacity-50"
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
<div className="flex items-center gap-2 w-full">
<UserPromptNavigation
messages={messages}
virtuosoRef={virtuosoRef}
visibleMessages={groupedMessages}
className="mr-2"
/>
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-1"
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
</div>
) : (
<>
{primaryButtonText && !isStreaming && (

View file

@ -0,0 +1,21 @@
@keyframes prompt-highlight {
0% {
background-color: var(--vscode-editor-findMatchHighlightBackground);
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
background-color: transparent;
opacity: 0;
}
}
.prompt-highlight {
animation: prompt-highlight 1.5s ease-in-out;
border-radius: 4px;
}

View file

@ -0,0 +1,181 @@
import React, { useMemo, useCallback, useState, useEffect } from "react"
import { ChevronUp, ChevronDown } from "lucide-react"
import { StandardTooltip } from "@src/components/ui"
import { LucideIconButton } from "./LucideIconButton"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import type { ClineMessage } from "@roo-code/types"
import type { VirtuosoHandle } from "react-virtuoso"
import "./UserPromptNavigation.css"
interface UserPromptNavigationProps {
messages: ClineMessage[]
virtuosoRef: React.RefObject<VirtuosoHandle>
visibleMessages: ClineMessage[]
className?: string
}
export const UserPromptNavigation: React.FC<UserPromptNavigationProps> = ({
messages,
virtuosoRef,
visibleMessages,
className = "",
}) => {
const { t } = useAppTranslation()
const [currentPromptIndex, setCurrentPromptIndex] = useState<number>(-1)
const [isNavigating, setIsNavigating] = useState(false)
// Find all user prompts in the visible messages
const userPromptIndices = useMemo(() => {
const indices: number[] = []
visibleMessages.forEach((msg, index) => {
if (msg.say === "user_feedback" && msg.text && msg.text.trim() !== "") {
indices.push(index)
}
})
return indices
}, [visibleMessages])
// Reset navigation when messages change significantly
useEffect(() => {
if (!isNavigating) {
setCurrentPromptIndex(-1)
}
}, [messages.length, isNavigating])
// Navigate to previous user prompt
const navigateToPreviousPrompt = useCallback(() => {
if (userPromptIndices.length === 0) return
setIsNavigating(true)
let targetIndex: number
if (currentPromptIndex === -1) {
// If not currently navigating, jump to the last prompt
targetIndex = userPromptIndices.length - 1
} else if (currentPromptIndex > 0) {
// Navigate to previous prompt
targetIndex = currentPromptIndex - 1
} else {
// Wrap around to the last prompt
targetIndex = userPromptIndices.length - 1
}
setCurrentPromptIndex(targetIndex)
const messageIndex = userPromptIndices[targetIndex]
// Scroll to the message with smooth animation
virtuosoRef.current?.scrollToIndex({
index: messageIndex,
behavior: "smooth",
align: "center",
})
// Briefly highlight the message after scrolling
setTimeout(() => {
const element = document.querySelector(`[data-message-index="${messageIndex}"]`)
if (element) {
element.classList.add("prompt-highlight")
setTimeout(() => {
element.classList.remove("prompt-highlight")
}, 1500)
}
}, 500)
// Clear navigation state after a delay
setTimeout(() => {
setIsNavigating(false)
}, 3000)
}, [userPromptIndices, currentPromptIndex, virtuosoRef])
// Navigate to next user prompt
const navigateToNextPrompt = useCallback(() => {
if (userPromptIndices.length === 0) return
setIsNavigating(true)
let targetIndex: number
if (currentPromptIndex === -1) {
// If not currently navigating, jump to the first prompt
targetIndex = 0
} else if (currentPromptIndex < userPromptIndices.length - 1) {
// Navigate to next prompt
targetIndex = currentPromptIndex + 1
} else {
// Wrap around to the first prompt
targetIndex = 0
}
setCurrentPromptIndex(targetIndex)
const messageIndex = userPromptIndices[targetIndex]
// Scroll to the message with smooth animation
virtuosoRef.current?.scrollToIndex({
index: messageIndex,
behavior: "smooth",
align: "center",
})
// Briefly highlight the message after scrolling
setTimeout(() => {
const element = document.querySelector(`[data-message-index="${messageIndex}"]`)
if (element) {
element.classList.add("prompt-highlight")
setTimeout(() => {
element.classList.remove("prompt-highlight")
}, 1500)
}
}, 500)
// Clear navigation state after a delay
setTimeout(() => {
setIsNavigating(false)
}, 3000)
}, [userPromptIndices, currentPromptIndex, virtuosoRef])
// Don't show navigation if there are no user prompts
if (userPromptIndices.length === 0) {
return null
}
const navigationInfo =
currentPromptIndex !== -1
? t("chat:promptNavigation.position", {
current: currentPromptIndex + 1,
total: userPromptIndices.length,
})
: t("chat:promptNavigation.total", { total: userPromptIndices.length })
return (
<div className={`flex items-center gap-1 ${className}`}>
<StandardTooltip content={`${t("chat:promptNavigation.previousTooltip")} (Alt+↑)`}>
<div data-prompt-nav="prev">
<LucideIconButton
icon={ChevronUp}
onClick={navigateToPreviousPrompt}
disabled={userPromptIndices.length === 0}
className="h-7 w-7"
title={t("chat:promptNavigation.previous")}
/>
</div>
</StandardTooltip>
{isNavigating && (
<span className="text-xs text-vscode-descriptionForeground px-1 min-w-[60px] text-center">
{navigationInfo}
</span>
)}
<StandardTooltip content={`${t("chat:promptNavigation.nextTooltip")} (Alt+↓)`}>
<div data-prompt-nav="next">
<LucideIconButton
icon={ChevronDown}
onClick={navigateToNextPrompt}
disabled={userPromptIndices.length === 0}
className="h-7 w-7"
title={t("chat:promptNavigation.next")}
/>
</div>
</StandardTooltip>
</div>
)
}

View file

@ -0,0 +1,280 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { UserPromptNavigation } from "../UserPromptNavigation"
import type { ClineMessage } from "@roo-code/types"
import type { VirtuosoHandle } from "react-virtuoso"
// Mock the translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: any) => {
if (key === "chat:promptNavigation.position") {
return `${params?.current} of ${params?.total}`
}
if (key === "chat:promptNavigation.total") {
return `${params?.total} prompts`
}
if (key === "chat:promptNavigation.previousTooltip") {
return "Jump to previous user prompt"
}
if (key === "chat:promptNavigation.nextTooltip") {
return "Jump to next user prompt"
}
return key
},
}),
}))
// Mock CSS import
vi.mock("../UserPromptNavigation.css", () => ({}))
// Mock StandardTooltip to avoid TooltipProvider requirement
vi.mock("@src/components/ui", () => ({
StandardTooltip: ({ children, content }: { children: React.ReactNode; content: string }) => (
<div title={content}>{children}</div>
),
}))
// Mock LucideIconButton
vi.mock("../LucideIconButton", () => ({
LucideIconButton: ({ onClick, disabled, title }: any) => (
<button onClick={onClick} disabled={disabled} title={title}>
{title}
</button>
),
}))
describe("UserPromptNavigation", () => {
const mockVirtuosoRef = {
current: {
scrollToIndex: vi.fn(),
} as unknown as VirtuosoHandle,
}
const createMessage = (say: string, text: string, ts: number): ClineMessage =>
({
type: "say",
say,
text,
ts,
}) as ClineMessage
beforeEach(() => {
vi.clearAllMocks()
})
it("should not render when there are no user prompts", () => {
const messages: ClineMessage[] = [
createMessage("text", "AI response", 1000),
createMessage("api_req_started", "API request", 2000),
]
const { container } = render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
expect(container.firstChild).toBeNull()
})
it("should render navigation buttons when there are user prompts", () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "First prompt", 1000),
createMessage("text", "AI response", 2000),
createMessage("user_feedback", "Second prompt", 3000),
]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
// Check for navigation buttons
expect(screen.getByTitle("chat:promptNavigation.previous")).toBeInTheDocument()
expect(screen.getByTitle("chat:promptNavigation.next")).toBeInTheDocument()
})
it("should navigate to the last prompt when clicking previous for the first time", async () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "First prompt", 1000),
createMessage("text", "AI response", 2000),
createMessage("user_feedback", "Second prompt", 3000),
createMessage("text", "AI response 2", 4000),
createMessage("user_feedback", "Third prompt", 5000),
]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
const prevButton = screen.getByTitle("chat:promptNavigation.previous")
fireEvent.click(prevButton)
await waitFor(() => {
expect(mockVirtuosoRef.current?.scrollToIndex).toHaveBeenCalledWith({
index: 4, // Index of the third prompt
behavior: "smooth",
align: "center",
})
})
})
it("should navigate to the first prompt when clicking next for the first time", async () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "First prompt", 1000),
createMessage("text", "AI response", 2000),
createMessage("user_feedback", "Second prompt", 3000),
]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
const nextButton = screen.getByTitle("chat:promptNavigation.next")
fireEvent.click(nextButton)
await waitFor(() => {
expect(mockVirtuosoRef.current?.scrollToIndex).toHaveBeenCalledWith({
index: 0, // Index of the first prompt
behavior: "smooth",
align: "center",
})
})
})
it("should cycle through prompts when navigating multiple times", async () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "First prompt", 1000),
createMessage("user_feedback", "Second prompt", 2000),
createMessage("user_feedback", "Third prompt", 3000),
]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
const nextButton = screen.getByTitle("chat:promptNavigation.next")
// First click - go to first prompt
fireEvent.click(nextButton)
await waitFor(() => {
expect(mockVirtuosoRef.current?.scrollToIndex).toHaveBeenCalledWith({
index: 0,
behavior: "smooth",
align: "center",
})
})
// Wait a bit and click again - should go to second prompt
await new Promise((resolve) => setTimeout(resolve, 100))
fireEvent.click(nextButton)
await waitFor(() => {
expect(mockVirtuosoRef.current?.scrollToIndex).toHaveBeenCalledWith({
index: 1,
behavior: "smooth",
align: "center",
})
})
})
it("should filter out empty user prompts", () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "Valid prompt", 1000),
createMessage("user_feedback", "", 2000), // Empty prompt
createMessage("user_feedback", " ", 3000), // Whitespace only
createMessage("user_feedback", "Another valid prompt", 4000),
]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
const nextButton = screen.getByTitle("chat:promptNavigation.next")
fireEvent.click(nextButton)
// Should navigate to first valid prompt (index 0)
expect(mockVirtuosoRef.current?.scrollToIndex).toHaveBeenCalledWith({
index: 0,
behavior: "smooth",
align: "center",
})
})
it("should show navigation info when navigating", async () => {
const messages: ClineMessage[] = [
createMessage("user_feedback", "First prompt", 1000),
createMessage("user_feedback", "Second prompt", 2000),
]
const { container } = render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
const nextButton = screen.getByTitle("chat:promptNavigation.next")
fireEvent.click(nextButton)
// Should show position info briefly
await waitFor(() => {
const info = container.querySelector(".text-xs")
expect(info?.textContent).toContain("1 of 2")
})
})
it("should have proper tooltips with keyboard shortcuts", () => {
const messages: ClineMessage[] = [createMessage("user_feedback", "First prompt", 1000)]
render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
/>,
)
// Check for data attributes for keyboard navigation
expect(document.querySelector('[data-prompt-nav="prev"]')).toBeInTheDocument()
expect(document.querySelector('[data-prompt-nav="next"]')).toBeInTheDocument()
})
it("should apply custom className when provided", () => {
const messages: ClineMessage[] = [createMessage("user_feedback", "First prompt", 1000)]
const { container } = render(
<UserPromptNavigation
messages={messages}
virtuosoRef={mockVirtuosoRef as React.RefObject<VirtuosoHandle>}
visibleMessages={messages}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass("custom-class")
})
})

View file

@ -98,6 +98,14 @@
"placeholder": "Edit your message..."
},
"scrollToBottom": "Scroll to bottom of chat",
"promptNavigation": {
"previous": "Previous prompt",
"previousTooltip": "Jump to previous user prompt",
"next": "Next prompt",
"nextTooltip": "Jump to next user prompt",
"position": "{{current}} of {{total}}",
"total": "{{total}} prompts"
},
"about": "Roo is a whole AI dev team in your editor",
"docs": "Check our <DocsLink>docs</DocsLink> to get started",
"onboarding": "What would you like to do?",