From 73ba67e8b61a8a2741aea6c6856b25140fad2a9b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:22:15 -0700 Subject: [PATCH] Allow directly search in google (#13863) * Allow direct search with configured search engines Users can now search directly from the tab creation menu using their configured search engine. Forced search mode (`?` prefix) skips file and tab matching for guaranteed search. Refactored tab-entry operations into focused modules for clarity: forced-search parsing, network-safe selection, keyboard focus, copy strings, empty options, and props types. Search routes through the same workspace browser tab opening mechanism used for URLs, with safe title and query presentation that doesn't retain sensitive details. * Allow direct search with configured search engines - Permit search and URL navigation while file index loads; require explicit selection only when needed, not automatic opening - Block malformed IPv6 addresses in bracket notation to prevent misclassification - Support Kagi private-session links via searchUrlOptions - Improve error handling with accessibility: show error messages in status region, disable input during submission, display loading state - Fall back to local browser tab creation when remote creation fails instead of throwing; avoids remote availability blocking local search/navigation - Add error translations for all supported locales (es, ja, ko, zh) * Allow direct search with configured search engines - Extract tab create entry lifecycle to key-driven component remounting, replacing conditional state reset with useEffect cleanup - Consolidate network tab entry classification and request building into reusable helpers, eliminating duplicate logic - Simplify owner resolution by inlining logic directly into openWorkspaceBrowserTab - Replace custom surrogate-pair handling with native String.toWellFormed() for search queries - Disable explicit URL classification to prioritize search-engine queries over raw URLs * Allow direct search from quick-open tab bar entry - Single-token queries keep file matches ranked above search (quick-open intent) - Multi-word phrases promote search to top, since they cannot be file paths - Arm network actions once file index fails or text is unambiguous search - Cache prepared file index to avoid re-processing per keystroke - Generate specific tab titles (e.g. 'example.com/docs') instead of generic 'Open URL' - Surface opening workspace when launching browser tabs remotely * Use readOnly instead of disabled for pending search input Maintain keyboard focus during submission so arrow/Escape navigation continues to work. Use aria-busy to indicate loading state accessibly. Also fixes button hover styling when disabled and cleans up error message handling in the classifier. * Treat bare searches as prompts; refine path and IP classification - Bare search queries (e.g., "?") no longer display as error rows - Path prefixes with existing matches are no longer blocked mid-keystroke - Private IPv4 addresses now use http, public addresses use https - Ambiguous inputs with non-numeric ports fall through to search instead of blocking - Improve diagnostics by logging failure reasons in openFailure (cherry picked from commit 55beeebd25d6f0e8802e63518d4614f68354a91b) --- .../src/components/quick-open-search.ts | 16 + .../tab-bar/TabBarCreateEntry.search.test.tsx | 315 ++++++++++++++++++ .../components/tab-bar/TabBarCreateEntry.tsx | 235 +++++++------ .../tab-bar/TabBarCreateEntryRow.test.tsx | 15 + .../tab-bar/TabBarCreateEntryRow.tsx | 32 +- .../tab-bar/tab-create-entry-action.test.ts | 88 +++-- .../tab-bar/tab-create-entry-action.ts | 111 +++--- .../tab-create-entry-classifier.test.ts | 231 ++++++++++++- .../tab-bar/tab-create-entry-classifier.ts | 288 ++++++++-------- .../tab-bar/tab-create-entry-copy.ts | 12 + .../tab-bar/tab-create-entry-empty-options.ts | 7 + .../tab-bar/tab-create-entry-file-matches.ts | 16 +- .../tab-create-entry-forced-search.test.ts | 17 + .../tab-bar/tab-create-entry-forced-search.ts | 8 + .../tab-create-entry-keyboard-focus.ts | 18 + .../tab-create-entry-network-selection.ts | 68 ++++ .../tab-bar/tab-create-entry-props.ts | 20 ++ .../tab-create-entry-url-classification.ts | 150 ++++++--- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- .../lib/workspace-browser-tab-open.test.ts | 202 +++++++++++ .../src/lib/workspace-browser-tab-open.ts | 162 +++++++++ .../src/runtime/web-runtime-session.test.ts | 57 ++++ .../src/runtime/web-runtime-session.ts | 23 +- src/shared/browser-url.test.ts | 15 + src/shared/browser-url.ts | 2 +- 29 files changed, 1784 insertions(+), 384 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-copy.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-empty-options.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-forced-search.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-keyboard-focus.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-props.ts create mode 100644 src/renderer/src/lib/workspace-browser-tab-open.test.ts create mode 100644 src/renderer/src/lib/workspace-browser-tab-open.ts 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 ? ( +