diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe49..e789f13fa0e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -120,6 +120,7 @@ export interface ExtensionMessage { | "cloudButtonClicked" | "didBecomeVisible" | "focusInput" + | "openSearch" | "switchTab" | "toggleAutoApprove" invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd28f2e9945..e9acf568f85 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -45,6 +45,7 @@ export const commandIds = [ "focusInput", "acceptInput", + "searchChat", "focusPanel", "toggleAutoApprove", ] as const diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f02ee8309a3..787ef953d50 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -183,6 +183,21 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + searchChat: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + try { + await focusPanel(tabPanel, sidebarPanel) + } catch (error) { + outputChannel.appendLine(`Error focusing panel for chat search: ${error}`) + } + + visibleProvider.postMessageToWebview({ type: "action", action: "openSearch" }) + }, toggleAutoApprove: async () => { const visibleProvider = getVisibleProviderOrLog(outputChannel) diff --git a/src/package.json b/src/package.json index cb3b93d1602..dbaf0e53265 100644 --- a/src/package.json +++ b/src/package.json @@ -165,6 +165,12 @@ "title": "%command.acceptInput.title%", "category": "%configuration.title%" }, + { + "command": "roo-cline.searchChat", + "title": "%command.searchChat.title%", + "category": "%configuration.title%", + "icon": "$(search)" + }, { "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", @@ -229,10 +235,15 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.marketplaceButtonClicked", + "command": "roo-cline.searchChat", "group": "navigation@4", "when": "view == roo-cline.SidebarProvider" }, + { + "command": "roo-cline.marketplaceButtonClicked", + "group": "navigation@5", + "when": "view == roo-cline.SidebarProvider" + }, { "command": "roo-cline.historyButtonClicked", "group": "overflow@1", @@ -261,10 +272,15 @@ "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, { - "command": "roo-cline.marketplaceButtonClicked", + "command": "roo-cline.searchChat", "group": "navigation@4", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" }, + { + "command": "roo-cline.marketplaceButtonClicked", + "group": "navigation@5", + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + }, { "command": "roo-cline.historyButtonClicked", "group": "overflow@1", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 2781ed169cf..d40b2182225 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Corregir Aquesta Ordre", "command.terminal.explainCommand.title": "Explicar Aquesta Ordre", "command.acceptInput.title": "Acceptar Entrada/Suggeriment", + "command.searchChat.title": "Cerca al xat", "command.toggleAutoApprove.title": "Alternar Auto-Aprovació", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index a77a253ef06..4d3c05134f7 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Diesen Befehl Reparieren", "command.terminal.explainCommand.title": "Diesen Befehl Erklären", "command.acceptInput.title": "Eingabe/Vorschlag Akzeptieren", + "command.searchChat.title": "Chat durchsuchen", "command.toggleAutoApprove.title": "Auto-Genehmigung Umschalten", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index a1c729080e2..c9f48bd6119 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Corregir Este Comando", "command.terminal.explainCommand.title": "Explicar Este Comando", "command.acceptInput.title": "Aceptar Entrada/Sugerencia", + "command.searchChat.title": "Buscar en el chat", "command.toggleAutoApprove.title": "Alternar Auto-Aprobación", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 2d009c0038d..79c2ef5d8fe 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Corriger cette Commande", "command.terminal.explainCommand.title": "Expliquer cette Commande", "command.acceptInput.title": "Accepter l'Entrée/Suggestion", + "command.searchChat.title": "Rechercher dans le chat", "command.toggleAutoApprove.title": "Basculer Auto-Approbation", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index c51f3ee95ee..323d95a9409 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "यह कमांड ठीक करें", "command.terminal.explainCommand.title": "यह कमांड समझाएं", "command.acceptInput.title": "इनपुट/सुझाव स्वीकारें", + "command.searchChat.title": "चैट खोजें", "command.toggleAutoApprove.title": "ऑटो-अनुमोदन टॉगल करें", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 2a7607f3e7c..d94d69eb96b 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -24,6 +24,7 @@ "command.terminal.fixCommand.title": "Perbaiki Perintah Ini", "command.terminal.explainCommand.title": "Jelaskan Perintah Ini", "command.acceptInput.title": "Terima Input/Saran", + "command.searchChat.title": "Cari Chat", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index c94471355d4..5f190ef5a2c 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Correggi Questo Comando", "command.terminal.explainCommand.title": "Spiega Questo Comando", "command.acceptInput.title": "Accetta Input/Suggerimento", + "command.searchChat.title": "Cerca nella chat", "command.toggleAutoApprove.title": "Attiva/Disattiva Auto-Approvazione", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index ff6040d7734..5fd57e9d0d5 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -24,6 +24,7 @@ "command.terminal.fixCommand.title": "このコマンドを修正", "command.terminal.explainCommand.title": "このコマンドを説明", "command.acceptInput.title": "入力/提案を承認", + "command.searchChat.title": "チャットを検索", "command.toggleAutoApprove.title": "自動承認を切替", "configuration.title": "Roo Code", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", diff --git a/src/package.nls.json b/src/package.nls.json index 177b392f775..aa69f31e4cb 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -24,6 +24,7 @@ "command.terminal.fixCommand.title": "Fix This Command", "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", + "command.searchChat.title": "Search Chat", "command.toggleAutoApprove.title": "Toggle Auto-Approve", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index f0912835b8b..18a71eec94b 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "이 명령어 수정", "command.terminal.explainCommand.title": "이 명령어 설명", "command.acceptInput.title": "입력/제안 수락", + "command.searchChat.title": "채팅 검색", "command.toggleAutoApprove.title": "자동 승인 전환", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index fef3ca7219c..90599b0b9b8 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -24,6 +24,7 @@ "command.terminal.fixCommand.title": "Repareer Dit Commando", "command.terminal.explainCommand.title": "Leg Dit Commando Uit", "command.acceptInput.title": "Invoer/Suggestie Accepteren", + "command.searchChat.title": "Chat doorzoeken", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 8c1f66450d1..4a777eb1cfb 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Napraw tę Komendę", "command.terminal.explainCommand.title": "Wyjaśnij tę Komendę", "command.acceptInput.title": "Akceptuj Wprowadzanie/Sugestię", + "command.searchChat.title": "Szukaj w czacie", "command.toggleAutoApprove.title": "Przełącz Auto-Zatwierdzanie", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 84cbf42c097..89ff3f6c937 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Corrigir Este Comando", "command.terminal.explainCommand.title": "Explicar Este Comando", "command.acceptInput.title": "Aceitar Entrada/Sugestão", + "command.searchChat.title": "Pesquisar no chat", "command.toggleAutoApprove.title": "Alternar Auto-Aprovação", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index be8df040323..7a0011227ca 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -24,6 +24,7 @@ "command.terminal.fixCommand.title": "Исправить эту команду", "command.terminal.explainCommand.title": "Объяснить эту команду", "command.acceptInput.title": "Принять ввод/предложение", + "command.searchChat.title": "Поиск в чате", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index a815188e8aa..b79b99efd8d 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Bu Komutu Düzelt", "command.terminal.explainCommand.title": "Bu Komutu Açıkla", "command.acceptInput.title": "Girişi/Öneriyi Kabul Et", + "command.searchChat.title": "Sohbette Ara", "command.toggleAutoApprove.title": "Otomatik Onayı Değiştir", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 6052080dfa3..68478c67c2b 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "Sửa Lệnh Này", "command.terminal.explainCommand.title": "Giải Thích Lệnh Này", "command.acceptInput.title": "Chấp Nhận Đầu Vào/Gợi Ý", + "command.searchChat.title": "Tìm kiếm trò chuyện", "command.toggleAutoApprove.title": "Bật/Tắt Tự Động Phê Duyệt", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 9254d494d9b..8070b6f404f 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "修复此命令", "command.terminal.explainCommand.title": "解释此命令", "command.acceptInput.title": "接受输入/建议", + "command.searchChat.title": "搜索聊天", "command.toggleAutoApprove.title": "切换自动批准", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index a8030d69141..a1a9be19e59 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -14,6 +14,7 @@ "command.terminal.fixCommand.title": "修復此命令", "command.terminal.explainCommand.title": "解釋此命令", "command.acceptInput.title": "接受輸入/建議", + "command.searchChat.title": "搜尋聊天", "command.toggleAutoApprove.title": "切換自動批准", "views.activitybar.title": "Roo Code", "views.contextMenu.label": "Roo Code", diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index cccb0422ca8..95f1b0a699f 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -121,6 +121,12 @@ const App = () => { const message: ExtensionMessage = e.data if (message.type === "action" && message.action) { + if (message.action === "openSearch") { + switchTab("chat") + window.setTimeout(() => chatViewRef.current?.openSearch(), 0) + return + } + // Handle switchTab action with tab parameter if (message.action === "switchTab" && message.tab) { const targetTab = message.tab as Tab diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 33c9acb2df2..0c651055e4a 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -125,6 +125,8 @@ interface ChatRowProps { editable?: boolean hasCheckpoint?: boolean onJumpToPreviousCheckpoint?: () => void + chatSearchQuery?: string + activeChatSearchMatchIndex?: number } // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -132,13 +134,14 @@ interface ChatRowContentProps extends Omit {} const ChatRow = memo( (props: ChatRowProps) => { - const { isLast, onHeightChange, message } = props + const { isLast, onHeightChange, message, chatSearchQuery, activeChatSearchMatchIndex } = props // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) + const chatSearchRowRef = useRef(null) const [chatrow, { height }] = useSize( -
+
, ) @@ -157,6 +160,67 @@ const ChatRow = memo( } }, [height, isLast, onHeightChange, message]) + useEffect(() => { + const row = chatSearchRowRef.current + + if (!row) { + return + } + + if (!chatSearchQuery) { + return + } + + const updateActiveHighlight = () => { + const matches = Array.from(row.querySelectorAll("[data-chat-search-match='true']")) + + for (const match of matches) { + match.classList.remove("chat-search-match-active") + } + + if (typeof activeChatSearchMatchIndex !== "number") { + return + } + + const activeMatch = matches[activeChatSearchMatchIndex] + + if (!activeMatch) { + return + } + + activeMatch.classList.add("chat-search-match-active") + } + + updateActiveHighlight() + + if (typeof activeChatSearchMatchIndex !== "number") { + return + } + + let animationFrame: number | undefined + + const scheduleUpdate = () => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame) + } + + animationFrame = requestAnimationFrame(() => { + animationFrame = undefined + updateActiveHighlight() + }) + } + + const mutationObserver = new MutationObserver(() => scheduleUpdate()) + mutationObserver.observe(row, { childList: true, characterData: true, subtree: true }) + + return () => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame) + } + mutationObserver.disconnect() + } + }, [activeChatSearchMatchIndex, chatSearchQuery, message.ts]) + // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered return chatrow }, @@ -179,6 +243,7 @@ export const ChatRowContent = ({ isFollowUpAnswered, isFollowUpAutoApprovalPaused, onJumpToPreviousCheckpoint, + chatSearchQuery, }: ChatRowContentProps) => { const { t, i18n } = useTranslation() @@ -868,7 +933,7 @@ export const ChatRowContent = ({
- +
{childTaskId && !isFollowedBySubtaskResult && (
- +
) @@ -1029,7 +1097,7 @@ export const ChatRowContent = ({ {t("chat:subtasks.resultContent")}
- + {completedChildTaskId && ( + + + + + + + + + )} + {checkpointWarning && (
diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index 87780d5df8d..e31e03e67af 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -6,62 +6,64 @@ import { StandardTooltip } from "@src/components/ui" import MarkdownBlock from "../common/MarkdownBlock" -export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => { - const [isHovering, setIsHovering] = useState(false) +export const Markdown = memo( + ({ markdown, partial, searchQuery }: { markdown?: string; partial?: boolean; searchQuery?: string }) => { + const [isHovering, setIsHovering] = useState(false) - // Shorter feedback duration for copy button flash. - const { copyWithFeedback } = useCopyToClipboard(200) + // Shorter feedback duration for copy button flash. + const { copyWithFeedback } = useCopyToClipboard(200) - if (!markdown || markdown.length === 0) { - return null - } + if (!markdown || markdown.length === 0) { + return null + } - return ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - style={{ position: "relative" }}> -
- -
- {markdown && !partial && isHovering && ( -
- - - { - const success = await copyWithFeedback(markdown) - if (success) { - const button = document.activeElement as HTMLElement - if (button) { - button.style.background = "var(--vscode-button-background)" - setTimeout(() => { - button.style.background = "" - }, 200) - } - } - }}> - - - + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + style={{ position: "relative" }}> +
+
- )} -
- ) -}) + {markdown && !partial && isHovering && ( +
+ + + { + const success = await copyWithFeedback(markdown) + if (success) { + const button = document.activeElement as HTMLElement + if (button) { + button.style.background = "var(--vscode-button-background)" + setTimeout(() => { + button.style.background = "" + }, 200) + } + } + }}> + + + +
+ )} +
+ ) + }, +) diff --git a/webview-ui/src/components/chat/Mention.tsx b/webview-ui/src/components/chat/Mention.tsx index 00130756554..69a891775d9 100644 --- a/webview-ui/src/components/chat/Mention.tsx +++ b/webview-ui/src/components/chat/Mention.tsx @@ -1,13 +1,15 @@ import { mentionRegexGlobal } from "@roo/context-mentions" import { vscode } from "../../utils/vscode" +import { HighlightedText } from "../../utils/searchHighlight" interface MentionProps { text?: string withShadow?: boolean + searchQuery?: string } -export const Mention = ({ text, withShadow = false }: MentionProps) => { +export const Mention = ({ text, withShadow = false, searchQuery }: MentionProps) => { if (!text) { return <>{text} } @@ -15,7 +17,7 @@ export const Mention = ({ text, withShadow = false }: MentionProps) => { const parts = text.split(mentionRegexGlobal).map((part, index) => { if (index % 2 === 0) { // This is regular text. - return part + return } else { // This is a mention. return ( @@ -23,7 +25,7 @@ export const Mention = ({ text, withShadow = false }: MentionProps) => { key={index} className={`${withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} text-[0.9em] cursor-pointer`} onClick={() => vscode.postMessage({ type: "openMention", text: part })}> - @{part} + ) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 52833ed335d..783a58b3d0a 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -41,6 +41,8 @@ export interface TaskHeaderProps { buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void todos?: any[] + chatSearchQuery?: string + activeChatSearchMatchIndex?: number } const TaskHeader = ({ @@ -58,6 +60,8 @@ const TaskHeader = ({ buttonsDisabled, handleCondenseContext, todos, + chatSearchQuery, + activeChatSearchMatchIndex, }: TaskHeaderProps) => { const { t } = useTranslation() const { apiConfiguration, currentTaskItem, clineMessages } = useExtensionState() @@ -94,8 +98,37 @@ const TaskHeader = ({ const textContainerRef = useRef(null) const textRef = useRef(null) + const taskHeaderRef = useRef(null) const contextWindow = model?.contextWindow || 1 + useEffect(() => { + const header = taskHeaderRef.current + + if (!header) { + return + } + + if (!chatSearchQuery) { + return + } + + const matches = Array.from(header.querySelectorAll("[data-chat-search-match='true']")) + + for (const match of matches) { + match.classList.remove("chat-search-match-active") + } + + if (typeof activeChatSearchMatchIndex !== "number") { + return + } + + const activeMatch = matches[activeChatSearchMatchIndex] + + if (activeMatch) { + activeMatch.classList.add("chat-search-match-active") + } + }, [activeChatSearchMatchIndex, chatSearchQuery, isTaskExpanded, task.text]) + // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( () => @@ -131,7 +164,7 @@ const TaskHeader = ({ } return ( -
+
{isSubtask && (
e.stopPropagation()}>
@@ -332,7 +365,7 @@ const TaskHeader = ({ WebkitLineClamp: "unset", WebkitBoxOrient: "vertical", }}> - +
{task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index 042b764a9a8..6d64639959d 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -8,6 +8,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime" import { ChevronDown, ChevronUp, Copy, Check } from "lucide-react" import { useAppTranslation } from "@src/i18n/TranslationContext" import { StandardTooltip } from "@/components/ui" +import { HighlightedText, applySearchHighlightsToHast } from "@src/utils/searchHighlight" export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" export const WRAPPER_ALPHA = "cc" // 80% opacity @@ -37,6 +38,7 @@ interface CodeBlockProps { initialWordWrap?: boolean collapsedHeight?: number initialWindowShade?: boolean + searchQuery?: string } const CodeBlockButton = styled.button` @@ -174,6 +176,7 @@ const CodeBlock = memo( initialWordWrap = true, initialWindowShade = true, collapsedHeight, + searchQuery, }: CodeBlockProps) => { // Use word wrap from props, default to true const wordWrap = initialWordWrap @@ -199,7 +202,9 @@ const CodeBlock = memo( // Create a safe fallback using React elements instead of HTML string const fallback = (
-					{source || ""}
+					
+						
+					
 				
) @@ -238,6 +243,8 @@ const CodeBlock = memo( }) if (!isMountedRef.current) return + applySearchHighlightsToHast(hast, searchQuery, { skipPre: false }) + // Convert HAST to React elements using hast-util-to-jsx-runtime // This approach eliminates XSS vulnerabilities by avoiding dangerouslySetInnerHTML // while maintaining the exact same visual output and syntax highlighting @@ -283,7 +290,7 @@ const CodeBlock = memo( collapseTimeout2Ref.current = null } } - }, [source, currentLanguage, collapsedHeight]) + }, [source, currentLanguage, collapsedHeight, searchQuery]) // Check if content height exceeds collapsed height whenever content changes useEffect(() => { diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 47c61a5ce02..878d54b54da 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -7,12 +7,15 @@ import remarkMath from "remark-math" import remarkGfm from "remark-gfm" import { vscode } from "@src/utils/vscode" +import { isRenderedDiagramCodeBlock } from "@src/utils/chatSearchText" +import { HighlightedText, applySearchHighlightsToHast } from "@src/utils/searchHighlight" import CodeBlock from "./CodeBlock" import MermaidBlock from "./MermaidBlock" interface MarkdownBlockProps { markdown?: string + searchQuery?: string } const StyledMarkdown = styled.div` @@ -203,7 +206,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, searchQuery }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -271,8 +274,8 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { codeString = codeChildren.filter((child) => typeof child === "string").join("") } - // Handle mermaid diagrams - if (className.includes("language-mermaid")) { + // Mermaid-like graph/flowchart blocks render as diagrams, so their raw text is not searchable. + if (isRenderedDiagramCodeBlock(className, codeString)) { return (
@@ -287,22 +290,28 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { // Wrap CodeBlock in a div to ensure proper separation return (
- +
) }, - code: ({ children, className, ...props }: any) => { + code: ({ children, className, node: _node, ...props }: any) => { // This handles inline code + const text = Array.isArray(children) ? children.join("") : String(children ?? "") + return ( - {children} + ) }, }), - [], + [searchQuery], ) + const searchHighlightPlugin = useMemo(() => { + return () => (tree: any) => applySearchHighlightsToHast(tree, searchQuery, { skipPre: true }) + }, [searchQuery]) + return ( { } }, ]} - rehypePlugins={[rehypeKatex as any]} + rehypePlugins={[rehypeKatex as any, searchHighlightPlugin]} components={components}> {markdown || ""} diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 95c795fdc5b..13ff65abb6d 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -89,7 +89,7 @@ interface MermaidBlockProps { export default function MermaidBlock({ code }: MermaidBlockProps) { const containerRef = useRef(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isErrorExpanded, setIsErrorExpanded] = useState(false) const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() @@ -315,7 +315,7 @@ interface SvgContainerProps { const SvgContainer = styled.div` opacity: ${(props) => (props.$isLoading ? 0.3 : 1)}; - min-height: 20px; + min-height: ${(props) => (props.$isLoading ? "180px" : "20px")}; transition: opacity 0.2s ease; cursor: pointer; display: flex; diff --git a/webview-ui/src/utils/__tests__/chatSearchText.spec.ts b/webview-ui/src/utils/__tests__/chatSearchText.spec.ts new file mode 100644 index 00000000000..2f2f7b111e7 --- /dev/null +++ b/webview-ui/src/utils/__tests__/chatSearchText.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" + +import { countSearchMatches, getChatSearchText } from "../chatSearchText" + +describe("chatSearchText", () => { + it("excludes readFile tool request text from search", () => { + const text = getChatSearchText({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "readFile", + path: "assignment3.md", + reason: "inspect tiny image notes", + content: "tiny nearest bag", + }), + }) + + expect(text).toBe("") + expect(countSearchMatches(text, "tiny")).toBe(0) + }) + + it("excludes batched readFile tool content from search", () => { + const text = getChatSearchText({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "readFile", + batchFiles: [ + { + path: "assignment3.md", + content: "tiny nearest bag", + }, + ], + }), + }) + + expect(text).toBe("") + expect(countSearchMatches(text, "nearest")).toBe(0) + }) + + it("excludes reasoning messages from search", () => { + const text = getChatSearchText({ + type: "say", + say: "reasoning", + text: "Reasoning mentions tiny, nearest, and bag but should not be searchable.", + }) + + expect(text).toBe("") + expect(countSearchMatches(text, "tiny")).toBe(0) + expect(countSearchMatches(text, "nearest")).toBe(0) + expect(countSearchMatches(text, "bag")).toBe(0) + }) + + it("searches inline markdown link labels but excludes link targets", () => { + const text = getChatSearchText({ + type: "say", + say: "text", + text: [ + "All code is in [`student_code_SID.py`](Assignment3_code/mycode/student_code_SID.py).", + "| # | Function | Points | Notes |", + "| 1 | [`get_tiny_images()`](Assignment3_code/mycode/student_code_SID.py:16) | 20 | normalize tiny image features |", + ].join("\n"), + }) + + expect(countSearchMatches(text, "student_code")).toBe(1) + expect(countSearchMatches(text, "get_tiny_images")).toBe(1) + expect(countSearchMatches(text, "Assignment3_code")).toBe(0) + expect(countSearchMatches(text, "student_code_SID.py:16")).toBe(0) + expect(countSearchMatches(text, "tiny image")).toBe(1) + }) + + it("searches reference-style markdown link labels but excludes definitions", () => { + const text = getChatSearchText({ + type: "say", + say: "text", + text: "Open [`helper.py`][helper] before nearest neighbor.\n\n[helper]: Assignment3_code/helper.py", + }) + + expect(countSearchMatches(text, "helper.py")).toBe(1) + expect(countSearchMatches(text, "Assignment3_code")).toBe(0) + expect(countSearchMatches(text, "nearest")).toBe(1) + }) +}) diff --git a/webview-ui/src/utils/chatSearchText.ts b/webview-ui/src/utils/chatSearchText.ts new file mode 100644 index 00000000000..4aa40ee21ca --- /dev/null +++ b/webview-ui/src/utils/chatSearchText.ts @@ -0,0 +1,167 @@ +import type { ClineMessage } from "@roo-code/types" + +const DIAGRAM_LANGUAGES = new Set(["mermaid"]) + +const DIAGRAM_START_RE = + /^(?:graph\s+(?:TB|BT|RL|LR|TD)|flowchart\s+(?:TB|BT|RL|LR|TD)|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie(?:\s+title)?|gitGraph|mindmap|timeline|quadrantChart|xychart-beta|block-beta|packet-beta|architecture-beta|sankey-beta|requirementDiagram|C4(?:Context|Container|Component|Dynamic|Deployment))/i + +const getCodeFenceLanguage = (info: string) => info.trim().split(/\s+/)[0]?.toLowerCase() ?? "" + +const getFirstNonEmptyLine = (text: string) => + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + +const EXCLUDED_TOOL_SEARCH_TEXT = new Set(["readFile", "read_file"]) +const MARKDOWN_REFERENCE_DEFINITION_RE = /^[ \t]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]*)*/gm +const MARKDOWN_INLINE_LINK_RE = /(!?)\[([^\]\n]*)\]\([^)\n]+\)/g +const MARKDOWN_REFERENCE_LINK_RE = /(!?)\[([^\]\n]+)\]\[[^\]\n]*\]/g + +export const normalizeSearchQuery = (query: string) => query.trim().toLocaleLowerCase() + +export const isRenderedDiagramCodeBlock = (languageOrInfo: string | undefined, code: string) => { + const language = getCodeFenceLanguage(languageOrInfo ?? "").replace(/^language-/, "") + + if (DIAGRAM_LANGUAGES.has(language)) { + return true + } + + return DIAGRAM_START_RE.test(getFirstNonEmptyLine(code)) +} + +export const stripRenderedDiagramBlocks = (markdown: string) => { + if (!markdown) { + return "" + } + + return markdown.replace( + /(^|\n)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)(?:\n\2[ \t]*(?=\n|$)|$)/g, + (match, prefix: string, _fence: string, info: string, code: string) => { + return isRenderedDiagramCodeBlock(info, code) ? `${prefix}\n` : match + }, + ) +} + +export const stripMarkdownReferences = (markdown: string) => { + if (!markdown) { + return "" + } + + return markdown + .replace(MARKDOWN_REFERENCE_DEFINITION_RE, "") + .replace(MARKDOWN_INLINE_LINK_RE, (_match, imageMarker: string, label: string) => + imageMarker ? "" : label.replace(/`([^`]+)`/g, "$1"), + ) + .replace(MARKDOWN_REFERENCE_LINK_RE, (_match, imageMarker: string, label: string) => + imageMarker ? "" : label.replace(/`([^`]+)`/g, "$1"), + ) +} + +const collectToolSearchText = (text: string) => { + try { + const tool = JSON.parse(text) + const parts: string[] = [] + + if (typeof tool.tool === "string" && EXCLUDED_TOOL_SEARCH_TEXT.has(tool.tool)) { + return "" + } + + for (const key of [ + "tool", + "path", + "content", + "diff", + "reason", + "query", + "command", + "args", + "serverName", + "toolName", + "question", + ]) { + if (typeof tool[key] === "string") { + parts.push(tool[key]) + } + } + + for (const key of ["batchFiles", "batchDirs", "batchDiffs", "todos", "suggest"]) { + if (Array.isArray(tool[key])) { + parts.push(JSON.stringify(tool[key])) + } + } + + return parts.length > 0 ? parts.join("\n") : text + } catch { + return text + } +} + +export const getChatSearchText = (message: Pick | undefined) => { + if (!message || typeof message.text !== "string") { + return "" + } + + if (message.type === "say" && message.say === "reasoning") { + return "" + } + + const rawText = + message.type === "ask" && message.ask === "tool" ? collectToolSearchText(message.text) : message.text + + return stripMarkdownReferences(stripRenderedDiagramBlocks(rawText)) +} + +export const countSearchMatches = (text: string, query: string) => { + const normalizedQuery = normalizeSearchQuery(query) + + if (!normalizedQuery) { + return 0 + } + + const normalizedText = text.toLocaleLowerCase() + let count = 0 + let index = 0 + + while (index <= normalizedText.length) { + const nextIndex = normalizedText.indexOf(normalizedQuery, index) + + if (nextIndex === -1) { + break + } + + count += 1 + index = nextIndex + normalizedQuery.length + } + + return count +} + +export const getSearchMatchSnippet = (text: string, query: string, matchIndex: number, radius = 48) => { + const normalizedQuery = normalizeSearchQuery(query) + + if (!normalizedQuery) { + return "" + } + + const normalizedText = text.toLocaleLowerCase() + let index = 0 + let foundIndex = -1 + + for (let i = 0; i <= matchIndex; i++) { + foundIndex = normalizedText.indexOf(normalizedQuery, index) + + if (foundIndex === -1) { + return "" + } + + index = foundIndex + normalizedQuery.length + } + + const start = Math.max(0, foundIndex - radius) + const end = Math.min(text.length, foundIndex + normalizedQuery.length + radius) + const prefix = start > 0 ? "..." : "" + const suffix = end < text.length ? "..." : "" + + return `${prefix}${text.slice(start, end)}${suffix}`.replace(/\s+/g, " ") +} diff --git a/webview-ui/src/utils/searchHighlight.tsx b/webview-ui/src/utils/searchHighlight.tsx new file mode 100644 index 00000000000..6c09dbc19c1 --- /dev/null +++ b/webview-ui/src/utils/searchHighlight.tsx @@ -0,0 +1,125 @@ +import React, { Fragment } from "react" + +import { normalizeSearchQuery } from "./chatSearchText" + +const createSearchMatchElement = (text: string) => ({ + type: "element", + tagName: "span", + properties: { + className: ["chat-search-match"], + "data-chat-search-match": "true", + }, + children: [{ type: "text", value: text }], +}) + +const splitTextByQuery = (text: string, query: string) => { + const normalizedQuery = normalizeSearchQuery(query) + + if (!normalizedQuery) { + return [{ text, match: false }] + } + + const normalizedText = text.toLocaleLowerCase() + const parts: Array<{ text: string; match: boolean }> = [] + let cursor = 0 + + while (cursor < text.length) { + const index = normalizedText.indexOf(normalizedQuery, cursor) + + if (index === -1) { + parts.push({ text: text.slice(cursor), match: false }) + break + } + + if (index > cursor) { + parts.push({ text: text.slice(cursor, index), match: false }) + } + + parts.push({ text: text.slice(index, index + normalizedQuery.length), match: true }) + cursor = index + normalizedQuery.length + } + + return parts.filter((part) => part.text.length > 0) +} + +export const HighlightedText = ({ text, query }: { text: string; query?: string }) => { + if (!query) { + return <>{text} + } + + const parts = splitTextByQuery(text, query) + + return ( + <> + {parts.map((part, index) => + part.match ? ( + + {part.text} + + ) : ( + {part.text} + ), + )} + + ) +} + +export const applySearchHighlightsToHast = ( + root: any, + query: string | undefined, + options: { skipPre?: boolean } = {}, +) => { + const normalizedQuery = normalizeSearchQuery(query ?? "") + + if (!normalizedQuery) { + return root + } + + const shouldSkipElement = (node: any) => { + if (node?.type !== "element") { + return false + } + + const tagName = String(node.tagName ?? "").toLowerCase() + + return ( + tagName === "script" || + tagName === "style" || + tagName === "svg" || + (options.skipPre && (tagName === "pre" || tagName === "code")) + ) + } + + const visitNode = (node: any, parent?: any) => { + if (!node || shouldSkipElement(node)) { + return + } + + if (node.type === "text" && typeof node.value === "string" && parent?.children) { + const parts = splitTextByQuery(node.value, normalizedQuery) + + if (parts.some((part) => part.match)) { + const replacement = parts.map((part) => + part.match ? createSearchMatchElement(part.text) : { type: "text", value: part.text }, + ) + const index = parent.children.indexOf(node) + + if (index !== -1) { + parent.children.splice(index, 1, ...replacement) + } + } + + return + } + + if (Array.isArray(node.children)) { + for (const child of [...node.children]) { + visitNode(child, node) + } + } + } + + visitNode(root) + + return root +}