) => {
+ 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" },