{
- let tool: any = {}
- try {
- tool = JSON.parse(messageOrGroup.text || "{}")
- } catch (_) {
- if (messageOrGroup.text?.includes("updateTodoList")) {
- tool = { tool: "updateTodoList" }
+
+ {
+ 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}
+ />
+
)
},
[
@@ -1330,6 +1335,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction 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
{showScrollToBottom ? (
-
-
-
+
+
+
+
+
+
) : (
<>
{primaryButtonText && !isStreaming && (
diff --git a/webview-ui/src/components/chat/UserPromptNavigation.css b/webview-ui/src/components/chat/UserPromptNavigation.css
new file mode 100644
index 00000000000..d64dcbb4171
--- /dev/null
+++ b/webview-ui/src/components/chat/UserPromptNavigation.css
@@ -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;
+}
diff --git a/webview-ui/src/components/chat/UserPromptNavigation.tsx b/webview-ui/src/components/chat/UserPromptNavigation.tsx
new file mode 100644
index 00000000000..6c6dc1bdf2d
--- /dev/null
+++ b/webview-ui/src/components/chat/UserPromptNavigation.tsx
@@ -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
+ visibleMessages: ClineMessage[]
+ className?: string
+}
+
+export const UserPromptNavigation: React.FC = ({
+ messages,
+ virtuosoRef,
+ visibleMessages,
+ className = "",
+}) => {
+ const { t } = useAppTranslation()
+ const [currentPromptIndex, setCurrentPromptIndex] = useState(-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 (
+
+
+
+
+
+
+
+ {isNavigating && (
+
+ {navigationInfo}
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/chat/__tests__/UserPromptNavigation.spec.tsx b/webview-ui/src/components/chat/__tests__/UserPromptNavigation.spec.tsx
new file mode 100644
index 00000000000..0f97863d382
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/UserPromptNavigation.spec.tsx
@@ -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 }) => (
+ {children}
+ ),
+}))
+
+// Mock LucideIconButton
+vi.mock("../LucideIconButton", () => ({
+ LucideIconButton: ({ onClick, disabled, title }: any) => (
+
+ ),
+}))
+
+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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ 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(
+ }
+ visibleMessages={messages}
+ className="custom-class"
+ />,
+ )
+
+ expect(container.firstChild).toHaveClass("custom-class")
+ })
+})
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index 67fb7f2a51a..eec83e60370 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -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 docs to get started",
"onboarding": "What would you like to do?",