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 000000000..67703e1d1 Binary files /dev/null and b/docs/pr-assets/search-panel-v056-refresh.png differ diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts index c6f838af9..10af023ec 100644 --- a/src/__tests__/e2e/global-search-modes.spec.ts +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -34,7 +34,10 @@ 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 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}`); @@ -44,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 }); @@ -54,45 +58,86 @@ 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 }); + }; + 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}`); - // 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(); + const emptyGeometry = await getSearchDialogGeometry(); - // 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: 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: searchResultTimeout }); + await expect( + page.getByTestId('global-search-section-files').getByTestId('global-search-item') + ).toHaveCount(1, { timeout: searchResultTimeout }); + await expect( + page.getByTestId('global-search-section-messages').getByTestId('global-search-item') + ).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); - // 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: searchResultTimeout }); + await expect( + page.getByTestId('global-search-section-sessions').getByTestId('global-search-item') + ).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. 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: searchResultTimeout }); + await expect( + page.getByTestId('global-search-section-messages').getByText(messageTokenB).first() + ).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 }); - // 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: searchResultTimeout }); + await expect( + page.getByTestId('global-search-section-files').getByText(fileNameA).first() + ).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 { 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..028c09e1d 100644 --- a/src/components/layout/GlobalSearchDialog.tsx +++ b/src/components/layout/GlobalSearchDialog.tsx @@ -1,19 +1,19 @@ '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 { Button } from '@/components/ui/button'; +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 +56,144 @@ 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 CONTENT_TYPE_LABEL_KEYS: Record = { + user: 'messageList.userLabel', + assistant: 'messageList.assistantLabel', + tool: 'globalSearch.toolLabel', }; +const SCOPE_OPTIONS: Array<{ + scope: SearchScope; + labelKey: TranslationKey; + prefix: string | null; + icon?: CodePilotIconName; +}> = [ + { scope: 'all', labelKey: 'globalSearch.all', prefix: null }, + { + scope: 'sessions', + labelKey: 'globalSearch.sessions', + prefix: 'session:', + icon: 'chat', + }, + { + scope: 'messages', + labelKey: 'globalSearch.messages', + prefix: 'message:', + icon: 'note', + }, + { + scope: 'files', + labelKey: 'globalSearch.files', + 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 [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [results, setResults] = useState({ + sessions: [], + messages: [], + files: [], + }); 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 +201,38 @@ 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 focusSearchInput = useCallback(() => { + if (typeof document === 'undefined') return; + requestAnimationFrame(() => { + const input = document.querySelector( + '[data-slot="command-input"]', + ); + input?.focus(); + }); + }, []); - const performSearch = useCallback(async (q: string) => { + const performSearch = useCallback(async (rawQuery: string, term: string) => { if (composingRef.current) return; - if (abortRef.current) { - abortRef.current.abort(); - } - if (!q.trim()) { + + abortRef.current?.abort(); + + if (!term.trim()) { abortRef.current = null; setResults({ sessions: [], messages: [], files: [] }); setLoading(false); @@ -126,10 +244,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 +267,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) { @@ -159,7 +278,6 @@ export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogPro abortRef.current = null; setQuery(''); setResults({ sessions: [], messages: [], files: [] }); - setCollapsedGroups(new Set()); setLoading(false); } }, [open]); @@ -170,222 +288,315 @@ 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); 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 handleScopeSelect = useCallback( + (scope: SearchScope) => { + setQuery(buildScopedQuery(scope, searchTerm)); + focusSearchInput(); + }, + [focusSearchInput, searchTerm], + ); - 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 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])} +
+ + {count} - ); - }; - - 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}

- - )} -
-
- ))} -
- ); - }; +
+ ); return ( - { composingRef.current = true; }} - onCompositionEnd={(e) => { - composingRef.current = false; - const value = (e.target as HTMLInputElement).value; - setQuery(value); - }} - /> - {normalizedQuery && activeScope !== 'all' && ( -
- - - {t('globalSearch.activeScope', { scope: t(TYPE_LABEL_KEYS[activeScope]) })} - - - {activePrefix} - -
- )} - - {!query && !loading && ( -
-

{t('globalSearch.hint')}

-

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

+
+
+
+ { + composingRef.current = true; + }} + onCompositionEnd={(event) => { + composingRef.current = false; + setQuery((event.target as HTMLInputElement).value); + }} + />
- )} - {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} - -
-
- {!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'; + +
+
+ {SCOPE_OPTIONS.map((option) => { + const isActive = option.scope === activeScope; return ( - handleSelect(item)} - className="flex items-start gap-2 py-2" + ); })} - - ); - })} - - {normalizedQuery && renderGroup('files', results.files)} - {loading && ( -
{t('globalSearch.searching')}
- )} - +
+ +
+ {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')} +

+ + {activeScopeLabel}: {searchTerm} + +
+ )} + + {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={resultItemClass} + data-testid="global-search-item" + > +
+

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

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

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

+
+
+ ))} +
+
+ )} + + {results.messages.length > 0 && ( +
+ {renderSectionHeader('messages', results.messages.length)} +
+ {results.messages.map((item) => ( + handleSelect(item)} + className={resultItemClass} + 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, + )} +

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

+ + {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': '工具',