From 479a9e7a6a491cfb751a1eadc53b6ec0ec9891f0 Mon Sep 17 00:00:00 2001 From: Jonathan Ashurov Date: Thu, 17 Sep 2026 15:36:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20find=20in=20conversation=20with?= =?UTF-8?q?=20=E2=8C=98F/Ctrl+F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search the loaded transcript's message prose from the keyboard: ⌘F opens a compact find bar over the thread, Enter/Shift+Enter and arrows walk matches (occurrence-level, capped at 500), Esc closes. The virtualizer jumps to the hit row and the matching message group gets an amber ring. Scoped to the active transcript so background tabs never steal the shortcut; terminal regions decline it (multiplexer chords keep working). Only message text is searched — tool calls, tool results, reasoning traces and chrome are excluded, matching what a reader scans visually. Same loaded-window degradation as the message navigator. --- src/components/message/find-in-chat.test.tsx | 52 +++++++ src/components/message/find-in-chat.tsx | 134 +++++++++++++++++++ src/components/message/message-list-view.tsx | 134 ++++++++++++++++++- src/i18n/messages/ar.json | 8 +- src/i18n/messages/de.json | 8 +- src/i18n/messages/en.json | 8 +- src/i18n/messages/es.json | 8 +- src/i18n/messages/fr.json | 8 +- src/i18n/messages/ja.json | 8 +- src/i18n/messages/ko.json | 8 +- src/i18n/messages/pt.json | 8 +- src/i18n/messages/zh-CN.json | 8 +- src/i18n/messages/zh-TW.json | 8 +- 13 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 src/components/message/find-in-chat.test.tsx create mode 100644 src/components/message/find-in-chat.tsx 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 ? (