diff --git a/src/components/message/find-in-chat.test.tsx b/src/components/message/find-in-chat.test.tsx new file mode 100644 index 0000000000..019f71c227 --- /dev/null +++ b/src/components/message/find-in-chat.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" +import { extractFindableText } from "./find-in-chat" +import type { ThreadRenderItem } from "./message-list-view" + +function turnItem( + parts: { type: string; text?: string; [k: string]: unknown }[], + role: "user" | "assistant" = "assistant" +): ThreadRenderItem { + return { + key: `turn-${role}-${Math.random().toString(36).slice(2)}`, + kind: "turn", + group: { + id: "g1", + role, + parts: parts as never, + resources: [], + images: [], + }, + phase: "persisted", + isResponseComplete: true, + showStats: false, + isRoleTransition: false, + previousUserIndex: null, + isLastAssistantRun: false, + isThreadTail: false, + sourceTurns: [], + } as unknown as ThreadRenderItem +} + +describe("extractFindableText", () => { + it("joins text parts and ignores tool calls and reasoning", () => { + const item = turnItem([ + { type: "text", text: "first paragraph" }, + { type: "tool-call", toolCallId: "t1", toolName: "bash" }, + { type: "reasoning", content: "thinking hard", isStreaming: false }, + { type: "text", text: "second paragraph" }, + ]) + expect(extractFindableText(item)).toBe("first paragraph\nsecond paragraph") + }) + + it("returns empty string for non-turn items", () => { + expect(extractFindableText({ key: "typing", kind: "typing" })).toBe("") + expect( + extractFindableText({ key: "compaction", kind: "compaction", meta: null }) + ).toBe("") + }) + + it("returns empty string for a turn with no text parts", () => { + const item = turnItem([{ type: "tool-call", toolCallId: "t1" }]) + expect(extractFindableText(item)).toBe("") + }) +}) diff --git a/src/components/message/find-in-chat.tsx b/src/components/message/find-in-chat.tsx new file mode 100644 index 0000000000..37c190f505 --- /dev/null +++ b/src/components/message/find-in-chat.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useTranslations } from "next-intl" +import { ArrowDown, ArrowUp, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { ThreadRenderItem } from "@/components/message/message-list-view" +import { cn } from "@/lib/utils" + +/** + * Plain-text projection of one thread item for find-in-chat. Only message + * prose is searchable (text parts of the adapted group) — tool calls, tool + * results, reasoning traces and chrome (typing indicator, compaction + * divider) are excluded, mirroring what a reader scans visually. + * + * Mirrors the `ThreadRenderItem` contract in `message-list-view.tsx`; keep in + * sync when new item kinds gain user-visible text. + */ +export function extractFindableText(item: ThreadRenderItem): string { + if (item.kind !== "turn") return "" + const texts: string[] = [] + for (const part of item.group.parts) { + if (part.type === "text") texts.push(part.text) + } + return texts.join("\n") +} + +interface FindInChatBarProps { + query: string + onQueryChange: (query: string) => void + /** Total matches in the loaded transcript window. */ + count: number + /** Zero-based index of the match currently in view. */ + index: number + onNext: () => void + onPrev: () => void + onClose: () => void +} + +/** + * Find bar for the open conversation transcript (⌘F / Ctrl+F). A compact + * overlay pinned by the parent — matches are jumped to by the parent via the + * virtualizer's `scrollToIndex`, and the hit row is highlighted there too + * (this bar stays stateless beyond its own input focus). + */ +export function FindInChatBar({ + query, + onQueryChange, + count, + index, + onNext, + onPrev, + onClose, +}: FindInChatBarProps) { + const t = useTranslations("Folder.chat.messageList") + const inputRef = useRef(null) + + // autoFocus misses the case where the bar mounts while the window itself + // regains focus; re-assert on open is cheap and idempotent. + useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, []) + + const hasQuery = query.trim().length > 0 + + return ( +
{ + if (e.key === "Escape") { + e.preventDefault() + onClose() + } else if (e.key === "Enter" && hasQuery) { + e.preventDefault() + if (e.shiftKey) onPrev() + else onNext() + } + }} + > + onQueryChange(e.target.value)} + placeholder={t("findPlaceholder")} + className="h-7 w-52 border-none bg-transparent text-sm shadow-none focus-visible:ring-0" + aria-label={t("findPlaceholder")} + /> + + {hasQuery + ? count > 0 + ? t("findMatchOf", { index: index + 1, count }) + : t("findNoResults") + : ""} + + + + +
+ ) +} diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index f83bbf62d8..6e79278f32 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -69,7 +69,7 @@ import { extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" import type { AgentType, ConnectionStatus, MessageTurn } from "@/lib/types" -import { copyTextToClipboard } from "@/lib/utils" +import { copyTextToClipboard, cn } from "@/lib/utils" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" import { SelectionActionBubble } from "@/components/message/selection-action-bubble" import { @@ -77,6 +77,10 @@ import { type MessageNavEntry, } from "@/components/message/conversation-message-nav" import type { MessageScrollContextValue } from "@/components/message/message-scroll-context" +import { + extractFindableText, + FindInChatBar, +} from "@/components/message/find-in-chat" import { extractSessionFilesGrouped } from "@/lib/session-files" import { unescapeComposerText } from "@/lib/composer-copy-text" import { useStickToBottomContext } from "use-stick-to-bottom" @@ -1322,6 +1326,97 @@ export function MessageListView({ [historicalPlanEntries] ) + // --- Find in chat (⌘F / Ctrl+F) ----------------------------------------- + // Searches the message prose of the LOADED transcript window only — the same + // accepted degradation as the message navigator (paging in older history + // extends what's findable; match indices are recomputed per prepend, so + // they never go stale). + const [findOpen, setFindOpen] = useState(false) + const [findQuery, setFindQuery] = useState("") + const [findHit, setFindHit] = useState(0) + + const findClose = useCallback(() => { + setFindOpen(false) + setFindQuery("") + }, []) + + // New query → restart at the first hit. Adjusted during render (the React + // pattern for state derived from other state) — an effect here would paint + // a stale hit first. + const [prevFindQuery, setPrevFindQuery] = useState(findQuery) + if (prevFindQuery !== findQuery) { + setPrevFindQuery(findQuery) + setFindHit(0) + } + + const findMatches = useMemo(() => { + const q = findQuery.trim().toLowerCase() + if (!findOpen || q.length === 0) { + return [] as { threadIndex: number; key: string }[] + } + const out: { threadIndex: number; key: string }[] = [] + // Occurrence-level granularity: one row can hold several hits. The cap + // keeps a pathological query ("e") over a huge window bounded. + const MAX_MATCHES = 500 + for (let i = 0; i < threadItems.length && out.length < MAX_MATCHES; i++) { + const item = threadItems[i] + if (item.kind !== "turn") continue + const hay = extractFindableText(item).toLowerCase() + let pos = hay.indexOf(q) + while (pos !== -1 && out.length < MAX_MATCHES) { + out.push({ threadIndex: i, key: item.key }) + pos = hay.indexOf(q, pos + q.length) + } + } + return out + }, [findOpen, findQuery, threadItems]) + + const findMatchCount = findMatches.length + const activeFindHit = + findMatchCount > 0 + ? findMatches[Math.min(findHit, findMatchCount - 1)] + : null + const activeFindThreadIndex = activeFindHit?.threadIndex ?? null + + // Scoped to the active transcript so background tabs never steal the + // shortcut. Declines inside terminal regions, where ⌘F may belong to the + // multiplexer (same precedent as the tab-switch chord decline in + // workspace-chrome-controller). + useEffect(() => { + if (!isActive) return + const onKeyDown = (e: KeyboardEvent) => { + if (findOpen && e.key === "Escape") { + e.preventDefault() + setFindOpen(false) + setFindQuery("") + return + } + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "f") return + const target = e.target as Element | null + if (target && target.closest('[data-terminal-panel-region="true"]')) { + return + } + e.preventDefault() + setFindOpen(true) + } + document.addEventListener("keydown", onKeyDown) + return () => document.removeEventListener("keydown", onKeyDown) + }, [isActive, findOpen]) + + // Jump the virtualizer to the current hit; re-runs when the hit moves + // (next/prev) or a new query recomputes the match list. + useEffect(() => { + if (!findOpen || activeFindThreadIndex == null) return + scrollApiRef.current?.scrollToIndex(activeFindThreadIndex, { + align: "center", + }) + }, [findOpen, activeFindThreadIndex]) + + // Lifted scroll handle — shared by find-in-chat above and the message + // navigator panel below (both live outside the MessageScrollProvider + // subtree and drive scrollToIndex). + const scrollApiRef = useRef(null) + // A turn in flight doesn't take the fork affordance away, it greys it out: // the host keeps `onForkFromTurn` set for the whole "prompting" window (see // its gate in `conversation-detail-panel`), and every reply's footer says @@ -1337,8 +1432,16 @@ export function MessageListView({ item.group.role === "user" && userTurnHeader ? userTurnHeader(item.group) : null + const isFindHit = findOpen && activeFindHit?.key === item.key return ( -
0 ? { paddingTop: pt } : undefined}> +
0 ? { paddingTop: pt } : undefined} + className={cn( + "rounded-lg", + isFindHit && + "ring-2 ring-amber-400/70 ring-offset-2 ring-offset-background" + )} + > {phaseLabel ? (