diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 80b57bfa5f5..deb0455f34a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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 } - // regular message + // regular message - add data attribute for navigation return ( - { - 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?",