diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdde62a3e0d..7ed31435670 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -195,6 +195,7 @@ import { formatElementContextLabel, } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; +import { isPreviewFocused } from "../lib/previewFocus"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -225,6 +226,12 @@ 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, + isThreadFindActiveForThread, + stepThreadFindIndex, +} from "./chat/threadFind"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -309,6 +316,28 @@ 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; + threadKey: string | null; + query: string; + activeIndex: number; + 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, + threadKey: null, + query: "", + activeIndex: 0, + matchCount: 0, + focusRequestId: 0, + navigationId: 0, +}; + function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1248,6 +1277,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 +4291,38 @@ function ChatViewContent(props: ChatViewProps) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + const openThreadFind = useCallback(() => { + setFindState((state) => ({ + ...state, + open: true, + threadKey: activeThreadKey, + focusRequestId: state.focusRequestId + 1, + })); + }, [activeThreadKey]); + 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), + navigationId: state.navigationId + 1, + })); + }, []); + 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 isThreadFindActive = isThreadFindActiveForThread(findState, activeThreadKey); + const threadFindActiveIndex = clampThreadFindIndex(findState.activeIndex, findState.matchCount); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) { @@ -4273,6 +4335,7 @@ function ChatViewContent(props: ChatViewProps) { const shortcutContext = { terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -4300,6 +4363,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 +4473,7 @@ function ChatViewContent(props: ChatViewProps) { onToggleDiff, toggleRightPanel, toggleTerminalVisibility, + openThreadFind, composerRef, ]); @@ -5739,8 +5810,27 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} + findQuery={isThreadFindActive ? findState.query : ""} + findActiveIndex={threadFindActiveIndex} + findNavigationId={findState.navigationId} + onFindMatchCountChange={handleThreadFindMatchCountChange} /> + {isThreadFindActive && ( +
+ 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.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) { + return; + } + 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..e7b90d9d910 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, @@ -81,20 +82,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"; @@ -130,6 +129,7 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; + findActive: boolean; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; @@ -150,6 +150,10 @@ 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; +/** 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) @@ -184,6 +188,16 @@ 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; + /** + * 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; } // --------------------------------------------------------------------------- @@ -219,10 +233,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + findQuery = "", + findActiveIndex = 0, + findNavigationId = 0, + onFindMatchCountChange, }: MessagesTimelineProps) { 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) => { @@ -425,6 +444,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + findActive: normalizedFindQuery.length > 0, onRevertUserMessage, onImageExpand, onOpenTurnDiff, @@ -439,6 +459,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + normalizedFindQuery, onRevertUserMessage, onImageExpand, onOpenTurnDiff, @@ -456,6 +477,139 @@ 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 findMatches = useMemo( + () => buildThreadFindMatches(timelineEntries, normalizedFindQuery), + [normalizedFindQuery, timelineEntries], + ); + const activeFindMatch = + findMatches.length > 0 + ? (findMatches[clampThreadFindIndex(findActiveIndex, findMatches.length)] ?? null) + : null; + + // 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]); + + 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 — 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; + } + + 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 = `${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, + 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. 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; + 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 }); + } + }; + findNudgeFrameRef.current = requestAnimationFrame(nudge); + }, [ + activeFindMatch, + activeRangeRef, + cancelFindNudge, + contentInsetEndAdjustment, + findNavigationId, + listRef, + normalizedFindQuery, + onManualNavigation, + 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 +1023,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,10 +1085,11 @@ function UserTimelineRow({ row }: { row: Extract ) : null}
@@ -1022,13 +1166,15 @@ function AssistantTimelineRow({ row }: { row: Extract
- +
+ +
); @@ -1316,7 +1463,11 @@ const UserMessageTerminalContextInlineLabel = memo( ? `${props.context.header}\n${props.context.body}` : props.context.header; - return ; + return ( + + + + ); }, ); @@ -1416,12 +1567,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 (
@@ -1429,6 +1582,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
) : null} - {canCollapse || props.footer ? ( + {showCollapseControl || props.footer ? (
- {canCollapse ? ( + {showCollapseControl ? (
-
- {canCollapse && !expanded ? ( +
+ {isCollapsed ? ( )} - {canCollapse && !expanded ? ( + {isCollapsed ? (
) : null}
- {canCollapse ? ( + {canCollapse && !expandForFind ? (