From 130874d200980b0d7a2835ca42a32102834866e4 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 5 Sep 2025 22:59:20 +0000 Subject: [PATCH] feat: add message navigator for improved conversation navigation - Add MessageNavigator component with search and filter functionality - Support filtering by message type (user, assistant, file edits, commands, errors, etc.) - Add keyboard shortcuts (Ctrl/Cmd+F to open, arrow keys to navigate, Enter to select, Esc to close) - Integrate with ChatView and TaskHeader - Add search button to TaskHeader for easy access - Add localization strings for the new feature Addresses #7721 --- webview-ui/src/components/chat/ChatView.tsx | 52 +- .../src/components/chat/MessageNavigator.tsx | 465 ++++++++++++++++++ webview-ui/src/components/chat/TaskHeader.tsx | 17 +- webview-ui/src/i18n/locales/en/chat.json | 22 + 4 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/chat/MessageNavigator.tsx diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 3e13905bc9..48de199327 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -56,6 +56,7 @@ import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" +import { MessageNavigator } from "./MessageNavigator" export interface ChatViewProps { isHidden: boolean @@ -193,6 +194,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [isCondensing, setIsCondensing] = useState(false) const [showAnnouncementModal, setShowAnnouncementModal] = useState(false) + const [showMessageNavigator, setShowMessageNavigator] = useState(false) const everVisibleMessagesTsRef = useRef>( new LRUCache({ max: 100, @@ -1728,8 +1730,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1759,6 +1766,40 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Find the message in visibleMessages + const targetMessage = visibleMessages[messageIndex] + if (targetMessage && virtuosoRef.current) { + // Find the index in groupedMessages + const groupIndex = groupedMessages.findIndex((item) => { + if (Array.isArray(item)) { + return item.some((msg) => msg.ts === targetMessage.ts) + } + return item.ts === targetMessage.ts + }) + + if (groupIndex !== -1) { + // Scroll to the message + virtuosoRef.current.scrollToIndex({ + index: groupIndex, + behavior: "smooth", + align: "center", + }) + + // Optionally expand the message if it's collapsible + if (!Array.isArray(groupedMessages[groupIndex])) { + const message = groupedMessages[groupIndex] as ClineMessage + setExpandedRows((prev) => ({ ...prev, [message.ts]: true })) + } + } + } + setShowMessageNavigator(false) + }, + [visibleMessages, groupedMessages], + ) + const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming return ( @@ -1790,6 +1831,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction setShowMessageNavigator(true)} /> {hasSystemPromptOverride && ( @@ -2013,6 +2055,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction + + {/* Message Navigator Overlay */} + setShowMessageNavigator(false)} + /> ) } diff --git a/webview-ui/src/components/chat/MessageNavigator.tsx b/webview-ui/src/components/chat/MessageNavigator.tsx new file mode 100644 index 0000000000..2244a5a6e4 --- /dev/null +++ b/webview-ui/src/components/chat/MessageNavigator.tsx @@ -0,0 +1,465 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from "react" +import { VSCodeButton, VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import { useTranslation } from "react-i18next" +import type { ClineMessage } from "@roo-code/types" +import { ClineSayTool } from "@roo/ExtensionMessage" +import { safeJsonParse } from "@roo/safeJsonParse" + +interface MessageNavigatorProps { + messages: ClineMessage[] + onNavigateToMessage: (messageIndex: number) => void + isVisible: boolean + onClose: () => void +} + +type MessageType = "all" | "user" | "assistant" | "file_edit" | "command" | "error" | "api_request" | "tool_use" + +interface FilteredMessage { + message: ClineMessage + originalIndex: number + matchedText?: string +} + +export const MessageNavigator: React.FC = ({ + messages, + onNavigateToMessage, + isVisible, + onClose, +}) => { + const { t } = useTranslation() + const [searchQuery, setSearchQuery] = useState("") + const [selectedFilter, setSelectedFilter] = useState("all") + const [selectedIndex, setSelectedIndex] = useState(0) + const searchInputRef = useRef(null) + const navigatorRef = useRef(null) + + // Focus search input when navigator becomes visible + useEffect(() => { + if (isVisible && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isVisible]) + + // Get message type for filtering + const getMessageType = useCallback((message: ClineMessage): MessageType[] => { + const types: MessageType[] = [] + + if (message.say === "user_feedback") { + types.push("user") + } else if (message.say === "text" || message.say === "completion_result") { + types.push("assistant") + } + + if (message.say === "error" || message.ask === "api_req_failed") { + types.push("error") + } + + if (message.ask === "command" || message.say === "command_output") { + types.push("command") + } + + if (message.say === "api_req_started") { + types.push("api_request") + } + + // Check for file operations + if (message.ask === "tool") { + const tool = safeJsonParse(message.text) + if (tool) { + types.push("tool_use") + if ( + [ + "editedExistingFile", + "appliedDiff", + "newFileCreated", + "insertContent", + "searchAndReplace", + ].includes(tool.tool) + ) { + types.push("file_edit") + } + } + } + + return types.length > 0 ? types : ["assistant"] + }, []) + + // Filter and search messages + const filteredMessages = useMemo((): FilteredMessage[] => { + let filtered = messages.map((message, index) => ({ + message, + originalIndex: index, + })) + + // Apply type filter + if (selectedFilter !== "all") { + filtered = filtered.filter(({ message }) => { + const types = getMessageType(message) + return types.includes(selectedFilter) + }) + } + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase() + filtered = filtered.filter(({ message }) => { + // Search in message text + if (message.text && message.text.toLowerCase().includes(query)) { + return true + } + + // Search in tool details + if (message.ask === "tool") { + const tool = safeJsonParse(message.text) + if (tool) { + // Search in file paths + if (tool.path && tool.path.toLowerCase().includes(query)) { + return true + } + // Search in content + if (tool.content && tool.content.toLowerCase().includes(query)) { + return true + } + // Search in diff + if (tool.diff && tool.diff.toLowerCase().includes(query)) { + return true + } + } + } + + return false + }) + + // Add matched text for highlighting + filtered = filtered.map((item) => { + const message = item.message + let matchedText = "" + + if (message.text && message.text.toLowerCase().includes(query)) { + // Extract a snippet around the match + const index = message.text.toLowerCase().indexOf(query) + const start = Math.max(0, index - 30) + const end = Math.min(message.text.length, index + query.length + 30) + matchedText = + (start > 0 ? "..." : "") + + message.text.substring(start, end) + + (end < message.text.length ? "..." : "") + } + + return { ...item, matchedText } + }) + } + + return filtered + }, [messages, selectedFilter, searchQuery, getMessageType]) + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isVisible) return + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, filteredMessages.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + break + case "Enter": + e.preventDefault() + if (filteredMessages[selectedIndex]) { + onNavigateToMessage(filteredMessages[selectedIndex].originalIndex) + } + break + case "Escape": + e.preventDefault() + onClose() + break + } + }, + [isVisible, selectedIndex, filteredMessages, onNavigateToMessage, onClose], + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + // Reset selected index when filters change + useEffect(() => { + setSelectedIndex(0) + }, [searchQuery, selectedFilter]) + + // Get display text for a message + const getMessageDisplayText = useCallback( + (message: ClineMessage, matchedText?: string): string => { + if (matchedText) { + return matchedText + } + + if (message.say === "user_feedback") { + return message.text || t("chat:navigator.userMessage") + } + + if (message.say === "error") { + return `${t("chat:error")}: ${message.text?.substring(0, 100)}...` + } + + if (message.ask === "command") { + return `${t("chat:runCommand.title")}: ${message.text?.substring(0, 100)}...` + } + + if (message.ask === "tool") { + const tool = safeJsonParse(message.text) + if (tool) { + switch (tool.tool) { + case "editedExistingFile": + case "appliedDiff": + return `${t("chat:fileOperations.wantsToEdit")}: ${tool.path}` + case "newFileCreated": + return `${t("chat:fileOperations.wantsToCreate")}: ${tool.path}` + case "readFile": + return `${t("chat:fileOperations.wantsToRead")}: ${tool.path}` + case "searchFiles": + return `${t("chat:directoryOperations.wantsToSearch")}: ${tool.regex}` + default: + return tool.tool + } + } + } + + if (message.say === "api_req_started") { + return t("chat:apiRequest.title") + } + + if (message.text) { + return message.text.substring(0, 100) + (message.text.length > 100 ? "..." : "") + } + + return t("chat:navigator.message") + }, + [t], + ) + + // Get icon for message type + const getMessageIcon = useCallback((message: ClineMessage): string => { + if (message.say === "user_feedback") { + return "account" + } + + if (message.say === "error" || message.ask === "api_req_failed") { + return "error" + } + + if (message.ask === "command") { + return "terminal" + } + + if (message.ask === "tool") { + const tool = safeJsonParse(message.text) + if (tool) { + switch (tool.tool) { + case "editedExistingFile": + case "appliedDiff": + return "edit" + case "newFileCreated": + return "new-file" + case "readFile": + return "file-code" + case "searchFiles": + return "search" + default: + return "tools" + } + } + } + + if (message.say === "api_req_started") { + return "cloud" + } + + return "comment" + }, []) + + if (!isVisible) { + return null + } + + return ( +
+ {/* Header */} +
+

+ {t("chat:navigator.title", "Message Navigator")} +

+ + + +
+ + {/* Search and Filter Bar */} +
+ setSearchQuery(e.target.value)} + style={{ flex: 1 }}> + + + setSelectedFilter(e.target.value as MessageType)} + style={{ minWidth: "150px" }}> + {t("chat:navigator.filter.all", "All Messages")} + {t("chat:navigator.filter.user", "User Messages")} + {t("chat:navigator.filter.assistant", "Assistant")} + {t("chat:navigator.filter.fileEdit", "File Edits")} + {t("chat:navigator.filter.command", "Commands")} + {t("chat:navigator.filter.error", "Errors")} + + {t("chat:navigator.filter.apiRequest", "API Requests")} + + {t("chat:navigator.filter.toolUse", "Tool Uses")} + +
+ + {/* Results List */} +
+ {filteredMessages.length === 0 ? ( +
+ {searchQuery + ? t("chat:navigator.noResults", "No messages found matching your search") + : t("chat:navigator.noMessages", "No messages to display")} +
+ ) : ( +
+ {filteredMessages.map((item, index) => ( +
onNavigateToMessage(item.originalIndex)} + onMouseEnter={() => setSelectedIndex(index)} + style={{ + padding: "8px 12px", + borderRadius: "4px", + cursor: "pointer", + backgroundColor: + index === selectedIndex + ? "var(--vscode-list-activeSelectionBackground)" + : "transparent", + color: + index === selectedIndex + ? "var(--vscode-list-activeSelectionForeground)" + : "var(--vscode-foreground)", + display: "flex", + alignItems: "center", + gap: "8px", + transition: "background-color 0.1s", + }}> + +
+
+ {getMessageDisplayText(item.message, item.matchedText)} +
+ {item.matchedText && searchQuery && ( +
+ {t("chat:navigator.messageNumber", "Message #{{number}}", { + number: item.originalIndex + 1, + })} +
+ )} +
+ + #{item.originalIndex + 1} + +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + {t("chat:navigator.showing", "Showing {{count}} of {{total}} messages", { + count: filteredMessages.length, + total: messages.length, + })} + + {t("chat:navigator.shortcuts", "↑↓ Navigate • Enter Select • Esc Close")} +
+
+ ) +} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8fd06b168f..8aee486a9e 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,6 +1,6 @@ import { memo, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react" +import { FoldVertical, ChevronUp, ChevronDown, Search } from "lucide-react" import prettyBytes from "pretty-bytes" import type { ClineMessage } from "@roo-code/types" @@ -31,6 +31,7 @@ export interface TaskHeaderProps { buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void todos?: any[] + onOpenNavigator?: () => void } const TaskHeader = ({ @@ -44,6 +45,7 @@ const TaskHeader = ({ buttonsDisabled, handleCondenseContext, todos, + onOpenNavigator, }: TaskHeaderProps) => { const { t } = useTranslation() const { apiConfiguration, currentTaskItem } = useExtensionState() @@ -117,6 +119,19 @@ const TaskHeader = ({ {isTaskExpanded ? : } + {onOpenNavigator && ( + + + + )} diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 72eacc5c58..3b66c8b678 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -388,5 +388,27 @@ "queuedMessages": { "title": "Queued Messages:", "clickToEdit": "Click to edit message" + }, + "navigator": { + "title": "Message Navigator", + "openTooltip": "Search messages (Ctrl/Cmd+F)", + "searchPlaceholder": "Search messages...", + "filter": { + "all": "All Messages", + "user": "User Messages", + "assistant": "Assistant", + "fileEdit": "File Edits", + "command": "Commands", + "error": "Errors", + "apiRequest": "API Requests", + "toolUse": "Tool Uses" + }, + "noResults": "No messages found matching your search", + "noMessages": "No messages to display", + "showing": "Showing {{count}} of {{total}} messages", + "shortcuts": "↑↓ Navigate • Enter Select • Esc Close", + "message": "Message", + "userMessage": "User message", + "messageNumber": "Message #{{number}}" } }