From 2f7ad965617528d27afb7ddbd01f5e8734718fba Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 26 Jul 2026 17:46:04 -0700 Subject: [PATCH 1/6] feat(web): add Cmd+F find in thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-thread find bar to the chat timeline, bound to the new `chat.find` keybinding command (mod+f outside terminal focus). Matches are modeled against timeline entries rather than rendered DOM, so counts stay stable while the virtualized list mounts and unmounts rows, and a match inside a folded turn unfolds that turn on navigation. Highlights are painted with the CSS Custom Highlight API, which leaves the DOM untouched — markdown output, syntax-highlighted code blocks and copyable text are unaffected, and browsers without the API still get working counting and navigation. Refs #1486 Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ChatView.tsx | 75 +++++++ .../src/components/chat/FindInThreadBar.tsx | 122 +++++++++++ .../src/components/chat/MessagesTimeline.tsx | 155 ++++++++++--- .../src/components/chat/threadFind.test.ts | 167 ++++++++++++++ apps/web/src/components/chat/threadFind.ts | 127 +++++++++++ .../chat/threadFindHighlights.test.ts | 35 +++ .../components/chat/threadFindHighlights.ts | 207 ++++++++++++++++++ apps/web/src/index.css | 20 ++ apps/web/src/lib/visibleMessageText.ts | 46 ++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 11 files changed, 928 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/chat/FindInThreadBar.tsx create mode 100644 apps/web/src/components/chat/threadFind.test.ts create mode 100644 apps/web/src/components/chat/threadFind.ts create mode 100644 apps/web/src/components/chat/threadFindHighlights.test.ts create mode 100644 apps/web/src/components/chat/threadFindHighlights.ts create mode 100644 apps/web/src/lib/visibleMessageText.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdde62a3e0d..36a830a07b4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -225,6 +225,8 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { FindInThreadBar } from "./chat/FindInThreadBar"; +import { clampThreadFindIndex, stepThreadFindIndex } from "./chat/threadFind"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -309,6 +311,23 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +interface ChatFindState { + open: boolean; + query: string; + activeIndex: number; + matchCount: number; + /** Bumped on every open request so a repeat Cmd+F re-selects the field. */ + focusRequestId: number; +} +const CLOSED_CHAT_FIND_STATE: ChatFindState = { + open: false, + query: "", + activeIndex: 0, + matchCount: 0, + focusRequestId: 0, +}; + function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1248,6 +1267,7 @@ function ChatViewContent(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [findState, setFindState] = useState(CLOSED_CHAT_FIND_STATE); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -4261,6 +4281,35 @@ function ChatViewContent(props: ChatViewProps) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + const openThreadFind = useCallback(() => { + setFindState((state) => ({ + ...state, + open: true, + focusRequestId: state.focusRequestId + 1, + })); + }, []); + const closeThreadFind = useCallback(() => { + setFindState(CLOSED_CHAT_FIND_STATE); + }, []); + const changeThreadFindQuery = useCallback((query: string) => { + setFindState((state) => ({ ...state, query, activeIndex: 0 })); + }, []); + const stepThreadFind = useCallback((delta: number) => { + setFindState((state) => ({ + ...state, + activeIndex: stepThreadFindIndex(state.activeIndex, state.matchCount, delta), + })); + }, []); + const handleThreadFindMatchCountChange = useCallback((matchCount: number) => { + setFindState((state) => (state.matchCount === matchCount ? state : { ...state, matchCount })); + }, []); + // Find is per-thread; switching threads drops it rather than carrying a query + // that matched somewhere else. + useEffect(() => { + setFindState(CLOSED_CHAT_FIND_STATE); + }, [activeThreadKey]); + const threadFindActiveIndex = clampThreadFindIndex(findState.activeIndex, findState.matchCount); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) { @@ -4300,6 +4349,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "chat.find") { + event.preventDefault(); + event.stopPropagation(); + openThreadFind(); + return; + } + if (command === "rightPanel.toggle") { event.preventDefault(); event.stopPropagation(); @@ -4403,6 +4459,7 @@ function ChatViewContent(props: ChatViewProps) { onToggleDiff, toggleRightPanel, toggleTerminalVisibility, + openThreadFind, composerRef, ]); @@ -5739,8 +5796,26 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} + findQuery={findState.open ? findState.query : ""} + findActiveIndex={threadFindActiveIndex} + onFindMatchCountChange={handleThreadFindMatchCountChange} /> + {findState.open && ( +
+ stepThreadFind(1)} + onPrevious={() => stepThreadFind(-1)} + onClose={closeThreadFind} + /> +
+ )} + {/* scroll to end pill — shown when user has scrolled away from the live edge */} {showScrollToBottom && (
void; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +export function FindInThreadBar({ + query, + matchCount, + activeIndex, + focusRequestId, + onQueryChange, + onNext, + onPrevious, + onClose, +}: FindInThreadBarProps) { + const inputRef = useRef(null); + + useEffect(() => { + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + input.select(); + }, [focusRequestId]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + onPrevious(); + } else { + onNext(); + } + } + }; + + const hasQuery = query.trim().length > 0; + const noResults = hasQuery && matchCount === 0; + + return ( +
+ onQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + className="h-6 w-44 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/72" + /> + + {hasQuery ? formatThreadFindCount(activeIndex, matchCount) : ""} + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..708c51e86f0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -81,20 +81,18 @@ import { TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, } from "./MessagesTimeline.logic"; +import { + buildThreadFindMatches, + clampThreadFindIndex, + normalizeThreadFindQuery, +} from "./threadFind"; +import { useThreadFindHighlights } from "./threadFindHighlights"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - deriveDisplayedUserMessageState, - type ParsedTerminalContextEntry, -} from "~/lib/terminalContext"; -import { - extractTrailingElementContexts, - type ParsedElementContextEntry, -} from "~/lib/elementContext"; -import { - extractTrailingPreviewAnnotation, - type ParsedPreviewAnnotation, -} from "~/lib/previewAnnotation"; +import { type ParsedTerminalContextEntry } from "~/lib/terminalContext"; +import { type ParsedElementContextEntry } from "~/lib/elementContext"; +import { type ParsedPreviewAnnotation } from "~/lib/previewAnnotation"; +import { deriveDisplayedUserMessageContent } from "~/lib/visibleMessageText"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -150,6 +148,8 @@ const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +/** Breathing room kept above a revealed find match, and below it. */ +const FIND_MATCH_VIEW_MARGIN = 96; // --------------------------------------------------------------------------- // Props (public API) @@ -184,6 +184,11 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + /** In-thread find query; empty disables find entirely. */ + findQuery?: string; + /** Index of the match to reveal, into the list this component reports back. */ + findActiveIndex?: number; + onFindMatchCountChange?: (count: number) => void; } // --------------------------------------------------------------------------- @@ -219,6 +224,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + findQuery = "", + findActiveIndex = 0, + onFindMatchCountChange, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -456,6 +464,108 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], ); + // ------------------------------------------------------------------------- + // In-thread find. Matches are counted against timeline entries so folded and + // virtualized-away content still participates; navigation unfolds and scrolls + // to the owning row, and painting happens over the mounted DOM. + // ------------------------------------------------------------------------- + const normalizedFindQuery = normalizeThreadFindQuery(findQuery); + const findMatches = useMemo( + () => buildThreadFindMatches(timelineEntries, normalizedFindQuery), + [normalizedFindQuery, timelineEntries], + ); + const activeFindMatch = + findMatches.length > 0 + ? (findMatches[clampThreadFindIndex(findActiveIndex, findMatches.length)] ?? null) + : null; + + useEffect(() => { + onFindMatchCountChange?.(findMatches.length); + }, [findMatches.length, onFindMatchCountChange]); + + const { repaint: repaintFindHighlights, activeRangeRef } = useThreadFindHighlights({ + container: timelineViewportElement, + query: normalizedFindQuery, + activeRowId: activeFindMatch?.entryId ?? null, + activeOccurrence: activeFindMatch?.occurrence ?? 0, + }); + + // Rows churn while a turn streams; only navigate when the target itself + // changes, otherwise every streamed token would yank the scroll position. + const navigatedFindMatchKeyRef = useRef(null); + useEffect(() => { + if (!activeFindMatch) { + navigatedFindMatchKeyRef.current = null; + return; + } + + const rowIndex = rows.findIndex((row) => row.id === activeFindMatch.entryId); + if (rowIndex === -1) { + // The match sits inside a folded turn — unfold it and let the recomputed + // rows re-run this effect. + const turnId = activeFindMatch.turnId; + if (turnId !== null) { + setExpandedTurnIds((existing) => { + if (existing.has(turnId)) { + return existing; + } + const next = new Set(existing); + next.add(turnId); + return next; + }); + } + return; + } + + const matchKey = `${activeFindMatch.entryId}:${activeFindMatch.occurrence}`; + if (navigatedFindMatchKeyRef.current === matchKey) { + return; + } + navigatedFindMatchKeyRef.current = matchKey; + + void listRef.current?.scrollToIndex({ + index: rowIndex, + animated: false, + viewOffset: FIND_MATCH_VIEW_MARGIN, + }); + + // Once the row is laid out, nudge the exact occurrence into view — a long + // message can scroll to its top with the match still below the fold. + const frame = requestAnimationFrame(() => { + repaintFindHighlights(); + const matchRect = activeRangeRef.current?.getBoundingClientRect(); + const viewportRect = timelineViewportElement?.getBoundingClientRect(); + if (!matchRect || !viewportRect || matchRect.height === 0) { + return; + } + const topBoundary = viewportRect.top + FIND_MATCH_VIEW_MARGIN; + const bottomBoundary = + viewportRect.bottom - FIND_MATCH_VIEW_MARGIN - contentInsetEndAdjustment; + let delta = 0; + if (matchRect.top < topBoundary) { + delta = matchRect.top - topBoundary; + } else if (matchRect.bottom > bottomBoundary) { + delta = matchRect.bottom - bottomBoundary; + } + if (Math.abs(delta) < 1) { + return; + } + const currentScroll = listRef.current?.getState?.().scroll; + if (typeof currentScroll === "number") { + listRef.current?.scrollToOffset({ offset: currentScroll + delta, animated: false }); + } + }); + return () => cancelAnimationFrame(frame); + }, [ + activeFindMatch, + activeRangeRef, + contentInsetEndAdjustment, + listRef, + repaintFindHighlights, + rows, + timelineViewportElement, + ]); + // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( @@ -869,21 +979,10 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const previewAnnotations: ParsedPreviewAnnotation[] = []; - let visibleText = displayedUserMessage.visibleText; - while (true) { - const extracted = extractTrailingPreviewAnnotation(visibleText); - if (!extracted.annotation) break; - previewAnnotations.unshift(extracted.annotation); - visibleText = extracted.promptText; - } - const elementContextState = extractTrailingElementContexts(visibleText); - const elementContexts = [ - ...displayedUserMessage.elementContexts, - ...elementContextState.contexts, - ]; + const displayedUserMessage = deriveDisplayedUserMessageContent(row.message.text); + const terminalContexts = displayedUserMessage.terminalContexts; + const previewAnnotations = displayedUserMessage.previewAnnotations; + const elementContexts = displayedUserMessage.elementContexts; const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -942,7 +1041,7 @@ function UserTimelineRow({ row }: { row: Extract ) : null} { + it("matches case-insensitively", () => { + expect(findThreadTextOccurrences("Deploy the Deployment", "deploy")).toEqual([0, 11]); + }); + + it("counts overlapping candidates only once, matching what a highlighter can paint", () => { + expect(findThreadTextOccurrences("aaa", "aa")).toEqual([0]); + expect(findThreadTextOccurrences("aaaa", "aa")).toEqual([0, 2]); + }); + + it("returns nothing for empty inputs", () => { + expect(findThreadTextOccurrences("", "a")).toEqual([]); + expect(findThreadTextOccurrences("a", "")).toEqual([]); + }); +}); + +describe("searchableThreadEntryText", () => { + it("searches the user text the timeline actually shows, not the appended contexts", () => { + const prompt = [ + "check the build", + "", + "", + "- pnpm build:", + " secret sentinel output", + "", + ].join("\n"); + + const searchable = searchableThreadEntryText(userEntry("m1", prompt)); + + expect(searchable).toBe("check the build"); + expect(searchable).not.toContain("sentinel"); + }); + + it("skips work-log entries", () => { + expect(searchableThreadEntryText(workEntry("w1", "ran the sentinel command"))).toBeNull(); + }); + + it("covers proposed plans", () => { + expect(searchableThreadEntryText(proposedPlanEntry("p1", "## Plan\nship it"))).toBe( + "## Plan\nship it", + ); + }); +}); + +describe("buildThreadFindMatches", () => { + const entries: TimelineEntry[] = [ + userEntry("m1", "deploy the service", "turn-1"), + assistantEntry("m2", "Deploying now. Deploy finished.", "turn-1"), + workEntry("w1", "deploy ran"), + proposedPlanEntry("p1", "deploy plan"), + ]; + + it("emits one match per occurrence, in timeline order, carrying the owning turn", () => { + expect(buildThreadFindMatches(entries, "deploy")).toEqual([ + { entryId: "m1", turnId: "turn-1", occurrence: 0 }, + { entryId: "m2", turnId: "turn-1", occurrence: 0 }, + { entryId: "m2", turnId: "turn-1", occurrence: 1 }, + { entryId: "p1", turnId: null, occurrence: 0 }, + ]); + }); + + it("ignores blank and whitespace-only queries", () => { + expect(buildThreadFindMatches(entries, "")).toEqual([]); + expect(buildThreadFindMatches(entries, " ")).toEqual([]); + }); + + it("trims the query rather than searching for the untrimmed string", () => { + expect(buildThreadFindMatches(entries, " deploy ")).toHaveLength(4); + }); +}); + +describe("clampThreadFindIndex", () => { + it("keeps the index inside a shrinking match list", () => { + expect(clampThreadFindIndex(4, 2)).toBe(1); + expect(clampThreadFindIndex(4, 0)).toBe(0); + expect(clampThreadFindIndex(-1, 3)).toBe(0); + }); +}); + +describe("stepThreadFindIndex", () => { + it("wraps in both directions", () => { + expect(stepThreadFindIndex(2, 3, 1)).toBe(0); + expect(stepThreadFindIndex(0, 3, -1)).toBe(2); + }); + + it("stays at zero without matches", () => { + expect(stepThreadFindIndex(0, 0, 1)).toBe(0); + }); +}); + +describe("formatThreadFindCount", () => { + it("never reports a position past the end", () => { + expect(formatThreadFindCount(4, 2)).toBe("2/2"); + expect(formatThreadFindCount(0, 0)).toBe("0/0"); + expect(formatThreadFindCount(1, 3)).toBe("2/3"); + }); +}); diff --git a/apps/web/src/components/chat/threadFind.ts b/apps/web/src/components/chat/threadFind.ts new file mode 100644 index 00000000000..6aff153a7b0 --- /dev/null +++ b/apps/web/src/components/chat/threadFind.ts @@ -0,0 +1,127 @@ +import { type TurnId } from "@t3tools/contracts"; +import { type TimelineEntry } from "../../session-logic"; +import { deriveDisplayedUserMessageContent } from "~/lib/visibleMessageText"; + +/** + * One occurrence of the find query inside one timeline entry. + * + * Matches are modeled against timeline *entries* rather than rendered DOM + * nodes: entries exist even while their row is folded away or unmounted by the + * virtualized list, so counts stay stable and every match stays reachable. + */ +export interface ThreadFindMatch { + /** Timeline entry id — also the id of the row that renders it. */ + entryId: string; + /** Turn that owns the entry, used to unfold a collapsed turn when navigating. */ + turnId: TurnId | null; + /** 0-based index of this occurrence within its own entry. */ + occurrence: number; +} + +export function normalizeThreadFindQuery(query: string): string { + return query.trim(); +} + +/** + * Offsets of every non-overlapping occurrence, case-insensitively. Advancing by + * the needle length (not by one) keeps the count in step with what a + * left-to-right highlighter can actually paint: "aa" occurs once in "aaa". + */ +export function findThreadTextOccurrences(haystack: string, needle: string): number[] { + if (needle.length === 0 || haystack.length === 0) { + return []; + } + const lowerHaystack = haystack.toLowerCase(); + const lowerNeedle = needle.toLowerCase(); + const offsets: number[] = []; + let cursor = 0; + while (cursor <= lowerHaystack.length - lowerNeedle.length) { + const found = lowerHaystack.indexOf(lowerNeedle, cursor); + if (found === -1) { + break; + } + offsets.push(found); + cursor = found + lowerNeedle.length; + } + return offsets; +} + +/** + * The text a timeline entry puts on screen, or `null` for entries find does not + * cover. Work-log rows (tool calls, commands, reasoning) are deliberately out of + * scope — find targets the conversation itself. + */ +export function searchableThreadEntryText(entry: TimelineEntry): string | null { + if (entry.kind === "proposed-plan") { + return entry.proposedPlan.planMarkdown; + } + if (entry.kind !== "message") { + return null; + } + if (entry.message.role === "user") { + return deriveDisplayedUserMessageContent(entry.message.text).visibleText; + } + return entry.message.text; +} + +function threadEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") { + return entry.message.turnId ?? null; + } + if (entry.kind === "proposed-plan") { + return entry.proposedPlan.turnId ?? null; + } + return null; +} + +export function buildThreadFindMatches( + entries: ReadonlyArray, + query: string, +): ThreadFindMatch[] { + const normalized = normalizeThreadFindQuery(query); + if (normalized.length === 0) { + return []; + } + + const matches: ThreadFindMatch[] = []; + for (const entry of entries) { + const text = searchableThreadEntryText(entry); + if (text === null) { + continue; + } + const occurrences = findThreadTextOccurrences(text, normalized); + for (let occurrence = 0; occurrence < occurrences.length; occurrence += 1) { + matches.push({ entryId: entry.id, turnId: threadEntryTurnId(entry), occurrence }); + } + } + return matches; +} + +/** + * Keeps the active position inside the match list as the thread changes under + * it — a streaming edit that drops matches must not leave "5 / 2" on screen. + */ +export function clampThreadFindIndex(index: number, total: number): number { + if (total <= 0) { + return 0; + } + if (!Number.isFinite(index) || index < 0) { + return 0; + } + return Math.min(Math.trunc(index), total - 1); +} + +export function stepThreadFindIndex(index: number, total: number, delta: number): number { + if (total <= 0) { + return 0; + } + const clamped = clampThreadFindIndex(index, total); + return (((clamped + delta) % total) + total) % total; +} + +export function formatThreadFindCount(index: number, total: number): string { + if (total <= 0) { + return "0/0"; + } + return `${clampThreadFindIndex(index, total) + 1}/${total}`; +} diff --git a/apps/web/src/components/chat/threadFindHighlights.test.ts b/apps/web/src/components/chat/threadFindHighlights.test.ts new file mode 100644 index 00000000000..15ecd047c3b --- /dev/null +++ b/apps/web/src/components/chat/threadFindHighlights.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { partitionThreadFindRanges } from "./threadFindHighlights"; + +const groups = [ + { rowId: "m1", ranges: ["m1-a", "m1-b"] }, + { rowId: "m2", ranges: ["m2-a", "m2-b", "m2-c"] }, +]; + +describe("partitionThreadFindRanges", () => { + it("marks exactly one occurrence active, not the whole row", () => { + expect(partitionThreadFindRanges(groups, "m2", 1)).toEqual({ + active: "m2-b", + inactive: ["m1-a", "m1-b", "m2-a", "m2-c"], + }); + }); + + it("clamps an occurrence the mounted DOM cannot offer", () => { + expect(partitionThreadFindRanges(groups, "m1", 9)).toEqual({ + active: "m1-b", + inactive: ["m1-a", "m2-a", "m2-b", "m2-c"], + }); + }); + + it("leaves every range inactive when the active row is not mounted", () => { + expect(partitionThreadFindRanges(groups, "m9", 0)).toEqual({ + active: null, + inactive: ["m1-a", "m1-b", "m2-a", "m2-b", "m2-c"], + }); + expect(partitionThreadFindRanges(groups, null, 0).active).toBeNull(); + }); + + it("handles an empty collection", () => { + expect(partitionThreadFindRanges([], "m1", 0)).toEqual({ active: null, inactive: [] }); + }); +}); diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts new file mode 100644 index 00000000000..c24c77168d8 --- /dev/null +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useRef, type RefObject } from "react"; +import { clampThreadFindIndex, findThreadTextOccurrences } from "./threadFind"; + +export const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; +export const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; + +export interface ThreadFindRangeGroup { + rowId: string; + ranges: T[]; +} + +/** + * Splits collected ranges into the single active one and the rest. + * + * Only the active *occurrence* may carry the active style — highlighting the + * whole row would light up every match inside a message at once. The row is + * authoritative and the occurrence index is best-effort (inline markup can + * split an occurrence across text nodes), so the index is clamped into the row + * rather than dropped. + */ +export function partitionThreadFindRanges( + groups: ReadonlyArray>, + activeRowId: string | null, + activeOccurrence: number, +): { active: T | null; inactive: T[] } { + const inactive: T[] = []; + let active: T | null = null; + + for (const group of groups) { + if (active === null && group.rowId === activeRowId && group.ranges.length > 0) { + const index = clampThreadFindIndex(activeOccurrence, group.ranges.length); + for (let position = 0; position < group.ranges.length; position += 1) { + const range = group.ranges[position]!; + if (position === index) { + active = range; + } else { + inactive.push(range); + } + } + continue; + } + inactive.push(...group.ranges); + } + + return { active, inactive }; +} + +interface HighlightConstructor { + new (...ranges: Range[]): object; +} + +interface HighlightRegistryLike { + set(name: string, value: object): void; + delete(name: string): void; +} + +/** + * The CSS Custom Highlight API paints ranges without touching the DOM, so + * markdown output, syntax-highlighted code, copy behavior and the source + * offsets other features derive from message text all stay untouched. Browsers + * without it simply render no highlights; counting and navigation still work. + */ +function resolveHighlightApi(): { + registry: HighlightRegistryLike; + Highlight: HighlightConstructor; +} | null { + const cssGlobal = globalThis.CSS as unknown as { highlights?: HighlightRegistryLike } | undefined; + const registry = cssGlobal?.highlights; + const Highlight = (globalThis as { Highlight?: HighlightConstructor }).Highlight; + if (!registry || !Highlight) { + return null; + } + return { registry, Highlight }; +} + +/** + * Collects a range per visible occurrence, grouped by timeline row, in document + * order. Occurrences split across text nodes by inline markup (`**bo**ld`) are + * not matched — the model-level index owns the count, so a row simply paints + * fewer highlights than it reports. + */ +export function collectThreadFindRanges( + container: HTMLElement, + query: string, +): ThreadFindRangeGroup[] { + if (query.length === 0) { + return []; + } + + const ownerDocument = container.ownerDocument; + const walker = ownerDocument.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const groups: ThreadFindRangeGroup[] = []; + const groupsByRowId = new Map(); + + let node = walker.nextNode(); + while (node) { + const text = node.nodeValue ?? ""; + const offsets = text.length > 0 ? findThreadTextOccurrences(text, query) : []; + if (offsets.length > 0) { + const rowElement = node.parentElement?.closest("[data-timeline-row-id]"); + const rowId = rowElement?.getAttribute("data-timeline-row-id"); + if (rowId) { + let group = groupsByRowId.get(rowId); + if (!group) { + group = { rowId, ranges: [] }; + groupsByRowId.set(rowId, group); + groups.push(group); + } + for (const offset of offsets) { + const range = ownerDocument.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + query.length); + group.ranges.push(range); + } + } + } + node = walker.nextNode(); + } + + return groups; +} + +export interface ThreadFindHighlightsInput { + container: HTMLElement | null; + /** Normalized query; empty disables painting entirely. */ + query: string; + /** Row id owning the active match, or null when there is no active match. */ + activeRowId: string | null; + /** Index of the active occurrence within its row. */ + activeOccurrence: number; +} + +/** + * Paints find highlights over whatever the virtualized list currently has + * mounted, repainting as rows mount, unmount or stream in. + */ +export function useThreadFindHighlights(input: ThreadFindHighlightsInput): { + repaint: () => void; + activeRangeRef: RefObject; +} { + const { container, query, activeRowId, activeOccurrence } = input; + const activeRangeRef = useRef(null); + + const repaint = useCallback(() => { + const api = resolveHighlightApi(); + if (!api) { + return; + } + if (!container || query.length === 0) { + activeRangeRef.current = null; + api.registry.delete(THREAD_FIND_HIGHLIGHT_NAME); + api.registry.delete(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME); + return; + } + + const { active, inactive } = partitionThreadFindRanges( + collectThreadFindRanges(container, query), + activeRowId, + activeOccurrence, + ); + + activeRangeRef.current = active; + api.registry.set(THREAD_FIND_HIGHLIGHT_NAME, new api.Highlight(...inactive)); + api.registry.set( + THREAD_FIND_ACTIVE_HIGHLIGHT_NAME, + new api.Highlight(...(active ? [active] : [])), + ); + }, [activeOccurrence, activeRowId, container, query]); + + useEffect(() => { + repaint(); + }, [repaint]); + + useEffect(() => { + if (!container || query.length === 0) { + return; + } + let frame: number | null = null; + const schedule = () => { + if (frame !== null) { + return; + } + frame = requestAnimationFrame(() => { + frame = null; + repaint(); + }); + }; + const observer = new MutationObserver(schedule); + observer.observe(container, { subtree: true, childList: true, characterData: true }); + return () => { + observer.disconnect(); + if (frame !== null) { + cancelAnimationFrame(frame); + } + }; + }, [container, query, repaint]); + + useEffect(() => { + return () => { + const api = resolveHighlightApi(); + api?.registry.delete(THREAD_FIND_HIGHLIGHT_NAME); + api?.registry.delete(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME); + }; + }, []); + + return { repaint, activeRangeRef }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 19c05845535..e2844977223 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,6 +241,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +/* In-thread find paints through the CSS Custom Highlight API, so matches never + mutate rendered markdown, code blocks or copyable text. */ +::highlight(t3-thread-find) { + background-color: var(--thread-find-match); + color: var(--thread-find-match-foreground); +} + +::highlight(t3-thread-find-active) { + background-color: var(--thread-find-match-active); + color: var(--thread-find-match-active-foreground); +} + @layer components { .sidebar-brand { display: none; @@ -851,6 +863,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-selected: var(--color-white); --sidebar-border: var(--border); --sidebar-stage-fade: var(--sidebar); + --thread-find-match: var(--color-amber-200); + --thread-find-match-foreground: var(--color-zinc-900); + --thread-find-match-active: var(--color-amber-400); + --thread-find-match-active-foreground: var(--color-zinc-900); @variant dark { color-scheme: dark; @@ -886,6 +902,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar: var(--card); --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); + --thread-find-match: color-mix(in srgb, var(--color-amber-400) 45%, transparent); + --thread-find-match-foreground: var(--color-neutral-100); + --thread-find-match-active: var(--color-amber-500); + --thread-find-match-active-foreground: var(--color-neutral-950); --sidebar-control-surface: var(--muted); --sidebar-row-hover: var(--accent); --sidebar-row-active: var(--accent); diff --git a/apps/web/src/lib/visibleMessageText.ts b/apps/web/src/lib/visibleMessageText.ts new file mode 100644 index 00000000000..1921a3ebfb6 --- /dev/null +++ b/apps/web/src/lib/visibleMessageText.ts @@ -0,0 +1,46 @@ +import { + deriveDisplayedUserMessageState, + type ParsedTerminalContextEntry, +} from "./terminalContext"; +import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; +import { + extractTrailingPreviewAnnotation, + type ParsedPreviewAnnotation, +} from "./previewAnnotation"; + +export interface DisplayedUserMessageContent { + /** The prompt text the timeline actually renders, with every trailing context block removed. */ + visibleText: string; + /** The unmodified prompt, including context blocks — what "copy message" yields. */ + copyText: string; + terminalContexts: ParsedTerminalContextEntry[]; + previewAnnotations: ParsedPreviewAnnotation[]; + elementContexts: ParsedElementContextEntry[]; +} + +/** + * Peels the send-time context blocks off a user prompt in the same order the + * timeline row does. Anything that reads "what does this message show?" — the + * row itself, in-thread find — must go through here so the rendered text and + * the searched text cannot drift apart. + */ +export function deriveDisplayedUserMessageContent(text: string): DisplayedUserMessageContent { + const displayed = deriveDisplayedUserMessageState(text); + const previewAnnotations: ParsedPreviewAnnotation[] = []; + let visibleText = displayed.visibleText; + while (true) { + const extracted = extractTrailingPreviewAnnotation(visibleText); + if (!extracted.annotation) break; + previewAnnotations.unshift(extracted.annotation); + visibleText = extracted.promptText; + } + const elementContextState = extractTrailingElementContexts(visibleText); + + return { + visibleText: elementContextState.promptText, + copyText: displayed.copyText, + terminalContexts: displayed.contexts, + previewAnnotations, + elementContexts: [...displayed.elementContexts, ...elementContextState.contexts], + }; +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d966c1e7e61..a26cff1c494 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -66,6 +66,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "chat.new", "chat.newLocal", + "chat.find", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0688cf07254..141bacf0ec9 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -39,6 +39,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+f", command: "chat.find", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, From 449a74a398afa26a191a207bd56bcd37d685d6ad Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 26 Jul 2026 19:03:59 -0700 Subject: [PATCH 2/6] fix(web): address find-in-thread review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Length-preserving case fold: lowercasing that changes UTF-16 length ("İ" → "i" + combining dot) no longer drifts offsets into text nodes, which could throw IndexSizeError from Range.setEnd. Code points whose lowercase form is longer simply don't case-fold. - Highlight painting is scoped to data-thread-find-text regions (user message body, assistant markdown, plan markdown) so row chrome, fold labels and work-log output can no longer light up for text the counter never counted, or shift the active-occurrence index within a row. - Find navigation calls onManualNavigation() so live follow cannot scroll the timeline back to the streaming edge right after revealing a match. - The navigation identity now includes the query and a step nonce: changing the query re-navigates even when the target row/occurrence is unchanged, and Enter on a lone match re-reveals it. - The post-scroll occurrence nudge retries across frames (and survives streaming re-renders) instead of giving up if the row hasn't mounted by the first requestAnimationFrame. - Match count is reported via useLayoutEffect so the find bar can never paint a total the step handlers aren't already using. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 5 ++ .../src/components/chat/MessagesTimeline.tsx | 72 +++++++++++++++---- .../src/components/chat/ProposedPlanCard.tsx | 5 +- .../src/components/chat/threadFind.test.ts | 15 ++++ apps/web/src/components/chat/threadFind.ts | 32 ++++++++- .../components/chat/threadFindHighlights.ts | 43 +++++++---- 6 files changed, 142 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 36a830a07b4..763db18e552 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -319,6 +319,8 @@ interface ChatFindState { matchCount: number; /** Bumped on every open request so a repeat Cmd+F re-selects the field. */ focusRequestId: number; + /** Bumped on every next/previous step so Enter on a lone match still re-reveals it. */ + navigationId: number; } const CLOSED_CHAT_FIND_STATE: ChatFindState = { open: false, @@ -326,6 +328,7 @@ const CLOSED_CHAT_FIND_STATE: ChatFindState = { activeIndex: 0, matchCount: 0, focusRequestId: 0, + navigationId: 0, }; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { @@ -4298,6 +4301,7 @@ function ChatViewContent(props: ChatViewProps) { setFindState((state) => ({ ...state, activeIndex: stepThreadFindIndex(state.activeIndex, state.matchCount, delta), + navigationId: state.navigationId + 1, })); }, []); const handleThreadFindMatchCountChange = useCallback((matchCount: number) => { @@ -5798,6 +5802,7 @@ function ChatViewContent(props: ChatViewProps) { topFadeEnabled={!hasTimelineTopBanner} findQuery={findState.open ? findState.query : ""} findActiveIndex={threadFindActiveIndex} + findNavigationId={findState.navigationId} onFindMatchCountChange={handleThreadFindMatchCountChange} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 708c51e86f0..21dc752092e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,6 +14,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -150,6 +151,8 @@ const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; /** Breathing room kept above a revealed find match, and below it. */ const FIND_MATCH_VIEW_MARGIN = 96; +/** Frames to wait for a navigated-to row to mount and lay out before giving up. */ +const FIND_NUDGE_MAX_FRAMES = 12; // --------------------------------------------------------------------------- // Props (public API) @@ -188,6 +191,11 @@ interface MessagesTimelineProps { findQuery?: string; /** Index of the match to reveal, into the list this component reports back. */ findActiveIndex?: number; + /** + * Bumped on every explicit next/previous step so re-stepping onto the same + * match (a single-match wrap-around) still re-reveals it. + */ + findNavigationId?: number; onFindMatchCountChange?: (count: number) => void; } @@ -226,6 +234,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ topFadeEnabled = false, findQuery = "", findActiveIndex = 0, + findNavigationId = 0, onFindMatchCountChange, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -479,7 +488,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ? (findMatches[clampThreadFindIndex(findActiveIndex, findMatches.length)] ?? null) : null; - useEffect(() => { + // Layout effect so the parent's count state commits before paint — the find + // bar must never display a total the step handlers aren't already using. + useLayoutEffect(() => { onFindMatchCountChange?.(findMatches.length); }, [findMatches.length, onFindMatchCountChange]); @@ -491,11 +502,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); // Rows churn while a turn streams; only navigate when the target itself - // changes, otherwise every streamed token would yank the scroll position. + // changes — or the user explicitly re-steps — otherwise every streamed token + // would yank the scroll position. const navigatedFindMatchKeyRef = useRef(null); + // The nudge loop lives in a ref rather than effect cleanup: this effect + // re-runs on every streamed token, and cleanup would cancel a loop that is + // still waiting for the target row to mount. + const findNudgeFrameRef = useRef(null); + const cancelFindNudge = useCallback(() => { + if (findNudgeFrameRef.current !== null) { + cancelAnimationFrame(findNudgeFrameRef.current); + findNudgeFrameRef.current = null; + } + }, []); + useEffect(() => cancelFindNudge, [cancelFindNudge]); + useEffect(() => { if (!activeFindMatch) { navigatedFindMatchKeyRef.current = null; + cancelFindNudge(); return; } @@ -517,12 +542,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - const matchKey = `${activeFindMatch.entryId}:${activeFindMatch.occurrence}`; + const matchKey = `${findNavigationId}:${normalizedFindQuery}:${activeFindMatch.entryId}:${activeFindMatch.occurrence}`; if (navigatedFindMatchKeyRef.current === matchKey) { return; } navigatedFindMatchKeyRef.current = matchKey; + // Find is user navigation: without this, live follow scrolls right back to + // the streaming edge on the next layout pass and the match is lost. + onManualNavigation(); void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, @@ -530,12 +558,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); // Once the row is laid out, nudge the exact occurrence into view — a long - // message can scroll to its top with the match still below the fold. - const frame = requestAnimationFrame(() => { + // message can scroll to its top with the match still below the fold. The + // row can take a few frames to mount and paint after scrollToIndex, so + // retry briefly instead of giving up on the first frame. + cancelFindNudge(); + let attemptsLeft = FIND_NUDGE_MAX_FRAMES; + const nudge = () => { + findNudgeFrameRef.current = null; repaintFindHighlights(); + attemptsLeft -= 1; const matchRect = activeRangeRef.current?.getBoundingClientRect(); const viewportRect = timelineViewportElement?.getBoundingClientRect(); if (!matchRect || !viewportRect || matchRect.height === 0) { + if (attemptsLeft > 0) { + findNudgeFrameRef.current = requestAnimationFrame(nudge); + } return; } const topBoundary = viewportRect.top + FIND_MATCH_VIEW_MARGIN; @@ -554,13 +591,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (typeof currentScroll === "number") { listRef.current?.scrollToOffset({ offset: currentScroll + delta, animated: false }); } - }); - return () => cancelAnimationFrame(frame); + }; + findNudgeFrameRef.current = requestAnimationFrame(nudge); }, [ activeFindMatch, activeRangeRef, + cancelFindNudge, contentInsetEndAdjustment, + findNavigationId, listRef, + normalizedFindQuery, + onManualNavigation, repaintFindHighlights, rows, timelineViewportElement, @@ -1121,13 +1162,15 @@ function AssistantTimelineRow({ row }: { row: Extract
- +
+ +
-
+
{canCollapse && !expanded ? ( { expect(findThreadTextOccurrences("", "a")).toEqual([]); expect(findThreadTextOccurrences("a", "")).toEqual([]); }); + + it("keeps offsets valid when lowercasing would change the string's length", () => { + // "İ".toLowerCase() is "i" + a combining dot — one code unit longer. Naive + // lowercased offsets would report "x" at 2 and push Range.setEnd past the + // end of the original text node. + expect(findThreadTextOccurrences("İx", "x")).toEqual([1]); + expect(findThreadTextOccurrences("İİİ needle", "needle")).toEqual([4]); + // The exotic character itself simply does not case-fold. + expect(findThreadTextOccurrences("İ", "İ")).toEqual([0]); + }); + + it("still case-folds surrogate-pair characters", () => { + // Deseret capital 𐐀 lowercases to 𐐨 — same UTF-16 length, so it folds. + expect(findThreadTextOccurrences("𐐀x", "𐐨")).toEqual([0]); + }); }); describe("searchableThreadEntryText", () => { diff --git a/apps/web/src/components/chat/threadFind.ts b/apps/web/src/components/chat/threadFind.ts index 6aff153a7b0..7f6cc05a1c0 100644 --- a/apps/web/src/components/chat/threadFind.ts +++ b/apps/web/src/components/chat/threadFind.ts @@ -22,6 +22,28 @@ export function normalizeThreadFindQuery(query: string): string { return query.trim(); } +/** + * Lowercases for matching without ever changing UTF-16 length: a code point + * whose lowercase form is longer (e.g. "İ" → "i" + combining dot) is kept + * as-is. Offsets into the folded string are therefore always valid in the + * original — the exotic character merely doesn't case-fold, instead of every + * offset after it drifting and Range.setEnd throwing past the node's length. + */ +function foldThreadFindCase(text: string): string { + const lowered = text.toLowerCase(); + // Per-code-point lowercase mappings never shrink, so equal length means + // every mapping was 1:1 and the fast path is exact. + if (lowered.length === text.length) { + return lowered; + } + let folded = ""; + for (const char of text) { + const loweredChar = char.toLowerCase(); + folded += loweredChar.length === char.length ? loweredChar : char; + } + return folded; +} + /** * Offsets of every non-overlapping occurrence, case-insensitively. Advancing by * the needle length (not by one) keeps the count in step with what a @@ -31,8 +53,8 @@ export function findThreadTextOccurrences(haystack: string, needle: string): num if (needle.length === 0 || haystack.length === 0) { return []; } - const lowerHaystack = haystack.toLowerCase(); - const lowerNeedle = needle.toLowerCase(); + const lowerHaystack = foldThreadFindCase(haystack); + const lowerNeedle = foldThreadFindCase(needle); const offsets: number[] = []; let cursor = 0; while (cursor <= lowerHaystack.length - lowerNeedle.length) { @@ -50,6 +72,12 @@ export function findThreadTextOccurrences(haystack: string, needle: string): num * The text a timeline entry puts on screen, or `null` for entries find does not * cover. Work-log rows (tool calls, commands, reasoning) are deliberately out of * scope — find targets the conversation itself. + * + * Assistant messages and plans are searched as markdown *source*, so syntax + * that the renderer hides (link URLs, emphasis markers) is matched too. That is + * intentional: extracting renderer-exact plain text would mean duplicating the + * markdown pipeline here and drifting from it. Such a match still navigates to + * the right message; it just may not paint. */ export function searchableThreadEntryText(entry: TimelineEntry): string | null { if (entry.kind === "proposed-plan") { diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts index c24c77168d8..35c3b44bcf4 100644 --- a/apps/web/src/components/chat/threadFindHighlights.ts +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -4,6 +4,15 @@ import { clampThreadFindIndex, findThreadTextOccurrences } from "./threadFind"; export const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; export const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; +/** + * `data-thread-find-text` marks an element as rendering text that + * `searchableThreadEntryText` indexes. Only text inside these regions is + * painted; row chrome (timestamps, buttons, fold labels, work-log output) + * stays unpainted so it can neither light up for text the counter never + * counted nor shift the active-occurrence index within a row. + */ +const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]"; + export interface ThreadFindRangeGroup { rowId: string; ranges: T[]; @@ -75,9 +84,11 @@ function resolveHighlightApi(): { /** * Collects a range per visible occurrence, grouped by timeline row, in document - * order. Occurrences split across text nodes by inline markup (`**bo**ld`) are - * not matched — the model-level index owns the count, so a row simply paints - * fewer highlights than it reports. + * order. Only text inside `data-thread-find-text` regions participates, so + * the painted set mirrors the searchable model rather than everything a row + * happens to render. Occurrences split across text nodes by inline markup + * (`**bo**ld`) are not matched — the model-level index owns the count, so a row + * simply paints fewer highlights than it reports. */ export function collectThreadFindRanges( container: HTMLElement, @@ -88,18 +99,24 @@ export function collectThreadFindRanges( } const ownerDocument = container.ownerDocument; - const walker = ownerDocument.createTreeWalker(container, NodeFilter.SHOW_TEXT); const groups: ThreadFindRangeGroup[] = []; const groupsByRowId = new Map(); - let node = walker.nextNode(); - while (node) { - const text = node.nodeValue ?? ""; - const offsets = text.length > 0 ? findThreadTextOccurrences(text, query) : []; - if (offsets.length > 0) { - const rowElement = node.parentElement?.closest("[data-timeline-row-id]"); - const rowId = rowElement?.getAttribute("data-timeline-row-id"); - if (rowId) { + for (const scope of container.querySelectorAll(THREAD_FIND_TEXT_SELECTOR)) { + if (scope.parentElement?.closest(THREAD_FIND_TEXT_SELECTOR)) { + continue; // Nested region — its text is already walked by the ancestor. + } + const rowId = scope.closest("[data-timeline-row-id]")?.getAttribute("data-timeline-row-id"); + if (!rowId) { + continue; + } + + const walker = ownerDocument.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const text = node.nodeValue ?? ""; + const offsets = text.length > 0 ? findThreadTextOccurrences(text, query) : []; + if (offsets.length > 0) { let group = groupsByRowId.get(rowId); if (!group) { group = { rowId, ranges: [] }; @@ -113,8 +130,8 @@ export function collectThreadFindRanges( group.ranges.push(range); } } + node = walker.nextNode(); } - node = walker.nextNode(); } return groups; From a9597b511141b040fa829dbfe6c7b862d7648ec1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 18:08:14 -0700 Subject: [PATCH 3/6] fix(web): address follow-up find review feedback --- apps/web/src/components/ChatView.tsx | 16 +++-- .../src/components/chat/MessagesTimeline.tsx | 8 ++- .../src/components/chat/ProposedPlanCard.tsx | 11 ++-- .../src/components/chat/threadFind.test.ts | 64 +++++++++++++++++-- apps/web/src/components/chat/threadFind.ts | 37 +++++++++-- 5 files changed, 112 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 763db18e552..dcd5a474e7c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,7 +226,11 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { FindInThreadBar } from "./chat/FindInThreadBar"; -import { clampThreadFindIndex, stepThreadFindIndex } from "./chat/threadFind"; +import { + clampThreadFindIndex, + isThreadFindActiveForThread, + stepThreadFindIndex, +} from "./chat/threadFind"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -314,6 +318,7 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record ({ ...state, open: true, + threadKey: activeThreadKey, focusRequestId: state.focusRequestId + 1, })); - }, []); + }, [activeThreadKey]); const closeThreadFind = useCallback(() => { setFindState(CLOSED_CHAT_FIND_STATE); }, []); @@ -4312,6 +4319,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setFindState(CLOSED_CHAT_FIND_STATE); }, [activeThreadKey]); + const isThreadFindActive = isThreadFindActiveForThread(findState, activeThreadKey); const threadFindActiveIndex = clampThreadFindIndex(findState.activeIndex, findState.matchCount); useEffect(() => { @@ -5800,13 +5808,13 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} - findQuery={findState.open ? findState.query : ""} + findQuery={isThreadFindActive ? findState.query : ""} findActiveIndex={threadFindActiveIndex} findNavigationId={findState.navigationId} onFindMatchCountChange={handleThreadFindMatchCountChange} /> - {findState.open && ( + {isThreadFindActive && (
>; activeThreadEnvironmentId: EnvironmentId; + findActive: boolean; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; @@ -240,6 +241,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); + const normalizedFindQuery = normalizeThreadFindQuery(findQuery); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -442,6 +444,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + findActive: normalizedFindQuery.length > 0, onRevertUserMessage, onImageExpand, onOpenTurnDiff, @@ -456,6 +459,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + normalizedFindQuery, onRevertUserMessage, onImageExpand, onOpenTurnDiff, @@ -478,7 +482,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // virtualized-away content still participates; navigation unfolds and scrolls // to the owning row, and painting happens over the mounted DOM. // ------------------------------------------------------------------------- - const normalizedFindQuery = normalizeThreadFindQuery(findQuery); const findMatches = useMemo( () => buildThreadFindMatches(timelineEntries, normalizedFindQuery), [normalizedFindQuery, timelineEntries], @@ -542,7 +545,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - const matchKey = `${findNavigationId}:${normalizedFindQuery}:${activeFindMatch.entryId}:${activeFindMatch.occurrence}`; + const matchKey = `${findNavigationId}:${normalizedFindQuery}:${activeFindMatch.entryId}:${activeFindMatch.occurrence}:${activeFindMatch.offset}`; if (navigatedFindMatchKeyRef.current === matchKey) { return; } @@ -1228,6 +1231,7 @@ function ProposedPlanTimelineRow({ threadRef={ctx.threadRef ?? undefined} cwd={ctx.markdownCwd} workspaceRoot={ctx.workspaceRoot} + expandForFind={ctx.findActive} />
); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 53850bf84bf..756f1f64d9b 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, + expandForFind = false, }: { planMarkdown: string; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | undefined; cwd: string | undefined; workspaceRoot: string | undefined; + expandForFind?: boolean; }) { const [expanded, setExpanded] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); @@ -73,6 +75,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const collapsedPreview = canCollapse ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) : null; + const isCollapsed = canCollapse && !expanded && !expandForFind; const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); const saveContents = normalizePlanMarkdownForExport(planMarkdown); @@ -171,10 +174,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
- {canCollapse && !expanded ? ( + {isCollapsed ? ( )} - {canCollapse && !expanded ? ( + {isCollapsed ? (
) : null}
- {canCollapse ? ( + {canCollapse && !expandForFind ? (
@@ -1562,12 +1563,14 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; + expandForFind?: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); - const isCollapsed = canCollapse && !expanded; + const isCollapsed = canCollapse && !expanded && !props.expandForFind; + const showCollapseControl = canCollapse && !props.expandForFind; return (
@@ -1596,15 +1599,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop />
) : null} - {canCollapse || props.footer ? ( + {showCollapseControl || props.footer ? (
- {canCollapse ? ( + {showCollapseControl ? (