diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index db13042c8fa..690c59aae00 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -29,6 +29,22 @@ export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexe }) } +const preparedQuickOpenFiles = new WeakMap() + +// Why: keystroke-driven callers re-classify on every character, but the index +// only changes when the file list array itself is replaced. +export function getPreparedQuickOpenFiles( + files: readonly string[] +): readonly QuickOpenIndexedFile[] { + const cached = preparedQuickOpenFiles.get(files) + if (cached) { + return cached + } + const prepared = prepareQuickOpenFiles(files) + preparedQuickOpenFiles.set(files, prepared) + return prepared +} + export function isQuickOpenQueryTooLarge( query: string, maxBytes = QUICK_OPEN_QUERY_MAX_BYTES diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx new file mode 100644 index 00000000000..b40166cbc1d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import type { TabCreateMenuOption } from './tab-create-menu-options' +import { QUICK_OPEN_QUERY_MAX_BYTES } from '../quick-open-search' + +const fileListMock = vi.hoisted(() => ({ + current: { files: [] as string[], loading: false, loadError: null as string | null } +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => fileListMock.current +})) + +const tabSearchMock = vi.hoisted(() => ({ + calls: [] as { enabled: boolean; query: string }[], + results: [] as unknown[] +})) +vi.mock('./use-open-tab-search', () => ({ + useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => { + tabSearchMock.calls.push({ enabled, query }) + return { query, results: tabSearchMock.results } + } +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let container: HTMLDivElement +let root: Root + +function renderEntry(props: Record = {}): void { + act(() => { + root.render( + + + + ) + }) +} + +function setQuery(value: string): void { + const input = container.querySelector('input')! + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function submit(): void { + act(() => { + container + .querySelector('form')! + .dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +function press(key: string): void { + act(() => { + container + .querySelector('input')! + .dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) +} + +beforeEach(() => { + fileListMock.current = { files: [], loading: false, loadError: null } + tabSearchMock.calls = [] + tabSearchMock.results = [] + useAppStore.setState( + { ...useAppStore.getInitialState(), browserDefaultSearchEngine: null } as AppState, + true + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('TabBarCreateEntry search behavior', () => { + it('tracks the configured provider and submits its exact classification', () => { + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('react hooks') + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + + act(() => useAppStore.setState({ browserDefaultSearchEngine: 'bing' })) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Bing') + submit() + + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'search', engine: 'bing', query: 'react hooks' } + }) + ) + }) + + it('searches a phrase on Enter while the file index is still loading', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('natural language') + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(1) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[role="status"]')?.textContent).toContain('Loading files...') + expect(container.querySelector('[aria-selected="true"]')).not.toBeNull() + + submit() + expect(onOpenEntry).toHaveBeenCalledOnce() + }) + + it('requires an explicit choice for text the loading index could still match', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('notes.md draft') + + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.') + + press('ArrowDown') + submit() + expect(onOpenEntry).toHaveBeenCalledOnce() + }) + + it('arms network actions once the index fails, since no file match can arrive', () => { + fileListMock.current = { files: [], loading: false, loadError: 'scan failed' } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('example.com') + + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open URL') + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ classification: expect.objectContaining({ kind: 'host-url' }) }) + ) + }) + + it('does not auto-open a host-like filename while the file index is unresolved', () => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('example.com') + + expect(container.querySelector('[role="option"]')?.textContent).toContain('Open URL') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + }) + + it('disables the active submission and exposes failures to assistive technology', async () => { + let rejectOpen: ((error: Error) => void) | undefined + const onOpenEntry = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectOpen = reject + }) + ) + renderEntry({ onOpenEntry }) + setQuery('react hooks') + submit() + + // Read-only rather than disabled so the pending input keeps keyboard focus. + expect(container.querySelector('input')?.disabled).toBe(false) + expect(container.querySelector('input')?.readOnly).toBe(true) + expect(container.querySelector('input')?.getAttribute('aria-busy')).toBe('true') + expect(container.querySelector('[role="option"]')?.disabled).toBe(true) + expect(container.querySelector('[role="option"] .animate-spin')).not.toBeNull() + + await act(async () => rejectOpen?.(new Error('Search failed safely.'))) + + const input = container.querySelector('input')! + expect(input.readOnly).toBe(false) + expect(input.getAttribute('aria-busy')).toBeNull() + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(input.getAttribute('aria-errormessage')).toBe('tab-create-entry-error') + expect(container.querySelector('#tab-create-entry-error')?.textContent).toContain( + 'Search failed safely.' + ) + }) + + it('ignores completion from a previous menu session', async () => { + let resolveOpen: (() => void) | undefined + const onDidOpenEntry = vi.fn() + const onOpenEntry = vi.fn( + () => + new Promise((resolve) => { + resolveOpen = resolve + }) + ) + renderEntry({ onDidOpenEntry, onOpenEntry }) + setQuery('react hooks') + submit() + + renderEntry({ menuOpen: false, onDidOpenEntry, onOpenEntry }) + renderEntry({ menuOpen: true, onDidOpenEntry, onOpenEntry }) + await act(async () => resolveOpen?.()) + + expect(onDidOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('input')?.readOnly).toBe(false) + }) + + it('does not arm ordinary search when a ranked file disappears asynchronously', () => { + fileListMock.current = { files: ['project codename'], loading: false, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('project codename') + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open file') + + fileListMock.current = { files: [], loading: true, loadError: null } + renderEntry({ onOpenEntry }) + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + expect(container.querySelector('input')?.getAttribute('aria-activedescendant')).toBeNull() + + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.') + + press('ArrowDown') + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ classification: expect.objectContaining({ kind: 'search' }) }) + ) + }) + + it('makes forced search the only category and disables open-tab matching', () => { + fileListMock.current = { files: ['gem'], loading: false, loadError: null } + tabSearchMock.results = [ + { + id: 'open-tab:workspace:tab-1', + title: 'gem tab', + contentType: 'terminal', + worktreeId: 'wt', + tabId: 'tab-1', + entityId: 'term-1', + groupId: 'g', + relativePath: null + } + ] + const menuOptions: TabCreateMenuOption[] = [ + { id: 'new-browser', kind: 'new-browser', keywords: ['gem'], label: 'Gem action' } + ] + const agentOptions: TabAgentLaunchOption[] = [ + { agent: 'gemini', aliases: ['gem'], label: 'Gemini' } + ] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ agentOptions, menuOptions, onOpenEntry }) + setQuery('?gem') + + const rows = container.querySelectorAll('[role="option"]') + expect(rows).toHaveLength(1) + expect(rows[0].textContent).toContain('Search Google') + expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' }) + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'search', engine: 'google', query: 'gem' } + }) + ) + }) + + it('treats a bare search prefix as a prompt rather than a submission error', () => { + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('?') + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + submit() + + expect(onOpenEntry).not.toHaveBeenCalled() + expect(container.querySelector('#tab-create-entry-error')).toBeNull() + expect(container.querySelector('input')?.getAttribute('aria-errormessage')).toBeNull() + }) + + it('blocks oversized forced input without surfacing global actions', () => { + renderEntry({ + menuOptions: [ + { id: 'new-browser', kind: 'new-browser', keywords: ['x'], label: 'New Browser' } + ], + agentOptions: [{ agent: 'gemini', aliases: ['x'], label: 'Gemini' }] + }) + setQuery(`?${'x'.repeat(QUICK_OPEN_QUERY_MAX_BYTES)}`) + + expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) + expect(container.textContent).toContain('Search text is too large.') + expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index fa65ce24e5f..482d81a1cf3 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -4,17 +4,10 @@ import { useRuntimeFileListForWorktree } from '../quick-open-file-list' import { createTabEntryAllowAbsolutePathsSelector, getTabEntryOptions, - isTabEntryAbsolutePathLike, - type TabCreateEntryArgs + isTabEntryAbsolutePathLike } from './tab-create-entry-action' -import { - findMatchingTabAgentLaunchOptions, - type TabAgentLaunchOption -} from './tab-agent-launch-options' -import { - findMatchingTabCreateMenuOptions, - type TabCreateMenuOption -} from './tab-create-menu-options' +import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options' +import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options' import { getActiveOptionId, isActiveEntryOption, @@ -28,42 +21,30 @@ import { } from './TabBarCreateEntryRow' import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe' import { activateOpenTabSearchResult } from './open-tab-selection-routing' -import type { OpenTabSearchResult } from './open-tab-search' import { useOpenTabSearch } from './use-open-tab-search' -import type { TuiAgent } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url' import { getRendererAppPlatform } from '@/lib/renderer-app-platform' import { useAppStore } from '@/store' +import { isQuickOpenQueryTooLarge } from '../quick-open-search' +import { parseForcedSearchQuery } from './tab-create-entry-forced-search' +import { useNetworkSafeTabEntrySelection } from './tab-create-entry-network-selection' +import { focusTabEntryMenuItemAtEdge } from './tab-create-entry-keyboard-focus' +import { + getTabEntryChooseActionMessage, + getTabEntryOmniboxPlaceholder +} from './tab-create-entry-copy' +import { + EMPTY_AGENT_OPTIONS, + EMPTY_MENU_OPTIONS, + EMPTY_TAB_RESULTS +} from './tab-create-entry-empty-options' +import type { TabBarCreateEntryProps } from './tab-create-entry-props' -// Leads with tabs: the omnibox now jumps to open tabs before it creates anything. -function omniboxPlaceholder(): string { - return translate( - 'auto.components.tab.bar.TabBarCreateEntry.0e5b7a3f16', - 'Search open tabs, files, URLs, agents…' - ) +export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element { + return } -const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = [] -const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = [] -const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = [] - -type TabBarCreateEntryProps = { - agentOptions?: readonly TabAgentLaunchOption[] - groupId: string - menuOpen: boolean - menuOptions?: readonly TabCreateMenuOption[] - onDidOpenEntry?: () => void - onLaunchAgent?: (agent: TuiAgent) => void - onOpenDefaultTerminal?: () => void - onOpenEntry?: (args: TabCreateEntryArgs) => Promise - onQueryChange?: (query: string) => void - /** Runs after the menu closes, so the tab jumped to actually takes focus. */ - onQueueSwitchFocus?: (focus: () => void) => void - onSelectMenuOption?: (option: TabCreateMenuOption) => void - worktreeId: string -} - -export default function TabBarCreateEntry({ +function TabBarCreateEntrySession({ agentOptions = EMPTY_AGENT_OPTIONS, groupId, menuOpen, @@ -81,16 +62,33 @@ export default function TabBarCreateEntry({ const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [switchError, setSwitchError] = useState(null) + const [selectionGuidance, setSelectionGuidance] = useState(null) // null = follow ranking (deferred tabs can prepend); set on arrow keys only. const [pinnedOptionId, setPinnedOptionId] = useState(null) - const [lastMenuOpen, setLastMenuOpen] = useState(menuOpen) const inputRef = useRef(null) + const submissionIdRef = useRef(0) + useEffect( + () => () => { + submissionIdRef.current += 1 + }, + [] + ) const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) - const tabSearch = useOpenTabSearch({ enabled: menuOpen, query, worktreeId }) + const rawQueryOversized = isQuickOpenQueryTooLarge(query) + const forcedSearch = parseForcedSearchQuery(query) + const terminalQueryMode = rawQueryOversized || forcedSearch.forced + const tabSearchQuery = terminalQueryMode ? '' : query + const tabSearch = useOpenTabSearch({ + enabled: menuOpen && !terminalQueryMode, + query: tabSearchQuery, + worktreeId + }) // Why gate on the query: the search defers, so its rows can still describe an // earlier query — Enter must never submit a tab the current query never matched. - const tabResults = tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS - const shouldResolveAbsolutePaths = menuOpen && isTabEntryAbsolutePathLike(query.trim()) + const tabResults = + !terminalQueryMode && tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS + const shouldResolveAbsolutePaths = + menuOpen && !terminalQueryMode && isTabEntryAbsolutePathLike(query.trim()) const allowAbsolutePathsSelector = useMemo( () => createTabEntryAllowAbsolutePathsSelector(worktreeId, { @@ -105,6 +103,9 @@ export default function TabBarCreateEntry({ menuOpen ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null ) const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix' + const searchEngine = useAppStore( + (state) => state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE + ) // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the // first item should return to the search box so the keyboard trip isn't @@ -144,14 +145,16 @@ export default function TabBarCreateEntry({ }, [menuOpen]) const matchingMenuOptions = useMemo( - () => findMatchingTabCreateMenuOptions(query, menuOptions), - [menuOptions, query] + () => + terminalQueryMode ? EMPTY_MENU_OPTIONS : findMatchingTabCreateMenuOptions(query, menuOptions), + [menuOptions, query, terminalQueryMode] ) const options = useMemo(() => { const entryOptions = dropFileEntriesCoveredByTabResults( getTabEntryOptions(query, fileList, 4, { allowAbsolutePaths, - localPlatform + localPlatform, + searchEngine }), tabResults, worktreePath @@ -167,25 +170,18 @@ export default function TabBarCreateEntry({ localPlatform, matchingMenuOptions.length, query, + searchEngine, tabResults, worktreePath ]) const matchingAgentOptions = useMemo( - () => findMatchingTabAgentLaunchOptions(query, agentOptions), - [agentOptions, query] + () => + terminalQueryMode + ? EMPTY_AGENT_OPTIONS + : findMatchingTabAgentLaunchOptions(query, agentOptions), + [agentOptions, query, terminalQueryMode] ) - if (lastMenuOpen !== menuOpen) { - setLastMenuOpen(menuOpen) - if (!menuOpen) { - setQuery('') - setPending(false) - setError(null) - setSwitchError(null) - setPinnedOptionId(null) - } - } - const disabled = !onOpenEntry const hasQuery = query.trim().length > 0 const activeOptions: ActiveOption[] = [ @@ -206,13 +202,15 @@ export default function TabBarCreateEntry({ option })) ] - // Why pin by id (not index): deferred tab rows prepend and would steal a - // user-moved highlight if we kept a raw index. Null pin follows top rank. - const pinnedOptionIndex = pinnedOptionId - ? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId) - : -1 - const activeSelectedIndex = Math.max(pinnedOptionIndex, 0) - const selectedActiveOption = activeOptions[activeSelectedIndex] + const { activeSelectedIndex, selectedActiveOption } = useNetworkSafeTabEntrySelection({ + activeOptions, + fileIndexFailed: Boolean(fileList.loadError), + fileIndexReady: !fileList.loading && !fileList.loadError, + forcedSearch: forcedSearch.forced, + menuOpen, + pinnedOptionId, + query + }) const statusOption = options.find( (option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked' ) @@ -220,7 +218,7 @@ export default function TabBarCreateEntry({ statusOption != null && (statusOption.classification.kind === 'empty' || statusOption.classification.kind === 'blocked') ? statusOption.classification.message - : omniboxPlaceholder() + : getTabEntryOmniboxPlaceholder() const submitOption = (option?: ActiveOption) => { if (disabled || pending) { @@ -233,7 +231,15 @@ export default function TabBarCreateEntry({ onDidOpenEntry?.() return } - setError(statusMessage) + if (activeOptions.length > 0) { + setSelectionGuidance(getTabEntryChooseActionMessage()) + return + } + // Why: an 'empty' status is the placeholder prompt, not a failure — showing + // it as an error turns a bare "?" into a red row that reads like a bug. + if (statusOption?.classification.kind !== 'empty') { + setError(statusMessage) + } return } if (selectedOption.kind === 'tab') { @@ -260,6 +266,7 @@ export default function TabBarCreateEntry({ } setPending(true) setError(null) + const submissionId = ++submissionIdRef.current void onOpenEntry({ query, worktreeId, @@ -268,13 +275,19 @@ export default function TabBarCreateEntry({ classification: selectedOption.option.classification }) .then(() => { - onDidOpenEntry?.() + if (submissionIdRef.current === submissionId) { + onDidOpenEntry?.() + } }) .catch((caught) => { - setError(caught instanceof Error ? caught.message : String(caught)) + if (submissionIdRef.current === submissionId) { + setError(caught instanceof Error ? caught.message : String(caught)) + } }) .finally(() => { - setPending(false) + if (submissionIdRef.current === submissionId) { + setPending(false) + } }) } @@ -292,15 +305,23 @@ export default function TabBarCreateEntry({ event.stopPropagation() const delta = event.key === 'ArrowDown' ? 1 : -1 const nextIndex = - (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length + activeSelectedIndex === null + ? event.key === 'ArrowDown' + ? 0 + : activeOptions.length - 1 + : (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length setPinnedOptionId(getActiveOptionId(activeOptions[nextIndex])) + setSelectionGuidance(null) return } // Why: with no result rows the static create/agent items render below; // move focus into that Radix menu list so it stays keyboard-navigable // from the search box instead of trapping focus in the input. if ( - focusMenuItemAtEdge(event.currentTarget, event.key === 'ArrowDown' ? 'first' : 'last') + focusTabEntryMenuItemAtEdge( + event.currentTarget, + event.key === 'ArrowDown' ? 'first' : 'last' + ) ) { event.preventDefault() event.stopPropagation() @@ -326,18 +347,26 @@ export default function TabBarCreateEntry({ setPinnedOptionId(null) setError(null) setSwitchError(null) + setSelectionGuidance(null) }} + // Why: disabling the focused input would hand focus to the body and + // break arrow/Escape navigation until the submission settles. disabled={disabled} + readOnly={pending} + aria-busy={pending || undefined} role="combobox" - aria-expanded={activeOptions.length > 0} - aria-controls={RESULT_LISTBOX_ID} + aria-expanded={!error && activeOptions.length > 0} + aria-controls={!error && activeOptions.length > 0 ? RESULT_LISTBOX_ID : undefined} aria-autocomplete="list" aria-activedescendant={ - activeOptions.length > 0 && !error ? resultOptionDomId(activeSelectedIndex) : undefined + activeSelectedIndex !== null && !error + ? resultOptionDomId(activeSelectedIndex) + : undefined } - aria-label={omniboxPlaceholder()} + aria-label={getTabEntryOmniboxPlaceholder()} aria-invalid={error ? true : undefined} - placeholder={omniboxPlaceholder()} + aria-errormessage={error ? 'tab-create-entry-error' : undefined} + placeholder={getTabEntryOmniboxPlaceholder()} className="h-9 rounded-none border-0 bg-transparent px-0 text-xs font-normal text-foreground shadow-none placeholder:font-normal placeholder:text-muted-foreground focus-visible:border-0 focus-visible:ring-0 aria-invalid:border-0 aria-invalid:ring-0 md:text-xs dark:bg-transparent" /> @@ -345,28 +374,46 @@ export default function TabBarCreateEntry({ the rows the user can still act on. The live region stays mounted so a screen reader announces the failure instead of missing the insertion. */}
+ {error ? ( +
+ +
+ ) : null} {switchError ? (
) : null} + {selectionGuidance ? ( +
+ +
+ ) : null} + {activeOptions.length > 0 && statusOption ? ( +
+ +
+ ) : null}
- {error || activeOptions.length > 0 || hasQuery ? ( + {!error && (activeOptions.length > 0 || hasQuery) ? (
0 && !error ? 'listbox' : undefined} + role={activeOptions.length > 0 ? 'listbox' : undefined} > - {error ? ( - - ) : activeOptions.length > 0 ? ( + {activeOptions.length > 0 ? ( activeOptions.map((option, index) => ( submitOption(option)} + disabled={disabled || pending} + loading={pending && index === activeSelectedIndex} + onClick={() => { + setSelectionGuidance(null) + submitOption(option) + }} /> )) ) : ( @@ -377,21 +424,3 @@ export default function TabBarCreateEntry({ ) } - -// Moves keyboard focus to the first/last enabled item of the enclosing Radix -// menu so the static create/agent list stays navigable from the search input. -function focusMenuItemAtEdge(fromElement: HTMLElement, edge: 'first' | 'last'): boolean { - const menu = fromElement.closest('[role="menu"]') - if (!menu) { - return false - } - const items = menu.querySelectorAll( - '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' - ) - const target = edge === 'first' ? items[0] : items.item(items.length - 1) - if (!target) { - return false - } - target.focus() - return true -} diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx index 616b3caa0c6..eafad8ad790 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx @@ -2,6 +2,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { getByRole } from '@testing-library/dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { ActiveOption } from './tab-create-entry-active-option' @@ -88,4 +89,18 @@ describe('EntryActionRow', () => { expect(renderRow(makeFileOption('README.md')).textContent).toContain('README.md') expect(container.textContent).not.toContain('/') }) + + it('names the provider and query without retaining a built URL', () => { + renderRow({ + kind: 'entry', + option: { + id: 'search:private project', + classification: { kind: 'search', engine: 'kagi', query: 'private project' } + } + }) + + expect(getByRole(container, 'option', { name: 'Search Kagi private project' })).toBeTruthy() + expect(container.textContent).toBe('Search Kagi·private project') + expect(container.textContent).not.toContain('https://') + }) }) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 0cca7d4b8c6..5e99818009d 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -5,6 +5,7 @@ import { GitCompare, Globe, Loader2, + Search, Smartphone, TerminalSquare } from 'lucide-react' @@ -12,6 +13,7 @@ import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' +import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' @@ -38,12 +40,16 @@ export function EntryStatusRow({ } export function EntryActionRow({ + disabled = false, id, + loading = false, onClick, option, selected }: { + disabled?: boolean id: string + loading?: boolean onClick: () => void option: ActiveOption selected: boolean @@ -56,15 +62,22 @@ export function EntryActionRow({ id={id} role="option" aria-selected={selected} + disabled={disabled} className={cn( - 'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none', + 'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none disabled:cursor-not-allowed disabled:opacity-50', selected ? 'bg-black/8 text-accent-foreground dark:bg-white/14' - : 'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground dark:hover:bg-white/14' + : // Why: CSS :hover still matches a disabled button, so a pending row would + // light up under the pointer despite being unactivatable. + 'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground disabled:hover:bg-transparent disabled:hover:text-muted-foreground dark:hover:bg-white/14 dark:disabled:hover:bg-transparent' )} onClick={onClick} > - {presentation.icon} + {loading ? ( +