From 4ab4c6ff4b2434e3b7a28e6666bc2cefa82ad3d9 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 13:30:31 -0700 Subject: [PATCH 01/17] feat(web): add in-thread find --- .../chat/MessagesTimeline.logic.test.ts | 8 + .../components/chat/MessagesTimeline.logic.ts | 21 +++ .../src/components/chat/MessagesTimeline.tsx | 152 ++++++++++++++++++ apps/web/src/index.css | 4 + 4 files changed, 185 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 70a330d46303..fca8e16ec370 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,11 +3,19 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + findTextMatches, normalizeCompactToolLabel, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("findTextMatches", () => { + it("finds every case-insensitive occurrence and returns its text index", () => { + expect(findTextMatches(["One one", null, "another ONE"], "one")).toEqual([0, 0, 2]); + expect(findTextMatches(["anything"], "")).toEqual([]); + }); +}); + describe("shouldPreserveAssistantLineBreaks", () => { it("preserves Claude insight formatting without changing regular markdown", () => { expect( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c89bbd0557d9..405b385c1e9e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -242,6 +242,27 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +export function findTextMatches( + texts: ReadonlyArray, + query: string, +): number[] { + const needle = query.toLowerCase(); + if (!needle) return []; + + const matches: number[] = []; + texts.forEach((text, index) => { + const haystack = text?.toLowerCase() ?? ""; + for ( + let offset = 0; + (offset = haystack.indexOf(needle, offset)) !== -1; + offset += needle.length + ) { + matches.push(index); + } + }); + return matches; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..7e045e95b67b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -59,10 +59,12 @@ import { PaintbrushIcon, MinusIcon, SquarePenIcon, + SearchIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, + ChevronUpIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; @@ -74,6 +76,7 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, + findTextMatches, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, @@ -188,6 +191,7 @@ function TimelineLoadEarlierHeader({ ); } const TIMELINE_LIST_FOOTER =
; +const THREAD_FIND_HIGHLIGHT = "thread-find"; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, @@ -420,6 +424,103 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); + const [findOpen, setFindOpen] = useState(false); + const [findQuery, setFindQuery] = useState(""); + const [findMatchIndex, setFindMatchIndex] = useState(0); + const findInputRef = useRef(null); + const findTexts = useMemo( + () => rows.map((row) => (row.kind === "message" ? row.message.text : null)), + [rows], + ); + const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]); + const updateFindQuery = useCallback( + (query: string) => { + setFindQuery(query); + setFindMatchIndex(0); + const firstMatch = findTextMatches(findTexts, query)[0]; + if (firstMatch === undefined) return; + onManualNavigation(); + void listRef.current?.scrollToIndex({ index: firstMatch, animated: false, viewOffset: 80 }); + }, + [findTexts, listRef, onManualNavigation], + ); + const goToFindMatch = useCallback( + (delta: number) => { + if (findMatches.length === 0) return; + const next = (findMatchIndex + delta + findMatches.length) % findMatches.length; + setFindMatchIndex(next); + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: findMatches[next]!, + animated: false, + viewOffset: 80, + }); + }, + [findMatchIndex, findMatches, listRef, onManualNavigation], + ); + + useEffect(() => { + const handleFindShortcut = (event: globalThis.KeyboardEvent) => { + if ( + event.key.toLowerCase() !== "f" || + (!event.metaKey && !event.ctrlKey) || + event.altKey || + event.shiftKey || + (event.target instanceof Element && event.target.closest("[data-terminal-owner]")) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setFindOpen(true); + }; + window.addEventListener("keydown", handleFindShortcut, true); + return () => window.removeEventListener("keydown", handleFindShortcut, true); + }, []); + + useEffect(() => { + if (!findOpen) return; + const frame = requestAnimationFrame(() => findInputRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [findOpen]); + + useEffect(() => { + CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); + const rowIndex = findMatches[findMatchIndex]; + const rowId = rowIndex === undefined ? undefined : rows[rowIndex]?.id; + if (!findOpen || !findQuery || !rowId) return; + + let paintFrame = 0; + const mountFrame = requestAnimationFrame(() => { + paintFrame = requestAnimationFrame(() => { + const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(rowId)}"]`); + if (!root) return; + + const ranges: Range[] = []; + const needle = findQuery.toLowerCase(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent?.toLowerCase() ?? ""; + for ( + let offset = 0; + (offset = text.indexOf(needle, offset)) !== -1; + offset += needle.length + ) { + const range = new Range(); + range.setStart(node, offset); + range.setEnd(node, offset + needle.length); + ranges.push(range); + } + } + CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(...ranges)); + }); + }); + return () => { + cancelAnimationFrame(mountFrame); + cancelAnimationFrame(paintFrame); + CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); + }; + }, [findMatchIndex, findMatches, findOpen, findQuery, rows]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -573,6 +674,57 @@ export const MessagesTimeline = memo(function MessagesTimeline({
+ {findOpen ? ( +
+
+ ) : null} ref={listRef} data={rows} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fea03489b7fa..f7edffd2f837 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,6 +5,10 @@ /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ @custom-variant wco (&:is(.wco, .wco *)); +::highlight(thread-find) { + background-color: color-mix(in srgb, var(--primary) 35%, transparent); +} + /* On mobile, morph the expanded hero composer into the compact docked composer while it moves; the rest of the app remains visually stationary. */ html[data-mobile-composer-route-transition="true"]::view-transition-old(root), From 409b73bcd75e58d9cf8b713bc57722c80aa9fd4d Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 13:53:36 -0700 Subject: [PATCH 02/17] fix(web): address in-thread find feedback --- .../chat/MessagesTimeline.logic.test.ts | 8 +- .../components/chat/MessagesTimeline.logic.ts | 15 +- .../src/components/chat/MessagesTimeline.tsx | 172 +++++++++++------- 3 files changed, 126 insertions(+), 69 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index fca8e16ec370..b515beaba50b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -10,8 +10,12 @@ import { } from "./MessagesTimeline.logic"; describe("findTextMatches", () => { - it("finds every case-insensitive occurrence and returns its text index", () => { - expect(findTextMatches(["One one", null, "another ONE"], "one")).toEqual([0, 0, 2]); + it("finds every case-insensitive occurrence and identifies it within its text", () => { + expect(findTextMatches(["One one", null, "another ONE"], "one")).toEqual([ + { textIndex: 0, occurrenceIndex: 0 }, + { textIndex: 0, occurrenceIndex: 1 }, + { textIndex: 2, occurrenceIndex: 0 }, + ]); expect(findTextMatches(["anything"], "")).toEqual([]); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 405b385c1e9e..756d26f4e004 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -242,22 +242,29 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +export interface TextMatch { + readonly textIndex: number; + readonly occurrenceIndex: number; +} + export function findTextMatches( texts: ReadonlyArray, query: string, -): number[] { +): TextMatch[] { const needle = query.toLowerCase(); if (!needle) return []; - const matches: number[] = []; - texts.forEach((text, index) => { + const matches: TextMatch[] = []; + texts.forEach((text, textIndex) => { const haystack = text?.toLowerCase() ?? ""; + let occurrenceIndex = 0; for ( let offset = 0; (offset = haystack.indexOf(needle, offset)) !== -1; offset += needle.length ) { - matches.push(index); + matches.push({ textIndex, occurrenceIndex }); + occurrenceIndex += 1; } }); return matches; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7e045e95b67b..9f57ca1a9372 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -68,6 +68,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; @@ -428,21 +429,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [findQuery, setFindQuery] = useState(""); const [findMatchIndex, setFindMatchIndex] = useState(0); const findInputRef = useRef(null); + const [timelineViewportElement, setTimelineViewportElement] = useState( + null, + ); + const findMessageEntries = useMemo( + () => timelineEntries.filter((entry) => entry.kind === "message"), + [timelineEntries], + ); const findTexts = useMemo( - () => rows.map((row) => (row.kind === "message" ? row.message.text : null)), - [rows], + () => findMessageEntries.map((entry) => entry.message.text), + [findMessageEntries], ); const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]); const updateFindQuery = useCallback( (query: string) => { setFindQuery(query); setFindMatchIndex(0); - const firstMatch = findTextMatches(findTexts, query)[0]; - if (firstMatch === undefined) return; - onManualNavigation(); - void listRef.current?.scrollToIndex({ index: firstMatch, animated: false, viewOffset: 80 }); + if (findTextMatches(findTexts, query).length > 0) onManualNavigation(); }, - [findTexts, listRef, onManualNavigation], + [findTexts, onManualNavigation], ); const goToFindMatch = useCallback( (delta: number) => { @@ -450,13 +455,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const next = (findMatchIndex + delta + findMatches.length) % findMatches.length; setFindMatchIndex(next); onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: findMatches[next]!, - animated: false, - viewOffset: 80, - }); }, - [findMatchIndex, findMatches, listRef, onManualNavigation], + [findMatchIndex, findMatches, onManualNavigation], ); useEffect(() => { @@ -466,7 +466,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey || - (event.target instanceof Element && event.target.closest("[data-terminal-owner]")) + !timelineViewportElement || + timelineViewportElement.getBoundingClientRect().width === 0 || + timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') || + (event.target instanceof Element && + event.target !== document.body && + (event.target.closest("[data-terminal-owner]") || + !timelineViewportElement + .closest("[data-chat-column-maximized-away]") + ?.contains(event.target))) ) { return; } @@ -476,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; window.addEventListener("keydown", handleFindShortcut, true); return () => window.removeEventListener("keydown", handleFindShortcut, true); - }, []); + }, [timelineViewportElement]); useEffect(() => { if (!findOpen) return; @@ -486,14 +494,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useEffect(() => { CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); - const rowIndex = findMatches[findMatchIndex]; - const rowId = rowIndex === undefined ? undefined : rows[rowIndex]?.id; - if (!findOpen || !findQuery || !rowId) return; + const match = findMatches[findMatchIndex]; + const entry = match === undefined ? undefined : findMessageEntries[match.textIndex]; + if (!findOpen || !findQuery || !match || !entry) return; + + const rowIndex = rows.findIndex((row) => row.id === entry.id); + if (rowIndex === -1) { + const turnId = entry.message.turnId; + if (turnId && !expandedTurnIds.has(turnId)) { + suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); + setExpandedTurnIds((existing) => new Set(existing).add(turnId)); + } + return; + } + + void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, viewOffset: 80 }); let paintFrame = 0; const mountFrame = requestAnimationFrame(() => { paintFrame = requestAnimationFrame(() => { - const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(rowId)}"]`); + const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(entry.id)}"]`); if (!root) return; const ranges: Range[] = []; @@ -512,7 +532,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ranges.push(range); } } - CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(...ranges)); + const activeRange = ranges[match.occurrenceIndex]; + if (!activeRange) return; + CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(activeRange)); + + let scrollParent = root.parentElement; + while (scrollParent && !/(auto|scroll)/u.test(getComputedStyle(scrollParent).overflowY)) { + scrollParent = scrollParent.parentElement; + } + if (scrollParent) { + const matchRect = activeRange.getBoundingClientRect(); + const viewportRect = scrollParent.getBoundingClientRect(); + if (matchRect.top < viewportRect.top || matchRect.bottom > viewportRect.bottom) { + scrollParent.scrollTop += + matchRect.top - viewportRect.top - scrollParent.clientHeight / 2; + } + } }); }); return () => { @@ -520,11 +555,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(paintFrame); CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); }; - }, [findMatchIndex, findMatches, findOpen, findQuery, rows]); + }, [ + expandedTurnIds, + findMatchIndex, + findMatches, + findMessageEntries, + findOpen, + findQuery, + listRef, + rows, + suspendEndScrollMaintenanceForDisclosure, + ]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); - const [timelineViewportElement, setTimelineViewportElement] = useState( - null, - ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( @@ -675,9 +717,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( -
-
+ + + {findQuery + ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` + : ""} + + + + + + ) : null} ref={listRef} From 378b338a6306dfcf549d201183aefbd5410b3857 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:01:54 -0700 Subject: [PATCH 03/17] fix(web): align find matches with rendered text --- apps/web/package.json | 3 + .../chat/MessagesTimeline.logic.test.ts | 12 ++++ .../components/chat/MessagesTimeline.logic.ts | 59 +++++++++++++++---- .../src/components/chat/MessagesTimeline.tsx | 19 +++--- pnpm-lock.yaml | 9 +++ 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..d48aeb239650 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "jszip": "3.10.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "mdast-util-to-string": "^4.0.0", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", @@ -46,7 +47,9 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b515beaba50b..8f02573813aa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,8 +3,10 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + findCaseInsensitiveTextRanges, findTextMatches, normalizeCompactToolLabel, + renderMarkdownSearchText, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; @@ -18,6 +20,16 @@ describe("findTextMatches", () => { ]); expect(findTextMatches(["anything"], "")).toEqual([]); }); + + it("uses original string offsets when case folding expands a Unicode character", () => { + expect(findCaseInsensitiveTextRanges("İstanbul", "İ")).toEqual([{ start: 0, end: 1 }]); + }); + + it("searches rendered Markdown text rather than hidden link destinations", () => { + const text = renderMarkdownSearchText("Read [the docs](https://example.com/hidden) now"); + expect(findTextMatches([text], "docs")).toHaveLength(1); + expect(findTextMatches([text], "hidden")).toHaveLength(0); + }); }); describe("shouldPreserveAssistantLineBreaks", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 756d26f4e004..7735e5a7fdb9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,8 @@ import * as Equal from "effect/Equal"; +import { toString } from "mdast-util-to-string"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; import { formatDuration, workEntryIndicatesToolNeutralStatus, @@ -247,25 +251,58 @@ export interface TextMatch { readonly occurrenceIndex: number; } +export interface TextRange { + readonly start: number; + readonly end: number; +} + +const markdownSearchTextProcessor = unified().use(remarkParse).use(remarkGfm); + +export function renderMarkdownSearchText(markdown: string): string { + return toString(markdownSearchTextProcessor.parse(markdown), { includeImageAlt: false }); +} + +export function findCaseInsensitiveTextRanges(text: string, query: string): TextRange[] { + const normalizedOffsets: TextRange[] = []; + let normalizedText = ""; + for (let start = 0; start < text.length; ) { + const codePoint = text.codePointAt(start)!; + const end = start + (codePoint > 0xffff ? 2 : 1); + const normalized = text.slice(start, end).toLowerCase(); + normalizedText += normalized; + for (let index = 0; index < normalized.length; index += 1) { + normalizedOffsets.push({ start, end }); + } + start = end; + } + + const needle = query.toLowerCase(); + if (!needle) return []; + + const ranges: TextRange[] = []; + for ( + let offset = 0; + (offset = normalizedText.indexOf(needle, offset)) !== -1; + offset += needle.length + ) { + const first = normalizedOffsets[offset]; + const last = normalizedOffsets[offset + needle.length - 1]; + if (first && last) ranges.push({ start: first.start, end: last.end }); + } + return ranges; +} + export function findTextMatches( texts: ReadonlyArray, query: string, ): TextMatch[] { - const needle = query.toLowerCase(); - if (!needle) return []; + if (!query) return []; const matches: TextMatch[] = []; texts.forEach((text, textIndex) => { - const haystack = text?.toLowerCase() ?? ""; - let occurrenceIndex = 0; - for ( - let offset = 0; - (offset = haystack.indexOf(needle, offset)) !== -1; - offset += needle.length - ) { + findCaseInsensitiveTextRanges(text ?? "", query).forEach((_, occurrenceIndex) => { matches.push({ textIndex, occurrenceIndex }); - occurrenceIndex += 1; - } + }); }); return matches; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9f57ca1a9372..48f39e1fb1e1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -77,8 +77,10 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, + findCaseInsensitiveTextRanges, findTextMatches, normalizeCompactToolLabel, + renderMarkdownSearchText, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -437,7 +439,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [timelineEntries], ); const findTexts = useMemo( - () => findMessageEntries.map((entry) => entry.message.text), + () => findMessageEntries.map((entry) => renderMarkdownSearchText(entry.message.text)), [findMessageEntries], ); const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]); @@ -520,15 +522,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const needle = findQuery.toLowerCase(); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const text = node.textContent?.toLowerCase() ?? ""; - for ( - let offset = 0; - (offset = text.indexOf(needle, offset)) !== -1; - offset += needle.length - ) { + const text = node.textContent ?? ""; + for (const { start, end } of findCaseInsensitiveTextRanges(text, needle)) { const range = new Range(); - range.setStart(node, offset); - range.setEnd(node, offset + needle.length); + range.setStart(node, start); + range.setEnd(node, end); ranges.push(range); } } @@ -717,7 +715,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( - + @@ -731,7 +729,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }} aria-label="Find in thread" placeholder="Find in thread" - className="w-48" /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0e..941a5b52443f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -597,6 +597,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mdast-util-to-string: + specifier: ^4.0.0 + version: 4.0.0 react: specifier: 19.2.6 version: 19.2.6 @@ -618,9 +621,15 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 zustand: specifier: ^5.0.11 version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) From 89d82cbcfb3091b247a16eb55fbe72f1e150c8c5 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:05:19 -0700 Subject: [PATCH 04/17] fix(web): highlight matches across markdown nodes --- .../chat/MessagesTimeline.logic.test.ts | 6 ++++ .../src/components/chat/MessagesTimeline.tsx | 31 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 8f02573813aa..5a3f90463018 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -30,6 +30,12 @@ describe("findTextMatches", () => { expect(findTextMatches([text], "docs")).toHaveLength(1); expect(findTextMatches([text], "hidden")).toHaveLength(0); }); + + it("preserves matches that span adjacent inline Markdown nodes", () => { + const text = renderMarkdownSearchText("**foo**bar"); + expect(text).toBe("foobar"); + expect(findTextMatches([text], "oob")).toHaveLength(1); + }); }); describe("shouldPreserveAssistantLineBreaks", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 48f39e1fb1e1..fb0b01d2a636 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -518,20 +518,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(entry.id)}"]`); if (!root) return; - const ranges: Range[] = []; const needle = findQuery.toLowerCase(); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const textNodes: Array<{ node: Text; start: number; end: number }> = []; + let text = ""; for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const text = node.textContent ?? ""; - for (const { start, end } of findCaseInsensitiveTextRanges(text, needle)) { - const range = new Range(); - range.setStart(node, start); - range.setEnd(node, end); - ranges.push(range); - } + const value = node.textContent ?? ""; + textNodes.push({ + node: node as Text, + start: text.length, + end: text.length + value.length, + }); + text += value; } - const activeRange = ranges[match.occurrenceIndex]; - if (!activeRange) return; + const activeMatch = findCaseInsensitiveTextRanges(text, needle)[match.occurrenceIndex]; + if (!activeMatch) return; + const startNode = textNodes.find( + ({ start, end }) => start <= activeMatch.start && activeMatch.start < end, + ); + const endNode = textNodes.find( + ({ start, end }) => start < activeMatch.end && activeMatch.end <= end, + ); + if (!startNode || !endNode) return; + const activeRange = new Range(); + activeRange.setStart(startNode.node, activeMatch.start - startNode.start); + activeRange.setEnd(endNode.node, activeMatch.end - endNode.start); CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(activeRange)); let scrollParent = root.parentElement; From 89460214f4e6720e963faa62f94a3ed844f2a016 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:07:53 -0700 Subject: [PATCH 05/17] fix(web): let find input own its surface --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fb0b01d2a636..5e08561735ba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -726,7 +726,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( - + From 6fec1d220fbeab7003f8725f6345b11100dc56bf Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:19:33 -0700 Subject: [PATCH 06/17] fix(web): make find overlay opaque in dark mode --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5e08561735ba..5cd1b8424625 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -726,7 +726,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( - + From 673fbf4650c0711bb603b6c69a5e9c8a20abd6e7 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:27:03 -0700 Subject: [PATCH 07/17] fix(web): keep find overlay elevation stable --- .../src/components/chat/MessagesTimeline.tsx | 106 +++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5cd1b8424625..62bae84044de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -726,58 +726,60 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( - - - - updateFindQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1); - if (event.key === "Escape") setFindOpen(false); - }} - aria-label="Find in thread" - placeholder="Find in thread" - /> - - - {findQuery - ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` - : ""} - - - - - - +
+ + + + updateFindQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1); + if (event.key === "Escape") setFindOpen(false); + }} + aria-label="Find in thread" + placeholder="Find in thread" + /> + + + {findQuery + ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` + : ""} + + + + + + +
) : null} ref={listRef} From d6df6773c84939bc4ad6c65f92ee4e2ac4c119a2 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:33:16 -0700 Subject: [PATCH 08/17] fix(web): clamp active find match --- apps/web/src/components/chat/MessagesTimeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 62bae84044de..61570c57785b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -429,7 +429,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useStableRows(rawRows); const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(""); - const [findMatchIndex, setFindMatchIndex] = useState(0); + const [findMatchCursor, setFindMatchIndex] = useState(0); const findInputRef = useRef(null); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -443,6 +443,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [findMessageEntries], ); const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]); + const findMatchIndex = Math.min(findMatchCursor, Math.max(0, findMatches.length - 1)); const updateFindQuery = useCallback( (query: string) => { setFindQuery(query); From 3c7ee6e4dec91bf9dbe0a07e93053ab0a19a5917 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:45:47 -0700 Subject: [PATCH 09/17] fix(web): refine in-thread find behavior --- .../chat/MessagesTimeline.logic.test.ts | 6 ++ .../src/components/chat/MessagesTimeline.tsx | 96 ++++++++++++++----- apps/web/src/index.css | 9 +- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5a3f90463018..7f7e2da2369b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -36,6 +36,12 @@ describe("findTextMatches", () => { expect(text).toBe("foobar"); expect(findTextMatches([text], "oob")).toHaveLength(1); }); + + it("searches inline and fenced code in prompts and responses", () => { + const text = renderMarkdownSearchText("Run `pnpm test`:\n\n```ts\nconst answer = 42;\n```"); + expect(findTextMatches([text], "pnpm test")).toHaveLength(1); + expect(findTextMatches([text], "answer = 42")).toHaveLength(1); + }); }); describe("shouldPreserveAssistantLineBreaks", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 61570c57785b..c332d263995f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -39,6 +39,7 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; +import { isElectron } from "../../env"; import { getRenderablePatch, resolveDiffThemeName, @@ -195,6 +196,7 @@ function TimelineLoadEarlierHeader({ } const TIMELINE_LIST_FOOTER =
; const THREAD_FIND_HIGHLIGHT = "thread-find"; +const THREAD_FIND_ACTIVE_HIGHLIGHT = "thread-find-active"; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, @@ -464,11 +466,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useEffect(() => { const handleFindShortcut = (event: globalThis.KeyboardEvent) => { + const key = event.key.toLowerCase(); if ( - event.key.toLowerCase() !== "f" || + (key !== "f" && key !== "g") || (!event.metaKey && !event.ctrlKey) || event.altKey || - event.shiftKey || !timelineViewportElement || timelineViewportElement.getBoundingClientRect().width === 0 || timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') || @@ -481,13 +483,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ) { return; } + + const inputFocused = document.activeElement === findInputRef.current; + if (key === "g") { + if (!findOpen || inputFocused) return; + event.preventDefault(); + event.stopPropagation(); + goToFindMatch(event.shiftKey ? -1 : 1); + return; + } + if (event.shiftKey || (!isElectron && findOpen && inputFocused)) return; + event.preventDefault(); event.stopPropagation(); - setFindOpen(true); + if (findOpen) { + findInputRef.current?.focus(); + } else { + setFindOpen(true); + } }; window.addEventListener("keydown", handleFindShortcut, true); return () => window.removeEventListener("keydown", handleFindShortcut, true); - }, [timelineViewportElement]); + }, [findOpen, goToFindMatch, timelineViewportElement]); useEffect(() => { if (!findOpen) return; @@ -497,9 +514,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useEffect(() => { CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); + CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT); const match = findMatches[findMatchIndex]; const entry = match === undefined ? undefined : findMessageEntries[match.textIndex]; - if (!findOpen || !findQuery || !match || !entry) return; + if (!findOpen || !findQuery || !match || !entry || !timelineViewportElement) return; const rowIndex = rows.findIndex((row) => row.id === entry.id); if (rowIndex === -1) { @@ -514,12 +532,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, viewOffset: 80 }); let paintFrame = 0; - const mountFrame = requestAnimationFrame(() => { - paintFrame = requestAnimationFrame(() => { - const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(entry.id)}"]`); - if (!root) return; + let repaintFrame = 0; + const paintHighlights = () => { + const allRanges: Range[] = []; + let activeRange: Range | undefined; + for (const [textIndex, messageEntry] of findMessageEntries.entries()) { + const root = timelineViewportElement?.querySelector( + `[data-timeline-row-id="${CSS.escape(messageEntry.id)}"]`, + ); + if (!root) continue; - const needle = findQuery.toLowerCase(); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const textNodes: Array<{ node: Text; start: number; end: number }> = []; let text = ""; @@ -532,19 +554,45 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); text += value; } - const activeMatch = findCaseInsensitiveTextRanges(text, needle)[match.occurrenceIndex]; - if (!activeMatch) return; - const startNode = textNodes.find( - ({ start, end }) => start <= activeMatch.start && activeMatch.start < end, - ); - const endNode = textNodes.find( - ({ start, end }) => start < activeMatch.end && activeMatch.end <= end, + for (const [occurrenceIndex, textRange] of findCaseInsensitiveTextRanges( + text, + findQuery, + ).entries()) { + const startNode = textNodes.find( + ({ start, end }) => start <= textRange.start && textRange.start < end, + ); + const endNode = textNodes.find( + ({ start, end }) => start < textRange.end && textRange.end <= end, + ); + if (!startNode || !endNode) continue; + const range = new Range(); + range.setStart(startNode.node, textRange.start - startNode.start); + range.setEnd(endNode.node, textRange.end - endNode.start); + allRanges.push(range); + if (textIndex === match.textIndex && occurrenceIndex === match.occurrenceIndex) { + activeRange = range; + } + } + } + CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(...allRanges)); + if (activeRange) { + CSS.highlights.set(THREAD_FIND_ACTIVE_HIGHLIGHT, new Highlight(activeRange)); + } + return activeRange; + }; + const scheduleRepaint = () => { + cancelAnimationFrame(repaintFrame); + repaintFrame = requestAnimationFrame(() => paintHighlights()); + }; + const observer = new MutationObserver(scheduleRepaint); + observer.observe(timelineViewportElement, { childList: true, subtree: true }); + const mountFrame = requestAnimationFrame(() => { + paintFrame = requestAnimationFrame(() => { + const activeRange = paintHighlights(); + const root = timelineViewportElement.querySelector( + `[data-timeline-row-id="${CSS.escape(entry.id)}"]`, ); - if (!startNode || !endNode) return; - const activeRange = new Range(); - activeRange.setStart(startNode.node, activeMatch.start - startNode.start); - activeRange.setEnd(endNode.node, activeMatch.end - endNode.start); - CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(activeRange)); + if (!root || !activeRange) return; let scrollParent = root.parentElement; while (scrollParent && !/(auto|scroll)/u.test(getComputedStyle(scrollParent).overflowY)) { @@ -563,7 +611,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { cancelAnimationFrame(mountFrame); cancelAnimationFrame(paintFrame); + cancelAnimationFrame(repaintFrame); + observer.disconnect(); CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); + CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT); }; }, [ expandedTurnIds, @@ -575,6 +626,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, rows, suspendEndScrollMaintenanceForDisclosure, + timelineViewportElement, ]); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f7edffd2f837..022355c44953 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,7 +6,14 @@ @custom-variant wco (&:is(.wco, .wco *)); ::highlight(thread-find) { - background-color: color-mix(in srgb, var(--primary) 35%, transparent); + background-color: color-mix(in srgb, var(--primary) 25%, transparent); +} + +::highlight(thread-find-active) { + background-color: color-mix(in srgb, var(--primary) 55%, transparent); + text-decoration: underline; + text-decoration-color: var(--primary); + text-decoration-thickness: 2px; } /* On mobile, morph the expanded hero composer into the compact docked From 2c808cb58e8ebe4a70d956ce795858649375bbff Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 14:53:14 -0700 Subject: [PATCH 10/17] feat(web): add match-case thread search --- .../chat/MessagesTimeline.logic.test.ts | 13 +++++++-- .../components/chat/MessagesTimeline.logic.ts | 14 +++++++-- .../src/components/chat/MessagesTimeline.tsx | 29 +++++++++++++++---- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7f7e2da2369b..cde6ea6f1bc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,7 +3,7 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, - findCaseInsensitiveTextRanges, + findTextRanges, findTextMatches, normalizeCompactToolLabel, renderMarkdownSearchText, @@ -22,7 +22,16 @@ describe("findTextMatches", () => { }); it("uses original string offsets when case folding expands a Unicode character", () => { - expect(findCaseInsensitiveTextRanges("İstanbul", "İ")).toEqual([{ start: 0, end: 1 }]); + expect(findTextRanges("İstanbul", "İ")).toEqual([{ start: 0, end: 1 }]); + }); + + it("optionally matches exact casing", () => { + expect(findTextMatches(["One one ONE"], "One", true)).toEqual([ + { textIndex: 0, occurrenceIndex: 0 }, + ]); + expect(findTextMatches(["One one ONE"], "one", true)).toEqual([ + { textIndex: 0, occurrenceIndex: 0 }, + ]); }); it("searches rendered Markdown text rather than hidden link destinations", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7735e5a7fdb9..f95d4abcec88 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -262,7 +262,16 @@ export function renderMarkdownSearchText(markdown: string): string { return toString(markdownSearchTextProcessor.parse(markdown), { includeImageAlt: false }); } -export function findCaseInsensitiveTextRanges(text: string, query: string): TextRange[] { +export function findTextRanges(text: string, query: string, caseSensitive = false): TextRange[] { + if (caseSensitive) { + if (!query) return []; + const ranges: TextRange[] = []; + for (let offset = 0; (offset = text.indexOf(query, offset)) !== -1; offset += query.length) { + ranges.push({ start: offset, end: offset + query.length }); + } + return ranges; + } + const normalizedOffsets: TextRange[] = []; let normalizedText = ""; for (let start = 0; start < text.length; ) { @@ -295,12 +304,13 @@ export function findCaseInsensitiveTextRanges(text: string, query: string): Text export function findTextMatches( texts: ReadonlyArray, query: string, + caseSensitive = false, ): TextMatch[] { if (!query) return []; const matches: TextMatch[] = []; texts.forEach((text, textIndex) => { - findCaseInsensitiveTextRanges(text ?? "", query).forEach((_, occurrenceIndex) => { + findTextRanges(text ?? "", query, caseSensitive).forEach((_, occurrenceIndex) => { matches.push({ textIndex, occurrenceIndex }); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c332d263995f..9a73b7bc1a94 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -69,6 +69,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; @@ -78,7 +79,7 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, - findCaseInsensitiveTextRanges, + findTextRanges, findTextMatches, normalizeCompactToolLabel, renderMarkdownSearchText, @@ -431,6 +432,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useStableRows(rawRows); const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(""); + const [findCaseSensitive, setFindCaseSensitive] = useState(false); const [findMatchCursor, setFindMatchIndex] = useState(0); const findInputRef = useRef(null); const [timelineViewportElement, setTimelineViewportElement] = useState( @@ -444,15 +446,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => findMessageEntries.map((entry) => renderMarkdownSearchText(entry.message.text)), [findMessageEntries], ); - const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]); + const findMatches = useMemo( + () => findTextMatches(findTexts, findQuery, findCaseSensitive), + [findCaseSensitive, findQuery, findTexts], + ); const findMatchIndex = Math.min(findMatchCursor, Math.max(0, findMatches.length - 1)); const updateFindQuery = useCallback( (query: string) => { setFindQuery(query); setFindMatchIndex(0); - if (findTextMatches(findTexts, query).length > 0) onManualNavigation(); + if (findTextMatches(findTexts, query, findCaseSensitive).length > 0) onManualNavigation(); }, - [findTexts, onManualNavigation], + [findCaseSensitive, findTexts, onManualNavigation], ); const goToFindMatch = useCallback( (delta: number) => { @@ -554,9 +559,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); text += value; } - for (const [occurrenceIndex, textRange] of findCaseInsensitiveTextRanges( + for (const [occurrenceIndex, textRange] of findTextRanges( text, findQuery, + findCaseSensitive, ).entries()) { const startNode = textNodes.find( ({ start, end }) => start <= textRange.start && textRange.start < end, @@ -618,6 +624,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [ expandedTurnIds, + findCaseSensitive, findMatchIndex, findMatches, findMessageEntries, @@ -796,6 +803,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ placeholder="Find in thread" /> + {findQuery ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` From 0b0266b72636a57c76362bb50029d384a0a1c5f7 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:07:16 -0700 Subject: [PATCH 11/17] fix(web): refine thread find controls --- .../src/components/chat/MessagesTimeline.tsx | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9a73b7bc1a94..e6e5c7797680 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -69,7 +69,6 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; -import { Checkbox } from "../ui/checkbox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; @@ -473,22 +472,40 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleFindShortcut = (event: globalThis.KeyboardEvent) => { const key = event.key.toLowerCase(); if ( - (key !== "f" && key !== "g") || - (!event.metaKey && !event.ctrlKey) || - event.altKey || !timelineViewportElement || timelineViewportElement.getBoundingClientRect().width === 0 || - timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') || - (event.target instanceof Element && - event.target !== document.body && - (event.target.closest("[data-terminal-owner]") || - !timelineViewportElement - .closest("[data-chat-column-maximized-away]") - ?.contains(event.target))) + timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') ) { return; } + if (key === "escape") { + if ( + !findOpen || + (event.target instanceof Element && + event.target.closest('[role="dialog"], [data-terminal-owner]')) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setFindOpen(false); + return; + } + if ( + event.target instanceof Element && + event.target !== document.body && + (event.target.closest("[data-terminal-owner]") || + !timelineViewportElement + .closest("[data-chat-column-maximized-away]") + ?.contains(event.target)) + ) { + return; + } + if ((key !== "f" && key !== "g") || (!event.metaKey && !event.ctrlKey) || event.altKey) { + return; + } + const inputFocused = document.activeElement === findInputRef.current; if (key === "g") { if (!findOpen || inputFocused) return; @@ -797,24 +814,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onChange={(event) => updateFindQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1); - if (event.key === "Escape") setFindOpen(false); }} aria-label="Find in thread" placeholder="Find in thread" /> - + {findQuery ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` From 48386913c083f02334f2d061d516f4fc867e9d23 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:10:46 -0700 Subject: [PATCH 12/17] fix(web): emphasize match case state --- apps/web/src/components/chat/MessagesTimeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e6e5c7797680..9ef3bbfadbc5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -823,6 +823,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type="button" size="icon-xs" variant="ghost" + className="data-pressed:border-primary! data-pressed:bg-primary! data-pressed:text-primary-foreground!" data-pressed={findCaseSensitive || undefined} aria-label="Match case" aria-pressed={findCaseSensitive} From cce4f3bd89a027636a7836a7699f6bbe435d9dd7 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:15:33 -0700 Subject: [PATCH 13/17] fix(web): use shared toggle for match case --- .../src/components/chat/MessagesTimeline.tsx | 21 +++++++++---------- apps/web/src/components/ui/toggle.tsx | 2 ++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9ef3bbfadbc5..18b824ba7da9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,6 +70,7 @@ import { } from "lucide-react"; import { Button } from "../ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Toggle } from "../ui/toggle"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; @@ -819,23 +820,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ placeholder="Find in thread" /> - + Aa + {findQuery ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 5bf04adf41a1..931aa7ec8f70 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -25,6 +25,8 @@ const toggleVariants = cva( default: "border-transparent", ghost: "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", + primary: + "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", }, From d5cf92e2388605f7a7647850d3f0491a18a67c56 Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:17:09 -0700 Subject: [PATCH 14/17] fix(web): keep thread find bar responsive --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 18b824ba7da9..f39bbb677136 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -804,8 +804,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( -
- +
+ From 8753a9bd9f7b0e798f99897084f8a5c014eedd7e Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:19:21 -0700 Subject: [PATCH 15/17] fix(web): right-anchor responsive find bar --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f39bbb677136..253d664fa43a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -804,8 +804,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{findOpen ? ( -
- +
+ From 666bcb9cb859963a9889fcd092686b589ca73cde Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:28:13 -0700 Subject: [PATCH 16/17] fix(web): align find toggle and reveal matches --- apps/web/src/components/chat/MessagesTimeline.tsx | 14 +++++++++++++- .../search/ProjectContentSearchDialog.tsx | 4 ++-- apps/web/src/components/ui/toggle.tsx | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 253d664fa43a..6a063a4c1669 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -151,6 +151,7 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; + activeFindMessageId: MessageId | null; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } @@ -451,6 +452,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [findCaseSensitive, findQuery, findTexts], ); const findMatchIndex = Math.min(findMatchCursor, Math.max(0, findMatches.length - 1)); + const activeFindMessageId = + findOpen && findQuery + ? (findMessageEntries[findMatches[findMatchIndex]?.textIndex ?? -1]?.message.id ?? null) + : null; const updateFindQuery = useCallback( (query: string) => { setFindQuery(query); @@ -746,6 +751,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + activeFindMessageId, agentPanelModel, onOpenAgents, }), @@ -762,6 +768,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + activeFindMessageId, agentPanelModel, onOpenAgents, ], @@ -824,7 +831,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ pressed={findCaseSensitive} size="xs" variant="primary" - className="font-mono text-[11px]" + className="font-mono text-[11px] sm:text-[11px]" aria-label="Match case" title="Match case" onPressedChange={(pressed) => { @@ -1333,6 +1340,7 @@ function UserTimelineRow({ row }: { row: Extract
@@ -1902,9 +1910,13 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; + forceExpanded?: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); + useEffect(() => { + if (props.forceExpanded) setExpanded(true); + }, [props.forceExpanded]); const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..c28403c67341 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -66,9 +66,9 @@ function SearchOptionButton(props: { } diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 931aa7ec8f70..902adf05cf61 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -26,7 +26,7 @@ const toggleVariants = cva( ghost: "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", primary: - "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", + "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground data-pressed:hover:bg-primary/90 disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", }, From 1e856b41bd1e387aede255e685313d27a300226b Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 18 Aug 2026 15:41:18 -0700 Subject: [PATCH 17/17] fix(web): reopen thread find with match shortcuts --- apps/web/src/components/chat/MessagesTimeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6a063a4c1669..55ad531dd131 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -514,9 +514,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const inputFocused = document.activeElement === findInputRef.current; if (key === "g") { - if (!findOpen || inputFocused) return; + if (inputFocused) return; event.preventDefault(); event.stopPropagation(); + if (!findOpen) setFindOpen(true); goToFindMatch(event.shiftKey ? -1 : 1); return; }