Skip to content

Commit 4b14357

Browse files
Imane-Idrissiclaudeadboio
authored
feat(code): add Cmd+F search within conversations (#2041)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Adam Bowker <adam.b@posthog.com>
1 parent b6df6c4 commit 4b14357

8 files changed

Lines changed: 677 additions & 10 deletions

File tree

apps/code/src/renderer/constants/keyboard-shortcuts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const SHORTCUTS = {
2121
INBOX: "mod+i",
2222
SPACE_UP: "mod+up",
2323
SPACE_DOWN: "mod+down",
24+
FIND_IN_CONVERSATION: "mod+f",
2425
BLUR: "escape",
2526
SUBMIT_BLUR: "mod+enter",
2627
} as const;
@@ -152,6 +153,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
152153
category: "panels",
153154
context: "Task detail",
154155
},
156+
{
157+
id: "find-in-conversation",
158+
keys: SHORTCUTS.FIND_IN_CONVERSATION,
159+
description: "Find in conversation",
160+
category: "panels",
161+
context: "Task detail",
162+
},
155163
{
156164
id: "paste-as-file",
157165
keys: SHORTCUTS.PASTE_AS_FILE,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { ArrowDown, ArrowUp, X } from "@phosphor-icons/react";
2+
import { IconButton } from "@radix-ui/themes";
3+
import {
4+
forwardRef,
5+
type KeyboardEvent as ReactKeyboardEvent,
6+
useCallback,
7+
useImperativeHandle,
8+
useLayoutEffect,
9+
useRef,
10+
} from "react";
11+
12+
export interface ConversationSearchBarHandle {
13+
focusAndSelect: () => void;
14+
}
15+
16+
interface ConversationSearchBarProps {
17+
query: string;
18+
currentMatch: number;
19+
totalMatches: number;
20+
onQueryChange: (query: string) => void;
21+
onNext: () => void;
22+
onPrev: () => void;
23+
onClose: () => void;
24+
}
25+
26+
export const ConversationSearchBar = forwardRef<
27+
ConversationSearchBarHandle,
28+
ConversationSearchBarProps
29+
>(function ConversationSearchBar(
30+
{ query, currentMatch, totalMatches, onQueryChange, onNext, onPrev, onClose },
31+
ref,
32+
) {
33+
const inputRef = useRef<HTMLInputElement>(null);
34+
35+
useLayoutEffect(() => {
36+
inputRef.current?.focus();
37+
}, []);
38+
39+
useImperativeHandle(
40+
ref,
41+
() => ({
42+
focusAndSelect: () => {
43+
const input = inputRef.current;
44+
if (!input) return;
45+
input.focus();
46+
input.select();
47+
},
48+
}),
49+
[],
50+
);
51+
52+
const handleKeyDown = useCallback(
53+
(e: ReactKeyboardEvent<HTMLInputElement>) => {
54+
if (e.key === "Escape") {
55+
e.preventDefault();
56+
onClose();
57+
} else if (e.key === "Enter" || e.key === "ArrowDown") {
58+
e.preventDefault();
59+
if (e.shiftKey) {
60+
onPrev();
61+
} else {
62+
onNext();
63+
}
64+
} else if (e.key === "ArrowUp") {
65+
e.preventDefault();
66+
onPrev();
67+
}
68+
},
69+
[onClose, onNext, onPrev],
70+
);
71+
72+
return (
73+
<div
74+
data-overlay
75+
className="absolute top-2 right-6 z-30 flex items-center gap-1 rounded-lg border border-(--gray-6) bg-(--color-background) px-2 py-1 shadow-md"
76+
>
77+
<input
78+
ref={inputRef}
79+
type="text"
80+
value={query}
81+
onChange={(e) => onQueryChange(e.target.value)}
82+
onKeyDown={handleKeyDown}
83+
placeholder="Find in conversation..."
84+
className="w-48 border-none bg-transparent text-(--gray-12) text-[13px] outline-none placeholder:text-(--gray-9)"
85+
/>
86+
{query && (
87+
<span className="shrink-0 text-(--gray-10) text-[12px]">
88+
{totalMatches > 0
89+
? `${currentMatch + 1} of ${totalMatches}`
90+
: "No results"}
91+
</span>
92+
)}
93+
<IconButton
94+
size="1"
95+
variant="ghost"
96+
color="gray"
97+
onClick={onPrev}
98+
disabled={totalMatches === 0}
99+
aria-label="Previous match"
100+
>
101+
<ArrowUp size={14} />
102+
</IconButton>
103+
<IconButton
104+
size="1"
105+
variant="ghost"
106+
color="gray"
107+
onClick={onNext}
108+
disabled={totalMatches === 0}
109+
aria-label="Next match"
110+
>
111+
<ArrowDown size={14} />
112+
</IconButton>
113+
<IconButton
114+
size="1"
115+
variant="ghost"
116+
color="gray"
117+
onClick={onClose}
118+
aria-label="Close search"
119+
>
120+
<X size={14} />
121+
</IconButton>
122+
</div>
123+
);
124+
});

apps/code/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants";
22
import { useContextUsage } from "@features/sessions/hooks/useContextUsage";
3+
import { useConversationSearch } from "@features/sessions/hooks/useConversationSearch";
34
import {
45
sessionStoreSetters,
56
useOptimisticItemsForTask,
@@ -20,6 +21,7 @@ import {
2021
type ConversationItem,
2122
type TurnContext,
2223
} from "./buildConversationItems";
24+
import { ConversationSearchBar } from "./ConversationSearchBar";
2325
import { GitActionMessage } from "./GitActionMessage";
2426
import { GitActionResult } from "./GitActionResult";
2527
import { mergeConversationItems } from "./mergeConversationItems";
@@ -150,6 +152,9 @@ export function ConversationView({
150152
return indices;
151153
}, [items]);
152154

155+
const containerRef = useRef<HTMLDivElement>(null);
156+
const search = useConversationSearch({ items, containerRef, listRef });
157+
153158
const handleScrollStateChange = useCallback((isAtBottom: boolean) => {
154159
isAtBottomRef.current = isAtBottom;
155160
setShowScrollButton(!isAtBottom);
@@ -241,11 +246,23 @@ export function ConversationView({
241246
poolOptions={DIFFS_POOL_OPTIONS}
242247
highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS}
243248
>
244-
<div className="relative flex-1">
249+
<div ref={containerRef} className="relative flex-1">
245250
<div
246251
id="fullscreen-portal"
247252
className="pointer-events-none absolute inset-0 z-20"
248253
/>
254+
{search.open && (
255+
<ConversationSearchBar
256+
ref={search.searchBarRef}
257+
query={search.query}
258+
currentMatch={search.currentIndex}
259+
totalMatches={search.totalMatches}
260+
onQueryChange={search.setQuery}
261+
onNext={search.next}
262+
onPrev={search.prev}
263+
onClose={search.close}
264+
/>
265+
)}
249266

250267
<VirtualizedList
251268
ref={listRef}

apps/code/src/renderer/features/sessions/components/VirtualizedList.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface VirtualizedListProps<T> {
2424

2525
export interface VirtualizedListHandle {
2626
scrollToBottom: () => void;
27+
scrollToIndex: (index: number) => void;
2728
}
2829

2930
const AT_BOTTOM_THRESHOLD = 50;
@@ -60,6 +61,13 @@ function VirtualizedListInner<T>(
6061
isAtBottomRef.current = true;
6162
}
6263
},
64+
scrollToIndex: (index: number) => {
65+
const handle = listRef.current;
66+
if (handle) {
67+
isAtBottomRef.current = false;
68+
handle.scrollToIndex(index, { align: "center" });
69+
}
70+
},
6371
}),
6472
[],
6573
);
@@ -116,15 +124,19 @@ function VirtualizedListInner<T>(
116124
keepMounted={keepMounted}
117125
className="flex-1"
118126
>
119-
{items.map((item, index) => (
120-
<div
121-
key={getItemKey ? getItemKey(item, index) : index}
122-
className={itemClassName}
123-
style={itemStyle}
124-
>
125-
{renderItem(item, index)}
126-
</div>
127-
))}
127+
{items.map((item, index) => {
128+
const key = getItemKey ? getItemKey(item, index) : index;
129+
return (
130+
<div
131+
key={key}
132+
className={itemClassName}
133+
style={itemStyle}
134+
data-conversation-item-id={key}
135+
>
136+
{renderItem(item, index)}
137+
</div>
138+
);
139+
})}
128140
{footer && (
129141
<div className={itemClassName} style={itemStyle}>
130142
{footer}

0 commit comments

Comments
 (0)