From 7f6fd6b3fa9593bd4d26df9ce9e31650daa7a8c8 Mon Sep 17 00:00:00 2001 From: Jun <1731972554@qq.com> Date: Fri, 8 May 2026 01:16:05 +0800 Subject: [PATCH 1/2] Improve chat search placement and navigation --- CHAT_SEARCH_DEBUG_NOTES.md | 40 ++ packages/types/src/vscode-extension-host.ts | 1 + packages/types/src/vscode.ts | 1 + src/activate/registerCommands.ts | 15 + src/package.json | 20 +- src/package.nls.json | 1 + webview-ui/src/App.tsx | 6 + webview-ui/src/components/chat/ChatRow.tsx | 107 +++++- webview-ui/src/components/chat/ChatView.tsx | 361 +++++++++++++++++- webview-ui/src/components/chat/Markdown.tsx | 112 +++--- webview-ui/src/components/chat/Mention.tsx | 8 +- webview-ui/src/components/chat/TaskHeader.tsx | 43 ++- .../src/components/common/CodeBlock.tsx | 11 +- .../src/components/common/MarkdownBlock.tsx | 23 +- webview-ui/src/utils/chatSearchText.ts | 139 +++++++ webview-ui/src/utils/searchHighlight.tsx | 125 ++++++ 16 files changed, 928 insertions(+), 85 deletions(-) create mode 100644 CHAT_SEARCH_DEBUG_NOTES.md create mode 100644 webview-ui/src/utils/chatSearchText.ts create mode 100644 webview-ui/src/utils/searchHighlight.tsx diff --git a/CHAT_SEARCH_DEBUG_NOTES.md b/CHAT_SEARCH_DEBUG_NOTES.md new file mode 100644 index 00000000000..837551b5d04 --- /dev/null +++ b/CHAT_SEARCH_DEBUG_NOTES.md @@ -0,0 +1,40 @@ +# Chat Search Debug Notes + +## 当前问题 + +搜索结果的计数基本正确,但跳转定位会出现偏移或二次跳转。 + +已观察到的现象: + +- 搜索 `tiny` 时,结果不应该跳进 Mermaid/graph/flowchart 图表内容,因为这些块最终被渲染成图片。 +- `📊 三种组合的预期准确率` 表格里的 `Tiny Image特征` 应该作为普通文本结果参与搜索和跳转。 +- 搜索 `nearest` 时,有两个结果;第二个结果如果不在当前窗口内,可能无法跳转,除非它已经被虚拟列表渲染出来。 +- 搜索 `bag` 时,`11 -> 12 -> 13 -> 12` 正常,但 `1 -> 17 -> ... -> 13 -> 12` 会把 no12 定位到 no11。 +- no12、no15、no17 等结果都出现过“先跳到正确位置,然后马上又跳走”的现象。 + +## 根因判断 + +定位问题不是单纯的计数错误,而是“搜索结果列表”和“实际 DOM 高亮节点”之间不完全一致: + +- 计数如果包含了 Mermaid/graph/flowchart 源码,但 DOM 中该部分被 Mermaid 渲染成 SVG/图片,就会导致第 N 个结果映射到错误 DOM 节点。 +- 虚拟列表中未挂载的行不能直接查找 DOM,高亮节点需要等 Virtuoso 渲染出目标行后再定位。 +- Shiki 代码高亮、Markdown 渲染、行高变化都会异步更新 DOM,过早 scroll 会出现目标行先到位、随后又被后续布局或旧目标覆盖。 + +## 已恢复的实现方向 + +- 新增 `webview-ui/src/utils/chatSearchText.ts`,统一抽取搜索文本。 +- 新增 `webview-ui/src/utils/searchHighlight.tsx`,统一生成可定位的 `data-chat-search-match` 高亮节点。 +- `graph`、`flowchart`、`mermaid` 等会渲染为图表的 fenced code block 会被排除在搜索文本之外。 +- Markdown 和 CodeBlock 的高亮都走同一个 query,避免计数和渲染分裂。 +- ChatRow 使用 `message.ts + matchIndex` 定位当前行内的第 N 个高亮节点。 +- 当目标行还未挂载时,ChatView 先让 Virtuoso 滚到目标 message index;行挂载后由 ChatRow 再定位到具体高亮节点。 +- 保留 `[chat-search]` console debug 输出,方便继续看 active result、row match count 和实际滚动目标。 + +## Timeline 恢复结果 + +检查过 VS Code Local History: + +- 未找到 `ChatView.tsx`、`ChatRow.tsx`、`chatSearch`、`searchHighlight` 等相关快照。 +- 这批改动大概率是通过工具直接写入文件,没有进入 VS Code Timeline。 + +因此当前内容是根据代码结构和之前调试结论重建,不是从 Timeline 直接恢复。 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.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/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..7b76370e656 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,73 @@ const ChatRow = memo( } }, [height, isLast, onHeightChange, message]) + useEffect(() => { + const row = chatSearchRowRef.current + + if (!row) { + return + } + + const updateActiveHighlight = (reason: string) => { + const matches = Array.from(row.querySelectorAll("[data-chat-search-match='true']")) + + for (const match of matches) { + match.classList.remove("chat-search-match-active") + } + + if (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + return + } + + const activeMatch = matches[activeChatSearchMatchIndex] + + console.debug("[chat-search]", { + event: "row-active-highlight", + reason, + messageTs: message.ts, + activeChatSearchMatchIndex, + matchCount: matches.length, + found: !!activeMatch, + text: activeMatch?.textContent, + }) + + if (!activeMatch) { + return + } + + activeMatch.classList.add("chat-search-match-active") + } + + updateActiveHighlight("effect") + + if (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + return + } + + let animationFrame: number | undefined + + const scheduleUpdate = (reason: string) => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame) + } + + animationFrame = requestAnimationFrame(() => { + animationFrame = undefined + updateActiveHighlight(reason) + }) + } + + const mutationObserver = new MutationObserver(() => scheduleUpdate("mutation")) + 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 +249,7 @@ export const ChatRowContent = ({ isFollowUpAnswered, isFollowUpAutoApprovalPaused, onJumpToPreviousCheckpoint, + chatSearchQuery, }: ChatRowContentProps) => { const { t, i18n } = useTranslation() @@ -868,7 +939,7 @@ export const ChatRowContent = ({
- +
{childTaskId && !isFollowedBySubtaskResult && (
- +
) @@ -1029,7 +1103,7 @@ export const ChatRowContent = ({ {t("chat:subtasks.resultContent")}
- + {completedChildTaskId && ( + + + )} + {isChatSearchOpen && ( +
+ + setChatSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + navigateChatSearch(event.shiftKey ? -1 : 1) + } + + if (event.key === "Escape") { + event.preventDefault() + closeChatSearch() + } + }} + placeholder="Search chat" + className="h-7 rounded-md px-2 py-0 text-sm" + /> + + {chatSearchCountText} + + + + + + + + + + +
+ )} + {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..662b1776bae 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,41 @@ 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 + } + + const matches = Array.from(header.querySelectorAll("[data-chat-search-match='true']")) + + for (const match of matches) { + match.classList.remove("chat-search-match-active") + } + + if (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + return + } + + const activeMatch = matches[activeChatSearchMatchIndex] + + console.debug("[chat-search]", { + event: "task-header-active-highlight", + activeChatSearchMatchIndex, + matchCount: matches.length, + found: !!activeMatch, + text: activeMatch?.textContent, + }) + + 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 +168,7 @@ const TaskHeader = ({ } return ( -
+
{isSubtask && (
e.stopPropagation()}>
@@ -332,7 +369,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..442dafd5a9e 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) => { // 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/utils/chatSearchText.ts b/webview-ui/src/utils/chatSearchText.ts new file mode 100644 index 00000000000..0cd6a365cc5 --- /dev/null +++ b/webview-ui/src/utils/chatSearchText.ts @@ -0,0 +1,139 @@ +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) ?? "" + +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 + }, + ) +} + +const collectToolSearchText = (text: string) => { + try { + const tool = JSON.parse(text) + const parts: string[] = [] + + 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 "" + } + + const rawText = + message.type === "ask" && message.ask === "tool" ? collectToolSearchText(message.text) : message.text + + return 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 +} From 6db562bc7deda62ff6cb1ab9fc1e6deb8549b4ae Mon Sep 17 00:00:00 2001 From: Jun <1731972554@qq.com> Date: Fri, 8 May 2026 17:49:49 +0800 Subject: [PATCH 2/2] Refine chat search exclusions and behavior --- CHAT_SEARCH_DEBUG_NOTES.md | 40 ----- src/package.nls.ca.json | 1 + src/package.nls.de.json | 1 + src/package.nls.es.json | 1 + src/package.nls.fr.json | 1 + src/package.nls.hi.json | 1 + src/package.nls.id.json | 1 + src/package.nls.it.json | 1 + src/package.nls.ja.json | 1 + src/package.nls.ko.json | 1 + src/package.nls.nl.json | 1 + src/package.nls.pl.json | 1 + src/package.nls.pt-BR.json | 1 + src/package.nls.ru.json | 1 + src/package.nls.tr.json | 1 + src/package.nls.vi.json | 1 + src/package.nls.zh-CN.json | 1 + src/package.nls.zh-TW.json | 1 + webview-ui/src/components/chat/ChatRow.tsx | 28 ++- webview-ui/src/components/chat/ChatView.tsx | 164 +++++++----------- webview-ui/src/components/chat/TaskHeader.tsx | 14 +- .../src/components/common/MarkdownBlock.tsx | 2 +- .../src/components/common/MermaidBlock.tsx | 4 +- .../utils/__tests__/chatSearchText.spec.ts | 83 +++++++++ webview-ui/src/utils/chatSearchText.ts | 30 +++- 25 files changed, 214 insertions(+), 168 deletions(-) delete mode 100644 CHAT_SEARCH_DEBUG_NOTES.md create mode 100644 webview-ui/src/utils/__tests__/chatSearchText.spec.ts diff --git a/CHAT_SEARCH_DEBUG_NOTES.md b/CHAT_SEARCH_DEBUG_NOTES.md deleted file mode 100644 index 837551b5d04..00000000000 --- a/CHAT_SEARCH_DEBUG_NOTES.md +++ /dev/null @@ -1,40 +0,0 @@ -# Chat Search Debug Notes - -## 当前问题 - -搜索结果的计数基本正确,但跳转定位会出现偏移或二次跳转。 - -已观察到的现象: - -- 搜索 `tiny` 时,结果不应该跳进 Mermaid/graph/flowchart 图表内容,因为这些块最终被渲染成图片。 -- `📊 三种组合的预期准确率` 表格里的 `Tiny Image特征` 应该作为普通文本结果参与搜索和跳转。 -- 搜索 `nearest` 时,有两个结果;第二个结果如果不在当前窗口内,可能无法跳转,除非它已经被虚拟列表渲染出来。 -- 搜索 `bag` 时,`11 -> 12 -> 13 -> 12` 正常,但 `1 -> 17 -> ... -> 13 -> 12` 会把 no12 定位到 no11。 -- no12、no15、no17 等结果都出现过“先跳到正确位置,然后马上又跳走”的现象。 - -## 根因判断 - -定位问题不是单纯的计数错误,而是“搜索结果列表”和“实际 DOM 高亮节点”之间不完全一致: - -- 计数如果包含了 Mermaid/graph/flowchart 源码,但 DOM 中该部分被 Mermaid 渲染成 SVG/图片,就会导致第 N 个结果映射到错误 DOM 节点。 -- 虚拟列表中未挂载的行不能直接查找 DOM,高亮节点需要等 Virtuoso 渲染出目标行后再定位。 -- Shiki 代码高亮、Markdown 渲染、行高变化都会异步更新 DOM,过早 scroll 会出现目标行先到位、随后又被后续布局或旧目标覆盖。 - -## 已恢复的实现方向 - -- 新增 `webview-ui/src/utils/chatSearchText.ts`,统一抽取搜索文本。 -- 新增 `webview-ui/src/utils/searchHighlight.tsx`,统一生成可定位的 `data-chat-search-match` 高亮节点。 -- `graph`、`flowchart`、`mermaid` 等会渲染为图表的 fenced code block 会被排除在搜索文本之外。 -- Markdown 和 CodeBlock 的高亮都走同一个 query,避免计数和渲染分裂。 -- ChatRow 使用 `message.ts + matchIndex` 定位当前行内的第 N 个高亮节点。 -- 当目标行还未挂载时,ChatView 先让 Virtuoso 滚到目标 message index;行挂载后由 ChatRow 再定位到具体高亮节点。 -- 保留 `[chat-search]` console debug 输出,方便继续看 active result、row match count 和实际滚动目标。 - -## Timeline 恢复结果 - -检查过 VS Code Local History: - -- 未找到 `ChatView.tsx`、`ChatRow.tsx`、`chatSearch`、`searchHighlight` 等相关快照。 -- 这批改动大概率是通过工具直接写入文件,没有进入 VS Code Timeline。 - -因此当前内容是根据代码结构和之前调试结论重建,不是从 Timeline 直接恢复。 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.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/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 7b76370e656..0c651055e4a 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -167,29 +167,23 @@ const ChatRow = memo( return } - const updateActiveHighlight = (reason: string) => { + 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 (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + if (typeof activeChatSearchMatchIndex !== "number") { return } const activeMatch = matches[activeChatSearchMatchIndex] - console.debug("[chat-search]", { - event: "row-active-highlight", - reason, - messageTs: message.ts, - activeChatSearchMatchIndex, - matchCount: matches.length, - found: !!activeMatch, - text: activeMatch?.textContent, - }) - if (!activeMatch) { return } @@ -197,26 +191,26 @@ const ChatRow = memo( activeMatch.classList.add("chat-search-match-active") } - updateActiveHighlight("effect") + updateActiveHighlight() - if (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + if (typeof activeChatSearchMatchIndex !== "number") { return } let animationFrame: number | undefined - const scheduleUpdate = (reason: string) => { + const scheduleUpdate = () => { if (animationFrame !== undefined) { cancelAnimationFrame(animationFrame) } animationFrame = requestAnimationFrame(() => { animationFrame = undefined - updateActiveHighlight(reason) + updateActiveHighlight() }) } - const mutationObserver = new MutationObserver(() => scheduleUpdate("mutation")) + const mutationObserver = new MutationObserver(() => scheduleUpdate()) mutationObserver.observe(row, { childList: true, characterData: true, subtree: true }) return () => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d940dc22c52..c76de621ba9 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -81,8 +81,10 @@ type ChatSearchResult = { snippet: string } +const getChatSearchScroller = () => document.querySelector('[data-virtuoso-scroller="true"]') + const getChatSearchScrollViewportRect = (element: HTMLElement) => { - const scroller = document.querySelector('[data-virtuoso-scroller="true"]') + const scroller = getChatSearchScroller() if (scroller?.contains(element)) { return scroller.getBoundingClientRect() @@ -201,6 +203,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const chatSearchInputRef = useRef(null) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) @@ -1309,16 +1312,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction normalizeSearchQuery(chatSearchQuery), [chatSearchQuery]) + const activeChatSearchQuery = isChatSearchOpen ? normalizedChatSearchQuery : "" const chatSearchResults = useMemo(() => { - if (!normalizedChatSearchQuery) { + if (!activeChatSearchQuery) { return [] } const results: ChatSearchResult[] = [] const appendMessageMatches = (message: ClineMessage, messageIndex: number, isTaskHeader = false) => { const searchableText = getChatSearchText(message) - const matchCount = countSearchMatches(searchableText, normalizedChatSearchQuery) + const matchCount = countSearchMatches(searchableText, activeChatSearchQuery) for (let matchIndex = 0; matchIndex < matchCount; matchIndex++) { results.push({ @@ -1327,7 +1331,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction appendMessageMatches(message, index)) - console.debug("[chat-search]", { - event: "computed-results", - query: normalizedChatSearchQuery, - resultCount: results.length, - results: results.map((result, index) => ({ - index, - messageTs: result.messageTs, - messageIndex: result.messageIndex, - matchIndex: result.matchIndex, - isTaskHeader: result.isTaskHeader, - snippet: result.snippet, - })), - }) - return results - }, [groupedMessages, normalizedChatSearchQuery, task]) + }, [activeChatSearchQuery, groupedMessages, task]) const activeChatSearchItem = chatSearchResults[activeChatSearchResult] useEffect(() => { setActiveChatSearchResult(0) - }, [normalizedChatSearchQuery]) + }, [activeChatSearchQuery]) useEffect(() => { setActiveChatSearchResult((previous) => { @@ -1550,9 +1540,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const searchBarHeight = chatSearchBarRef.current?.getBoundingClientRect().height ?? 0 + const scroller = getChatSearchScroller() + const previousScrollTop = scroller?.scrollTop + setIsChatSearchOpen(false) setChatSearchQuery("") setActiveChatSearchResult(0) + + if (scroller && previousScrollTop !== undefined && searchBarHeight > 0) { + window.requestAnimationFrame(() => { + const currentScroller = getChatSearchScroller() ?? scroller + currentScroller.scrollTop = Math.max(0, previousScrollTop - searchBarHeight) + }) + } }, []) const navigateChatSearch = useCallback( @@ -1563,64 +1564,57 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const next = (previous + direction + chatSearchResults.length) % chatSearchResults.length - console.debug("[chat-search]", { - event: "navigate", - direction, - previous, - next, - resultCount: chatSearchResults.length, - nextResult: chatSearchResults[next], - }) - return next + return (previous + direction + chatSearchResults.length) % chatSearchResults.length }) }, [chatSearchResults, enterUserBrowsingHistory], ) useEffect(() => { - if (!normalizedChatSearchQuery || !activeChatSearchItem) { + if (!activeChatSearchQuery || !activeChatSearchItem) { return } + const STABILITY_WINDOW_MS = 1_200 let animationFrame: number | undefined - let mutationObserver: MutationObserver | undefined + let resizeObserver: ResizeObserver | undefined + let observedResizeElement: HTMLElement | undefined let hasRequestedVirtualScroll = false - const getTargetMatch = () => { + const getTargetContainer = () => { if (activeChatSearchItem.isTaskHeader) { - const header = document.querySelector("[data-chat-search-task-header='true']") + return document.querySelector("[data-chat-search-task-header='true']") + } - return header?.querySelectorAll("[data-chat-search-match='true']")[ - activeChatSearchItem.matchIndex - ] + return document.querySelector(`[data-chat-search-row-ts='${activeChatSearchItem.messageTs}']`) + } + + const observeTargetResize = (element: HTMLElement | null | undefined) => { + if (!element || observedResizeElement === element) { + return } - const row = document.querySelector( - `[data-chat-search-row-ts='${activeChatSearchItem.messageTs}']`, - ) + resizeObserver?.disconnect() + resizeObserver = new ResizeObserver(() => scheduleLocateTarget()) + resizeObserver.observe(element) + observedResizeElement = element + } + + const getTargetMatch = () => { + const container = getTargetContainer() + observeTargetResize(container) - return row?.querySelectorAll("[data-chat-search-match='true']")[ + return container?.querySelectorAll("[data-chat-search-match='true']")[ activeChatSearchItem.matchIndex ] } - const locateTarget = (reason: string) => { + const locateTarget = () => { const match = getTargetMatch() if (match) { const isVisible = isElementInChatViewport(match) - console.debug("[chat-search]", { - event: "active-target", - reason, - activeIndex: activeChatSearchResult, - resultCount: chatSearchResults.length, - target: activeChatSearchItem, - isVisible, - text: match.textContent, - }) - if (!isVisible) { match.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" }) } @@ -1628,14 +1622,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction= 0 && @@ -1652,36 +1638,38 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const scheduleLocateTarget = () => { if (animationFrame !== undefined) { cancelAnimationFrame(animationFrame) } animationFrame = requestAnimationFrame(() => { animationFrame = undefined - - if (locateTarget(reason)) { - mutationObserver?.disconnect() - } + locateTarget() }) } - if (locateTarget("effect")) { - return - } - const observerRoot = document.querySelector('[data-virtuoso-scroller="true"]') ?? document.body - mutationObserver = new MutationObserver(() => scheduleLocateTarget("mutation")) + const mutationObserver = new MutationObserver(() => scheduleLocateTarget()) mutationObserver.observe(observerRoot, { childList: true, subtree: true }) - scheduleLocateTarget("post-observe") + locateTarget() + scheduleLocateTarget() + const stabilityTimeout = setTimeout(() => { + mutationObserver?.disconnect() + resizeObserver?.disconnect() + }, STABILITY_WINDOW_MS) return () => { if (animationFrame !== undefined) { cancelAnimationFrame(animationFrame) } + if (stabilityTimeout !== undefined) { + clearTimeout(stabilityTimeout) + } mutationObserver?.disconnect() + resizeObserver?.disconnect() } - }, [activeChatSearchItem, activeChatSearchResult, chatSearchResults.length, normalizedChatSearchQuery]) + }, [activeChatSearchItem, activeChatSearchQuery, activeChatSearchResult, chatSearchResults.length]) const itemContent = useCallback( (index: number, messageOrGroup: ClineMessage) => { @@ -1720,7 +1708,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "f") { - event.preventDefault() - openChatSearch() - return - } - if (event.key === "Escape" && isChatSearchOpen) { closeChatSearch() return @@ -1793,7 +1775,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1836,7 +1818,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction - {!isChatSearchOpen && ( -
- - - -
- )} {isChatSearchOpen && ( -
+
("[data-chat-search-match='true']")) for (const match of matches) { match.classList.remove("chat-search-match-active") } - if (!chatSearchQuery || typeof activeChatSearchMatchIndex !== "number") { + if (typeof activeChatSearchMatchIndex !== "number") { return } const activeMatch = matches[activeChatSearchMatchIndex] - console.debug("[chat-search]", { - event: "task-header-active-highlight", - activeChatSearchMatchIndex, - matchCount: matches.length, - found: !!activeMatch, - text: activeMatch?.textContent, - }) - if (activeMatch) { activeMatch.classList.add("chat-search-match-active") } diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 442dafd5a9e..878d54b54da 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -294,7 +294,7 @@ const MarkdownBlock = memo(({ markdown, searchQuery }: MarkdownBlockProps) => {
) }, - 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 ?? "") 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 index 0cd6a365cc5..4aa40ee21ca 100644 --- a/webview-ui/src/utils/chatSearchText.ts +++ b/webview-ui/src/utils/chatSearchText.ts @@ -13,6 +13,11 @@ const getFirstNonEmptyLine = (text: string) => .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) => { @@ -38,11 +43,30 @@ export const stripRenderedDiagramBlocks = (markdown: string) => { ) } +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", @@ -78,10 +102,14 @@ export const getChatSearchText = (message: Pick {