From 31755845fcc80e9ba581eb7a208d79833f9a84d2 Mon Sep 17 00:00:00 2001 From: David Mashburn Date: Tue, 7 Jul 2026 18:18:15 -0400 Subject: [PATCH] feat(web): add find-in-thread search with Cmd+F Restore thread-local search with highlighted matches, next/previous navigation, and scroll-to-active-match behavior in the chat timeline. Co-authored-by: Cursor --- apps/web/src/components/ChatMarkdown.tsx | 14 +- apps/web/src/components/ChatView.tsx | 110 ++++++++++++ apps/web/src/components/chat/FindInThread.tsx | 160 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 32 ++++ .../src/components/chat/ProposedPlanCard.tsx | 4 + apps/web/src/index.css | 27 +++ apps/web/src/lib/searchHighlight.ts | 28 +++ 7 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/FindInThread.tsx create mode 100644 apps/web/src/lib/searchHighlight.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..267957e7770 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,6 +67,7 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { highlightHtml } from "../lib/searchHighlight"; import { useRightPanelStore } from "../rightPanelStore"; import { useActiveEnvironmentId } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -114,6 +115,7 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + searchQuery?: string; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -153,10 +155,12 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), "mark"], attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + mark: ["className", "class"], }, protocols: { ...defaultSchema.protocols, @@ -1237,6 +1241,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + searchQuery, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1253,6 +1258,10 @@ function ChatMarkdown({ serverConfig?.availableEditors ?? [], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const highlightedText = useMemo( + () => (searchQuery ? highlightHtml(text, searchQuery) : text), + [searchQuery, text], + ); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, @@ -1328,6 +1337,9 @@ function ChatMarkdown({ ); const markdownComponents = useMemo( () => ({ + mark({ children }) { + return {children}; + }, p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1553,7 +1565,7 @@ function ChatMarkdown({ components={markdownComponents} urlTransform={markdownUrlTransform} > - {text} + {highlightedText} ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..433e60a1dc5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -203,6 +203,8 @@ import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { findAllMatches, type TextMatch } from "../lib/searchHighlight"; +import { FindInThread } from "./chat/FindInThread"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; @@ -1110,6 +1112,9 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [findOpen, setFindOpen] = useState(false); + const [findQuery, setFindQuery] = useState(""); + const [findActiveMatchIndex, setFindActiveMatchIndex] = useState(0); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -2063,6 +2068,65 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const timelineMessagesFlat = useMemo( + () => timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + [timelineEntries], + ); + const findMatches = useMemo(() => { + if (!findQuery || !findOpen) return []; + const results: Array<{ messageId: string; match: TextMatch }> = []; + for (const message of timelineMessagesFlat) { + const text = message.text ?? ""; + const matches = findAllMatches(text, findQuery); + for (const match of matches) { + results.push({ messageId: message.id, match }); + } + } + return results; + }, [findQuery, findOpen, timelineMessagesFlat]); + const findTotalMatches = findMatches.length; + const findMessageIdByMatchIndex = useMemo( + () => findMatches.map((match) => match.messageId), + [findMatches], + ); + const handleFindNext = useCallback(() => { + if (findTotalMatches === 0) return; + setFindActiveMatchIndex((index) => { + const next = Math.min(index + 1, findTotalMatches - 1); + const messageId = findMessageIdByMatchIndex[next]; + if (messageId) { + const element = document.querySelector(`[data-message-id="${messageId}"]`); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + return next; + }); + }, [findTotalMatches, findMessageIdByMatchIndex]); + const handleFindPrevious = useCallback(() => { + if (findTotalMatches === 0) return; + setFindActiveMatchIndex((index) => { + const next = Math.max(index - 1, 0); + const messageId = findMessageIdByMatchIndex[next]; + if (messageId) { + const element = document.querySelector(`[data-message-id="${messageId}"]`); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + return next; + }); + }, [findTotalMatches, findMessageIdByMatchIndex]); + const openFind = useCallback(() => { + setFindOpen(true); + setFindQuery(""); + setFindActiveMatchIndex(0); + }, []); + const closeFind = useCallback(() => { + setFindOpen(false); + setFindQuery(""); + setFindActiveMatchIndex(0); + }, []); + const handleFindQueryChange = useCallback((query: string) => { + setFindQuery(query); + setFindActiveMatchIndex(0); + }, []); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { @@ -3673,6 +3737,35 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (findOpen) { + if (event.key === "Escape" && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + closeFind(); + return; + } + } + + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "f" && + !event.shiftKey && + !event.altKey + ) { + if (isCommandPaletteOpen()) return; + if (!activeThreadId) return; + event.preventDefault(); + event.stopPropagation(); + if (findOpen) { + closeFind(); + } else { + openFind(); + } + return; + } + if (!activeThreadId || isCommandPaletteOpen()) { return; } @@ -3814,6 +3907,9 @@ function ChatViewContent(props: ChatViewProps) { toggleRightPanel, toggleTerminalVisibility, composerRef, + findOpen, + closeFind, + openFind, ]); const onRevertToTurnCount = useCallback( @@ -5068,6 +5164,17 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages Wrapper */}
+ {findOpen ? ( + + ) : null} {/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/FindInThread.tsx b/apps/web/src/components/chat/FindInThread.tsx new file mode 100644 index 00000000000..0a3061c77a5 --- /dev/null +++ b/apps/web/src/components/chat/FindInThread.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef } from "react"; +import { cn } from "~/lib/utils"; + +interface FindInThreadProps { + query: string; + onQueryChange: (query: string) => void; + matchIndex: number; + totalMatches: number; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +export function FindInThread({ + query, + onQueryChange, + matchIndex, + totalMatches, + onNext, + onPrevious, + onClose, +}: FindInThreadProps) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onQueryChange(e.target.value); + }, + [onQueryChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + onPrevious(); + } else { + onNext(); + } + return; + } + }, + [onClose, onNext, onPrevious], + ); + + const hasMatches = totalMatches > 0; + + return ( +
e.stopPropagation()} + > + + + + + + {query && ( + + {hasMatches ? `${matchIndex + 1}/${totalMatches}` : "0/0"} + + )} + {query && ( + <> + + + + )} + +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..ab7b7a9a3c2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -39,6 +39,7 @@ import { resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; +import { type TextMatch } from "../../lib/searchHighlight"; import { BotIcon, CheckIcon, @@ -134,6 +135,9 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + findQuery: string; + findActiveMatchIndex: number; + findMatches: Array<{ messageId: string; match: TextMatch }>; } interface TimelineRowActivityState { @@ -179,6 +183,9 @@ interface MessagesTimelineProps { contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; + findQuery?: string; + findActiveMatchIndex?: number; + findMatches?: Array<{ messageId: string; match: TextMatch }>; } // --------------------------------------------------------------------------- @@ -212,6 +219,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, onIsAtEndChange, onManualNavigation, + findQuery = "", + findActiveMatchIndex = 0, + findMatches = [], }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -421,6 +431,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + findQuery, + findActiveMatchIndex, + findMatches, }), [ timestampFormat, @@ -435,6 +448,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + findQuery, + findActiveMatchIndex, + findMatches, ], ); const activityState = useMemo( @@ -795,6 +811,14 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const ctx = use(TimelineRowCtx); + const messageId = row.kind === "message" ? row.message.id : null; + const hasActiveMatch = + messageId !== null && + ctx.findQuery.length > 0 && + ctx.findMatches.length > 0 && + ctx.findMatches[ctx.findActiveMatchIndex]?.messageId === messageId; + return (
{row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} @@ -987,6 +1012,7 @@ function AssistantTimelineRow({ row }: { row: Extract
); @@ -1462,6 +1489,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { markdownCwd: string | undefined; }) { const ctx = use(TimelineRowCtx); + const searchQueryProps = ctx.findQuery ? { searchQuery: ctx.findQuery } : {}; const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); @@ -1482,6 +1510,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + {...searchQueryProps} /> ) : null} {trailingWhitespace ? : null} @@ -1504,6 +1533,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + {...searchQueryProps} />
) : null @@ -1592,6 +1622,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + {...searchQueryProps} />, ); } else if (inlinePrefix.length === 0) { @@ -1617,6 +1648,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + {...searchQueryProps} /> ); }); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 9746857a8ca..c8d97438abf 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -39,12 +39,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ threadRef, cwd, workspaceRoot, + searchQuery, }: { planMarkdown: string; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | undefined; cwd: string | undefined; workspaceRoot: string | undefined; + searchQuery?: string; }) { const [expanded, setExpanded] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); @@ -177,6 +179,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ cwd={cwd} threadRef={threadRef} isStreaming={false} + {...(searchQuery ? { searchQuery } : {})} /> ) : ( )} {canCollapse && !expanded ? ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3ae42a3e61c..2d5eb23b0db 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -891,3 +891,30 @@ label:has(> select#reasoning-effort) select { .dark .model-picker-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +mark.search-highlight { + background: oklch(0.87 0.15 85); + color: inherit; + border-radius: 0.125rem; +} + +.dark mark.search-highlight { + background: oklch(0.45 0.12 85); + color: inherit; +} + +[data-search-active-match] mark.search-highlight { + background: oklch(0.78 0.18 85); + outline: 2px solid oklch(0.65 0.2 85); + outline-offset: 1px; +} + +.dark [data-search-active-match] mark.search-highlight { + background: oklch(0.5 0.15 85); + outline: 2px solid oklch(0.6 0.18 85); + outline-offset: 1px; +} + +[data-search-active-match] { + scroll-margin: 120px; +} diff --git a/apps/web/src/lib/searchHighlight.ts b/apps/web/src/lib/searchHighlight.ts new file mode 100644 index 00000000000..7a337b78afd --- /dev/null +++ b/apps/web/src/lib/searchHighlight.ts @@ -0,0 +1,28 @@ +export interface TextMatch { + start: number; + end: number; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function findAllMatches(text: string, query: string): TextMatch[] { + if (!query || !text) return []; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const matches: TextMatch[] = []; + let pos = 0; + while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) { + matches.push({ start: pos, end: pos + lowerQuery.length }); + pos += 1; + } + return matches; +} + +/** Wraps all occurrences of `query` in `text` with `` tags. */ +export function highlightHtml(text: string, query: string): string { + if (!query || !text) return text; + const escaped = escapeRegex(query); + return text.replace(new RegExp(`(${escaped})`, "gi"), '$1'); +}