Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Pick<ServerProviderSkill, "name" | "displayName">> = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1237,6 +1241,7 @@ function ChatMarkdown({
skills = EMPTY_MARKDOWN_SKILLS,
className,
lineBreaks = false,
searchQuery,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
Expand All @@ -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,
Expand Down Expand Up @@ -1328,6 +1337,9 @@ function ChatMarkdown({
);
const markdownComponents = useMemo<Components>(
() => ({
mark({ children }) {
return <mark className="search-highlight">{children}</mark>;
},

Copy link
Copy Markdown
Contributor

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-offset from the original text prop while ReactMarkdown parses highlightedText with injected mark tags. With an active searchQuery, AST offsets no longer align with findTaskListMarkerOffset(text, …), so toggling tasks can target the wrong checkbox.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3175584. Configure here.

p({ node: _node, children, ...props }) {
return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
},
Expand Down Expand Up @@ -1553,7 +1565,7 @@ function ChatMarkdown({
components={markdownComponents}
urlTransform={markdownUrlTransform}
>
{text}
{highlightedText}
</ReactMarkdown>
</div>
);
Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Find counts hidden user text

Medium Severity

The "Find in Thread" feature searches the raw message.text, but the UI renders a stripped version of this text (e.g., without terminal/element context). This causes match counts and navigation to include text that isn't visible or highlightable in the chat.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3175584. Configure here.

}, [findQuery, findOpen, timelineMessagesFlat]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed plans omitted from matches

Medium Severity

The "Find in Thread" feature only counts and navigates matches within chat messages. This means that while ProposedPlanCard content can still highlight matching text, these matches are excluded from the total count, next/previous navigation, and active-match styling, resulting in a confusing 0/0 display despite visible highlights in plans.

Fix in Cursor Fix in Web

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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First Enter skips first match

Low Severity

After typing a query the active index starts at 0, but Enter runs handleFindNext, which moves to index 1 when there are multiple hits. The first match is never scrolled into view via Enter, and the first Enter jumps to the second occurrence.

Additional Locations (1)
Fix in Cursor Fix in Web

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;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded turn matches not reachable

Medium Severity

The "Find in Thread" next/previous navigation silently fails to scroll to matches. This occurs because it relies on document.querySelector for message elements, which are not present in the DOM when messages are part of collapsed turns or unmounted by the LegendList's virtualization.

Fix in Cursor Fix in Web

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);
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active match index not clamped

Medium Severity

findActiveMatchIndex resets when the query changes or find closes, but not when the match list shrinks (switching threads, reverting messages, etc.). The UI can show an index above totalMatches, and findMatches[findActiveMatchIndex] becomes undefined so no row gets data-search-active-match.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3175584. Configure here.

const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -3814,6 +3907,9 @@ function ChatViewContent(props: ChatViewProps) {
toggleRightPanel,
toggleTerminalVisibility,
composerRef,
findOpen,
closeFind,
openFind,
]);

const onRevertToTurnCount = useCallback(
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 */}
Expand Down
160 changes: 160 additions & 0 deletions apps/web/src/components/chat/FindInThread.tsx
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"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/FindInThread.tsx:91

The match counter renders matchIndex + 1 without clamping, so when the thread contents change while find is open (e.g., a streaming message update reduces the hit count) the counter can show impossible values like 5/2 because matchIndex is only reset on open/close/query change. In that state the displayed position no longer corresponds to any real match, and the counter stays inconsistent until the user manually navigates. Consider clamping matchIndex to totalMatches - 1 (or resetting it to 0) when totalMatches drops below the current index.

Suggested change
{hasMatches ? `${matchIndex + 1}/${totalMatches}` : "0/0"}
{hasMatches ? `${Math.min(matchIndex, totalMatches - 1) + 1}/${totalMatches}` : "0/0"}
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/FindInThread.tsx around line 91:

The match counter renders `matchIndex + 1` without clamping, so when the thread contents change while find is open (e.g., a streaming message update reduces the hit count) the counter can show impossible values like `5/2` because `matchIndex` is only reset on open/close/query change. In that state the displayed position no longer corresponds to any real match, and the counter stays inconsistent until the user manually navigates. Consider clamping `matchIndex` to `totalMatches - 1` (or resetting it to 0) when `totalMatches` drops below the current index.

</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>
);
}
Loading
Loading