From 2346c6208b5229c1600e6bb58a2ac24734bab0d6 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Sun, 14 Jun 2026 16:17:32 +0800 Subject: [PATCH 1/2] feat(search): refresh global search panel for v0.56 --- src/__tests__/e2e/global-search-modes.spec.ts | 83 +- src/components/layout/GlobalSearchDialog.tsx | 848 +++++++++++++----- src/i18n/en.ts | 19 +- src/i18n/zh.ts | 19 +- 4 files changed, 730 insertions(+), 239 deletions(-) diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts index c6f838af9..80f8bb157 100644 --- a/src/__tests__/e2e/global-search-modes.spec.ts +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -34,6 +34,8 @@ async function createSession(page: Page, title: string, workingDirectory: string } test.describe('Global Search modes UX', () => { + test.setTimeout(60_000); + 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}`); @@ -54,45 +56,74 @@ test.describe('Global Search modes UX', () => { 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(); + const searchInput = page.locator('input[data-slot="command-input"]').first(); + const searchSurface = page.getByTestId('global-search-surface'); + const openSearch = async () => { + await expect(page.getByRole('button', { name: /^(搜索|Search)$/ }).first()).toBeVisible({ + timeout: 10_000, + }); + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('open-global-search')); + }); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await expect(searchSurface).toBeVisible({ timeout: 10_000 }); + }; 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 }); + await openSearch(); - // Default all-mode caps its file-branch scan at the N most-recent - // workspaces (see ALL_MODE_FILE_SESSION_LIMIT in /api/search) so the - // POST stays under ~1s even on a populated DB — the earlier 30s - // timeout was masking the unbounded-scan latency that Codex flagged. + // All-mode returns all three result types. 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(); + await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('global-search-section-files')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('global-search-section-messages')).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-sessions').getByTestId('global-search-item') + ).toHaveCount(2, { timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-files').getByTestId('global-search-item') + ).toHaveCount(1, { timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-messages').getByTestId('global-search-item') + ).toHaveCount(2, { timeout: 10_000 }); - // 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); + // Clicking a scope chip rewrites the prefix and narrows the result set. + await page.getByTestId('global-search-scope-sessions').click(); + await expect(searchInput).toHaveValue(`session:${suffix}`); + await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-sessions').getByTestId('global-search-item') + ).toHaveCount(2, { timeout: 10_000 }); + await expect(page.getByTestId('global-search-section-files')).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.getByTestId('global-search-scope-messages')).toHaveAttribute('aria-pressed', 'true'); + await expect( + page.getByTestId('global-search-section-messages').getByTestId('global-search-item') + ).toHaveCount(1, { timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-messages').getByText(messageTokenB).first() + ).toBeVisible({ timeout: 10_000 }); + await page.getByTestId('global-search-section-messages').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 }); + // Re-open and verify file scope still works in the same UX flow. + await openSearch(); + await searchInput.fill(''); + await page.getByTestId('global-search-scope-files').click(); + await expect(searchInput).toHaveValue('file:'); 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.getByTestId('global-search-scope-files')).toHaveAttribute('aria-pressed', 'true'); + await expect( + page.getByTestId('global-search-section-files').getByTestId('global-search-item') + ).toHaveCount(1, { timeout: 10_000 }); + await expect( + page.getByTestId('global-search-section-files').getByText(fileNameA).first() + ).toBeVisible({ timeout: 10_000 }); + await page.getByTestId('global-search-section-files').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(() => {}); diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index 308393feb..e957db3f1 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -1,19 +1,24 @@ 'use client'; -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Fragment, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslation } from '@/hooks/useTranslation'; import { CommandDialog, CommandInput, CommandList, - CommandEmpty, - CommandGroup, CommandItem, } from '@/components/ui/command'; -import { ChatCircleText, NotePencil, Folder, File, UserCircle, Sparkle, Wrench, CaretDown, CaretRight } from '@/components/ui/icon'; -import type { IconComponent } from '@/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CaretDown, CaretRight } from '@/components/ui/icon'; +import { + CodePilotIcon, + type CodePilotIconName, +} from '@/components/ui/semantic-icon'; +import { cn } from '@/lib/utils'; import type { TranslationKey } from '@/i18n'; +import { formatRelativeTime } from './chat-list-utils'; interface SearchResultSession { type: 'session'; @@ -56,44 +61,162 @@ interface GlobalSearchDialogProps { type SearchScope = 'all' | 'sessions' | 'messages' | 'files'; -const TYPE_ICONS: Record = { - sessions: ChatCircleText, - messages: NotePencil, - files: Folder, -}; - -const TYPE_LABEL_KEYS: Record = { +const TYPE_LABEL_KEYS: Record, TranslationKey> = { sessions: 'globalSearch.sessions', messages: 'globalSearch.messages', files: 'globalSearch.files', }; -const CONTENT_TYPE_ICONS: Record = { - user: UserCircle, - assistant: Sparkle, - tool: Wrench, +const GROUP_ICON_NAMES: Record = { + all: 'search', + sessions: 'chat', + messages: 'note', + files: 'file_tree', +}; + +const CONTENT_TYPE_ICONS: Record = { + user: 'chat', + assistant: 'assistant', + tool: 'wrench', +}; + +const CONTENT_TYPE_LABEL_KEYS: Record = { + user: 'messageList.userLabel', + assistant: 'messageList.assistantLabel', + tool: 'globalSearch.toolLabel', }; +const SCOPE_OPTIONS: Array<{ + scope: SearchScope; + labelKey: TranslationKey; + descriptionKey?: TranslationKey; + prefix: string | null; + icon: CodePilotIconName; +}> = [ + { scope: 'all', labelKey: 'globalSearch.all', prefix: null, icon: 'search' }, + { + scope: 'sessions', + labelKey: 'globalSearch.sessions', + descriptionKey: 'globalSearch.scopeSessionsHint', + prefix: 'session:', + icon: 'chat', + }, + { + scope: 'messages', + labelKey: 'globalSearch.messages', + descriptionKey: 'globalSearch.scopeMessagesHint', + prefix: 'message:', + icon: 'note', + }, + { + scope: 'files', + labelKey: 'globalSearch.files', + descriptionKey: 'globalSearch.scopeFilesHint', + prefix: 'file:', + icon: 'file_tree', + }, +]; + +function buildScopedQuery(scope: SearchScope, term: string) { + const option = SCOPE_OPTIONS.find((item) => item.scope === scope); + if (!option || !option.prefix) { + return term; + } + return `${option.prefix}${term}`; +} + +function normalizeInlineText(value: string) { + return value.replace(/\s+/g, ' ').trim(); +} + +function formatPathTail(filePath: string) { + const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.length === 0) return filePath; + return parts.slice(-3).join('/'); +} + +function renderHighlightedText(text: string, searchTerm: string) { + if (!searchTerm) return text; + + const normalizedTerm = searchTerm.trim(); + if (!normalizedTerm) return text; + + const lowerText = text.toLowerCase(); + const lowerTerm = normalizedTerm.toLowerCase(); + const segments: Array<{ text: string; match: boolean }> = []; + let cursor = 0; + + while (cursor < text.length) { + const matchIndex = lowerText.indexOf(lowerTerm, cursor); + if (matchIndex === -1) { + segments.push({ text: text.slice(cursor), match: false }); + break; + } + + if (matchIndex > cursor) { + segments.push({ text: text.slice(cursor, matchIndex), match: false }); + } + + segments.push({ + text: text.slice(matchIndex, matchIndex + normalizedTerm.length), + match: true, + }); + cursor = matchIndex + normalizedTerm.length; + } + + return segments.map((segment, index) => ( + + {segment.match ? ( + + {segment.text} + + ) : ( + segment.text + )} + + )); +} + 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 [results, setResults] = useState({ + sessions: [], + messages: [], + files: [], + }); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const abortRef = useRef(null); const composingRef = useRef(false); - const normalizedQuery = query.trim(); - const parsedQuery = useMemo<{ scope: SearchScope; term: string; prefix: string | null }>(() => { + + const parsedQuery = useMemo<{ + scope: SearchScope; + term: string; + prefix: string | null; + }>(() => { const trimmed = query.trim(); const lower = trimmed.toLowerCase(); - const parsePrefix = (single: string, plural: string, scope: Exclude) => { + const parsePrefix = ( + single: string, + plural: string, + scope: Exclude, + ) => { if (lower.startsWith(`${single}:`)) { - return { scope, term: trimmed.slice(single.length + 1).trim(), prefix: `${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 { + scope, + term: trimmed.slice(plural.length + 1).trim(), + prefix: `${single}:`, + }; } return null; }; @@ -101,20 +224,61 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro return ( parsePrefix('session', 'sessions', 'sessions') ?? parsePrefix('message', 'messages', 'messages') ?? - parsePrefix('file', 'files', 'files') ?? - { scope: 'all', term: trimmed, prefix: null } + parsePrefix('file', 'files', 'files') ?? { + scope: 'all', + term: trimmed, + prefix: null, + } ); }, [query]); + const searchTerm = parsedQuery.term; const activeScope = parsedQuery.scope; const activePrefix = parsedQuery.prefix; + const hasSearchTerm = searchTerm.length > 0; + const totalResults = + results.sessions.length + results.messages.length + results.files.length; + const hasResults = totalResults > 0; - const performSearch = useCallback(async (q: string) => { - if (composingRef.current) return; - if (abortRef.current) { - abortRef.current.abort(); + const groupedMessages = useMemo(() => { + const groups: Record< + string, + { sessionTitle: string; messages: SearchResultMessage[] } + > = {}; + + for (const message of results.messages) { + if (!groups[message.sessionId]) { + groups[message.sessionId] = { + sessionTitle: message.sessionTitle, + messages: [], + }; + } + groups[message.sessionId].messages.push(message); } - if (!q.trim()) { + + return Object.entries(groups).map(([sessionId, value]) => ({ + sessionId, + sessionTitle: value.sessionTitle, + messages: value.messages, + })); + }, [results.messages]); + + const focusSearchInput = useCallback(() => { + if (typeof document === 'undefined') return; + requestAnimationFrame(() => { + const input = document.querySelector( + '[data-slot="command-input"]', + ); + input?.focus(); + }); + }, []); + + const performSearch = useCallback(async (rawQuery: string, term: string) => { + if (composingRef.current) return; + + abortRef.current?.abort(); + + if (!term.trim()) { abortRef.current = null; setResults({ sessions: [], messages: [], files: [] }); setLoading(false); @@ -126,10 +290,11 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro setLoading(true); try { - const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { + const res = await fetch(`/api/search?q=${encodeURIComponent(rawQuery)}`, { signal: controller.signal, }); if (!res.ok) throw new Error('Search failed'); + const data: SearchResponse = await res.json(); if (!controller.signal.aborted) { setResults(data); @@ -148,10 +313,10 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro useEffect(() => { const timer = setTimeout(() => { - performSearch(query); - }, 150); + void performSearch(query, searchTerm); + }, 160); return () => clearTimeout(timer); - }, [query, performSearch]); + }, [performSearch, query, searchTerm]); useEffect(() => { if (!open) { @@ -171,7 +336,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro }, []); const toggleGroup = useCallback((sessionId: string) => { - setCollapsedGroups(prev => { + setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(sessionId)) { next.delete(sessionId); @@ -186,205 +351,478 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro (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') { + return; + } + + if (item.type === 'message') { router.push(`/chat/${item.sessionId}?message=${item.messageId}${qParam}`); - } else if (item.type === 'file') { - const seek = Date.now().toString(36); - router.push(`/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}&seek=${seek}${qParam}`); + return; } + + const seek = Date.now().toString(36); + router.push( + `/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}&seek=${seek}${qParam}`, + ); }, - [router, onOpenChange, query], + [onOpenChange, query, router], ); - const hasResults = - results.sessions.length > 0 || - 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 handleScopeSelect = useCallback( + (scope: SearchScope) => { + setQuery(buildScopedQuery(scope, searchTerm)); + focusSearchInput(); + }, + [focusSearchInput, searchTerm], + ); - 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 | 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.projectName && ( -

{item.projectName}

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

{item.name}

-

{item.sessionTitle}

- - )} -
-
- ))} -
- ); - }; + const renderSectionHeader = ( + scope: Exclude, + count: number, + ) => ( +
+
+ + {t(TYPE_LABEL_KEYS[scope])} +
+ + {count} + +
+ ); return ( +
+
+
+
+ + {t('globalSearch.title')} +
+

+ {t('globalSearch.description')} +

+
+ + ⌘K + +
+
+ { composingRef.current = true; }} - onCompositionEnd={(e) => { + className="h-12 text-[15px]" + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={(event) => { composingRef.current = false; - const value = (e.target as HTMLInputElement).value; - setQuery(value); + setQuery((event.target as HTMLInputElement).value); }} /> - {normalizedQuery && activeScope !== 'all' && ( -
- - - {t('globalSearch.activeScope', { scope: t(TYPE_LABEL_KEYS[activeScope]) })} + +
+
+ {SCOPE_OPTIONS.map((option) => { + const isActive = option.scope === activeScope; + return ( + + ); + })} +
+ +
+ {activePrefix && ( + + {activePrefix} + + )} + + {loading ? ( + <> + + {t('globalSearch.searching')} + + ) : hasSearchTerm ? ( + t('globalSearch.resultsSummary', { count: totalResults }) + ) : ( + t('globalSearch.hintPrefix') + )} - - {activePrefix} -
- )} - - {!query && !loading && ( -
-

{t('globalSearch.hint')}

-

- {t('globalSearch.hintPrefix')}{' '} - session:{' '} - message:{' '} - file:{' '} - {t('globalSearch.toNarrowScope')} -

-
- )} - {normalizedQuery && !loading && !hasResults && ( - {t('globalSearch.noResults')} - )} - {normalizedQuery && renderGroup('sessions', results.sessions)} - - {normalizedQuery && groupedMessages.map((group, groupIdx) => { - const isCollapsed = collapsedGroups.has(group.messages[0]?.sessionId || `group-${groupIdx}`); - const sessionId = group.messages[0]?.sessionId || `group-${groupIdx}`; - return ( - - 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 ? ( - - ) : ( - - )} - - - {group.sessionTitle.replace(/\n/g, ' ')} - - - {group.messages.length} - +
+ + +
+ {!hasSearchTerm && !loading && ( +
+
+
+
- - {!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" +

+ {t('globalSearch.hint')} +

+

+ {t('globalSearch.emptyDescription')} +

+
+ +
+ {SCOPE_OPTIONS.filter((option) => option.scope !== 'all').map((option) => ( + + ))} +
+
+ )} + + {hasSearchTerm && !loading && !hasResults && ( +
+
+
+ +
+

+ {t('globalSearch.noResults')} +

+

+ {t('globalSearch.noResultsHint')} +

+ + {searchTerm} + +
+
+ )} + + {loading && hasSearchTerm && ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {hasSearchTerm && !loading && hasResults && ( + <> + {results.sessions.length > 0 && ( +
+ {renderSectionHeader('sessions', results.sessions.length)} +
+ {results.sessions.map((item) => ( + handleSelect(item)} + className="rounded-2xl border border-border/60 bg-background/80 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/50" + data-testid="global-search-item" + > +
+
+ +
+
+
+

+ {renderHighlightedText( + normalizeInlineText(item.title), + searchTerm, + )} +

+ + {formatRelativeTime(item.updatedAt, t)} + +
+

+ {renderHighlightedText(item.projectName, searchTerm)} +

+
+
+
+ ))} +
+
+ )} + + {groupedMessages.length > 0 && ( +
+ {renderSectionHeader('messages', results.messages.length)} +
+ {groupedMessages.map((group) => { + const isCollapsed = collapsedGroups.has(group.sessionId); + return ( +
+ + + {!isCollapsed && ( +
+ {group.messages.map((item) => ( + handleSelect(item)} + className="rounded-2xl border border-border/60 bg-background/85 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/45" + data-testid="global-search-item" + > +
+
+ +
+
+
+ + {t(CONTENT_TYPE_LABEL_KEYS[item.contentType])} + + + {formatRelativeTime(item.createdAt, t)} + +
+

+ {renderHighlightedText( + normalizeInlineText(item.snippet), + searchTerm, + )} +

+
+
+
+ ))} +
+ )} +
+ ); + })} +
+
+ )} + + {results.files.length > 0 && ( +
+ {renderSectionHeader('files', results.files.length)} +
+ {results.files.map((item) => ( + handleSelect(item)} + className="rounded-2xl border border-border/60 bg-background/80 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/50" + data-testid="global-search-item" + > +
+
+ +
+
+
+

+ {renderHighlightedText(item.name, searchTerm)} +

+ + {item.nodeType === 'directory' + ? t('globalSearch.directoryLabel') + : t('globalSearch.fileLabel')} + +
+

+ {normalizeInlineText(item.sessionTitle)} +

+

+ {renderHighlightedText(formatPathTail(item.path), searchTerm)} +

+
+
+
+ ))} +
+
+ )} + + )} +
); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 02d438f46..efca1f5fc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -31,7 +31,7 @@ const en = { 'chatList.removeProject': 'Remove Project', 'chatList.openFolder': 'Open Folder', 'chatList.copyFolderPath': 'Copy Folder Path', - 'chatList.searchSessions': 'Search sessions...', + 'chatList.searchSessions': 'Search', 'chatList.noSessions': 'No sessions yet', 'chatList.importFromCli': 'Import from Claude Code', 'chatList.addProjectFolder': 'New Project', @@ -43,15 +43,26 @@ const en = { '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.title': 'Search', + 'globalSearch.description': 'Search across sessions, messages, and files', + 'globalSearch.placeholder': 'Search sessions, messages, and files', + 'globalSearch.hint': 'Search across conversations, message history, and recent files', + 'globalSearch.emptyDescription': 'Jump to a session, reopen a file, or find the exact message you need.', + 'globalSearch.hintPrefix': 'Scope filters', 'globalSearch.toNarrowScope': 'to narrow scope', + 'globalSearch.all': 'All', 'globalSearch.noResults': 'No results found', + 'globalSearch.noResultsHint': 'Try a different keyword or switch scope.', 'globalSearch.searching': 'Searching...', + 'globalSearch.resultsSummary': '{count} matches', 'globalSearch.sessions': 'Sessions', 'globalSearch.messages': 'Messages', 'globalSearch.files': 'Files', + 'globalSearch.fileLabel': 'File', + 'globalSearch.directoryLabel': 'Folder', + 'globalSearch.scopeSessionsHint': 'Session titles and project names', + 'globalSearch.scopeMessagesHint': 'Message history and tool output', + 'globalSearch.scopeFilesHint': 'Recent workspace files', 'globalSearch.activeScope': 'Searching in {scope}', 'globalSearch.toolLabel': 'Tool', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 70934a487..aaa31fe80 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -28,7 +28,7 @@ const zh: Record = { 'chatList.removeProject': '移出项目', 'chatList.openFolder': '打开文件夹', 'chatList.copyFolderPath': '复制文件夹路径', - 'chatList.searchSessions': '搜索会话...', + 'chatList.searchSessions': '搜索', 'chatList.noSessions': '暂无会话', 'chatList.importFromCli': '从 Claude Code 导入', 'chatList.addProjectFolder': '新建项目', @@ -40,15 +40,26 @@ const zh: Record = { 'chatList.showLess': '收起', // ── Global search ─────────────────────────────────────────── - 'globalSearch.placeholder': '搜索...(尝试 session: / message: / file:)', - 'globalSearch.hint': '输入关键词搜索会话和消息', - 'globalSearch.hintPrefix': '使用前缀', + 'globalSearch.title': '搜索', + 'globalSearch.description': '跨会话、消息记录和文件检索', + 'globalSearch.placeholder': '搜索会话、消息和文件', + 'globalSearch.hint': '搜索对话、消息记录和最近文件', + 'globalSearch.emptyDescription': '快速跳转到会话、重新打开文件,或定位一条具体消息。', + 'globalSearch.hintPrefix': '范围过滤', 'globalSearch.toNarrowScope': '限定搜索范围', + 'globalSearch.all': '全部', 'globalSearch.noResults': '未找到结果', + 'globalSearch.noResultsHint': '换个关键词,或切换到其他范围再试。', 'globalSearch.searching': '搜索中...', + 'globalSearch.resultsSummary': '{count} 项匹配', 'globalSearch.sessions': '会话', 'globalSearch.messages': '消息', 'globalSearch.files': '文件', + 'globalSearch.fileLabel': '文件', + 'globalSearch.directoryLabel': '文件夹', + 'globalSearch.scopeSessionsHint': '按会话标题和项目名查找', + 'globalSearch.scopeMessagesHint': '检索消息历史和工具输出', + 'globalSearch.scopeFilesHint': '检索最近工作区里的文件', 'globalSearch.activeScope': '当前搜索范围:{scope}', 'globalSearch.toolLabel': '工具', From fe6dd092ea88678a4b850c31fc8361b64b4ac466 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Sun, 14 Jun 2026 17:48:23 +0800 Subject: [PATCH 2/2] fix(search): polish v0.56 global search panel --- docs/pr-assets/search-panel-v056-refresh.png | Bin 0 -> 15535 bytes src/__tests__/e2e/global-search-modes.spec.ts | 38 +- src/components/layout/GlobalSearchDialog.tsx | 695 ++++++------------ 3 files changed, 260 insertions(+), 473 deletions(-) create mode 100644 docs/pr-assets/search-panel-v056-refresh.png diff --git a/docs/pr-assets/search-panel-v056-refresh.png b/docs/pr-assets/search-panel-v056-refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..67703e1d1d7d8ab3a0597197fb70f79dbeb2f43e GIT binary patch literal 15535 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 literal 0 HcmV?d00001 diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts index 80f8bb157..10af023ec 100644 --- a/src/__tests__/e2e/global-search-modes.spec.ts +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -37,6 +37,7 @@ test.describe('Global Search modes UX', () => { test.setTimeout(60_000); test('supports all/session/message/file modes and keyboard open', async ({ page }) => { + const searchResultTimeout = 20_000; 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}`); @@ -46,6 +47,7 @@ test.describe('Global Search modes UX', () => { const sessionTitleB = `Search Session Beta ${suffix}`; const messageTokenA = `message-token-alpha-${suffix}`; const messageTokenB = `message-token-beta-${suffix}`; + const searchDialog = page.locator('[data-slot="dialog-content"]').first(); await fs.mkdir(path.dirname(filePathA), { recursive: true }); await fs.mkdir(rootB, { recursive: true }); @@ -68,34 +70,46 @@ test.describe('Global Search modes UX', () => { await expect(searchInput).toBeVisible({ timeout: 10_000 }); await expect(searchSurface).toBeVisible({ timeout: 10_000 }); }; + const getSearchDialogGeometry = async () => { + const box = await searchDialog.boundingBox(); + expect(box).toBeTruthy(); + return { + top: Math.round(box!.y), + height: Math.round(box!.height), + }; + }; try { await page.goto(`/chat/${sessionA}`); await openSearch(); + const emptyGeometry = await getSearchDialogGeometry(); // All-mode returns all three result types. await searchInput.fill(suffix); - await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByTestId('global-search-section-files')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByTestId('global-search-section-messages')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: searchResultTimeout }); + await expect(page.getByTestId('global-search-section-files')).toBeVisible({ timeout: searchResultTimeout }); + await expect(page.getByTestId('global-search-section-messages')).toBeVisible({ timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-sessions').getByTestId('global-search-item') - ).toHaveCount(2, { timeout: 10_000 }); + ).toHaveCount(2, { timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-files').getByTestId('global-search-item') - ).toHaveCount(1, { timeout: 10_000 }); + ).toHaveCount(1, { timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-messages').getByTestId('global-search-item') - ).toHaveCount(2, { timeout: 10_000 }); + ).toHaveCount(2, { timeout: searchResultTimeout }); + const resultGeometry = await getSearchDialogGeometry(); + expect(Math.abs(resultGeometry.top - emptyGeometry.top)).toBeLessThanOrEqual(1); + expect(Math.abs(resultGeometry.height - emptyGeometry.height)).toBeLessThanOrEqual(2); // Clicking a scope chip rewrites the prefix and narrows the result set. await page.getByTestId('global-search-scope-sessions').click(); await expect(searchInput).toHaveValue(`session:${suffix}`); - await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('global-search-section-sessions')).toBeVisible({ timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-sessions').getByTestId('global-search-item') - ).toHaveCount(2, { timeout: 10_000 }); + ).toHaveCount(2, { timeout: searchResultTimeout }); await expect(page.getByTestId('global-search-section-files')).toHaveCount(0); // message: prefix narrows to message snippets and supports navigation to target session. @@ -103,10 +117,10 @@ test.describe('Global Search modes UX', () => { await expect(page.getByTestId('global-search-scope-messages')).toHaveAttribute('aria-pressed', 'true'); await expect( page.getByTestId('global-search-section-messages').getByTestId('global-search-item') - ).toHaveCount(1, { timeout: 10_000 }); + ).toHaveCount(1, { timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-messages').getByText(messageTokenB).first() - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: searchResultTimeout }); await page.getByTestId('global-search-section-messages').getByText(messageTokenB).first().click(); await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 }); @@ -119,10 +133,10 @@ test.describe('Global Search modes UX', () => { await expect(page.getByTestId('global-search-scope-files')).toHaveAttribute('aria-pressed', 'true'); await expect( page.getByTestId('global-search-section-files').getByTestId('global-search-item') - ).toHaveCount(1, { timeout: 10_000 }); + ).toHaveCount(1, { timeout: searchResultTimeout }); await expect( page.getByTestId('global-search-section-files').getByText(fileNameA).first() - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible({ timeout: searchResultTimeout }); await page.getByTestId('global-search-section-files').getByText(fileNameA).first().click(); await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 }); } finally { diff --git a/src/components/layout/GlobalSearchDialog.tsx b/src/components/layout/GlobalSearchDialog.tsx index e957db3f1..028c09e1d 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -9,13 +9,8 @@ import { CommandList, CommandItem, } from '@/components/ui/command'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { CaretDown, CaretRight } from '@/components/ui/icon'; -import { - CodePilotIcon, - type CodePilotIconName, -} from '@/components/ui/semantic-icon'; +import { CodePilotIcon, type CodePilotIconName } from '@/components/ui/semantic-icon'; import { cn } from '@/lib/utils'; import type { TranslationKey } from '@/i18n'; import { formatRelativeTime } from './chat-list-utils'; @@ -67,19 +62,6 @@ const TYPE_LABEL_KEYS: Record, TranslationKey> = { files: 'globalSearch.files', }; -const GROUP_ICON_NAMES: Record = { - all: 'search', - sessions: 'chat', - messages: 'note', - files: 'file_tree', -}; - -const CONTENT_TYPE_ICONS: Record = { - user: 'chat', - assistant: 'assistant', - tool: 'wrench', -}; - const CONTENT_TYPE_LABEL_KEYS: Record = { user: 'messageList.userLabel', assistant: 'messageList.assistantLabel', @@ -89,29 +71,25 @@ const CONTENT_TYPE_LABEL_KEYS: Record = [ - { scope: 'all', labelKey: 'globalSearch.all', prefix: null, icon: 'search' }, + { scope: 'all', labelKey: 'globalSearch.all', prefix: null }, { scope: 'sessions', labelKey: 'globalSearch.sessions', - descriptionKey: 'globalSearch.scopeSessionsHint', prefix: 'session:', icon: 'chat', }, { scope: 'messages', labelKey: 'globalSearch.messages', - descriptionKey: 'globalSearch.scopeMessagesHint', prefix: 'message:', icon: 'note', }, { scope: 'files', labelKey: 'globalSearch.files', - descriptionKey: 'globalSearch.scopeFilesHint', prefix: 'file:', icon: 'file_tree', }, @@ -187,7 +165,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro messages: [], files: [], }); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const abortRef = useRef(null); const composingRef = useRef(false); @@ -240,29 +217,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro results.sessions.length + results.messages.length + results.files.length; const hasResults = totalResults > 0; - const groupedMessages = useMemo(() => { - const groups: Record< - string, - { sessionTitle: string; messages: SearchResultMessage[] } - > = {}; - - for (const message of results.messages) { - if (!groups[message.sessionId]) { - groups[message.sessionId] = { - sessionTitle: message.sessionTitle, - messages: [], - }; - } - groups[message.sessionId].messages.push(message); - } - - return Object.entries(groups).map(([sessionId, value]) => ({ - sessionId, - sessionTitle: value.sessionTitle, - messages: value.messages, - })); - }, [results.messages]); - const focusSearchInput = useCallback(() => { if (typeof document === 'undefined') return; requestAnimationFrame(() => { @@ -324,7 +278,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro abortRef.current = null; setQuery(''); setResults({ sessions: [], messages: [], files: [] }); - setCollapsedGroups(new Set()); setLoading(false); } }, [open]); @@ -335,18 +288,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro }; }, []); - 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); @@ -378,23 +319,25 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro [focusSearchInput, searchTerm], ); + const activeScopeLabel = + activeScope === 'all' ? t('globalSearch.all') : t(TYPE_LABEL_KEYS[activeScope]); + const expanded = hasSearchTerm || loading; + const resultStackClass = + 'overflow-hidden rounded-2xl border border-border/60 bg-background/80'; + const resultItemClass = + 'rounded-none border-0 border-b border-border/45 bg-transparent px-3.5 py-2.5 data-[selected=true]:bg-accent/70 last:border-b-0'; + const renderSectionHeader = ( scope: Exclude, count: number, ) => ( -
-
- - {t(TYPE_LABEL_KEYS[scope])} +
+
+ {t(TYPE_LABEL_KEYS[scope])}
- + {count} - +
); @@ -404,426 +347,256 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro onOpenChange={onOpenChange} title={t('globalSearch.title')} description={t('globalSearch.description')} - className="h-[min(88vh,720px)] overflow-hidden rounded-[28px] border border-border/70 bg-background/95 p-0 shadow-2xl backdrop-blur-xl sm:max-w-4xl" + className={cn( + 'top-[44%] h-[min(70vh,560px)] overflow-hidden rounded-[24px] border border-border/70 bg-background/98 p-0 shadow-[var(--shadow-diffuse)] backdrop-blur-xl sm:max-w-[760px]', + '[&_[data-slot=command-input-wrapper]]:h-12 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-3', + )} showCloseButton={false} shouldFilter={false} > -
-
-
-
- - {t('globalSearch.title')} -
-

- {t('globalSearch.description')} -

+
+
+
+ { + composingRef.current = true; + }} + onCompositionEnd={(event) => { + composingRef.current = false; + setQuery((event.target as HTMLInputElement).value); + }} + />
- - ⌘K - -
-
- - { - composingRef.current = true; - }} - onCompositionEnd={(event) => { - composingRef.current = false; - setQuery((event.target as HTMLInputElement).value); - }} - /> - -
-
- {SCOPE_OPTIONS.map((option) => { - const isActive = option.scope === activeScope; - return ( - - ); - })} -
-
- {activePrefix && ( - - {activePrefix} - - )} - - {loading ? ( - <> - - {t('globalSearch.searching')} - - ) : hasSearchTerm ? ( - t('globalSearch.resultsSummary', { count: totalResults }) - ) : ( - t('globalSearch.hintPrefix') - )} - -
-
- - -
- {!hasSearchTerm && !loading && ( -
-
-
- -
-

- {t('globalSearch.hint')} -

-

- {t('globalSearch.emptyDescription')} -

-
- -
- {SCOPE_OPTIONS.filter((option) => option.scope !== 'all').map((option) => ( - - ))} -
+ {option.icon && ( + + )} + {t(option.labelKey)} + + ); + })}
- )} - {hasSearchTerm && !loading && !hasResults && (
-
-
- -
-

+ {activePrefix && ( + + {activePrefix} + + )} + {loading ? ( + + + {t('globalSearch.searching')} + + ) : hasSearchTerm ? ( + {t('globalSearch.resultsSummary', { count: totalResults })} + ) : null} +

+
+
+ + +
+ {!hasSearchTerm && !loading && ( +
+ + Enter + + + Esc + +
+ )} + + {hasSearchTerm && !loading && !hasResults && ( +
+

{t('globalSearch.noResults')}

-

+

{t('globalSearch.noResultsHint')}

- - {searchTerm} + + {activeScopeLabel}: {searchTerm}
-
- )} - - {loading && hasSearchTerm && ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
-
-
-
-
-
+ )} + + {loading && hasSearchTerm && ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
-
- ))} -
- )} - - {hasSearchTerm && !loading && hasResults && ( - <> - {results.sessions.length > 0 && ( -
- {renderSectionHeader('sessions', results.sessions.length)} -
- {results.sessions.map((item) => ( - handleSelect(item)} - className="rounded-2xl border border-border/60 bg-background/80 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/50" - data-testid="global-search-item" - > -
-
- -
-
-
-

- {renderHighlightedText( - normalizeInlineText(item.title), - searchTerm, - )} -

- - {formatRelativeTime(item.updatedAt, t)} - -
-

+ ))} +

+ )} + + {hasSearchTerm && !loading && hasResults && ( +
+ {results.sessions.length > 0 && ( +
+ {renderSectionHeader('sessions', results.sessions.length)} +
+ {results.sessions.map((item) => ( + handleSelect(item)} + className={resultItemClass} + data-testid="global-search-item" + > +
+

+ {renderHighlightedText( + normalizeInlineText(item.title), + searchTerm, + )} +

+ + {formatRelativeTime(item.updatedAt, t)} + +

{renderHighlightedText(item.projectName, searchTerm)}

-
- - ))} -
-
- )} + + ))} +
+ + )} - {groupedMessages.length > 0 && ( -
- {renderSectionHeader('messages', results.messages.length)} -
- {groupedMessages.map((group) => { - const isCollapsed = collapsedGroups.has(group.sessionId); - return ( -
0 && ( +
+ {renderSectionHeader('messages', results.messages.length)} +
+ {results.messages.map((item) => ( + handleSelect(item)} + className={resultItemClass} + data-testid="global-search-item" > - - - {!isCollapsed && ( -
- {group.messages.map((item) => ( - handleSelect(item)} - className="rounded-2xl border border-border/60 bg-background/85 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/45" - data-testid="global-search-item" - > -
-
- -
-
-
- - {t(CONTENT_TYPE_LABEL_KEYS[item.contentType])} - - - {formatRelativeTime(item.createdAt, t)} - -
-

- {renderHighlightedText( - normalizeInlineText(item.snippet), - searchTerm, - )} -

-
-
-
- ))} -
- )} -
- ); - })} -
-
- )} - - {results.files.length > 0 && ( -
- {renderSectionHeader('files', results.files.length)} -
- {results.files.map((item) => ( - handleSelect(item)} - className="rounded-2xl border border-border/60 bg-background/80 px-3 py-3 data-[selected=true]:border-border data-[selected=true]:bg-muted/50" - data-testid="global-search-item" - > -
-
- + {renderHighlightedText( + normalizeInlineText(item.sessionTitle), + searchTerm, + )} +

+ + {t(CONTENT_TYPE_LABEL_KEYS[item.contentType])} + · + {formatRelativeTime(item.createdAt, t)} + +

+ {renderHighlightedText( + normalizeInlineText(item.snippet), + searchTerm, + )} +

-
-
-

- {renderHighlightedText(item.name, searchTerm)} -

- - {item.nodeType === 'directory' - ? t('globalSearch.directoryLabel') - : t('globalSearch.fileLabel')} - -
-

- {normalizeInlineText(item.sessionTitle)} + + ))} +

+
+ )} + + {results.files.length > 0 && ( +
+ {renderSectionHeader('files', results.files.length)} +
+ {results.files.map((item) => ( + handleSelect(item)} + className={resultItemClass} + data-testid="global-search-item" + > +
+

+ {renderHighlightedText(item.name, searchTerm)}

-

- {renderHighlightedText(formatPathTail(item.path), searchTerm)} + + {item.nodeType === 'directory' + ? t('globalSearch.directoryLabel') + : t('globalSearch.fileLabel')} + +

+ {normalizeInlineText(item.sessionTitle)} + · + + {renderHighlightedText(formatPathTail(item.path), searchTerm)} +

-
- - ))} -
- - )} - - )} -
- + + ))} +
+ + )} +
+ )} +
+ +
); }