diff --git a/webview-ui/src/components/history/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx
index 1ed422df22..1db3ea01f5 100644
--- a/webview-ui/src/components/history/CopyButton.tsx
+++ b/webview-ui/src/components/history/CopyButton.tsx
@@ -10,19 +10,6 @@ type CopyButtonProps = {
className?: string
}
-/**
- * Strips only history highlight spans from text while preserving other HTML
- * Targets: content
- * @param text - Text that may contain highlight spans
- * @returns Text with highlight spans removed but content preserved
- */
-const stripHistoryHighlightSpans = (text: string): string => {
- // Match opening tag, capture content until closing tag
- // The [\s\S]*? pattern matches any character (including newlines) non-greedily,
- // which properly handles content with < characters
- return text.replace(/([\s\S]*?)<\/span>/g, "$1")
-}
-
export const CopyButton = ({ itemTask, className }: CopyButtonProps) => {
const { isCopied, copy } = useClipboard()
const { t } = useAppTranslation()
@@ -30,10 +17,9 @@ export const CopyButton = ({ itemTask, className }: CopyButtonProps) => {
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
+
if (!isCopied) {
- // Strip only history highlight spans before copying to clipboard
- const cleanText = stripHistoryHighlightSpans(itemTask)
- copy(cleanText)
+ copy(itemTask)
}
},
[isCopied, copy, itemTask],
diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx
index 9fd74b6769..29b0775b3e 100644
--- a/webview-ui/src/components/history/TaskItem.tsx
+++ b/webview-ui/src/components/history/TaskItem.tsx
@@ -9,8 +9,12 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
import TaskItemHeader from "./TaskItemHeader"
import TaskItemFooter from "./TaskItemFooter"
+interface DisplayHistoryItem extends HistoryItem {
+ highlight?: string
+}
+
interface TaskItemProps {
- item: HistoryItem
+ item: DisplayHistoryItem
variant: "compact" | "full"
showWorkspace?: boolean
isSelectionMode?: boolean
@@ -103,8 +107,8 @@ const TaskItem = ({
overflowWrap: "anywhere",
}}
data-testid={isCompact ? undefined : "task-content"}
- {...(isCompact ? {} : { dangerouslySetInnerHTML: { __html: item.task } })}>
- {isCompact ? item.task : undefined}
+ {...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
+ {item.highlight ? undefined : item.task}
{/* Task Item Footer */}
diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts
index 47d5c3719c..3969985b98 100644
--- a/webview-ui/src/components/history/useTaskSearch.ts
+++ b/webview-ui/src/components/history/useTaskSearch.ts
@@ -48,7 +48,7 @@ export const useTaskSearch = () => {
return {
...result.item,
- task: highlightFzfMatch(
+ highlight: highlightFzfMatch(
result.item.task,
positions.filter((p) => p < taskEndIndex),
),
diff --git a/webview-ui/src/utils/highlight.ts b/webview-ui/src/utils/highlight.ts
index fe8f3c49a7..21c84f993a 100644
--- a/webview-ui/src/utils/highlight.ts
+++ b/webview-ui/src/utils/highlight.ts
@@ -1,3 +1,29 @@
+import { LRUCache } from "lru-cache"
+
+// LRU cache for escapeHtml with reasonable size limit
+const escapeHtmlCache = new LRUCache({ max: 500 })
+
+function escapeHtml(text: string): string {
+ // Check cache first
+ const cached = escapeHtmlCache.get(text)
+ if (cached !== undefined) {
+ return cached
+ }
+
+ // Compute escaped text
+ const escaped = text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+
+ // Cache the result
+ escapeHtmlCache.set(text, escaped)
+
+ return escaped
+}
+
export function highlightFzfMatch(
text: string,
positions: number[],
@@ -39,6 +65,9 @@ export function highlightFzfMatch(
// Build final string
return parts
- .map((part) => (part.highlight ? `${part.text}` : part.text))
+ .map((part) => {
+ const escapedText = escapeHtml(part.text)
+ return part.highlight ? `${escapedText}` : escapedText
+ })
.join("")
}