From 4bdeedc52853a41475ab672241646db1dfed7864 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 13:18:34 +0800 Subject: [PATCH 01/17] fix: persist elapsed timer across session switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题** 切换 Session 后,`StreamingMessage` 底部的计时器会从 0 重新开始计数。根因是 `ElapsedTimer` 组件在 mount 时用 `Date.now()` 本地初始化开始时间,Session 切换导致组件 remount 后时间重置。 **修复** - `stream-session-manager` 的 `SessionStreamSnapshot` 已包含 `startedAt` 字段,记录流的真实开始时间 - 将 `startedAt` 从 `ChatView` → `MessageList` → `StreamingMessage` → `StreamingStatusBar` → `ElapsedTimer` 逐级透传 - `ElapsedTimer` 改为基于传入的 `startedAt` 计算 elapsed,组件 remount 后仍能恢复真实累计时长 **影响范围** 仅影响流式响应状态下的底部计时器显示,不改变任何持久化逻辑或计时行为。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/chat/ChatView.tsx | 1 + src/components/chat/MessageList.tsx | 3 +++ src/components/chat/StreamingMessage.tsx | 18 +++++++++--------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 5155df93..6f4d8fc5 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -651,6 +651,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal onLoadMore={loadEarlierMessages} rewindPoints={rewindPoints} sessionId={sessionId} + startedAt={streamSnapshot?.startedAt} isAssistantProject={isAssistantProject} assistantName={assistantName} /> diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index ff8c3840..91205fa6 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -172,6 +172,7 @@ interface MessageListProps { /** SDK rewind points — only emitted for visible prompt-level user messages (not tool results or auto-triggers), mapped by position */ rewindPoints?: RewindPoint[]; sessionId?: string; + startedAt?: number; /** Whether this is an assistant workspace project */ isAssistantProject?: boolean; /** Assistant name for avatar display */ @@ -193,6 +194,7 @@ export function MessageList({ onLoadMore, rewindPoints = [], sessionId, + startedAt, isAssistantProject, assistantName, }: MessageListProps) { @@ -315,6 +317,7 @@ export function MessageList({ content={streamingContent} isStreaming={isStreaming} sessionId={sessionId} + startedAt={startedAt} toolUses={toolUses} toolResults={toolResults} streamingToolOutput={streamingToolOutput} diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 849d5317..152af613 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -106,6 +106,7 @@ interface StreamingMessageProps { content: string; isStreaming: boolean; sessionId?: string; + startedAt?: number; toolUses?: ToolUseInfo[]; toolResults?: ToolResultInfo[]; streamingToolOutput?: string; @@ -196,17 +197,15 @@ function ThinkingPhaseLabel() { return {text}; } -function ElapsedTimer() { - const [elapsed, setElapsed] = useState(0); - const startRef = useRef(0); +function ElapsedTimer({ startedAt }: { startedAt: number }) { + const [elapsed, setElapsed] = useState(() => Math.floor((Date.now() - startedAt) / 1000)); useEffect(() => { - startRef.current = Date.now(); const interval = setInterval(() => { - setElapsed(Math.floor((Date.now() - startRef.current) / 1000)); + setElapsed(Math.floor((Date.now() - startedAt) / 1000)); }, 1000); return () => clearInterval(interval); - }, []); + }, [startedAt]); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; @@ -218,7 +217,7 @@ function ElapsedTimer() { ); } -function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; onForceStop?: () => void }) { +function StreamingStatusBar({ statusText, onForceStop, startedAt }: { statusText?: string; onForceStop?: () => void; startedAt?: number }) { const displayText = statusText || 'Thinking'; // Parse elapsed seconds from statusText like "Running bash... (45s)" @@ -241,7 +240,7 @@ function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; )} | - + {isCritical && onForceStop && ( - ))} - - )} - - )} - - - - {/* Folder Picker Dialog */} void; +} + +const TYPE_ICONS: Record = { + session: ChatCircleText, + message: NotePencil, + file: Folder, +}; + +const TYPE_LABELS: Record = { + session: 'Sessions', + message: 'Messages', + file: 'Files', +}; + +export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogProps) { + const { t } = useTranslation(); + const router = useRouter(); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState({ sessions: [], messages: [], files: [] }); + const abortRef = useRef(null); + + const performSearch = useCallback(async (q: string) => { + if (abortRef.current) { + abortRef.current.abort(); + } + if (!q.trim()) { + setResults({ sessions: [], messages: [], files: [] }); + setLoading(false); + return; + } + + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error('Search failed'); + const data: SearchResponse = await res.json(); + if (!controller.signal.aborted) { + setResults(data); + } + } catch { + if (!controller.signal.aborted) { + setResults({ sessions: [], messages: [], files: [] }); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + performSearch(query); + }, 150); + return () => clearTimeout(timer); + }, [query, performSearch]); + + useEffect(() => { + if (!open) { + setQuery(''); + setResults({ sessions: [], messages: [], files: [] }); + } + }, [open]); + + const handleSelect = useCallback( + (item: SearchResultSession | SearchResultMessage | SearchResultFile) => { + onOpenChange(false); + if (item.type === 'session') { + router.push(`/chat/${item.id}`); + } else if (item.type === 'message') { + router.push(`/chat/${item.sessionId}`); + } else if (item.type === 'file') { + // For files, navigate to the session and let the file tree show it + router.push(`/chat/${item.sessionId}`); + } + }, + [router, onOpenChange], + ); + + const hasResults = + results.sessions.length > 0 || + results.messages.length > 0 || + results.files.length > 0; + + const renderGroup = ( + key: keyof SearchResponse, + items: (SearchResultSession | SearchResultMessage | SearchResultFile)[], + ) => { + if (items.length === 0) return null; + const Icon = TYPE_ICONS[key]; + return ( + + {items.map((item, idx) => ( + handleSelect(item)} + className="flex items-start gap-2 py-2" + > + +
+ {item.type === 'session' && ( + <> +

{item.title}

+ {item.projectName && ( +

{item.projectName}

+ )} + + )} + {item.type === 'message' && ( + <> +

+ {item.sessionTitle} · {item.role === 'user' ? 'User' : 'Assistant'} +

+

{item.snippet}

+ + )} + {item.type === 'file' && ( + <> +

{item.name}

+

{item.sessionTitle}

+ + )} +
+
+ ))} +
+ ); + }; + + return ( + + + + {!query && !loading && ( +
+

Type to search across sessions and messages

+

+ Prefix with sessions:{' '} + messages:{' '} + files: to narrow scope +

+
+ )} + {query && !loading && !hasResults && ( + No results found + )} + {renderGroup('sessions', results.sessions)} + {renderGroup('messages', results.messages)} + {renderGroup('files', results.files)} + {loading && ( +
Searching...
+ )} +
+
+ ); +} diff --git a/src/hooks/useGlobalSearchShortcut.ts b/src/hooks/useGlobalSearchShortcut.ts new file mode 100644 index 00000000..2f06e0a9 --- /dev/null +++ b/src/hooks/useGlobalSearchShortcut.ts @@ -0,0 +1,28 @@ +import { useEffect, useCallback } from 'react'; + +export function useGlobalSearchShortcut(onOpen: () => void) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isModifier = e.metaKey || e.ctrlKey; + if (isModifier && e.key.toLowerCase() === 'k') { + // Avoid intercepting when an input/textarea is focused + const active = document.activeElement; + const isEditing = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.getAttribute('contenteditable') === 'true'; + // Still allow shortcut when focus is on body or non-editable elements + if (!isEditing) { + e.preventDefault(); + onOpen(); + } + } + }, + [onOpen], + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} From 1f459794378a24e218688358df454134cd827799 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 17:41:57 +0800 Subject: [PATCH 07/17] fix(global-search): match TYPE_ICONS keys to plural group names --- src/components/layout/GlobalSearchDialog.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index 17038550..f4011706 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -52,15 +52,15 @@ interface GlobalSearchDialogProps { } const TYPE_ICONS: Record = { - session: ChatCircleText, - message: NotePencil, - file: Folder, + sessions: ChatCircleText, + messages: NotePencil, + files: Folder, }; const TYPE_LABELS: Record = { - session: 'Sessions', - message: 'Messages', - file: 'Files', + sessions: 'Sessions', + messages: 'Messages', + files: 'Files', }; export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogProps) { From df1255db9a61fd433dc7a72bd9d8188b1f0e9167 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:26:41 +0800 Subject: [PATCH 08/17] feat(search): global search UI overhaul with file and folder targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the global search dialog with session-grouped messages, Obsidian-style previews, and larger dialog sizing. - GlobalSearchDialog: group messages by session with foldable groups; distinguish user/assistant/tool via icons; enlarge to sm:max-w-3xl; highlight matched keyword in snippet with primary color - File/Folder search: pass ?file=path&q=query; auto-open file tree, expand parent folders and target directory, scroll and flash-highlight the matched item (files and directories both supported) - Search API:兼容单数前缀 (session:/message:/file:) and return contentType for icon selection; folders are now searchable - Snippet generation: bias keyword toward the front so it survives single-line truncation in the UI list - i18n: add globalSearch.toolLabel for zh/en Relates to #482 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/search/route.ts | 28 ++- src/app/chat/[id]/page.tsx | 10 + src/app/globals.css | 37 ++++ src/components/layout/GlobalSearchDialog.tsx | 195 ++++++++++++++---- .../layout/panels/FileTreePanel.tsx | 5 + src/components/project/FileTree.tsx | 62 +++++- src/components/ui/command.tsx | 7 +- src/i18n/en.ts | 14 ++ src/i18n/zh.ts | 14 ++ src/lib/db.ts | 26 ++- 10 files changed, 339 insertions(+), 59 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 9eaeb587..f16eb553 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -22,6 +22,7 @@ interface SearchResultMessage { role: 'user' | 'assistant'; snippet: string; createdAt: string; + contentType: 'user' | 'assistant' | 'tool'; } interface SearchResultFile { @@ -30,6 +31,7 @@ interface SearchResultFile { sessionTitle: string; path: string; name: string; + nodeType: 'file' | 'directory'; } export interface SearchResponse { @@ -40,14 +42,18 @@ export interface SearchResponse { function parseQuery(raw: string): { scope: 'all' | 'sessions' | 'messages' | 'files'; query: string } { const trimmed = raw.trim(); - if (trimmed.toLowerCase().startsWith('sessions:')) { - return { scope: 'sessions', query: trimmed.slice(9).trim() }; + const lower = trimmed.toLowerCase(); + if (lower.startsWith('session:') || lower.startsWith('sessions:')) { + const prefixLen = lower.startsWith('session:') ? 8 : 9; + return { scope: 'sessions', query: trimmed.slice(prefixLen).trim() }; } - if (trimmed.toLowerCase().startsWith('messages:')) { - return { scope: 'messages', query: trimmed.slice(9).trim() }; + if (lower.startsWith('message:') || lower.startsWith('messages:')) { + const prefixLen = lower.startsWith('message:') ? 8 : 9; + return { scope: 'messages', query: trimmed.slice(prefixLen).trim() }; } - if (trimmed.toLowerCase().startsWith('files:')) { - return { scope: 'files', query: trimmed.slice(6).trim() }; + if (lower.startsWith('file:') || lower.startsWith('files:')) { + const prefixLen = lower.startsWith('file:') ? 5 : 6; + return { scope: 'files', query: trimmed.slice(prefixLen).trim() }; } return { scope: 'all', query: trimmed }; } @@ -70,7 +76,7 @@ function filterSessions(sessions: ChatSession[], query: string): SearchResultSes })); } -function collectFiles( +function collectNodes( tree: FileTreeNode[], sessionId: string, sessionTitle: string, @@ -81,17 +87,18 @@ function collectFiles( const q = query.toLowerCase(); for (const node of tree) { if (results.length >= MAX_RESULTS_PER_TYPE) break; - if (node.type === 'file' && node.name.toLowerCase().includes(q)) { + if (node.name.toLowerCase().includes(q)) { results.push({ type: 'file', sessionId, sessionTitle, path: node.path, name: node.name, + nodeType: node.type, }); } if (node.type === 'directory' && node.children) { - collectFiles(node.children, sessionId, sessionTitle, query, results); + collectNodes(node.children, sessionId, sessionTitle, query, results); } } } @@ -123,6 +130,7 @@ export async function GET(request: NextRequest) { role: r.role, snippet: r.snippet, createdAt: r.createdAt, + contentType: r.contentType, })); } @@ -130,7 +138,7 @@ export async function GET(request: NextRequest) { for (const session of allSessions) { if (!session.working_directory) continue; const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH); - collectFiles(tree, session.id, session.title, query, result.files); + collectNodes(tree, session.id, session.title, query, result.files); if (result.files.length >= MAX_RESULTS_PER_TYPE) break; } } diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 3bd3babd..48b40dbf 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -25,6 +25,9 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionMode, setSessionMode] = useState<'code' | 'plan'>('code'); const [sessionHasSummary, setSessionHasSummary] = useState(false); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen } = usePanel(); + const targetFilePath = typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('file') || undefined + : undefined; const { t } = useTranslation(); const defaultPanelAppliedRef = useRef(false); @@ -113,6 +116,13 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { return () => { cancelled = true; }; }, [id]); + // Auto-open file tree when jumping from a file search result + useEffect(() => { + if (targetFilePath) { + setFileTreeOpen(true); + } + }, [targetFilePath, setFileTreeOpen]); + // Auto-open default panel the first time a session is ever opened. // Uses sessionStorage to track which sessions have already been initialized, // so re-opening an untouched (zero-message) session won't override the layout. diff --git a/src/app/globals.css b/src/app/globals.css index 93a9936f..50a2ee17 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -269,6 +269,43 @@ } } +/* Search result highlight flash animation for messages */ +@keyframes search-highlight-pulse { + 0% { + background-color: color-mix(in oklch, var(--primary) 40%, transparent); + } + 50% { + background-color: color-mix(in oklch, var(--primary) 20%, transparent); + } + 100% { + background-color: transparent; + } +} + +@utility search-highlight-flash { + animation: search-highlight-pulse 2s ease-in-out 1; + border-radius: 2px; + padding: 0 2px; + margin: 0 -2px; +} + +/* File tree item flash animation */ +@keyframes file-tree-pulse { + 0% { + background-color: color-mix(in oklch, var(--primary) 35%, transparent); + } + 50% { + background-color: color-mix(in oklch, var(--primary) 15%, transparent); + } + 100% { + background-color: transparent; + } +} + +@utility file-tree-flash { + animation: file-tree-pulse 2s ease-in-out 1; +} + /* Widget skeleton shimmer animation */ @keyframes widget-shimmer { 0% { diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index f4011706..987a8025 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslation } from '@/hooks/useTranslation'; import { @@ -11,8 +11,9 @@ import { CommandGroup, CommandItem, } from '@/components/ui/command'; -import { MagnifyingGlass, ChatCircleText, NotePencil, Folder } from '@/components/ui/icon'; +import { ChatCircleText, NotePencil, Folder, FolderOpen, File, UserCircle, Sparkle, Wrench, CaretDown, CaretRight } from '@/components/ui/icon'; import type { IconComponent } from '@/types'; +import type { TranslationKey } from '@/i18n'; interface SearchResultSession { type: 'session'; @@ -30,6 +31,7 @@ interface SearchResultMessage { role: 'user' | 'assistant'; snippet: string; createdAt: string; + contentType: 'user' | 'assistant' | 'tool'; } interface SearchResultFile { @@ -38,6 +40,7 @@ interface SearchResultFile { sessionTitle: string; path: string; name: string; + nodeType: 'file' | 'directory'; } interface SearchResponse { @@ -57,10 +60,16 @@ const TYPE_ICONS: Record = { files: Folder, }; -const TYPE_LABELS: Record = { - sessions: 'Sessions', - messages: 'Messages', - files: 'Files', +const TYPE_LABEL_KEYS: Record = { + sessions: 'globalSearch.sessions', + messages: 'globalSearch.messages', + files: 'globalSearch.files', +}; + +const CONTENT_TYPE_ICONS: Record = { + user: UserCircle, + assistant: Sparkle, + tool: Wrench, }; export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogProps) { @@ -69,9 +78,12 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [results, setResults] = useState({ sessions: [], messages: [], files: [] }); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const abortRef = useRef(null); + const composingRef = useRef(false); const performSearch = useCallback(async (q: string) => { + if (composingRef.current) return; if (abortRef.current) { abortRef.current.abort(); } @@ -116,22 +128,35 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro if (!open) { setQuery(''); setResults({ sessions: [], messages: [], files: [] }); + setCollapsedGroups(new Set()); } }, [open]); + const toggleGroup = useCallback((sessionId: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + const handleSelect = useCallback( (item: SearchResultSession | SearchResultMessage | SearchResultFile) => { onOpenChange(false); + const qParam = query.trim() ? `&q=${encodeURIComponent(query.trim())}` : ''; if (item.type === 'session') { router.push(`/chat/${item.id}`); } else if (item.type === 'message') { - router.push(`/chat/${item.sessionId}`); + router.push(`/chat/${item.sessionId}?message=${item.messageId}${qParam}`); } else if (item.type === 'file') { - // For files, navigate to the session and let the file tree show it - router.push(`/chat/${item.sessionId}`); + router.push(`/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}${qParam}`); } }, - [router, onOpenChange], + [router, onOpenChange, query], ); const hasResults = @@ -139,43 +164,71 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro results.messages.length > 0 || results.files.length > 0; + const groupedMessages = useMemo(() => { + const groups: Record = {}; + for (const msg of results.messages) { + if (!groups[msg.sessionId]) { + groups[msg.sessionId] = { sessionTitle: msg.sessionTitle, messages: [] }; + } + groups[msg.sessionId].messages.push(msg); + } + return Object.values(groups); + }, [results.messages]); + + const renderHighlightedSnippet = (snippet: string, searchTerm: string) => { + if (!searchTerm) return {snippet}; + const lowerSnippet = snippet.toLowerCase(); + const lowerTerm = searchTerm.toLowerCase(); + const idx = lowerSnippet.indexOf(lowerTerm); + if (idx === -1) return {snippet}; + return ( + + {snippet.slice(0, idx)} + + {snippet.slice(idx, idx + searchTerm.length)} + + {snippet.slice(idx + searchTerm.length)} + + ); + }; + const renderGroup = ( key: keyof SearchResponse, - items: (SearchResultSession | SearchResultMessage | SearchResultFile)[], + items: (SearchResultSession | SearchResultFile)[], ) => { if (items.length === 0) return null; const Icon = TYPE_ICONS[key]; return ( - + {items.map((item, idx) => ( handleSelect(item)} className="flex items-start gap-2 py-2" > - + {item.type === 'file' ? ( + item.nodeType === 'directory' ? ( + + ) : ( + + ) + ) : ( + + )}
{item.type === 'session' && ( <> -

{item.title}

+

{item.title}

{item.projectName && ( -

{item.projectName}

+

{item.projectName}

)} )} - {item.type === 'message' && ( - <> -

- {item.sessionTitle} · {item.role === 'user' ? 'User' : 'Assistant'} -

-

{item.snippet}

- - )} {item.type === 'file' && ( <> -

{item.name}

-

{item.sessionTitle}

+

{item.name}

+

{item.sessionTitle}

)}
@@ -191,34 +244,102 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro onOpenChange={onOpenChange} title="Global Search" description="Search across sessions, messages, and files" - className="sm:max-w-lg" + className="sm:max-w-3xl flex flex-col overflow-hidden overflow-y-hidden" showCloseButton={false} + shouldFilter={false} > { composingRef.current = true; }} + onCompositionEnd={(e) => { + composingRef.current = false; + const value = (e.target as HTMLInputElement).value; + setQuery(value); + performSearch(value); + }} /> - + {!query && !loading && (
-

Type to search across sessions and messages

+

{t('globalSearch.hint')}

- Prefix with sessions:{' '} - messages:{' '} - files: to narrow scope + {t('globalSearch.hintPrefix')}{' '} + session:{' '} + message:{' '} + file:{' '} + {t('globalSearch.toNarrowScope')}

)} {query && !loading && !hasResults && ( - No results found + {t('globalSearch.noResults')} )} {renderGroup('sessions', results.sessions)} - {renderGroup('messages', results.messages)} + + {groupedMessages.map((group, groupIdx) => { + const isCollapsed = collapsedGroups.has(group.messages[0]?.sessionId || `group-${groupIdx}`); + const sessionId = group.messages[0]?.sessionId || `group-${groupIdx}`; + return ( + { + e.preventDefault(); + e.stopPropagation(); + toggleGroup(sessionId); + }} + className="flex w-full items-center gap-1.5 py-1 text-left outline-none" + > + {isCollapsed ? ( + + ) : ( + + )} + + + {group.sessionTitle.replace(/\n/g, ' ')} + + + {group.messages.length} + + + } + > + {!isCollapsed && group.messages.map((item, idx) => { + const Icon = CONTENT_TYPE_ICONS[item.contentType]; + const labelKey: TranslationKey = + item.contentType === 'user' + ? 'messageList.userLabel' + : item.contentType === 'tool' + ? ('globalSearch.toolLabel' as TranslationKey) + : 'messageList.assistantLabel'; + return ( + handleSelect(item)} + className="flex items-start gap-2 py-2" + > + +
+

{renderHighlightedSnippet(item.snippet, query)}

+

{t(labelKey)}

+
+
+ ); + })} +
+ ); + })} + {renderGroup('files', results.files)} {loading && ( -
Searching...
+
{t('globalSearch.searching')}
)}
diff --git a/src/components/layout/panels/FileTreePanel.tsx b/src/components/layout/panels/FileTreePanel.tsx index 20905353..be94f0d0 100644 --- a/src/components/layout/panels/FileTreePanel.tsx +++ b/src/components/layout/panels/FileTreePanel.tsx @@ -18,6 +18,10 @@ export function FileTreePanel() { const { t } = useTranslation(); const [width, setWidth] = useState(TREE_DEFAULT_WIDTH); + const highlightPath = typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('file') || undefined + : undefined; + const handleResize = useCallback((delta: number) => { setWidth((w) => Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, w - delta))); }, []); @@ -84,6 +88,7 @@ export function FileTreePanel() { workingDirectory={workingDirectory} onFileSelect={handleFileSelect} onFileAdd={handleFileAdd} + highlightPath={highlightPath} /> diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 7dfe896a..155e4a2a 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { ArrowsClockwise, MagnifyingGlass, FileCode, Code, File } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,6 +18,7 @@ interface FileTreeProps { workingDirectory: string; onFileSelect: (path: string) => void; onFileAdd?: (path: string) => void; + highlightPath?: string; } function getFileIcon(extension?: string): ReactNode { @@ -77,27 +78,37 @@ function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { })); } -function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) { +function RenderTreeNodes({ nodes, searchQuery, highlightPath }: { nodes: FileTreeNode[]; searchQuery: string; highlightPath?: string }) { const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes; return ( <> {filtered.map((node) => { if (node.type === "directory") { + const isHighlighted = node.path === highlightPath; return ( - + {node.children && ( - + )} ); } + const isHighlighted = node.path === highlightPath; return ( ); })} @@ -105,13 +116,26 @@ function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; search ); } -export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTreeProps) { +function getParentPaths(filePath: string): string[] { + const parents: string[] = []; + let current = filePath; + while (true) { + const parent = current.substring(0, current.lastIndexOf('/')); + if (!parent || parent === current) break; + parents.push(parent); + current = parent; + } + return parents; +} + +export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightPath }: FileTreeProps) { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const abortRef = useRef(null); const { t } = useTranslation(); + const hasFlashedRef = useRef(false); const fetchTree = useCallback(async () => { // Always cancel in-flight request first — even when clearing directory, @@ -179,8 +203,30 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree return () => window.removeEventListener('refresh-file-tree', handler); }, [fetchTree]); - // Default to all directories collapsed - const defaultExpanded = new Set(); + // Scroll to and flash highlighted file from search results + useEffect(() => { + if (!highlightPath || hasFlashedRef.current) return; + const timer = setTimeout(() => { + const el = document.getElementById('file-tree-highlight'); + if (el) { + hasFlashedRef.current = true; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 300); + return () => clearTimeout(timer); + }, [highlightPath, tree, loading]); + + // Default to all directories collapsed; expand parents and the target itself + const defaultExpanded = useMemo(() => { + const expanded = new Set(); + if (highlightPath) { + for (const parent of getParentPaths(highlightPath)) { + expanded.add(parent); + } + expanded.add(highlightPath); + } + return expanded; + }, [highlightPath]); return (
@@ -225,7 +271,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree onAdd={onFileAdd} className="border-0 rounded-none" > - + )}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 1012b0c6..fb8b4212 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,12 +35,14 @@ function CommandDialog({ children, className, showCloseButton = true, + shouldFilter, ...props }: React.ComponentProps & { title?: string description?: string className?: string showCloseButton?: boolean + shouldFilter?: boolean }) { return ( @@ -52,7 +54,10 @@ function CommandDialog({ className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > - + {children} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cbb1495a..aad71f98 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -39,11 +39,25 @@ const en = { 'chatList.showMore': 'Show {count} more', 'chatList.showLess': 'Show less', + // ── Global search ─────────────────────────────────────────── + 'globalSearch.placeholder': 'Search... (try session:, message:, file:)', + 'globalSearch.hint': 'Type to search across sessions and messages', + 'globalSearch.hintPrefix': 'Prefix with', + 'globalSearch.toNarrowScope': 'to narrow scope', + 'globalSearch.noResults': 'No results found', + 'globalSearch.searching': 'Searching...', + 'globalSearch.sessions': 'Sessions', + 'globalSearch.messages': 'Messages', + 'globalSearch.files': 'Files', + 'globalSearch.toolLabel': 'Tool', + // ── Message list ──────────────────────────────────────────── 'messageList.claudeChat': 'CodePilot Chat', 'messageList.emptyDescription': 'Start a conversation with CodePilot. Ask questions, get help with code, or explore ideas.', 'messageList.loadEarlier': 'Load earlier messages', 'messageList.loading': 'Loading...', + 'messageList.userLabel': 'User', + 'messageList.assistantLabel': 'Assistant', // ── Message input ─────────────────────────────────────────── 'messageInput.attachFiles': 'Attach files', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3176aae5..373c83f2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,11 +36,25 @@ const zh: Record = { 'chatList.showMore': '展开更多({count} 条)', 'chatList.showLess': '收起', + // ── Global search ─────────────────────────────────────────── + 'globalSearch.placeholder': '搜索...(尝试 session: / message: / file:)', + 'globalSearch.hint': '输入关键词搜索会话和消息', + 'globalSearch.hintPrefix': '使用前缀', + 'globalSearch.toNarrowScope': '限定搜索范围', + 'globalSearch.noResults': '未找到结果', + 'globalSearch.searching': '搜索中...', + 'globalSearch.sessions': '会话', + 'globalSearch.messages': '消息', + 'globalSearch.files': '文件', + 'globalSearch.toolLabel': '工具', + // ── Message list ──────────────────────────────────────────── 'messageList.claudeChat': 'CodePilot 对话', 'messageList.emptyDescription': '开始与 CodePilot 对话。提问、获取代码帮助或探索想法。', 'messageList.loadEarlier': '加载更早的消息', 'messageList.loading': '加载中...', + 'messageList.userLabel': '用户', + 'messageList.assistantLabel': '助手', // ── Message input ─────────────────────────────────────────── 'messageInput.attachFiles': '附加文件', diff --git a/src/lib/db.ts b/src/lib/db.ts index 3dda10a8..c98d8119 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1203,6 +1203,8 @@ export interface SessionSearchResult { createdAt: string; /** Snippet extracted from content with query context (up to ~200 chars). */ snippet: string; + /** Derived message type for search UI icons/filtering. */ + contentType: 'user' | 'assistant' | 'tool'; } /** @@ -1282,10 +1284,26 @@ export function searchMessages( role: row.role, createdAt: row.createdAt, snippet: buildSnippet(row.content, lowerQuery), + contentType: deriveContentType(row.role, row.content), })); } -/** Extract a ~200-char snippet around the first match (case-insensitive). */ +function deriveContentType(role: 'user' | 'assistant', content: string): 'user' | 'assistant' | 'tool' { + if (role === 'user') return 'user'; + try { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + if (parsed.some((b: unknown) => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'tool_use')) { + return 'tool'; + } + } + } catch { + // fallback to plain text assistant + } + return 'assistant'; +} + +/** Extract a ~140-char snippet with the match near the front so it survives single-line truncation in UI lists. */ function buildSnippet(content: string, lowerQuery: string): string { if (!content) return ''; const lowerContent = content.toLowerCase(); @@ -1295,8 +1313,10 @@ function buildSnippet(content: string, lowerQuery: string): string { // and the query matches bytes inside quoted strings. return content.length > 200 ? content.slice(0, 200) + '…' : content; } - const start = Math.max(0, idx - 80); - const end = Math.min(content.length, idx + lowerQuery.length + 120); + const LEADING = 28; + const TAIL = 100; + const start = Math.max(0, idx - LEADING); + const end = Math.min(content.length, idx + lowerQuery.length + TAIL); const prefix = start > 0 ? '…' : ''; const suffix = end < content.length ? '…' : ''; return prefix + content.slice(start, end) + suffix; From 839955ec3e401a4bb0ef53427a4ba08fb4361927 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:30:42 +0800 Subject: [PATCH 09/17] fix(search): stabilize dialog height and pin input at top Give CommandDialog a fixed height (h-[60vh] max-h-[600px]) so the overall dialog no longer expands and contracts as results appear. Remove max-h from CommandList and let it fill remaining space with flex-1, so only the result list scrolls while the input stays put. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/GlobalSearchDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index 987a8025..451ff901 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -244,7 +244,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro onOpenChange={onOpenChange} title="Global Search" description="Search across sessions, messages, and files" - className="sm:max-w-3xl flex flex-col overflow-hidden overflow-y-hidden" + className="sm:max-w-3xl sm:h-[520px] h-[80vh] flex flex-col overflow-hidden" showCloseButton={false} shouldFilter={false} > @@ -261,7 +261,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro performSearch(value); }} /> - + {!query && !loading && (

{t('globalSearch.hint')}

From 4e17f5983f298ef6b91daff3d3dfd389ea0dd62a Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:44:09 +0800 Subject: [PATCH 10/17] fix(search): responsive dialog height, remove bottom mask, fix a11y warning - Use h-[min(80vh,520px)] for smooth viewport scaling instead of breakpoint-based hard switch - Override CommandList default max-h-[300px] with max-h-none so results fill the entire dialog and the bottom white area is gone - Replace +
} > {!isCollapsed && group.messages.map((item, idx) => { From 2ca4c4d3c2c813a5d6604064d641d5827c6c815d Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:47:02 +0800 Subject: [PATCH 11/17] feat(search): add subtle background and emphasis to message group headings Apply bg-muted/40, rounded corners, and font-medium text-foreground to session-level message group headers so they visually separate from individual message items and create clearer hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/GlobalSearchDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index 959f7d5c..ba2d9a91 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -292,7 +292,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro e.stopPropagation(); toggleGroup(sessionId); }} - className="flex w-full cursor-pointer items-center gap-1.5 py-1 text-left outline-none" + className="flex w-full cursor-pointer items-center gap-1.5 rounded bg-muted/40 px-1 py-1 text-left font-medium text-foreground outline-none" > {isCollapsed ? ( From b675d2a298c916ae3c1f9b3179cb4d6733287e53 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:57:42 +0800 Subject: [PATCH 12/17] fix(file-tree): make search-driven expansion controlled and wait for animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch AIFileTree from defaultExpanded to controlled expanded so that changing highlightPath actually opens parent folders in real time. Add polling (100ms × 15) instead of a single setTimeout so the scroll-to-highlight waits for Collapsible animation to finish. Reset flash tracker on highlightPath change to avoid stale state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/project/FileTree.tsx | 53 +++++++++++++++++++---------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 155e4a2a..d0991b48 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { ArrowsClockwise, MagnifyingGlass, FileCode, Code, File } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -203,31 +203,47 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP return () => window.removeEventListener('refresh-file-tree', handler); }, [fetchTree]); + // Controlled expansion state for search-driven highlighting + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + + // Sync expanded paths when highlightPath changes + useEffect(() => { + if (highlightPath) { + const next = new Set(); + for (const parent of getParentPaths(highlightPath)) { + next.add(parent); + } + next.add(highlightPath); + setExpandedPaths(next); + } else { + setExpandedPaths(new Set()); + } + }, [highlightPath]); + + // Reset flash tracker when highlightPath changes + useEffect(() => { + hasFlashedRef.current = false; + }, [highlightPath]); + // Scroll to and flash highlighted file from search results useEffect(() => { - if (!highlightPath || hasFlashedRef.current) return; - const timer = setTimeout(() => { + if (!highlightPath || hasFlashedRef.current || tree.length === 0) return; + let attempts = 0; + const maxAttempts = 15; + const interval = setInterval(() => { + attempts++; const el = document.getElementById('file-tree-highlight'); if (el) { hasFlashedRef.current = true; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + clearInterval(interval); + } else if (attempts >= maxAttempts) { + clearInterval(interval); } - }, 300); - return () => clearTimeout(timer); + }, 100); + return () => clearInterval(interval); }, [highlightPath, tree, loading]); - // Default to all directories collapsed; expand parents and the target itself - const defaultExpanded = useMemo(() => { - const expanded = new Set(); - if (highlightPath) { - for (const parent of getParentPaths(highlightPath)) { - expanded.add(parent); - } - expanded.add(highlightPath); - } - return expanded; - }, [highlightPath]); - return (
{/* Search + Refresh */} @@ -265,7 +281,8 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP

) : ( Date: Wed, 15 Apr 2026 21:03:09 +0800 Subject: [PATCH 13/17] fix(file-tree): prevent repeated scroll hijacking after tree auto-refreshes Replace the global hasFlashedRef flag with a seekKeyRef tied to the specific highlightPath. This stops the polling interval from restarting whenever the file tree auto-refreshes (e.g. after streaming ends), which was causing users to be snapped back to the highlighted file while they were manually scrolling. Also removes the unnecessary loading dependency from the scroll effect. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/project/FileTree.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index d0991b48..86668466 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -135,7 +135,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP const [searchQuery, setSearchQuery] = useState(""); const abortRef = useRef(null); const { t } = useTranslation(); - const hasFlashedRef = useRef(false); + const seekKeyRef = useRef(null); const fetchTree = useCallback(async () => { // Always cancel in-flight request first — even when clearing directory, @@ -220,21 +220,19 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP } }, [highlightPath]); - // Reset flash tracker when highlightPath changes + // Scroll to and flash highlighted file from search results. + // Guarded by seekKeyRef so tree auto-refreshes don't re-trigger the scroll. useEffect(() => { - hasFlashedRef.current = false; - }, [highlightPath]); + if (!highlightPath || tree.length === 0) return; + if (seekKeyRef.current === highlightPath) return; + seekKeyRef.current = highlightPath; - // Scroll to and flash highlighted file from search results - useEffect(() => { - if (!highlightPath || hasFlashedRef.current || tree.length === 0) return; let attempts = 0; const maxAttempts = 15; const interval = setInterval(() => { attempts++; const el = document.getElementById('file-tree-highlight'); if (el) { - hasFlashedRef.current = true; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); clearInterval(interval); } else if (attempts >= maxAttempts) { @@ -242,7 +240,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP } }, 100); return () => clearInterval(interval); - }, [highlightPath, tree, loading]); + }, [highlightPath, tree]); return (
From 59de61717bdca133f306d14d440c24304db60968 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:33:14 +0800 Subject: [PATCH 14/17] fix(search): harden global search UX and deep-link behavior --- src/app/api/search/route.ts | 2 +- src/app/chat/[id]/page.tsx | 7 ++- src/components/layout/GlobalSearchDialog.tsx | 56 +++++++++++++------- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f16eb553..1d0c484d 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -134,7 +134,7 @@ export async function GET(request: NextRequest) { })); } - if (scope === 'files') { + if (scope === 'all' || scope === 'files') { for (const session of allSessions) { if (!session.working_directory) continue; const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH); diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 48b40dbf..c1f02824 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -139,6 +139,11 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { (async () => { try { + if (targetFilePath) { + // Preserve explicit deep-link intent from global search. + setFileTreeOpen(true); + return; + } const res = await fetch('/api/settings/app'); if (!res.ok) return; const data = await res.json(); @@ -156,7 +161,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setFileTreeOpen(true); } })(); - }, [id, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen]); + }, [id, targetFilePath, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen]); if (loading || !sessionInfoLoaded) { return ( diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index ba2d9a91..a8711440 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -81,6 +81,18 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const abortRef = useRef(null); const composingRef = useRef(false); + const normalizedQuery = query.trim(); + const searchTerm = useMemo(() => { + const trimmed = query.trim(); + const lower = trimmed.toLowerCase(); + if (lower.startsWith('session:')) return trimmed.slice(8).trim(); + if (lower.startsWith('sessions:')) return trimmed.slice(9).trim(); + if (lower.startsWith('message:')) return trimmed.slice(8).trim(); + if (lower.startsWith('messages:')) return trimmed.slice(9).trim(); + if (lower.startsWith('file:')) return trimmed.slice(5).trim(); + if (lower.startsWith('files:')) return trimmed.slice(6).trim(); + return trimmed; + }, [query]); const performSearch = useCallback(async (q: string) => { if (composingRef.current) return; @@ -88,6 +100,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro abortRef.current.abort(); } if (!q.trim()) { + abortRef.current = null; setResults({ sessions: [], messages: [], files: [] }); setLoading(false); return; @@ -112,6 +125,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro } } finally { if (!controller.signal.aborted) { + abortRef.current = null; setLoading(false); } } @@ -126,12 +140,21 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro useEffect(() => { if (!open) { + abortRef.current?.abort(); + abortRef.current = null; setQuery(''); setResults({ sessions: [], messages: [], files: [] }); setCollapsedGroups(new Set()); + setLoading(false); } }, [open]); + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + const toggleGroup = useCallback((sessionId: string) => { setCollapsedGroups(prev => { const next = new Set(prev); @@ -258,7 +281,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro composingRef.current = false; const value = (e.target as HTMLInputElement).value; setQuery(value); - performSearch(value); }} /> @@ -274,26 +296,23 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro

)} - {query && !loading && !hasResults && ( + {normalizedQuery && !loading && !hasResults && ( {t('globalSearch.noResults')} )} - {renderGroup('sessions', results.sessions)} + {normalizedQuery && renderGroup('sessions', results.sessions)} - {groupedMessages.map((group, groupIdx) => { + {normalizedQuery && groupedMessages.map((group, groupIdx) => { const isCollapsed = collapsedGroups.has(group.messages[0]?.sessionId || `group-${groupIdx}`); const sessionId = group.messages[0]?.sessionId || `group-${groupIdx}`; return ( - { - e.preventDefault(); - e.stopPropagation(); - toggleGroup(sessionId); - }} - className="flex w-full cursor-pointer items-center gap-1.5 rounded bg-muted/40 px-1 py-1 text-left font-medium text-foreground outline-none" - > + + toggleGroup(sessionId)} + className="flex w-full items-center gap-1.5 rounded bg-muted/40 px-1 py-1 text-left font-medium text-foreground" + aria-expanded={!isCollapsed} + > +
{isCollapsed ? ( ) : ( @@ -307,8 +326,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro {group.messages.length}
- } - > +
{!isCollapsed && group.messages.map((item, idx) => { const Icon = CONTENT_TYPE_ICONS[item.contentType]; const labelKey: TranslationKey = @@ -326,7 +344,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro >
-

{renderHighlightedSnippet(item.snippet, query)}

+

{renderHighlightedSnippet(item.snippet, searchTerm)}

{t(labelKey)}

@@ -336,7 +354,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro ); })} - {renderGroup('files', results.files)} + {normalizedQuery && renderGroup('files', results.files)} {loading && (
{t('globalSearch.searching')}
)} From 80a2b6a5cefbc7bfa2e0d8bd6ef793e6dcfb8a9f Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:41:39 +0800 Subject: [PATCH 15/17] fix(search): make repeated file deep-link seeking reliable - add seek token on file result navigation - use reactive search params in chat/file-tree panels - key file-tree seeking by path+seek token - degrade update API to no-update payload on upstream failures --- src/app/api/app/updates/route.ts | 30 ++++++++++++++----- src/app/chat/[id]/page.tsx | 6 ++-- src/components/layout/GlobalSearchDialog.tsx | 3 +- .../layout/panels/FileTreePanel.tsx | 8 +++-- src/components/project/FileTree.tsx | 12 ++++---- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/app/api/app/updates/route.ts b/src/app/api/app/updates/route.ts index 19c34881..aca63e71 100644 --- a/src/app/api/app/updates/route.ts +++ b/src/app/api/app/updates/route.ts @@ -4,6 +4,24 @@ import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-r const GITHUB_REPO = "op7418/CodePilot"; +function noUpdatePayload(currentVersion: string, runtimeInfo: ReturnType) { + return { + latestVersion: currentVersion, + currentVersion, + updateAvailable: false, + releaseName: "", + releaseNotes: "", + publishedAt: "", + releaseUrl: "", + downloadUrl: "", + downloadAssetName: "", + detectedPlatform: runtimeInfo.platform, + detectedArch: runtimeInfo.processArch, + hostArch: runtimeInfo.hostArch, + runningUnderRosetta: runtimeInfo.runningUnderRosetta, + }; +} + function compareSemver(a: string, b: string): number { const pa = a.replace(/^v/, "").split(".").map(Number); const pb = b.replace(/^v/, "").split(".").map(Number); @@ -28,10 +46,7 @@ export async function GET() { ); if (!res.ok) { - return NextResponse.json( - { error: "Failed to fetch release info" }, - { status: 502 } - ); + return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo)); } const release = await res.json(); @@ -58,9 +73,8 @@ export async function GET() { runningUnderRosetta: runtimeInfo.runningUnderRosetta, }); } catch { - return NextResponse.json( - { error: "Failed to check for updates" }, - { status: 500 } - ); + const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; + const runtimeInfo = getRuntimeArchitectureInfo(); + return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo)); } } diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index c1f02824..d978a9e9 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, use } from 'react'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import type { Message, MessagesResponse, ChatSession } from '@/types'; import { ChatView } from '@/components/chat/ChatView'; import { SpinnerGap } from "@/components/ui/icon"; @@ -14,6 +15,7 @@ interface ChatSessionPageProps { export default function ChatSessionPage({ params }: ChatSessionPageProps) { const { id } = use(params); + const searchParams = useSearchParams(); const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); @@ -25,9 +27,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionMode, setSessionMode] = useState<'code' | 'plan'>('code'); const [sessionHasSummary, setSessionHasSummary] = useState(false); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen } = usePanel(); - const targetFilePath = typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('file') || undefined - : undefined; + const targetFilePath = searchParams.get('file') || undefined; const { t } = useTranslation(); const defaultPanelAppliedRef = useRef(false); diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index a8711440..60c7fbf1 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -176,7 +176,8 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro } else if (item.type === 'message') { router.push(`/chat/${item.sessionId}?message=${item.messageId}${qParam}`); } else if (item.type === 'file') { - router.push(`/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}${qParam}`); + const seek = Date.now().toString(36); + router.push(`/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}&seek=${seek}${qParam}`); } }, [router, onOpenChange, query], diff --git a/src/components/layout/panels/FileTreePanel.tsx b/src/components/layout/panels/FileTreePanel.tsx index be94f0d0..c0fdd9c7 100644 --- a/src/components/layout/panels/FileTreePanel.tsx +++ b/src/components/layout/panels/FileTreePanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { X } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { usePanel } from "@/hooks/usePanel"; @@ -16,11 +17,11 @@ const TREE_DEFAULT_WIDTH = 280; export function FileTreePanel() { const { workingDirectory, sessionId, previewFile, setPreviewFile, setPreviewOpen, setFileTreeOpen } = usePanel(); const { t } = useTranslation(); + const searchParams = useSearchParams(); const [width, setWidth] = useState(TREE_DEFAULT_WIDTH); - const highlightPath = typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('file') || undefined - : undefined; + const highlightPath = searchParams.get('file') || undefined; + const highlightSeek = searchParams.get('seek') || undefined; const handleResize = useCallback((delta: number) => { setWidth((w) => Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, w - delta))); @@ -89,6 +90,7 @@ export function FileTreePanel() { onFileSelect={handleFileSelect} onFileAdd={handleFileAdd} highlightPath={highlightPath} + highlightSeek={highlightSeek} />
diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 86668466..04abc05f 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -19,6 +19,7 @@ interface FileTreeProps { onFileSelect: (path: string) => void; onFileAdd?: (path: string) => void; highlightPath?: string; + highlightSeek?: string; } function getFileIcon(extension?: string): ReactNode { @@ -128,7 +129,7 @@ function getParentPaths(filePath: string): string[] { return parents; } -export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightPath }: FileTreeProps) { +export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightPath, highlightSeek }: FileTreeProps) { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -218,14 +219,15 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP } else { setExpandedPaths(new Set()); } - }, [highlightPath]); + }, [highlightPath, highlightSeek]); // Scroll to and flash highlighted file from search results. // Guarded by seekKeyRef so tree auto-refreshes don't re-trigger the scroll. useEffect(() => { if (!highlightPath || tree.length === 0) return; - if (seekKeyRef.current === highlightPath) return; - seekKeyRef.current = highlightPath; + const seekTargetKey = `${highlightPath}::${highlightSeek || ''}`; + if (seekKeyRef.current === seekTargetKey) return; + seekKeyRef.current = seekTargetKey; let attempts = 0; const maxAttempts = 15; @@ -240,7 +242,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP } }, 100); return () => clearInterval(interval); - }, [highlightPath, tree]); + }, [highlightPath, highlightSeek, tree]); return (
From f8ad12c6e3d4ec4c1e2075e2b662fd3eacad96ad Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:46:21 +0800 Subject: [PATCH 16/17] fix(file-tree): harden cross-session deep-link seek behavior - avoid consuming seek key before target is found - include workingDirectory in seek key - clear stale tree state on project switch - add Playwright regression for repeated + cross-session file seeks --- .../e2e/global-search-file-seek.spec.ts | 61 +++++++++++++++++++ src/components/project/FileTree.tsx | 15 +++-- 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/e2e/global-search-file-seek.spec.ts diff --git a/src/__tests__/e2e/global-search-file-seek.spec.ts b/src/__tests__/e2e/global-search-file-seek.spec.ts new file mode 100644 index 00000000..4f3ea5f6 --- /dev/null +++ b/src/__tests__/e2e/global-search-file-seek.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +async function createSession(page: Page, title: string, workingDirectory: string) { + const res = await page.request.post('/api/chat/sessions', { + data: { title, working_directory: workingDirectory }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + return data.session.id as string; +} + +test.describe('Global Search file deep-link seek UX', () => { + test('same-session repeat seek and cross-session seek both locate target file', async ({ page }) => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const rootA = path.join(os.tmpdir(), `codepilot-search-a-${suffix}`); + const rootB = path.join(os.tmpdir(), `codepilot-search-b-${suffix}`); + const fileA = path.join(rootA, 'src', 'feature-a', 'target-a.ts'); + const fileB = path.join(rootB, 'src', 'feature-b', 'target-b.ts'); + + await fs.mkdir(path.dirname(fileA), { recursive: true }); + await fs.mkdir(path.dirname(fileB), { recursive: true }); + await fs.writeFile(fileA, 'export const targetA = 1;\n', 'utf8'); + await fs.writeFile(fileB, 'export const targetB = 2;\n', 'utf8'); + + // Add filler files to make vertical scrolling observable. + for (let i = 0; i < 120; i++) { + const fillerA = path.join(rootA, 'src', `filler-a-${String(i).padStart(3, '0')}.ts`); + const fillerB = path.join(rootB, 'src', `filler-b-${String(i).padStart(3, '0')}.ts`); + await fs.writeFile(fillerA, `export const a${i} = ${i};\n`, 'utf8'); + await fs.writeFile(fillerB, `export const b${i} = ${i};\n`, 'utf8'); + } + + const sessionA = await createSession(page, `E2E Search Session A ${suffix}`, rootA); + const sessionB = await createSession(page, `E2E Search Session B ${suffix}`, rootB); + + try { + // 1) First locate in session A. + await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek1`); + const panel = page.locator('div[style*="width: 280"]'); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 }); + + // 2) Re-seek same file in same session; should remain stable and highlighted. + await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek2`); + await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 }); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?`)); + await expect(page).toHaveURL(/seek=seek2/); + + // 3) Cross-session locate should still work after previous seeks. + await page.goto(`/chat/${sessionB}?file=${encodeURIComponent(fileB)}&seek=seek3`); + await expect(page.locator('#file-tree-highlight')).toContainText('target-b.ts', { timeout: 15_000 }); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?`)); + } finally { + await fs.rm(rootA, { recursive: true, force: true }); + await fs.rm(rootB, { recursive: true, force: true }); + } + }); +}); diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 04abc05f..a7a25dbb 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -138,6 +138,13 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP const { t } = useTranslation(); const seekKeyRef = useRef(null); + // Clear stale tree data when switching projects to avoid cross-session seek races. + useEffect(() => { + setTree([]); + setError(null); + seekKeyRef.current = null; + }, [workingDirectory]); + const fetchTree = useCallback(async () => { // Always cancel in-flight request first — even when clearing directory, // otherwise a stale response from the old project can arrive and repopulate the tree. @@ -224,10 +231,9 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP // Scroll to and flash highlighted file from search results. // Guarded by seekKeyRef so tree auto-refreshes don't re-trigger the scroll. useEffect(() => { - if (!highlightPath || tree.length === 0) return; - const seekTargetKey = `${highlightPath}::${highlightSeek || ''}`; + if (!workingDirectory || !highlightPath || tree.length === 0) return; + const seekTargetKey = `${workingDirectory}::${highlightPath}::${highlightSeek || ''}`; if (seekKeyRef.current === seekTargetKey) return; - seekKeyRef.current = seekTargetKey; let attempts = 0; const maxAttempts = 15; @@ -236,13 +242,14 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightP const el = document.getElementById('file-tree-highlight'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + seekKeyRef.current = seekTargetKey; clearInterval(interval); } else if (attempts >= maxAttempts) { clearInterval(interval); } }, 100); return () => clearInterval(interval); - }, [highlightPath, highlightSeek, tree]); + }, [workingDirectory, highlightPath, highlightSeek, tree]); return (
From 4b849e6e5cc76533a5fc06c2bebe86854c95e2e4 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:54:24 +0800 Subject: [PATCH 17/17] test(search): add Playwright coverage for multi-mode global search UX - cover all/session/message/file search modes - seed sessions/messages/files deterministically - make Cmd/Ctrl+K open global search even while editing --- src/__tests__/e2e/global-search-modes.spec.ts | 99 +++++++++++++++++++ src/hooks/useGlobalSearchShortcut.ts | 14 +-- 2 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/e2e/global-search-modes.spec.ts diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts new file mode 100644 index 00000000..74d30f80 --- /dev/null +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import Database from 'better-sqlite3'; + +function getDbPath() { + const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'); + return path.join(dataDir, 'codepilot.db'); +} + +function addMessage(sessionId: string, role: 'user' | 'assistant', content: string) { + const db = new Database(getDbPath()); + try { + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + 'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)' + ).run(id, sessionId, role, content, now, null); + db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId); + } finally { + db.close(); + } +} + +async function createSession(page: Page, title: string, workingDirectory: string) { + const res = await page.request.post('/api/chat/sessions', { + data: { title, working_directory: workingDirectory }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + return data.session.id as string; +} + +test.describe('Global Search modes UX', () => { + test('supports all/session/message/file modes and keyboard open', async ({ page }) => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`); + const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`); + const fileNameA = `alpha-${suffix}.ts`; + const filePathA = path.join(rootA, 'src', fileNameA); + const sessionTitleA = `Search Session Alpha ${suffix}`; + const sessionTitleB = `Search Session Beta ${suffix}`; + const messageTokenA = `message-token-alpha-${suffix}`; + const messageTokenB = `message-token-beta-${suffix}`; + + await fs.mkdir(path.dirname(filePathA), { recursive: true }); + await fs.mkdir(rootB, { recursive: true }); + await fs.writeFile(filePathA, 'export const alpha = true;\n', 'utf8'); + + const sessionA = await createSession(page, sessionTitleA, rootA); + const sessionB = await createSession(page, sessionTitleB, rootB); + addMessage(sessionA, 'user', `User says ${messageTokenA}`); + addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`); + + const searchInput = page.locator( + 'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]' + ).first(); + + try { + await page.goto(`/chat/${sessionA}`); + + // Open global search from the sidebar trigger (language-agnostic fallback). + await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + + // Default all-mode can find sessions, messages and files. + await searchInput.fill(suffix); + await expect(page.getByText(sessionTitleA).first()).toBeVisible(); + await expect(page.getByText(fileNameA).first()).toBeVisible(); + await expect(page.getByText(messageTokenA).first()).toBeVisible(); + + // session: prefix narrows to session result. + await searchInput.fill(`session:${sessionTitleA}`); + await expect(page.getByText(sessionTitleA).first()).toBeVisible(); + await expect(page.getByText(fileNameA)).toHaveCount(0); + + // message: prefix narrows to message snippets and supports navigation to target session. + await searchInput.fill(`message:${messageTokenB}`); + await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 }); + await page.getByText(messageTokenB).first().click(); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 }); + + // Re-open and verify file: prefix still works in the same UX flow. + await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill(`file:${fileNameA}`); + await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 }); + await page.getByText(fileNameA).first().click(); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 }); + } finally { + await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {}); + await page.request.delete(`/api/chat/sessions/${sessionB}`, { timeout: 5_000 }).catch(() => {}); + await fs.rm(rootA, { recursive: true, force: true }); + await fs.rm(rootB, { recursive: true, force: true }); + } + }); +}); diff --git a/src/hooks/useGlobalSearchShortcut.ts b/src/hooks/useGlobalSearchShortcut.ts index 2f06e0a9..3dd58056 100644 --- a/src/hooks/useGlobalSearchShortcut.ts +++ b/src/hooks/useGlobalSearchShortcut.ts @@ -5,17 +5,9 @@ export function useGlobalSearchShortcut(onOpen: () => void) { (e: KeyboardEvent) => { const isModifier = e.metaKey || e.ctrlKey; if (isModifier && e.key.toLowerCase() === 'k') { - // Avoid intercepting when an input/textarea is focused - const active = document.activeElement; - const isEditing = - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.getAttribute('contenteditable') === 'true'; - // Still allow shortcut when focus is on body or non-editable elements - if (!isEditing) { - e.preventDefault(); - onOpen(); - } + // Global search should be reachable from anywhere, including the chat input. + e.preventDefault(); + onOpen(); } }, [onOpen],