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 70a330d46303..cde6ea6f1bc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,11 +3,56 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + findTextRanges, + findTextMatches, normalizeCompactToolLabel, + renderMarkdownSearchText, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("findTextMatches", () => { + 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([]); + }); + + it("uses original string offsets when case folding expands a Unicode character", () => { + 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", () => { + const text = renderMarkdownSearchText("Read [the docs](https://example.com/hidden) now"); + 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); + }); + + 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", () => { 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..f95d4abcec88 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, @@ -242,6 +246,77 @@ 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 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 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; ) { + 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, + caseSensitive = false, +): TextMatch[] { + if (!query) return []; + + const matches: TextMatch[] = []; + texts.forEach((text, textIndex) => { + findTextRanges(text ?? "", query, caseSensitive).forEach((_, occurrenceIndex) => { + matches.push({ textIndex, occurrenceIndex }); + }); + }); + 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..55ad531dd131 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, @@ -59,13 +60,17 @@ import { PaintbrushIcon, MinusIcon, SquarePenIcon, + SearchIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, + ChevronUpIcon, ZapIcon, } 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"; @@ -74,7 +79,10 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, + findTextRanges, + findTextMatches, normalizeCompactToolLabel, + renderMarkdownSearchText, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -143,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; } @@ -188,6 +197,8 @@ 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, @@ -420,10 +431,235 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); - const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + 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( null, ); + const findMessageEntries = useMemo( + () => timelineEntries.filter((entry) => entry.kind === "message"), + [timelineEntries], + ); + const findTexts = useMemo( + () => findMessageEntries.map((entry) => renderMarkdownSearchText(entry.message.text)), + [findMessageEntries], + ); + const findMatches = useMemo( + () => findTextMatches(findTexts, findQuery, findCaseSensitive), + [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); + setFindMatchIndex(0); + if (findTextMatches(findTexts, query, findCaseSensitive).length > 0) onManualNavigation(); + }, + [findCaseSensitive, findTexts, onManualNavigation], + ); + const goToFindMatch = useCallback( + (delta: number) => { + if (findMatches.length === 0) return; + const next = (findMatchIndex + delta + findMatches.length) % findMatches.length; + setFindMatchIndex(next); + onManualNavigation(); + }, + [findMatchIndex, findMatches, onManualNavigation], + ); + + useEffect(() => { + const handleFindShortcut = (event: globalThis.KeyboardEvent) => { + const key = event.key.toLowerCase(); + if ( + !timelineViewportElement || + timelineViewportElement.getBoundingClientRect().width === 0 || + 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 (inputFocused) return; + event.preventDefault(); + event.stopPropagation(); + if (!findOpen) setFindOpen(true); + goToFindMatch(event.shiftKey ? -1 : 1); + return; + } + if (event.shiftKey || (!isElectron && findOpen && inputFocused)) return; + + event.preventDefault(); + event.stopPropagation(); + if (findOpen) { + findInputRef.current?.focus(); + } else { + setFindOpen(true); + } + }; + window.addEventListener("keydown", handleFindShortcut, true); + return () => window.removeEventListener("keydown", handleFindShortcut, true); + }, [findOpen, goToFindMatch, timelineViewportElement]); + + useEffect(() => { + if (!findOpen) return; + const frame = requestAnimationFrame(() => findInputRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [findOpen]); + + 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 || !timelineViewportElement) 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; + 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 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 value = node.textContent ?? ""; + textNodes.push({ + node: node as Text, + start: text.length, + end: text.length + value.length, + }); + text += value; + } + for (const [occurrenceIndex, textRange] of findTextRanges( + text, + findQuery, + findCaseSensitive, + ).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 (!root || !activeRange) return; + + 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 () => { + cancelAnimationFrame(mountFrame); + cancelAnimationFrame(paintFrame); + cancelAnimationFrame(repaintFrame); + observer.disconnect(); + CSS.highlights.delete(THREAD_FIND_HIGHLIGHT); + CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT); + }; + }, [ + expandedTurnIds, + findCaseSensitive, + findMatchIndex, + findMatches, + findMessageEntries, + findOpen, + findQuery, + listRef, + rows, + suspendEndScrollMaintenanceForDisclosure, + timelineViewportElement, + ]); + const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( @@ -516,6 +752,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + activeFindMessageId, agentPanelModel, onOpenAgents, }), @@ -532,6 +769,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + activeFindMessageId, agentPanelModel, onOpenAgents, ], @@ -573,6 +811,76 @@ export const MessagesTimeline = memo(function MessagesTimeline({
+ {findOpen ? ( +
+ + + + updateFindQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1); + }} + aria-label="Find in thread" + placeholder="Find in thread" + /> + + { + setFindCaseSensitive(pressed); + setFindMatchIndex(0); + onManualNavigation(); + }} + > + Aa + + + {findQuery + ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}` + : ""} + + + + + + +
+ ) : null} ref={listRef} data={rows} @@ -1033,6 +1341,7 @@ function UserTimelineRow({ row }: { row: Extract
@@ -1602,9 +1911,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 5bf04adf41a1..902adf05cf61 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 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", }, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fea03489b7fa..022355c44953 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -5,6 +5,17 @@ /* 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) 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 composer while it moves; the rest of the app remains visually stationary. */ html[data-mobile-composer-route-transition="true"]::view-transition-old(root), 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))