-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(web): add find-in-thread search with Cmd+F #3779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string | null>( | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Find counts hidden user textMedium Severity The "Find in Thread" feature searches the raw Reviewed by Cursor Bugbot for commit 3175584. Configure here. |
||
| }, [findQuery, findOpen, timelineMessagesFlat]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Proposed plans omitted from matchesMedium Severity The "Find in Thread" feature only counts and navigates matches within chat messages. This means that while Reviewed by Cursor Bugbot for commit 3175584. Configure here. |
||
| 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]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First Enter skips first matchLow Severity After typing a query the active index starts at Additional Locations (1)Reviewed by Cursor Bugbot for commit 3175584. Configure here. |
||
| 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; | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Folded turn matches not reachableMedium Severity The "Find in Thread" next/previous navigation silently fails to scroll to matches. This occurs because it relies on Reviewed by Cursor Bugbot for commit 3175584. Configure here. |
||
| }, [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); | ||
| }, []); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Active match index not clampedMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 3175584. Configure here. |
||
| 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) { | |
| <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> | ||
| {/* Messages Wrapper */} | ||
| <div className="relative flex min-h-0 flex-1 flex-col"> | ||
| {findOpen ? ( | ||
| <FindInThread | ||
| query={findQuery} | ||
| onQueryChange={handleFindQueryChange} | ||
| matchIndex={findActiveMatchIndex} | ||
| totalMatches={findTotalMatches} | ||
| onNext={handleFindNext} | ||
| onPrevious={handleFindPrevious} | ||
| onClose={closeFind} | ||
| /> | ||
| ) : null} | ||
| {/* Messages — LegendList handles virtualization and scrolling internally */} | ||
| <MessagesTimeline | ||
| key={activeThread.id} | ||
|
|
@@ -5101,6 +5208,9 @@ function ChatViewContent(props: ChatViewProps) { | |
| contentInsetEndAdjustment={composerOverlayHeight} | ||
| onIsAtEndChange={onIsAtEndChange} | ||
| onManualNavigation={cancelTimelineLiveFollowForUserNavigation} | ||
| findQuery={findQuery} | ||
| findActiveMatchIndex={findActiveMatchIndex} | ||
| findMatches={findMatches} | ||
| /> | ||
|
|
||
| {/* scroll to end pill — shown when user has scrolled away from the live edge */} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<HTMLInputElement>(null); | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| inputRef.current?.focus(); | ||||||
| inputRef.current?.select(); | ||||||
| }, []); | ||||||
|
|
||||||
| const handleChange = useCallback( | ||||||
| (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
| 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 ( | ||||||
| <div | ||||||
| className={cn( | ||||||
| "flex items-center gap-2 border-b border-border bg-background px-3 py-1.5 text-xs sm:px-5", | ||||||
| )} | ||||||
| onPointerDown={(e) => e.stopPropagation()} | ||||||
| > | ||||||
| <svg | ||||||
| className="size-3.5 shrink-0 text-muted-foreground/60" | ||||||
| viewBox="0 0 24 24" | ||||||
| fill="none" | ||||||
| stroke="currentColor" | ||||||
| strokeWidth="2" | ||||||
| strokeLinecap="round" | ||||||
| strokeLinejoin="round" | ||||||
| > | ||||||
| <circle cx="11" cy="11" r="8" /> | ||||||
| <path d="m21 21-4.3-4.3" /> | ||||||
| </svg> | ||||||
| <input | ||||||
| ref={inputRef} | ||||||
| type="text" | ||||||
| value={query} | ||||||
| onChange={handleChange} | ||||||
| onKeyDown={handleKeyDown} | ||||||
| placeholder="Find in thread..." | ||||||
| spellCheck={false} | ||||||
| autoComplete="off" | ||||||
| className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/40" | ||||||
| /> | ||||||
| {query && ( | ||||||
| <span className="whitespace-nowrap tabular-nums text-muted-foreground/60"> | ||||||
| {hasMatches ? `${matchIndex + 1}/${totalMatches}` : "0/0"} | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium The match counter renders
Suggested change
🤖 Copy this AI Prompt to have your agent fix this: |
||||||
| </span> | ||||||
| )} | ||||||
| {query && ( | ||||||
| <> | ||||||
| <button | ||||||
| type="button" | ||||||
| onClick={onPrevious} | ||||||
| disabled={!hasMatches} | ||||||
| className="flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:text-foreground disabled:opacity-30" | ||||||
| title="Previous match (Shift+Enter)" | ||||||
| aria-label="Previous match" | ||||||
| > | ||||||
| <svg | ||||||
| className="size-3" | ||||||
| viewBox="0 0 24 24" | ||||||
| fill="none" | ||||||
| stroke="currentColor" | ||||||
| strokeWidth="2.5" | ||||||
| strokeLinecap="round" | ||||||
| strokeLinejoin="round" | ||||||
| > | ||||||
| <path d="m18 15-6-6-6 6" /> | ||||||
| </svg> | ||||||
| </button> | ||||||
| <button | ||||||
| type="button" | ||||||
| onClick={onNext} | ||||||
| disabled={!hasMatches} | ||||||
| className="flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:text-foreground disabled:opacity-30" | ||||||
| title="Next match (Enter)" | ||||||
| aria-label="Next match" | ||||||
| > | ||||||
| <svg | ||||||
| className="size-3" | ||||||
| viewBox="0 0 24 24" | ||||||
| fill="none" | ||||||
| stroke="currentColor" | ||||||
| strokeWidth="2.5" | ||||||
| strokeLinecap="round" | ||||||
| strokeLinejoin="round" | ||||||
| > | ||||||
| <path d="m6 9 6 6 6-6" /> | ||||||
| </svg> | ||||||
| </button> | ||||||
| </> | ||||||
| )} | ||||||
| <button | ||||||
| type="button" | ||||||
| onClick={onClose} | ||||||
| className="flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:text-foreground" | ||||||
| title="Close (Escape)" | ||||||
| aria-label="Close find" | ||||||
| > | ||||||
| <svg | ||||||
| className="size-3.5" | ||||||
| viewBox="0 0 24 24" | ||||||
| fill="none" | ||||||
| stroke="currentColor" | ||||||
| strokeWidth="2" | ||||||
| strokeLinecap="round" | ||||||
| strokeLinejoin="round" | ||||||
| > | ||||||
| <path d="M18 6 6 18" /> | ||||||
| <path d="m6 6 12 12" /> | ||||||
| </svg> | ||||||
| </button> | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Task checkbox offsets break with search
Medium Severity
Task list items compute
data-task-marker-offsetfrom the originaltextprop whileReactMarkdownparseshighlightedTextwith injectedmarktags. With an activesearchQuery, AST offsets no longer align withfindTaskListMarkerOffset(text, …), so toggling tasks can target the wrong checkbox.Reviewed by Cursor Bugbot for commit 3175584. Configure here.