From b9cd99e03197bfb60dacf8f28aa67c409b1e115d Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 17:31:35 +0800 Subject: [PATCH 01/14] feat: global search panel with sessions/messages/files support - Add /api/search endpoint supporting scoped queries (sessions:, messages:, files:) and default cross-dimension search - Add GlobalSearchDialog using cmdk CommandDialog with grouped results - Add Cmd/Ctrl+K shortcut via useGlobalSearchShortcut hook - Wire ChatListPanel search button to open global search - Remove legacy session-only search dialog from ChatListPanel Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/search/route.ts | 144 ++++++++++++ src/components/layout/AppShell.tsx | 13 ++ src/components/layout/ChatListPanel.tsx | 85 +------ src/components/layout/GlobalSearchDialog.tsx | 226 +++++++++++++++++++ src/hooks/useGlobalSearchShortcut.ts | 28 +++ 5 files changed, 417 insertions(+), 79 deletions(-) create mode 100644 src/app/api/search/route.ts create mode 100644 src/components/layout/GlobalSearchDialog.tsx create mode 100644 src/hooks/useGlobalSearchShortcut.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 00000000..9eaeb587 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,144 @@ +import { NextRequest } from 'next/server'; +import { getAllSessions, searchMessages } from '@/lib/db'; +import { scanDirectory } from '@/lib/files'; +import type { ChatSession, FileTreeNode } from '@/types'; + +const FILE_SCAN_DEPTH = 2; +const MAX_RESULTS_PER_TYPE = 10; + +interface SearchResultSession { + type: 'session'; + id: string; + title: string; + projectName: string; + updatedAt: string; +} + +interface SearchResultMessage { + type: 'message'; + sessionId: string; + sessionTitle: string; + messageId: string; + role: 'user' | 'assistant'; + snippet: string; + createdAt: string; +} + +interface SearchResultFile { + type: 'file'; + sessionId: string; + sessionTitle: string; + path: string; + name: string; +} + +export interface SearchResponse { + sessions: SearchResultSession[]; + messages: SearchResultMessage[]; + files: SearchResultFile[]; +} + +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() }; + } + if (trimmed.toLowerCase().startsWith('messages:')) { + return { scope: 'messages', query: trimmed.slice(9).trim() }; + } + if (trimmed.toLowerCase().startsWith('files:')) { + return { scope: 'files', query: trimmed.slice(6).trim() }; + } + return { scope: 'all', query: trimmed }; +} + +function filterSessions(sessions: ChatSession[], query: string): SearchResultSession[] { + const q = query.toLowerCase(); + return sessions + .filter( + (s) => + s.title.toLowerCase().includes(q) || + s.project_name.toLowerCase().includes(q), + ) + .slice(0, MAX_RESULTS_PER_TYPE) + .map((s) => ({ + type: 'session' as const, + id: s.id, + title: s.title, + projectName: s.project_name, + updatedAt: s.updated_at, + })); +} + +function collectFiles( + tree: FileTreeNode[], + sessionId: string, + sessionTitle: string, + query: string, + results: SearchResultFile[], +): void { + if (results.length >= MAX_RESULTS_PER_TYPE) return; + 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)) { + results.push({ + type: 'file', + sessionId, + sessionTitle, + path: node.path, + name: node.name, + }); + } + if (node.type === 'directory' && node.children) { + collectFiles(node.children, sessionId, sessionTitle, query, results); + } + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const rawQuery = searchParams.get('q') || ''; + const { scope, query } = parseQuery(rawQuery); + + if (!query) { + return Response.json({ sessions: [], messages: [], files: [] }); + } + + const allSessions = getAllSessions(); + const result: SearchResponse = { sessions: [], messages: [], files: [] }; + + if (scope === 'all' || scope === 'sessions') { + result.sessions = filterSessions(allSessions, query); + } + + if (scope === 'all' || scope === 'messages') { + const messageRows = searchMessages(query, { limit: MAX_RESULTS_PER_TYPE }); + result.messages = messageRows.map((r) => ({ + type: 'message' as const, + sessionId: r.sessionId, + sessionTitle: r.sessionTitle, + messageId: r.messageId, + role: r.role, + snippet: r.snippet, + createdAt: r.createdAt, + })); + } + + if (scope === 'files') { + 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); + if (result.files.length >= MAX_RESULTS_PER_TYPE) break; + } + } + + return Response.json(result); + } catch (error) { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error('[GET /api/search] Error:', message); + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index b58fc42e..e8bca338 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -25,6 +25,8 @@ import { useGitStatus } from "@/hooks/useGitStatus"; import { SetupCenter } from '@/components/setup/SetupCenter'; import { Toaster } from '@/components/ui/toast'; import { useNotificationPoll } from '@/hooks/useNotificationPoll'; +import { useGlobalSearchShortcut } from '@/hooks/useGlobalSearchShortcut'; +import { GlobalSearchDialog } from './GlobalSearchDialog'; const SPLIT_SESSIONS_KEY = "codepilot:split-sessions"; const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column"; @@ -76,6 +78,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [chatListOpenRaw, setChatListOpenRaw] = useState(false); const [setupOpen, setSetupOpen] = useState(false); const [setupInitialCard, setSetupInitialCard] = useState<'claude' | 'provider' | 'project' | undefined>(); + const [searchOpen, setSearchOpen] = useState(false); + + useGlobalSearchShortcut(() => setSearchOpen(true)); // Poll server-side notification queue and display as toasts useNotificationPoll(); @@ -123,6 +128,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => window.removeEventListener('hashchange', maybeOpenFromHash); }, []); + // Listen for open-global-search events from ChatListPanel + useEffect(() => { + const handler = () => setSearchOpen(true); + window.addEventListener('open-global-search', handler); + return () => window.removeEventListener('open-global-search', handler); + }, []); + // Sync with viewport after hydration to avoid SSR mismatch /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { @@ -483,6 +495,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + {setupOpen && ( setSetupOpen(false)} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 5a87ca90..6b03abfa 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -17,12 +17,7 @@ import { Gear, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; import { Tooltip, TooltipContent, @@ -68,8 +63,6 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi const [sessions, setSessions] = useState([]); const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [expandedSessionGroups, setExpandedSessionGroups] = useState>(new Set()); const SESSION_TRUNCATE_LIMIT = 10; // importDialogOpen removed — Import CLI moved to Settings @@ -376,29 +369,18 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi } }; - const isSearching = searchQuery.length > 0; - const splitSessionIds = useMemo( () => new Set(splitSessions.map((s) => s.sessionId)), [splitSessions] ); const filteredSessions = useMemo(() => { - let result = sessions; - if (searchQuery) { - result = result.filter( - (s) => - s.title.toLowerCase().includes(searchQuery.toLowerCase()) || - (s.project_name && - s.project_name.toLowerCase().includes(searchQuery.toLowerCase())) - ); - } // Exclude sessions in split group (they are shown in the split section) if (isSplitActive) { - result = result.filter((s) => !splitSessionIds.has(s.id)); + return sessions.filter((s) => !splitSessionIds.has(s.id)); } - return result; - }, [sessions, searchQuery, isSplitActive, splitSessionIds]); + return sessions; + }, [sessions, isSplitActive, splitSessionIds]); const projectGroups = useMemo(() => { const groups = groupSessionsByProject(filteredSessions); @@ -473,7 +455,7 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi variant="outline" size="icon-sm" className="h-8 w-8 shrink-0" - onClick={() => setSearchDialogOpen(true)} + onClick={() => window.dispatchEvent(new CustomEvent('open-global-search'))} > {t('chatList.searchSessions')} @@ -556,12 +538,12 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi {filteredSessions.length === 0 && (!isSplitActive || splitSessions.length === 0) ? (

- {searchQuery ? "No matching threads" : t('chatList.noSessions')} + {t('chatList.noSessions')}

) : ( projectGroups.map((group) => { const isCollapsed = - !isSearching && collapsedProjects.has(group.workingDirectory); + collapsedProjects.has(group.workingDirectory); const isFolderHovered = hoveredFolder === group.workingDirectory; @@ -698,61 +680,6 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi - {/* Search Dialog */} - { setSearchDialogOpen(open); if (!open) setSearchQuery(""); }}> - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - autoFocus - /> -
-
- {searchQuery && ( -
- {filteredSessions.length === 0 ? ( -

- {t('chatList.noSessions')} -

- ) : ( -
- {filteredSessions.slice(0, 20).map((session) => ( - - ))} -
- )} -
- )} -
-
- - {/* 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 effb5eebd74891a47f397cc7f4ec69d944a301de Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 17:41:57 +0800 Subject: [PATCH 02/14] 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 b79d5b4a45ce397aeba64691a41c09df7634f69a Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:26:41 +0800 Subject: [PATCH 03/14] 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 1c1af4a3..0a0b912c 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 b7050447..b1ad2a1a 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 c197c707f95cdca9a47af8c89ee79bf6a785a7bc Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:30:42 +0800 Subject: [PATCH 04/14] 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 1872ece1ee2a5b6f4a31612c31e7d4283a0da265 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:44:09 +0800 Subject: [PATCH 05/14] 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 f3dc93548e017b6280788dc26fe6b90839f73d1b Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:47:02 +0800 Subject: [PATCH 06/14] 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 a2ca5da12fc0520027eab962180f15e518648a5f Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 20:57:42 +0800 Subject: [PATCH 07/14] 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 08/14] 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 ee9a5e8f2c31703a843e8afe5b94ec95a9bb4740 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:33:14 +0800 Subject: [PATCH 09/14] 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 1596e17e095b93a188013f0a6f33f582af0c728e Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:41:39 +0800 Subject: [PATCH 10/14] 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 4279d5be873039c16d21e27fd12c791d49bfb89b Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:46:21 +0800 Subject: [PATCH 11/14] 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 33b223316ba0b8506e62f2d183ac20661d669027 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Wed, 15 Apr 2026 21:54:24 +0800 Subject: [PATCH 12/14] 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], From 1d49f1331495b4b86a3ef9c44118020fe7b7fc5e Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Thu, 16 Apr 2026 09:23:24 +0800 Subject: [PATCH 13/14] feat(search): highlight active prefix scope in global search --- src/__tests__/e2e/global-search-modes.spec.ts | 2 + src/components/layout/GlobalSearchDialog.tsx | 42 +++++++++++++++---- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts index 74d30f80..2c6d764c 100644 --- a/src/__tests__/e2e/global-search-modes.spec.ts +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -86,6 +86,8 @@ test.describe('Global Search modes UX', () => { 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(/(Searching in|当前搜索范围)/)).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('file:')).toBeVisible({ timeout: 10_000 }); 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 }); diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index 60c7fbf1..aad9e36e 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -54,6 +54,8 @@ interface GlobalSearchDialogProps { onOpenChange: (open: boolean) => void; } +type SearchScope = 'all' | 'sessions' | 'messages' | 'files'; + const TYPE_ICONS: Record = { sessions: ChatCircleText, messages: NotePencil, @@ -82,17 +84,30 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro const abortRef = useRef(null); const composingRef = useRef(false); const normalizedQuery = query.trim(); - const searchTerm = useMemo(() => { + const parsedQuery = useMemo<{ scope: SearchScope; term: string; prefix: string | null }>(() => { 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; + + const parsePrefix = (single: string, plural: string, scope: Exclude) => { + if (lower.startsWith(`${single}:`)) { + return { scope, term: trimmed.slice(single.length + 1).trim(), prefix: `${single}:` }; + } + if (lower.startsWith(`${plural}:`)) { + return { scope, term: trimmed.slice(plural.length + 1).trim(), prefix: `${single}:` }; + } + return null; + }; + + return ( + parsePrefix('session', 'sessions', 'sessions') ?? + parsePrefix('message', 'messages', 'messages') ?? + parsePrefix('file', 'files', 'files') ?? + { scope: 'all', term: trimmed, prefix: null } + ); }, [query]); + const searchTerm = parsedQuery.term; + const activeScope = parsedQuery.scope; + const activePrefix = parsedQuery.prefix; const performSearch = useCallback(async (q: string) => { if (composingRef.current) return; @@ -284,6 +299,17 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro setQuery(value); }} /> + {normalizedQuery && activeScope !== 'all' && ( +
+ + + {t('globalSearch.activeScope', { scope: t(TYPE_LABEL_KEYS[activeScope]) })} + + + {activePrefix} + +
+ )} {!query && !loading && (
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0a0b912c..fa95dbc6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -49,6 +49,7 @@ const en = { 'globalSearch.sessions': 'Sessions', 'globalSearch.messages': 'Messages', 'globalSearch.files': 'Files', + 'globalSearch.activeScope': 'Searching in {scope}', 'globalSearch.toolLabel': 'Tool', // ── Message list ──────────────────────────────────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index b1ad2a1a..3aa524c2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -46,6 +46,7 @@ const zh: Record = { 'globalSearch.sessions': '会话', 'globalSearch.messages': '消息', 'globalSearch.files': '文件', + 'globalSearch.activeScope': '当前搜索范围:{scope}', 'globalSearch.toolLabel': '工具', // ── Message list ──────────────────────────────────────────── From 6261b024b8d1aee59eb0021105e1066a8193365b Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Thu, 16 Apr 2026 19:04:44 +0800 Subject: [PATCH 14/14] fix(search): tolerate invalid session directories in file scope --- src/app/api/search/route.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 1d0c484d..5ea4aaec 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -137,9 +137,14 @@ export async function GET(request: NextRequest) { 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); - collectNodes(tree, session.id, session.title, query, result.files); - if (result.files.length >= MAX_RESULTS_PER_TYPE) break; + try { + const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH); + collectNodes(tree, session.id, session.title, query, result.files); + if (result.files.length >= MAX_RESULTS_PER_TYPE) break; + } catch { + // Skip inaccessible/invalid session directories instead of failing the whole search. + continue; + } } }