diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6c32235c27f8..b03194c80092 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -204,6 +204,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); + assert.equal(defaultsByCommand.get("chat.find"), "mod+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c6be46498164..32c79f178b63 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -49,6 +49,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -149,6 +150,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; +import { openChatFind } from "./chat/chatFindBus"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { @@ -1655,6 +1657,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (activeThread != null) { + actionItems.push({ + kind: "action", + value: "action:find-in-chat", + searchTerms: ["find in chat", "find in thread", "search chat", "search messages", "find"], + title: "Find in chat", + icon: , + shortcutCommand: "chat.find", + run: async () => { + openChatFind(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", diff --git a/apps/web/src/components/chat/ChatFindBar.logic.test.ts b/apps/web/src/components/chat/ChatFindBar.logic.test.ts new file mode 100644 index 000000000000..0449f2cf87be --- /dev/null +++ b/apps/web/src/components/chat/ChatFindBar.logic.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + buildChatFindPattern, + countChatFindOccurrences, + deriveChatFindMatches, + resolveChatFindActiveIndex, + resolveChatFindStartIndex, + stepChatFindIndex, +} from "./ChatFindBar.logic"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +function messageRow(id: string, role: "user" | "assistant", text: string): MessagesTimelineRow { + return { + kind: "message", + id, + createdAt: "2026-01-01T00:00:00Z", + message: { + id: id as never, + role, + text, + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + durationStart: "2026-01-01T00:00:00Z", + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + }; +} + +const foldRow: MessagesTimelineRow = { + kind: "turn-fold", + id: "turn-fold:turn-1", + createdAt: "2026-01-01T00:00:00Z", + turnId: "turn-1" as never, + label: "Worked for 3s on error handling", + expanded: false, +}; + +const rows: MessagesTimelineRow[] = [ + messageRow("user-1", "user", "Fix the Error in the parser"), + foldRow, + messageRow("assistant-1", "assistant", "No error here."), + messageRow("user-2", "user", "still broken"), + messageRow("assistant-2", "assistant", "Found the error; the ERROR was a typo. error fixed."), +]; + +function find(query: string, options?: Parameters[1]) { + return deriveChatFindMatches(rows, buildChatFindPattern(query, options)); +} + +describe("buildChatFindPattern", () => { + it("returns null for an empty or whitespace query", () => { + expect(buildChatFindPattern("")).toBeNull(); + expect(buildChatFindPattern(" ")).toBeNull(); + }); + + it("escapes regex metacharacters", () => { + expect(countChatFindOccurrences("a.b a-b a.b", buildChatFindPattern("a.b")!)).toBe(2); + expect(countChatFindOccurrences("x(1) x(2)", buildChatFindPattern("(1)")!)).toBe(1); + }); +}); + +describe("countChatFindOccurrences", () => { + it("counts case-insensitive, non-overlapping occurrences by default", () => { + expect(countChatFindOccurrences("Error error ERROR", buildChatFindPattern("error")!)).toBe(3); + expect(countChatFindOccurrences("aaaa", buildChatFindPattern("aa")!)).toBe(2); + expect(countChatFindOccurrences("nothing", buildChatFindPattern("error")!)).toBe(0); + }); + + it("honors case sensitivity", () => { + const pattern = buildChatFindPattern("Error", { caseSensitive: true, wholeWord: false })!; + expect(countChatFindOccurrences("Error error ERROR", pattern)).toBe(1); + }); + + it("honors whole word matching around punctuation and unicode letters", () => { + const pattern = buildChatFindPattern("error", { caseSensitive: false, wholeWord: true })!; + expect(countChatFindOccurrences("error errors (error) my_error Error.", pattern)).toBe(3); + expect(countChatFindOccurrences("erroré", pattern)).toBe(0); + const dotted = buildChatFindPattern("foo.", { caseSensitive: false, wholeWord: true })!; + expect(countChatFindOccurrences("foo. foo.bar", dotted)).toBe(1); + }); +}); + +describe("deriveChatFindMatches", () => { + it("returns nothing without a pattern", () => { + expect(deriveChatFindMatches(rows, null)).toEqual([]); + }); + + it("lists one match per occurrence in timeline order and skips non-message rows", () => { + expect(find(" error ")).toEqual([ + { rowId: "user-1", rowIndex: 0, occurrence: 0 }, + { rowId: "assistant-1", rowIndex: 2, occurrence: 0 }, + { rowId: "assistant-2", rowIndex: 4, occurrence: 0 }, + { rowId: "assistant-2", rowIndex: 4, occurrence: 1 }, + { rowId: "assistant-2", rowIndex: 4, occurrence: 2 }, + ]); + expect(find("Worked for")).toEqual([]); + }); + + it("applies the match options to every row", () => { + expect(find("ERROR", { caseSensitive: true, wholeWord: false })).toEqual([ + { rowId: "assistant-2", rowIndex: 4, occurrence: 0 }, + ]); + expect(find("Error", { caseSensitive: false, wholeWord: true })).toHaveLength(5); + }); +}); + +describe("stepChatFindIndex", () => { + it("wraps in both directions and tolerates a single match", () => { + expect(stepChatFindIndex(0, 3, 1)).toBe(1); + expect(stepChatFindIndex(2, 3, 1)).toBe(0); + expect(stepChatFindIndex(0, 3, -1)).toBe(2); + expect(stepChatFindIndex(0, 1, 1)).toBe(0); + expect(stepChatFindIndex(0, 1, -1)).toBe(0); + expect(stepChatFindIndex(4, 0, 1)).toBe(0); + }); +}); + +describe("resolveChatFindActiveIndex", () => { + const matches = find("error"); + + it("follows the same occurrence when rows shift underneath it", () => { + const previous = matches[3]!; + const shifted = deriveChatFindMatches( + [messageRow("user-0", "user", "hi"), ...rows], + buildChatFindPattern("error"), + ); + expect(resolveChatFindActiveIndex(shifted, 3, previous)).toBe(3); + expect(shifted[3]).toEqual({ rowId: "assistant-2", rowIndex: 5, occurrence: 1 }); + }); + + it("clamps when the previous occurrence is gone", () => { + const previous = matches[4]!; + const fewer = deriveChatFindMatches(rows.slice(0, 3), buildChatFindPattern("error")); + expect(resolveChatFindActiveIndex(fewer, 4, previous)).toBe(1); + expect(resolveChatFindActiveIndex([], 4, previous)).toBe(0); + expect(resolveChatFindActiveIndex(matches, -2, null)).toBe(0); + }); +}); + +describe("resolveChatFindStartIndex", () => { + const matches = find("error"); + + it("starts at the first match at or after the visible row", () => { + expect(resolveChatFindStartIndex(matches, null)).toBe(0); + expect(resolveChatFindStartIndex(matches, 0)).toBe(0); + expect(resolveChatFindStartIndex(matches, 1)).toBe(1); + expect(resolveChatFindStartIndex(matches, 3)).toBe(2); + }); + + it("falls back to the first match when nothing follows the visible row", () => { + expect(resolveChatFindStartIndex(matches, 10)).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/ChatFindBar.logic.ts b/apps/web/src/components/chat/ChatFindBar.logic.ts new file mode 100644 index 000000000000..582ee3eabe01 --- /dev/null +++ b/apps/web/src/components/chat/ChatFindBar.logic.ts @@ -0,0 +1,99 @@ +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +export interface ChatFindMatch { + readonly rowId: string; + readonly rowIndex: number; + readonly occurrence: number; +} + +export function chatFindMatchKey(match: ChatFindMatch): string { + return `${match.rowId}:${match.occurrence}`; +} + +export function chatFindSearchableText(row: MessagesTimelineRow): string | null { + if (row.kind !== "message") return null; + const text = row.message.text; + return typeof text === "string" && text.length > 0 ? text : null; +} + +export interface ChatFindOptions { + readonly caseSensitive: boolean; + readonly wholeWord: boolean; +} + +export const DEFAULT_CHAT_FIND_OPTIONS: ChatFindOptions = { + caseSensitive: false, + wholeWord: false, +}; + +const WORD_CHARACTER = String.raw`[\p{L}\p{N}_]`; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function buildChatFindPattern( + query: string, + options: ChatFindOptions = DEFAULT_CHAT_FIND_OPTIONS, +): RegExp | null { + const trimmed = query.trim(); + if (trimmed.length === 0) return null; + const escaped = escapeRegExp(trimmed); + const source = options.wholeWord + ? `(?, + pattern: RegExp | null, +): ChatFindMatch[] { + if (pattern === null) return []; + const matches: ChatFindMatch[] = []; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (!row) continue; + const text = chatFindSearchableText(row); + if (text === null) continue; + const count = countChatFindOccurrences(text, pattern); + for (let occurrence = 0; occurrence < count; occurrence += 1) { + matches.push({ rowId: row.id, rowIndex, occurrence }); + } + } + return matches; +} + +export function resolveChatFindStartIndex( + matches: ReadonlyArray, + fromRowIndex: number | null, +): number { + if (fromRowIndex === null) return 0; + const index = matches.findIndex((match) => match.rowIndex >= fromRowIndex); + return index === -1 ? 0 : index; +} + +export function stepChatFindIndex(index: number, count: number, direction: 1 | -1): number { + if (count <= 0) return 0; + return (((index + direction) % count) + count) % count; +} + +export function resolveChatFindActiveIndex( + matches: ReadonlyArray, + previousIndex: number, + previousMatch: ChatFindMatch | null, +): number { + if (matches.length === 0) return 0; + if (previousMatch !== null) { + const key = chatFindMatchKey(previousMatch); + const sameIndex = matches.findIndex((match) => chatFindMatchKey(match) === key); + if (sameIndex !== -1) return sameIndex; + } + return Math.min(Math.max(previousIndex, 0), matches.length - 1); +} diff --git a/apps/web/src/components/chat/ChatFindBar.tsx b/apps/web/src/components/chat/ChatFindBar.tsx new file mode 100644 index 000000000000..8f6dacacaa5a --- /dev/null +++ b/apps/web/src/components/chat/ChatFindBar.tsx @@ -0,0 +1,432 @@ +"use client"; + +import { useAtomValue } from "@effect/atom-react"; +import type { LegendListRef } from "@legendapp/list/react"; +import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type RefObject, +} from "react"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { resolveShortcutCommand } from "../../keybindings"; +import { isTerminalFocused } from "../../lib/terminalFocus"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Separator } from "../ui/separator"; +import { Toggle } from "../ui/toggle"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { cn } from "~/lib/utils"; +import { + type ChatFindMatch, + type ChatFindOptions, + buildChatFindPattern, + chatFindMatchKey, + DEFAULT_CHAT_FIND_OPTIONS, + deriveChatFindMatches, + resolveChatFindActiveIndex, + resolveChatFindStartIndex, + stepChatFindIndex, +} from "./ChatFindBar.logic"; +import { + applyChatFindHighlights, + clearChatFindHighlights, + findMountedChatRow, +} from "./chatFindHighlight"; +import { onOpenChatFind } from "./chatFindBus"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +const EMPTY_MATCHES: ReadonlyArray = []; +const REVEAL_VIEW_OFFSET = 96; +const REVEAL_TOP_MARGIN = 24; +const REVEAL_BOTTOM_FRACTION = 0.6; +const MAX_PREFILL_LENGTH = 200; + +function readSelectionPrefill(): string | null { + const selection = window.getSelection()?.toString() ?? ""; + const line = selection.split(/\r?\n/, 1)[0]?.trim() ?? ""; + if (line.length === 0 || line.length > MAX_PREFILL_LENGTH) return null; + return line; +} + +interface ChatFindBarProps { + rows: ReadonlyArray; + listRef: RefObject; + topFadeEnabled: boolean; + onManualNavigation: () => void; +} + +interface ChatFindHighlightState { + open: boolean; + pattern: RegExp | null; + matchRowIds: ReadonlySet; + activeMatch: ChatFindMatch | null; +} + +export const ChatFindBar = memo(function ChatFindBar({ + rows, + listRef, + topFadeEnabled, + onManualNavigation, +}: ChatFindBarProps) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [options, setOptions] = useState(DEFAULT_CHAT_FIND_OPTIONS); + const [activeIndex, setActiveIndex] = useState(0); + const [focusToken, setFocusToken] = useState(0); + const inputRef = useRef(null); + const openRef = useRef(false); + const queryRef = useRef(""); + const activeMatchRef = useRef(null); + const revealKeyRef = useRef(null); + const revealFirstMatchRef = useRef(false); + const scrollingRef = useRef(false); + const frameRef = useRef(null); + const highlightStateRef = useRef({ + open: false, + pattern: null, + matchRowIds: new Set(), + activeMatch: null, + }); + + const trimmedQuery = query.trim(); + const pattern = useMemo( + () => buildChatFindPattern(trimmedQuery, options), + [options, trimmedQuery], + ); + const matches = useMemo( + () => (open && pattern !== null ? deriveChatFindMatches(rows, pattern) : EMPTY_MATCHES), + [open, pattern, rows], + ); + const matchRowIds = useMemo(() => new Set(matches.map((match) => match.rowId)), [matches]); + const activeMatch = matches[activeIndex] ?? null; + + const runHighlight = useCallback(() => { + frameRef.current = null; + const list = listRef.current; + const scrollNode = list?.getScrollableNode(); + if (!list || !(scrollNode instanceof HTMLElement)) return; + const state = highlightStateRef.current; + if (!state.open || state.pattern === null) { + clearChatFindHighlights(); + return; + } + const activeRange = applyChatFindHighlights({ + scrollNode, + pattern: state.pattern, + matchRowIds: state.matchRowIds, + active: state.activeMatch, + }); + if (revealKeyRef.current === null || scrollingRef.current) return; + if ( + state.activeMatch === null || + revealKeyRef.current !== chatFindMatchKey(state.activeMatch) + ) { + revealKeyRef.current = null; + return; + } + if (activeRange === null) return; + const rect = activeRange.getBoundingClientRect(); + if (rect.height <= 0) return; + revealKeyRef.current = null; + const scrollRect = scrollNode.getBoundingClientRect(); + const inView = + rect.top >= scrollRect.top + REVEAL_TOP_MARGIN && + rect.bottom <= scrollRect.top + scrollNode.clientHeight * REVEAL_BOTTOM_FRACTION; + if (inView) return; + const listState = list.getState(); + const offset = Math.max( + 0, + (listState.scroll ?? scrollNode.scrollTop) + rect.top - scrollRect.top - REVEAL_VIEW_OFFSET, + ); + scrollingRef.current = true; + void list.scrollToOffset({ offset, animated: true }).finally(() => { + scrollingRef.current = false; + }); + }, [listRef]); + + const scheduleHighlight = useCallback(() => { + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(runHighlight); + }, [runHighlight]); + + useLayoutEffect(() => { + highlightStateRef.current = { open, pattern, matchRowIds, activeMatch }; + activeMatchRef.current = activeMatch; + openRef.current = open; + queryRef.current = trimmedQuery; + if (open) scheduleHighlight(); + }); + + const reveal = useCallback( + (match: ChatFindMatch) => { + const list = listRef.current; + if (!list) return; + onManualNavigation(); + revealKeyRef.current = chatFindMatchKey(match); + const scrollNode = list.getScrollableNode(); + const mounted = + scrollNode instanceof HTMLElement && findMountedChatRow(scrollNode, match.rowId) !== null; + if (!mounted) { + scrollingRef.current = true; + void list + .scrollToIndex({ + index: match.rowIndex, + animated: true, + viewOffset: REVEAL_VIEW_OFFSET, + }) + .catch(() => {}) + .finally(() => { + scrollingRef.current = false; + scheduleHighlight(); + }); + } + scheduleHighlight(); + }, + [listRef, onManualNavigation, scheduleHighlight], + ); + + useEffect(() => { + setActiveIndex((current) => + resolveChatFindActiveIndex(matches, current, activeMatchRef.current), + ); + if (!revealFirstMatchRef.current) return; + revealFirstMatchRef.current = false; + if (matches.length === 0) return; + const scrollNode = listRef.current?.getScrollableNode(); + let fromRowIndex: number | null = null; + if (scrollNode instanceof HTMLElement) { + const top = scrollNode.getBoundingClientRect().top; + for (const element of scrollNode.querySelectorAll("[data-timeline-row-id]")) { + if (element.getBoundingClientRect().bottom <= top) continue; + const rowId = element.dataset.timelineRowId; + const rowIndex = rows.findIndex((row) => row.id === rowId); + if (rowIndex !== -1) fromRowIndex = rowIndex; + break; + } + } + const startIndex = resolveChatFindStartIndex(matches, fromRowIndex); + setActiveIndex(startIndex); + const match = matches[startIndex]; + if (match) reveal(match); + }, [listRef, matches, reveal, rows]); + + useEffect(() => { + if (!open) { + clearChatFindHighlights(); + return; + } + const scrollNode = listRef.current?.getScrollableNode(); + if (!(scrollNode instanceof HTMLElement)) return; + const observer = new MutationObserver(scheduleHighlight); + observer.observe(scrollNode, { childList: true, characterData: true, subtree: true }); + const cancelReveal = () => { + revealKeyRef.current = null; + }; + scrollNode.addEventListener("wheel", cancelReveal, { passive: true }); + scrollNode.addEventListener("touchstart", cancelReveal, { passive: true }); + return () => { + observer.disconnect(); + scrollNode.removeEventListener("wheel", cancelReveal); + scrollNode.removeEventListener("touchstart", cancelReveal); + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + clearChatFindHighlights(); + }; + }, [listRef, open, scheduleHighlight]); + + const restartFromQuery = useCallback((value: string) => { + setQuery(value); + setActiveIndex(0); + activeMatchRef.current = null; + revealFirstMatchRef.current = value.trim().length > 0; + }, []); + + const openBar = useCallback(() => { + const prefill = readSelectionPrefill(); + if (prefill !== null) { + restartFromQuery(prefill); + } else if (!openRef.current && queryRef.current.length > 0) { + activeMatchRef.current = null; + revealFirstMatchRef.current = true; + } + setOpen(true); + setFocusToken((token) => token + 1); + }, [restartFromQuery]); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + if (command !== "chat.find") return; + event.preventDefault(); + event.stopPropagation(); + openBar(); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [keybindings, openBar]); + + useEffect(() => onOpenChatFind(openBar), [openBar]); + + useEffect(() => { + if (!open || focusToken === 0) return; + const input = inputRef.current; + if (!input) return; + input.focus(); + input.select(); + }, [focusToken, open]); + + const close = useCallback(() => { + setOpen(false); + revealKeyRef.current = null; + revealFirstMatchRef.current = false; + }, []); + + const step = useCallback( + (direction: 1 | -1) => { + if (matches.length === 0) return; + const nextIndex = stepChatFindIndex(activeIndex, matches.length, direction); + setActiveIndex(nextIndex); + const match = matches[nextIndex]; + if (match) reveal(match); + }, + [activeIndex, matches, reveal], + ); + + const toggleOption = useCallback((key: keyof ChatFindOptions) => { + setOptions((current) => ({ ...current, [key]: !current[key] })); + setActiveIndex(0); + activeMatchRef.current = null; + revealFirstMatchRef.current = queryRef.current.length > 0; + }, []); + + const onInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + step(event.shiftKey ? -1 : 1); + } + }, + [close, step], + ); + + if (!open) return null; + + const hasQuery = trimmedQuery.length > 0; + const countLabel = !hasQuery + ? null + : matches.length === 0 + ? "No matches" + : `${activeIndex + 1} of ${matches.length}`; + + return ( +
+ + restartFromQuery(event.currentTarget.value)} + onKeyDown={onInputKeyDown} + placeholder="Find in chat" + aria-label="Find in chat" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + className="min-w-0 flex-1 text-sm [&_input]:px-1.5" + /> + + {countLabel} + + + toggleOption("caseSensitive")} + className="size-7 min-w-0 px-0 font-mono text-xs text-muted-foreground data-pressed:text-foreground sm:size-6" + > + Aa + + toggleOption("wholeWord")} + className="size-7 min-w-0 px-0 font-mono text-xs text-muted-foreground data-pressed:text-foreground sm:size-6" + > + ab + + + + +
+ ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e97f10c39439..ea53f0bfa639 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -113,6 +113,7 @@ import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; +import { ChatFindBar } from "./ChatFindBar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { AssistantCitationSource, @@ -735,6 +736,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCite={onCiteAssistantText} /> ) : null} + ref={listRef} data={rows} diff --git a/apps/web/src/components/chat/chatFindBus.ts b/apps/web/src/components/chat/chatFindBus.ts new file mode 100644 index 000000000000..dd771b141b33 --- /dev/null +++ b/apps/web/src/components/chat/chatFindBus.ts @@ -0,0 +1,14 @@ +"use client"; + +const EVENT_NAME = "t3code:chat-find-open"; + +export function openChatFind(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(EVENT_NAME)); +} + +export function onOpenChatFind(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(EVENT_NAME, listener); + return () => window.removeEventListener(EVENT_NAME, listener); +} diff --git a/apps/web/src/components/chat/chatFindHighlight.ts b/apps/web/src/components/chat/chatFindHighlight.ts new file mode 100644 index 000000000000..0a04497ebd06 --- /dev/null +++ b/apps/web/src/components/chat/chatFindHighlight.ts @@ -0,0 +1,73 @@ +import { readAssistantText } from "~/lib/assistantTextSelection"; +import type { ChatFindMatch } from "./ChatFindBar.logic"; + +const FIND_HIGHLIGHT_NAME = "t3-chat-find"; +const FIND_ACTIVE_HIGHLIGHT_NAME = "t3-chat-find-active"; + +export function supportsChatFindHighlight(): boolean { + return ( + typeof Highlight !== "undefined" && typeof CSS !== "undefined" && CSS.highlights !== undefined + ); +} + +export function clearChatFindHighlights(): void { + if (!supportsChatFindHighlight()) return; + CSS.highlights.delete(FIND_HIGHLIGHT_NAME); + CSS.highlights.delete(FIND_ACTIVE_HIGHLIGHT_NAME); +} + +export function findChatRowTextRanges(root: HTMLElement, pattern: RegExp): Range[] { + const stream = readAssistantText(root); + const ranges: Range[] = []; + for (const match of stream.text.matchAll(pattern)) { + const start = match.index; + const end = start + match[0].length; + if (end <= start) continue; + const first = stream.chunks.find((chunk) => chunk.end > start); + const last = stream.chunks.findLast((chunk) => chunk.start < end); + if (first === undefined || last === undefined) continue; + const range = root.ownerDocument.createRange(); + range.setStart(first.node, Math.max(0, start - first.start)); + range.setEnd(last.node, Math.min(last.node.length, end - last.start)); + if (!range.collapsed) ranges.push(range); + } + return ranges; +} + +export function findMountedChatRow(scrollNode: HTMLElement, rowId: string): HTMLElement | null { + return scrollNode.querySelector(`[data-timeline-row-id="${CSS.escape(rowId)}"]`); +} + +export function applyChatFindHighlights({ + scrollNode, + pattern, + matchRowIds, + active, +}: { + scrollNode: HTMLElement; + pattern: RegExp; + matchRowIds: ReadonlySet; + active: ChatFindMatch | null; +}): Range | null { + if (!supportsChatFindHighlight()) return null; + const ranges: Range[] = []; + let activeRange: Range | null = null; + for (const element of scrollNode.querySelectorAll("[data-timeline-row-id]")) { + const rowId = element.dataset.timelineRowId; + if (!rowId || !matchRowIds.has(rowId)) continue; + const rowRanges = findChatRowTextRanges(element, pattern); + if (active !== null && rowId === active.rowId && rowRanges.length > 0) { + activeRange = rowRanges[Math.min(active.occurrence, rowRanges.length - 1)] ?? null; + } + for (const range of rowRanges) { + if (range !== activeRange) ranges.push(range); + } + } + CSS.highlights.set(FIND_HIGHLIGHT_NAME, new Highlight(...ranges)); + if (activeRange) { + CSS.highlights.set(FIND_ACTIVE_HIGHLIGHT_NAME, new Highlight(activeRange)); + } else { + CSS.highlights.delete(FIND_ACTIVE_HIGHLIGHT_NAME); + } + return activeRange; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 63dc9e21c094..f8d58a068bb2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -516,6 +516,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ::highlight(t3-assistant-citation-comment) { background-color: color-mix(in oklab, var(--primary) 45%, transparent); } + ::highlight(t3-chat-find) { + background-color: color-mix(in oklab, var(--primary) 28%, transparent); + } + ::highlight(t3-chat-find-active) { + background-color: color-mix(in oklab, var(--primary) 60%, transparent); + color: var(--primary-foreground); + } :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in themes override them with their own pigments in the components layer. */ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8cbc45966529..60f624fa51d1 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -133,6 +133,11 @@ const DEFAULT_BINDINGS = compile([ command: "projectSearch.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("f"), + command: "chat.find", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("t", { altKey: true, shiftKey: true }), command: "themeEditor.toggle", @@ -647,6 +652,37 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches chat.find shortcut outside terminal focus without shift", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.find", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { terminalFocus: false }, + }), + "chat.find", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + null, + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "chat.find", + ); + }); + it("matches themeEditor.toggle on macOS and Windows", () => { assert.strictEqual( resolveShortcutCommand( diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index f118555f3eac..9a06ebd36c90 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -128,7 +128,7 @@ export function findAssistantCitationText( return match ?? (quoteCount === 1 ? onlyQuote : null); } -type TextChunk = { node: Text; start: number; end: number }; +export type TextChunk = { node: Text; start: number; end: number }; /** * Uses DOM text order, with a line break between HTML blocks and at
. @@ -137,7 +137,7 @@ type TextChunk = { node: Text; start: number; end: number }; * reads, CSS-generated content, or soft-wrap line breaks enter the stream, so * reflow cannot move it. */ -function readAssistantText(root: HTMLElement) { +export function readAssistantText(root: HTMLElement) { const parts: string[] = []; const chunks: TextChunk[] = []; let length = 0; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bcc22daa4a9d..2332c262912f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2186,6 +2186,7 @@ function PullRequestsColumn({ listBody: ReactNode; scrollRef: RefObject; }) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const markerRef = useRef(null); const [condensed, setCondensed] = useState(false); useEffect(() => { @@ -2212,9 +2213,11 @@ function PullRequestsColumn({ // it focuses the in-flow bar and selects the query the way a find field would. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key.toLowerCase() !== "f" || !(event.metaKey || event.ctrlKey)) return; - if (event.altKey || event.shiftKey) return; + if (event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + if (command !== "chat.find") return; event.preventDefault(); if (condensed) { setSearchOpen(true); @@ -2227,7 +2230,7 @@ function PullRequestsColumn({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [condensed]); + }, [condensed, keybindings]); useEffect(() => { if (condensed) return; // The fold-out is gone from the chrome; forgetting it open keeps the next condensing diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index fe97eedadbca..b6d07d6896d8 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -40,6 +40,11 @@ Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refre `filePicker.toggle` opens file search for the active project and defaults to `mod+p`. `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. Repeating either shortcut closes that search, and switching shortcuts replaces the open search. +`chat.find` opens a find bar over the open thread and defaults to `mod+f`. Any text selected when +you press it becomes the search. Type to highlight every message that contains the text, then press +`Enter` or `Shift+Enter` to step between matches and `Escape` to close it. The `Aa` and `ab` toggles +match case and whole words only. On the pull requests page the same shortcut focuses the list +search instead. `themeEditor.toggle` opens or closes the floating theme editor and defaults to `mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label again to clear the spotlight. The swatch and hex field keep that color selected while you edit it. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 71b2547e532c..7fb0baec6d7e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -70,6 +70,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "filePicker.toggle", "projectSearch.toggle", + "chat.find", "themeEditor.toggle", "composer.stash", "chat.new", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 1107873c8285..ba5f82507d54 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -38,6 +38,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, + { key: "mod+f", command: "chat.find", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" },