diff --git a/src/renderer/src/components/browser-pane/browser-focus.test.ts b/src/renderer/src/components/browser-pane/browser-focus.test.ts index 722aec3fa9f..3e58d4ed637 100644 --- a/src/renderer/src/components/browser-pane/browser-focus.test.ts +++ b/src/renderer/src/components/browser-pane/browser-focus.test.ts @@ -1,5 +1,12 @@ +// @vitest-environment happy-dom + import { afterEach, describe, expect, it, vi } from 'vitest' -import { consumeBrowserFocusRequest, queueBrowserFocusRequest } from './browser-focus' +import { + consumeBrowserFocusRequest, + ORCA_BROWSER_FOCUS_REQUEST_EVENT, + queueBrowserFocusRequest, + requestBrowserFocus +} from './browser-focus' describe('browser-focus', () => { afterEach(() => { @@ -13,6 +20,20 @@ describe('browser-focus', () => { expect(consumeBrowserFocusRequest('page-1')).toBeNull() }) + it('requestBrowserFocus queues and dispatches the focus event', () => { + const detail = { pageId: 'page-req', target: 'address-bar' as const } + const events: CustomEvent[] = [] + const onFocusRequest = (event: Event): void => { + events.push(event as CustomEvent) + } + window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, onFocusRequest) + requestBrowserFocus(detail) + window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, onFocusRequest) + + expect(consumeBrowserFocusRequest('page-req')).toBe('address-bar') + expect(events[0]?.detail).toEqual(detail) + }) + it('overwrites older requests for the same page id', () => { queueBrowserFocusRequest({ pageId: 'page-2', target: 'webview' }) queueBrowserFocusRequest({ pageId: 'page-2', target: 'address-bar' }) diff --git a/src/renderer/src/components/browser-pane/browser-focus.ts b/src/renderer/src/components/browser-pane/browser-focus.ts index fb2324ebb17..e8b204c8139 100644 --- a/src/renderer/src/components/browser-pane/browser-focus.ts +++ b/src/renderer/src/components/browser-pane/browser-focus.ts @@ -64,6 +64,12 @@ export function queueBrowserFocusRequest(detail: BrowserFocusRequestDetail): voi scheduleExpiredRequestCleanup() } +/** Queue + announce so a mounting browser pane and live listeners both see the request. */ +export function requestBrowserFocus(detail: BrowserFocusRequestDetail): void { + queueBrowserFocusRequest(detail) + window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail })) +} + export function consumeBrowserFocusRequest(pageId: string): BrowserFocusTarget | null { purgeExpiredFocusRequests() const pending = pendingBrowserFocusByPageId.get(pageId) ?? null diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx index 844882f9d04..d96d28b89d2 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx @@ -25,14 +25,20 @@ vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) -// `hold` stands in for the hook's deferred query: rows the user has not seen yet. +// `hold` stands in for the hook's deferred query: it pins the results to the +// query they were built from, so later keystrokes leave them stale. const tabSearchMock = vi.hoisted(() => ({ - hold: false, + hold: null as string | null, resultsByQuery: {} as Record })) vi.mock('./use-open-tab-search', () => ({ - useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => - enabled && !tabSearchMock.hold ? (tabSearchMock.resultsByQuery[query.trim()] ?? []) : [] + useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => { + const resolved = tabSearchMock.hold ?? query + return { + query: enabled ? resolved : query, + results: enabled ? (tabSearchMock.resultsByQuery[resolved.trim()] ?? []) : [] + } + } })) // Selection routing itself stays real, so the focus handoff and failure messages @@ -42,7 +48,7 @@ const activationMocks = vi.hoisted(() => ({ browser: vi.fn(), simulator: vi.fn(), focusTerminalTabSurface: vi.fn(), - queueBrowserFocusRequest: vi.fn() + requestBrowserFocus: vi.fn() })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ activateWorkspaceTabPaletteResult: activationMocks.workspace @@ -57,8 +63,7 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: activationMocks.focusTerminalTabSurface })) vi.mock('@/components/browser-pane/browser-focus', () => ({ - ORCA_BROWSER_FOCUS_REQUEST_EVENT: 'orca:browser-focus-request', - queueBrowserFocusRequest: activationMocks.queueBrowserFocusRequest + requestBrowserFocus: activationMocks.requestBrowserFocus })) import TabBarCreateEntry from './TabBarCreateEntry' @@ -67,6 +72,7 @@ import TabBarCreateEntry from './TabBarCreateEntry' function terminalResult(overrides: Partial = {}): OpenTabSearchResult { return { + executionHostId: 'local', source: 'workspace', id: 'open-tab:workspace:tab-1', title: 'Add tab search and jump in worktree', @@ -158,7 +164,7 @@ function renderEntry(props: Record = {}): void { beforeEach(() => { vi.clearAllMocks() entryOptionsMock.options = [] - tabSearchMock.hold = false + tabSearchMock.hold = null tabSearchMock.resultsByQuery = {} activationMocks.workspace.mockReturnValue({ status: 'activated' }) activationMocks.browser.mockReturnValue({ @@ -226,17 +232,40 @@ describe('TabBarCreateEntry tab results', () => { expect(rows[3]).toContain('Open file') }) - it('keeps Enter on the row the user saw when a tab row arrives a render later', () => { + it('re-pins Enter to a deferred tab row that ranks above the auto-selected file', () => { entryOptionsMock.options = [newFileOption] tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] - tabSearchMock.hold = true + tabSearchMock.hold = '' + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + const onDidOpenEntry = vi.fn() + renderEntry({ onDidOpenEntry, onOpenEntry }) + + setQuery('add tab') + expect(rowTexts()).toHaveLength(1) + + tabSearchMock.hold = null + renderEntry({ onDidOpenEntry, onOpenEntry }) + expect(rowTexts()[0]).toContain('Switch to tab') + submitForm() + + expect(activationMocks.workspace).toHaveBeenCalledTimes(1) + expect(onDidOpenEntry).toHaveBeenCalledTimes(1) + expect(onOpenEntry).not.toHaveBeenCalled() + }) + + it('keeps a manually chosen row when a tab row arrives a render later', () => { + entryOptionsMock.options = [newFileOption] + tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] + tabSearchMock.hold = '' const onOpenEntry = vi.fn().mockResolvedValue(undefined) renderEntry({ onOpenEntry }) setQuery('add tab') expect(rowTexts()).toHaveLength(1) + // Arrow on the only row marks selection as user-owned, not auto-default. + pressKey(queryInput(), 'ArrowDown') - tabSearchMock.hold = false + tabSearchMock.hold = null renderEntry({ onOpenEntry }) expect(rowTexts()[0]).toContain('Switch to tab') submitForm() @@ -245,6 +274,26 @@ describe('TabBarCreateEntry tab results', () => { expect(activationMocks.workspace).not.toHaveBeenCalled() }) + it('drops tab rows built for an earlier query until the search catches up', () => { + entryOptionsMock.options = [newFileOption] + tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + + setQuery('add tab') + expect(rowTexts()[0]).toContain('Switch to tab') + + // The user keeps typing; the deferred search still describes 'add tab'. + tabSearchMock.hold = 'add tab' + setQuery('add tabs') + + expect(rowTexts().some((row) => row.includes('Switch to tab'))).toBe(false) + submitForm() + + expect(activationMocks.workspace).not.toHaveBeenCalled() + expect(onOpenEntry).toHaveBeenCalledTimes(1) + }) + it('activates a clicked tab row and closes the menu', () => { tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] const onDidOpenEntry = vi.fn() @@ -293,7 +342,10 @@ describe('TabBarCreateEntry tab results', () => { submitForm() expect(onDidOpenEntry).not.toHaveBeenCalled() - expect(container.textContent).toContain('Tab no longer exists') + // Announced, not just drawn: the failure lands inside the live region. + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'Tab no longer exists' + ) expect(rowTexts()).toHaveLength(3) // The other tab row and the create row below it still act. diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index f55a7e15f9e..9e741eaaef0 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -28,6 +28,7 @@ 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' @@ -45,6 +46,7 @@ function omniboxPlaceholder(): string { const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = [] const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = [] +const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = [] type TabBarCreateEntryProps = { agentOptions?: readonly TabAgentLaunchOption[] @@ -80,13 +82,16 @@ export default function TabBarCreateEntry({ const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [switchError, setSwitchError] = useState(null) - const [selectedOptionId, setSelectedOptionId] = useState(null) - const [selectedOptionQuery, setSelectedOptionQuery] = useState(query) + // 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 imeEnter = useImeEnterGestureOwnership() const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) - const tabResults = useOpenTabSearch({ enabled: menuOpen, query, worktreeId }) + const tabSearch = useOpenTabSearch({ enabled: menuOpen, query, 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 allowAbsolutePathsSelector = useMemo( () => @@ -96,6 +101,11 @@ export default function TabBarCreateEntry({ [shouldResolveAbsolutePaths, worktreeId] ) const allowAbsolutePaths = useAppStore(allowAbsolutePathsSelector) + // Why the worktree path: editor↔file dedupe folds case by the worktree's + // filesystem, which a Windows client's own platform does not describe. + const worktreePath = useAppStore((state) => + menuOpen ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null + ) const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix' // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the @@ -145,14 +155,23 @@ export default function TabBarCreateEntry({ allowAbsolutePaths, localPlatform }), - tabResults + tabResults, + worktreePath ) if (matchingMenuOptions.length === 0) { return entryOptions } // Why: a matched create-menu action should win over a generic new-file fallback. return entryOptions.filter((option) => option.classification.kind !== 'new-file') - }, [allowAbsolutePaths, fileList, localPlatform, matchingMenuOptions.length, query, tabResults]) + }, [ + allowAbsolutePaths, + fileList, + localPlatform, + matchingMenuOptions.length, + query, + tabResults, + worktreePath + ]) const matchingAgentOptions = useMemo( () => findMatchingTabAgentLaunchOptions(query, agentOptions), [agentOptions, query] @@ -165,7 +184,7 @@ export default function TabBarCreateEntry({ setPending(false) setError(null) setSwitchError(null) - setSelectedOptionId(null) + setPinnedOptionId(null) } } @@ -189,25 +208,19 @@ export default function TabBarCreateEntry({ option })) ] - const topOptionId = activeOptions.length > 0 ? getActiveOptionId(activeOptions[0]) : null - if (selectedOptionQuery !== query) { - setSelectedOptionQuery(query) - setSelectedOptionId(topOptionId) - } else if (selectedOptionId === null && topOptionId !== null) { - // Why pin the top row by id: the tab search defers the query, so tab rows - // arrive a render later and would otherwise slide under an index-kept highlight. - setSelectedOptionId(topOptionId) - } - const selectedOptionIndex = selectedOptionId - ? activeOptions.findIndex((option) => getActiveOptionId(option) === selectedOptionId) + // 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(selectedOptionIndex, 0) + const activeSelectedIndex = Math.max(pinnedOptionIndex, 0) const selectedActiveOption = activeOptions[activeSelectedIndex] const statusOption = options.find( (option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked' ) const statusMessage = - statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked' + statusOption != null && + (statusOption.classification.kind === 'empty' || statusOption.classification.kind === 'blocked') ? statusOption.classification.message : omniboxPlaceholder() @@ -289,7 +302,7 @@ export default function TabBarCreateEntry({ const delta = event.key === 'ArrowDown' ? 1 : -1 const nextIndex = (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length - setSelectedOptionId(getActiveOptionId(activeOptions[nextIndex])) + setPinnedOptionId(getActiveOptionId(activeOptions[nextIndex])) return } // Why: with no result rows the static create/agent items render below; @@ -321,6 +334,7 @@ export default function TabBarCreateEntry({ // it in this event rather than a later effect after the render commits. setQuery(nextQuery) onQueryChange?.(nextQuery) + setPinnedOptionId(null) setError(null) setSwitchError(null) }} @@ -339,12 +353,15 @@ export default function TabBarCreateEntry({ /> {/* Above the list, not instead of it: a stale switch target must not wipe - the rows the user can still act on. */} - {switchError ? ( -
- -
- ) : null} + 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. */} +
+ {switchError ? ( +
+ +
+ ) : null} +
{error || activeOptions.length > 0 || hasQuery ? (
{ return { + executionHostId: 'local', source: 'workspace', id: `open-tab:workspace:tab-${relativePath ?? 'none'}`, title: 'zebra.ts', @@ -27,12 +28,16 @@ function editorTab( } } +const POSIX_ROOT = '/tmp/wt-1' +const WINDOWS_ROOT = 'C:\\repos\\wt-1' +const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\wt-1' + describe('dropFileEntriesCoveredByTabResults', () => { it('drops the file row that duplicates an open editor tab', () => { const options = [existingFile('src/zebra.ts'), existingFile('src/other.ts')] expect( - dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')]).map( + dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')], POSIX_ROOT).map( (option) => option.id ) ).toEqual(['existing-file:src/other.ts']) @@ -42,7 +47,8 @@ describe('dropFileEntriesCoveredByTabResults', () => { expect( dropFileEntriesCoveredByTabResults( [existingFile('src/zebra.ts')], - [editorTab('src\\zebra.ts')] + [editorTab('src\\zebra.ts')], + POSIX_ROOT ) ).toEqual([]) }) @@ -63,13 +69,18 @@ describe('dropFileEntriesCoveredByTabResults', () => { } ] - expect(dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')])).toHaveLength(3) + expect( + dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')], POSIX_ROOT) + ).toHaveLength(3) }) it('never lets a terminal, browser or simulator result suppress a file entry', () => { const results: OpenTabSearchResult[] = [ - { ...editorTab(null), contentType: 'terminal' }, + // Non-null path on purpose: the fold must turn on contentType, not on a + // path the engine happens to leave empty for non-editor tabs. + { ...editorTab('src/zebra.ts'), contentType: 'terminal' }, { + executionHostId: 'local', source: 'browser', id: 'open-tab:browser:page-1', title: 'zebra', @@ -80,6 +91,7 @@ describe('dropFileEntriesCoveredByTabResults', () => { workspaceId: 'ws-1' }, { + executionHostId: 'local', source: 'simulator', id: 'open-tab:simulator:tab-2', title: 'zebra', @@ -92,13 +104,57 @@ describe('dropFileEntriesCoveredByTabResults', () => { ] expect( - dropFileEntriesCoveredByTabResults([existingFile('src/zebra.ts')], results) + dropFileEntriesCoveredByTabResults([existingFile('src/zebra.ts')], results, POSIX_ROOT) ).toHaveLength(1) }) + it('dedupes case-only differences on a Windows worktree', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('SRC/zebra.ts')], + WINDOWS_ROOT + ) + ).toEqual([]) + }) + + it('keeps case-only differences on case-sensitive worktrees', () => { + for (const root of [POSIX_ROOT, WSL_ROOT]) { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('src/zebra.ts')], + root + ) + ).toHaveLength(1) + } + }) + + // A Windows client can drive a case-sensitive SSH worktree, so the client + // platform must never decide the fold. + it('keeps case-only differences when the worktree path is unknown', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('src/zebra.ts')], + null + ) + ).toHaveLength(1) + }) + + it('matches a decomposed listing against a composed editor path', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/café.ts'.normalize('NFD'))], + [editorTab('src/café.ts'.normalize('NFC'))], + POSIX_ROOT + ) + ).toEqual([]) + }) + it('returns the same array when no tab result carries a path', () => { const options = [existingFile('src/zebra.ts')] - expect(dropFileEntriesCoveredByTabResults(options, [])).toBe(options) + expect(dropFileEntriesCoveredByTabResults(options, [], POSIX_ROOT)).toBe(options) }) }) diff --git a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts index 1e1b839d771..939948b3709 100644 --- a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts +++ b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts @@ -2,19 +2,29 @@ // switch row wins and the omnibox never offers to reopen what is already open. import { normalizeRelativePath } from '@/lib/path' +import { isCaseInsensitiveRuntimeRoot } from '../../../../shared/cross-platform-path' import type { OpenTabSearchResult } from './open-tab-search' import type { TabEntryOption } from './tab-create-entry-action' +// NFC so a macOS NFD directory listing matches the NFC path an editor recorded. +function comparisonKey(relativePath: string, foldCase: boolean): string { + const normalized = normalizeRelativePath(relativePath).normalize('NFC') + return foldCase ? normalized.toLowerCase() : normalized +} + export function dropFileEntriesCoveredByTabResults( options: readonly TabEntryOption[], - tabResults: readonly OpenTabSearchResult[] + tabResults: readonly OpenTabSearchResult[], + worktreePath: string | null ): readonly TabEntryOption[] { + // Folding follows the worktree's filesystem, not the client platform. + const foldCase = worktreePath !== null && isCaseInsensitiveRuntimeRoot(worktreePath) const openPaths = new Set() for (const result of tabResults) { - // Only editor-backed results carry a path; terminal, browser and simulator - // rows must never suppress a file entry. - if (result.source === 'workspace' && result.relativePath) { - openPaths.add(normalizeRelativePath(result.relativePath)) + // Only an open editor is the same destination as the file row; terminal, + // diff, review, browser and simulator rows must never suppress it. + if (result.source === 'workspace' && result.contentType === 'editor' && result.relativePath) { + openPaths.add(comparisonKey(result.relativePath, foldCase)) } } if (openPaths.size === 0) { @@ -23,6 +33,6 @@ export function dropFileEntriesCoveredByTabResults( return options.filter( (option) => option.classification.kind !== 'existing-file' || - !openPaths.has(normalizeRelativePath(option.classification.relativePath)) + !openPaths.has(comparisonKey(option.classification.relativePath, foldCase)) ) } diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index a57c6dfe891..1b16a7d845f 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -13,6 +13,11 @@ import { type SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search' import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' export type OpenTabSearchEntries = { workspaceTabs: readonly SearchableWorkspaceTab[] @@ -31,35 +36,50 @@ export type OpenTabSearchEntryState = Pick< | 'activeTabType' | 'activeTabTypeByWorktree' | 'activeWorktreeId' - | 'agentStatusByPaneKey' | 'browserPagesByWorkspace' | 'browserTabsByWorktree' | 'groupsByWorktree' | 'openFiles' - | 'retainedAgentsByPaneKey' - | 'sleepingAgentSessionsByPaneKey' | 'tabsByWorktree' | 'unifiedTabsByWorktree' > & { + executionHostId: ExecutionHostId generatedTitlesEnabled: boolean - repo: Pick | null - worktree: Worktree | null + repo: Pick | null + worktree: Worktree } -const EMPTY_ENTRIES: OpenTabSearchEntries = { - workspaceTabs: [], - browserPages: [], - simulatorTabs: [] -} +export type OpenTabSearchAgentState = Pick< + AppState, + 'agentStatusByPaneKey' | 'retainedAgentsByPaneKey' | 'sleepingAgentSessionsByPaneKey' +> // No group id: every tab of the worktree is offered, including the one the // column already shows, matching how Cmd+J lists the tab you are on. export function selectOpenTabSearchEntryState( state: AppState, worktreeId: string -): OpenTabSearchEntryState { +): OpenTabSearchEntryState | null { + const preferredHostId = + state.activeWorktreeId === worktreeId + ? (state.activeWorkspaceExecutionHostId ?? undefined) + : undefined // Why getKnownWorktreeById: folder workspaces are absent from worktreesByRepo. - const worktree = state.getKnownWorktreeById(worktreeId) ?? null + const worktree = state.getKnownWorktreeById(worktreeId, preferredHostId) ?? null + if (!worktree) { + return null + } + const repoCandidates = state.repos.filter((candidate) => candidate.id === worktree.repoId) + const resolvedHostId = worktree.hostId ?? preferredHostId + const repo = + (resolvedHostId + ? repoCandidates.find((candidate) => getRepoExecutionHostId(candidate) === resolvedHostId) + : undefined) ?? + repoCandidates[0] ?? + null + // preferredHostId last: it found this worktree, so it beats the local default + // when neither the worktree nor a repo names a host. + const executionHostId = getWorktreeExecutionHostId(worktree, repo ?? undefined, preferredHostId) return { activeBrowserTabId: state.activeBrowserTabId, activeFileId: state.activeFileId, @@ -70,33 +90,40 @@ export function selectOpenTabSearchEntryState( activeTabType: state.activeTabType, activeTabTypeByWorktree: state.activeTabTypeByWorktree, activeWorktreeId: state.activeWorktreeId, - agentStatusByPaneKey: state.agentStatusByPaneKey, browserPagesByWorkspace: state.browserPagesByWorkspace, browserTabsByWorktree: state.browserTabsByWorktree, + executionHostId, generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, groupsByWorktree: state.groupsByWorktree, openFiles: state.openFiles, - repo: worktree - ? (state.repos.find((candidate) => candidate.id === worktree.repoId) ?? null) - : null, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + repo, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktree } } -export function buildOpenTabSearchEntries(state: OpenTabSearchEntryState): OpenTabSearchEntries { - if (!state.worktree) { - return EMPTY_ENTRIES +export function selectOpenTabSearchAgentState(state: AppState): OpenTabSearchAgentState { + return { + agentStatusByPaneKey: state.agentStatusByPaneKey, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey } +} +export function buildOpenTabSearchEntries( + state: OpenTabSearchEntryState, + agentState: OpenTabSearchAgentState +): OpenTabSearchEntries { const { repo, worktree } = state - const worktrees = [worktree] + const scopedWorktree = + worktree.hostId === state.executionHostId + ? worktree + : { ...worktree, hostId: state.executionHostId } + const worktrees = [scopedWorktree] const scope = { worktrees, - repoMap: new Map(repo ? [[repo.id, { displayName: repo.displayName }]] : []), + repoMap: new Map(repo ? [[repo.id, repo]] : []), worktreeOrder: new Map([[worktree.id, 0]]) } @@ -106,9 +133,9 @@ export function buildOpenTabSearchEntries(state: OpenTabSearchEntryState): OpenT unifiedTabsByWorktree: state.unifiedTabsByWorktree, tabsByWorktree: state.tabsByWorktree, openFiles: state.openFiles, - agentStatusByPaneKey: state.agentStatusByPaneKey, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + agentStatusByPaneKey: agentState.agentStatusByPaneKey, + retainedAgentsByPaneKey: agentState.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: agentState.sleepingAgentSessionsByPaneKey, activeGroupIdByWorktree: state.activeGroupIdByWorktree, groupsByWorktree: state.groupsByWorktree, activeWorktreeId: state.activeWorktreeId, diff --git a/src/renderer/src/components/tab-bar/open-tab-search.ts b/src/renderer/src/components/tab-bar/open-tab-search.ts index 9e882984f2f..29be24f2834 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.ts @@ -2,6 +2,7 @@ // omnibox. Pure: no store, no React. import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { searchBrowserPages, type BrowserPaletteSearchResult, @@ -27,6 +28,7 @@ export const OPEN_TAB_SEARCH_QUERY_MAX_BYTES = 2 * 1024 export type OpenTabSearchSource = 'workspace' | 'browser' | 'simulator' type OpenTabSearchResultBase = { + executionHostId: ExecutionHostId /** Stable across renders, so selection survives the deferred query. */ id: string title: string @@ -84,7 +86,7 @@ const TITLE_SUBSTRING_TIER = 1 // weights. See the plan's tiering decision. const SECONDARY_TIER = 2 -export function isOpenTabSearchQueryTooLarge( +function isOpenTabSearchQueryTooLarge( query: string, maxBytes = OPEN_TAB_SEARCH_QUERY_MAX_BYTES ): boolean { @@ -128,9 +130,11 @@ function getEditorRelativePath(entry: SearchableWorkspaceTab | undefined): strin function baseResult( source: OpenTabSearchSource, id: string, - result: EngineResult + result: EngineResult, + executionHostId: ExecutionHostId ): OpenTabSearchResultBase { return { + executionHostId, id: `open-tab:${source}:${id}`, title: result.title, matchedText: getMatchedText(result), @@ -164,13 +168,20 @@ export function searchOpenTabs({ return [] } + // Single-worktree builders stamp one host on every entry; resolve once. + const executionHostId = + workspaceTabs[0]?.worktree.hostId ?? + browserPages[0]?.worktree.hostId ?? + simulatorTabs[0]?.worktree.hostId ?? + LOCAL_EXECUTION_HOST_ID + // Why map workspace only: editor relativePath is read from the searchable entry. const workspaceEntriesByTabId = new Map(workspaceTabs.map((entry) => [entry.tab.id, entry])) return [ // Why no isCurrentTab filter: Cmd+J lists the tab you are on, and hiding it // made the omnibox look broken when you searched for the tab on screen. ...rank('workspace', searchWorkspaceTabs([...workspaceTabs], trimmed), (result) => ({ - ...baseResult('workspace', result.tabId, result), + ...baseResult('workspace', result.tabId, result, executionHostId), source: 'workspace', contentType: result.contentType, tabId: result.tabId, @@ -179,14 +190,14 @@ export function searchOpenTabs({ relativePath: getEditorRelativePath(workspaceEntriesByTabId.get(result.tabId)) })), ...rank('browser', searchBrowserPages([...browserPages], trimmed), (result) => ({ - ...baseResult('browser', result.pageId, result), + ...baseResult('browser', result.pageId, result, executionHostId), source: 'browser', contentType: 'browser', pageId: result.pageId, workspaceId: result.workspaceId })), ...rank('simulator', searchSimulatorTabs([...simulatorTabs], trimmed), (result) => ({ - ...baseResult('simulator', result.tabId, result), + ...baseResult('simulator', result.tabId, result, executionHostId), source: 'simulator', contentType: 'simulator', tabId: result.tabId, diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts index 330374f019a..f9ec2cd76e5 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ activateBrowserPage: vi.fn(), activateSimulatorTab: vi.fn(), focusTerminalTabSurface: vi.fn(), - queueBrowserFocusRequest: vi.fn() + requestBrowserFocus: vi.fn() })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ @@ -24,13 +24,13 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: mocks.focusTerminalTabSurface })) vi.mock('@/components/browser-pane/browser-focus', () => ({ - ORCA_BROWSER_FOCUS_REQUEST_EVENT: 'orca:browser-focus-request', - queueBrowserFocusRequest: mocks.queueBrowserFocusRequest + requestBrowserFocus: mocks.requestBrowserFocus })) import { activateOpenTabSearchResult } from './open-tab-selection-routing' -const terminalResult: OpenTabSearchResult = { +const terminalResult: Extract = { + executionHostId: 'runtime:host-1', source: 'workspace', id: 'open-tab:workspace:tab-1', title: 'Claude Code', @@ -53,6 +53,7 @@ const editorResult: OpenTabSearchResult = { } const browserResult: OpenTabSearchResult = { + executionHostId: 'runtime:host-1', source: 'browser', id: 'open-tab:browser:page-1', title: 'Project Docs', @@ -64,6 +65,7 @@ const browserResult: OpenTabSearchResult = { } const simulatorResult: OpenTabSearchResult = { + executionHostId: 'runtime:host-1', source: 'simulator', id: 'open-tab:simulator:tab-3', title: 'iPhone 15', @@ -92,6 +94,7 @@ describe('activateOpenTabSearchResult', () => { expect(mocks.activateWorkspaceTab).toHaveBeenCalledWith({ contentType: 'terminal', entityId: 'term-1', + executionHostId: 'runtime:host-1', groupId: 'group-2', tabId: 'tab-1', worktreeId: 'wt-1' @@ -112,36 +115,33 @@ describe('activateOpenTabSearchResult', () => { it('carries the activation focus target into the browser focus request', () => { const outcome = activateOpenTabSearchResult(browserResult) expect(mocks.activateBrowserPage).toHaveBeenCalledWith({ + executionHostId: 'runtime:host-1', pageId: 'page-1', workspaceId: 'ws-1', worktreeId: 'wt-1' }) - const events: CustomEvent[] = [] - const onFocusRequest = (event: Event): void => { - events.push(event as CustomEvent) - } - window.addEventListener('orca:browser-focus-request', onFocusRequest) if (outcome.status !== 'activated') { throw new Error('expected activation') } outcome.focus?.() - window.removeEventListener('orca:browser-focus-request', onFocusRequest) - const detail = { pageId: 'page-1', target: 'address-bar' } - expect(mocks.queueBrowserFocusRequest).toHaveBeenCalledWith(detail) - expect(events[0]?.detail).toEqual(detail) + expect(mocks.requestBrowserFocus).toHaveBeenCalledWith({ + pageId: 'page-1', + target: 'address-bar' + }) }) - it('focuses the simulator tab the activation reports', () => { + it('leaves focus unchanged after activating a simulator tab', () => { const outcome = activateOpenTabSearchResult(simulatorResult) - if (outcome.status !== 'activated') { - throw new Error('expected activation') - } - outcome.focus?.() - expect(mocks.activateSimulatorTab).toHaveBeenCalledWith({ tabId: 'tab-3', worktreeId: 'wt-1' }) - expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + expect(mocks.activateSimulatorTab).toHaveBeenCalledWith({ + executionHostId: 'runtime:host-1', + tabId: 'tab-3', + worktreeId: 'wt-1' + }) + expect(outcome).toEqual({ status: 'activated', focus: null }) + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() }) it('reports a stale target per source', () => { @@ -166,12 +166,17 @@ describe('activateOpenTabSearchResult', () => { it('reports a missing worktree distinguishably from a stale tab', () => { mocks.activateWorkspaceTab.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) + mocks.activateBrowserPage.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) mocks.activateSimulatorTab.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) expect(activateOpenTabSearchResult(terminalResult)).toEqual({ status: 'failed', message: 'Workspace no longer exists' }) + expect(activateOpenTabSearchResult(browserResult)).toEqual({ + status: 'failed', + message: 'Workspace no longer exists' + }) expect(activateOpenTabSearchResult(simulatorResult)).toEqual({ status: 'failed', message: 'Workspace no longer exists' diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts index 3a024e52468..ceeabe7d8a7 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts @@ -1,11 +1,7 @@ // Routes an omnibox switch row to the matching palette activation and reports // how the destination should take keyboard focus once the menu closes. -import { - ORCA_BROWSER_FOCUS_REQUEST_EVENT, - queueBrowserFocusRequest, - type BrowserFocusRequestDetail -} from '@/components/browser-pane/browser-focus' +import { requestBrowserFocus } from '@/components/browser-pane/browser-focus' import { translate } from '@/i18n/i18n' import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -32,14 +28,10 @@ function failed(reason: string, staleMessage: string): OpenTabSelectionOutcome { } } -function requestBrowserPageFocus(detail: BrowserFocusRequestDetail): void { - queueBrowserFocusRequest(detail) - window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail })) -} - export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTabSelectionOutcome { if (result.source === 'browser') { const activation = activateBrowserPagePaletteResult({ + executionHostId: result.executionHostId, pageId: result.pageId, workspaceId: result.workspaceId, worktreeId: result.worktreeId @@ -56,12 +48,13 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa return { status: 'activated', focus: () => - requestBrowserPageFocus({ pageId: activation.pageId, target: activation.focusTarget }) + requestBrowserFocus({ pageId: activation.pageId, target: activation.focusTarget }) } } if (result.source === 'simulator') { const activation = activateSimulatorTabPaletteResult({ + executionHostId: result.executionHostId, tabId: result.tabId, worktreeId: result.worktreeId }) @@ -74,10 +67,11 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa ) ) } - return { status: 'activated', focus: () => focusTerminalTabSurface(activation.tabId) } + return { status: 'activated', focus: null } } const activation = activateWorkspaceTabPaletteResult({ + executionHostId: result.executionHostId, contentType: result.contentType, entityId: result.entityId, groupId: result.groupId, diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts index 62fdeb1c5e7..43a92cbf1f4 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts @@ -227,14 +227,113 @@ describe('useOpenTabSearch', () => { it('returns no results while disabled', () => { const { result } = renderSearch({ enabled: false }) - expect(result.current).toEqual([]) + expect(result.current.results).toEqual([]) }) it('returns only tabs from the requested worktree', () => { const { result } = renderSearch() - expect(result.current.map((entry) => entry.title)).not.toContain('zebra delta') - expect(result.current.every((entry) => entry.worktreeId === 'wt-1')).toBe(true) + expect(result.current.results.map((entry) => entry.title)).not.toContain('zebra delta') + expect(result.current.results.every((entry) => entry.worktreeId === 'wt-1')).toBe(true) + }) + + it('keeps the active runtime host when worktree ids collide', () => { + const runtimeHost = 'runtime:host-1' as const + const localWorktree = { ...makeWorktree('wt-1', 'Local'), hostId: 'local' as const } + const runtimeWorktree = { + ...makeWorktree('wt-1', 'Runtime'), + hostId: runtimeHost, + path: '/runtime/wt-1' + } + seedStore({ + activeWorkspaceExecutionHostId: runtimeHost, + repos: [ + { ...repo, executionHostId: 'local', path: '/local/repo-1' }, + { ...repo, executionHostId: runtimeHost, path: '/runtime/repo-1' } + ], + worktreesByRepo: { 'repo-1': [localWorktree, runtimeWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('resolves a hosted worktree when the active host is unknown', () => { + const runtimeHost = 'runtime:host-1' as const + const runtimeWorktree = { + ...makeWorktree('wt-1', 'Runtime'), + hostId: runtimeHost, + path: '/runtime/wt-1' + } + const localWorktree = { ...makeWorktree('wt-1', 'Local'), hostId: 'local' as const } + seedStore({ + activeWorkspaceExecutionHostId: null, + repos: [ + { ...repo, executionHostId: runtimeHost, path: '/runtime/repo-1' }, + { ...repo, executionHostId: 'local', path: '/local/repo-1' } + ], + worktreesByRepo: { 'repo-1': [runtimeWorktree, localWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('returns tabs for a remote-only worktree when the active host is unknown', () => { + const sshHost = 'ssh:remote-1' as const + const remoteWorktree = { + ...makeWorktree('wt-1', 'Remote'), + hostId: sshHost, + path: '/remote/wt-1' + } + seedStore({ + activeWorkspaceExecutionHostId: null, + repos: [{ ...repo, executionHostId: sshHost, path: '/remote/repo-1' }], + worktreesByRepo: { 'repo-1': [remoteWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === sshHost)).toBe(true) + }) + + it('falls back to the active host when neither the worktree nor a repo names one', () => { + const runtimeHost = 'runtime:env-1' as const + seedStore({ + activeWorkspaceExecutionHostId: runtimeHost, + repos: [], + worktreesByRepo: { + 'repo-1': [{ ...makeWorktree('wt-1', 'Runtime'), runtimeOwnerEnvironmentId: 'env-1' }] + } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('does not rebuild results for agent-status heartbeats while open', () => { + const { result } = renderSearch() + const initialResults = result.current.results + const state = useAppStore.getState() + + act(() => { + useAppStore.setState({ agentStatusByPaneKey: { ...state.agentStatusByPaneKey } }) + }) + + expect(result.current.results).toBe(initialResults) }) it('includes tabs from every column of the worktree, not just the focused one', () => { @@ -242,7 +341,7 @@ describe('useOpenTabSearch', () => { // zebra alpha is the focused tab and still listed, ranked first by the // engine's current-tab bonus, the way Cmd+J lists the tab you are on. - expect(result.current.map((entry) => entry.title)).toEqual([ + expect(result.current.results.map((entry) => entry.title)).toEqual([ 'zebra alpha', 'zebra beta', 'zebra gamma', @@ -252,7 +351,7 @@ describe('useOpenTabSearch', () => { it('reflects tab changes while open', () => { const { result } = renderSearch({ query: 'epsilon' }) - expect(result.current).toEqual([]) + expect(result.current.results).toEqual([]) const state = useAppStore.getState() act(() => { @@ -281,7 +380,7 @@ describe('useOpenTabSearch', () => { }) }) - expect(result.current.map((entry) => entry.title)).toEqual(['zebra epsilon']) + expect(result.current.results.map((entry) => entry.title)).toEqual(['zebra epsilon']) }) it('reflects the generated-titles setting in matched titles', () => { @@ -296,7 +395,7 @@ describe('useOpenTabSearch', () => { }) const { result } = renderSearch({ query: 'generated' }) - expect(result.current.map((entry) => entry.title)).toEqual(['zebra generated']) + expect(result.current.results.map((entry) => entry.title)).toEqual(['zebra generated']) seedStore({ tabsByWorktree: { @@ -304,6 +403,6 @@ describe('useOpenTabSearch', () => { } }) const disabled = renderSearch({ query: 'generated' }) - expect(disabled.result.current).toEqual([]) + expect(disabled.result.current.results).toEqual([]) }) }) diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.ts index 0d0644c4725..9374efca953 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.ts @@ -3,7 +3,11 @@ import { useDeferredValue, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' -import { buildOpenTabSearchEntries, selectOpenTabSearchEntryState } from './open-tab-search-entries' +import { + buildOpenTabSearchEntries, + selectOpenTabSearchAgentState, + selectOpenTabSearchEntryState +} from './open-tab-search-entries' import { searchOpenTabs, type OpenTabSearchResult } from './open-tab-search' const EMPTY_RESULTS: OpenTabSearchResult[] = [] @@ -14,20 +18,39 @@ export type UseOpenTabSearchOptions = { worktreeId: string } +export type OpenTabSearchSnapshot = { + /** The query `results` describe; lags the requested query while deferred. */ + query: string + results: OpenTabSearchResult[] +} + export function useOpenTabSearch({ enabled, query, worktreeId -}: UseOpenTabSearchOptions): OpenTabSearchResult[] { +}: UseOpenTabSearchOptions): OpenTabSearchSnapshot { // Why null while disabled: a closed menu stays stable across store churn. const state = useAppStore( useShallow((store) => (enabled ? selectOpenTabSearchEntryState(store, worktreeId) : null)) ) - const entries = useMemo(() => (state ? buildOpenTabSearchEntries(state) : null), [state]) + // Why snapshot: agent status is a high-frequency stream; tab search metadata + // stays stable while the menu is open and refreshes when its tab set changes. + const agentState = useMemo( + () => (enabled ? selectOpenTabSearchAgentState(useAppStore.getState()) : null), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Refresh on open or tab-set changes, never agent-status churn. + [enabled, state?.tabsByWorktree, state?.unifiedTabsByWorktree, worktreeId] + ) + const entries = useMemo( + () => (state && agentState ? buildOpenTabSearchEntries(state, agentState) : null), + [agentState, state] + ) const deferredQuery = useDeferredValue(query) return useMemo( - () => (entries ? searchOpenTabs({ ...entries, query: deferredQuery }) : EMPTY_RESULTS), + () => ({ + query: deferredQuery, + results: entries ? searchOpenTabs({ ...entries, query: deferredQuery }) : EMPTY_RESULTS + }), [deferredQuery, entries] ) } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b776c361487..21bb7d9149a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2987,7 +2987,13 @@ "d62d63b807": "Crear archivo", "25dc1cd653": "Abrir archivo", "7cdf8ee0c8": "Abrir URL", - "b27864279e": "Iniciar agente" + "b27864279e": "Iniciar agente", + "0e5b7a3f16": "Buscar pestañas abiertas, archivos, URL y agentes…", + "8f0a1c4d92": "Cambiar a la pestaña", + "2c38630a01": "El espacio de trabajo ya no existe", + "4f0d9a71c2": "La pestaña ya no existe", + "d7d496a451": "La página del navegador ya no existe", + "7726ce9970": "La pestaña del emulador móvil ya no existe" }, "TabBarQuickCommandsButton": { "a2c7a33831": "Comando", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "No hay acción disponible.", "097a982ee0": "Cargando archivos...", + "c41f8d20b7": "Buscar pestañas abiertas, archivos, URL y agentes…", "90eb94dc48": "Introduce una URL http:// o https://.", "5553b283ce": "Introduce una URL o ruta de archivo.", "queryTooLarge": "El texto de búsqueda es demasiado grande.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 31f0d31a827..c2d28dcc7ed 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2987,7 +2987,13 @@ "d62d63b807": "ファイルの作成", "25dc1cd653": "ファイルを開く", "7cdf8ee0c8": "URLを開く", - "b27864279e": "agent を起動" + "b27864279e": "agent を起動", + "0e5b7a3f16": "開いているタブ、ファイル、URL、agent を検索…", + "8f0a1c4d92": "タブに切り替える", + "2c38630a01": "ワークスペースは存在しません", + "4f0d9a71c2": "タブは存在しません", + "d7d496a451": "ブラウザページは存在しません", + "7726ce9970": "モバイルエミュレータのタブは存在しません" }, "TabBarQuickCommandsButton": { "a2c7a33831": "コマンド", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "利用可能な操作はありません。", "097a982ee0": "ファイルをロード中...", + "c41f8d20b7": "開いているタブ、ファイル、URL、agent を検索…", "90eb94dc48": "http:// または https:// URL を入力します。", "5553b283ce": "URL またはファイル パスを入力します。", "queryTooLarge": "検索テキストが大きすぎます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4e3c59f3569..edd4ce24e9e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2987,7 +2987,13 @@ "d62d63b807": "파일 생성", "25dc1cd653": "파일 열기", "7cdf8ee0c8": "URL 열기", - "b27864279e": "agent 실행" + "b27864279e": "agent 실행", + "0e5b7a3f16": "열린 탭, 파일, URL, agent 검색…", + "8f0a1c4d92": "탭으로 전환", + "2c38630a01": "워크스페이스가 더 이상 존재하지 않습니다", + "4f0d9a71c2": "탭이 더 이상 존재하지 않습니다", + "d7d496a451": "브라우저 페이지가 더 이상 존재하지 않습니다", + "7726ce9970": "모바일 에뮬레이터 탭이 더 이상 존재하지 않습니다" }, "TabBarQuickCommandsButton": { "a2c7a33831": "명령", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "사용할 수 있는 작업이 없습니다.", "097a982ee0": "파일 로드 중...", + "c41f8d20b7": "열린 탭, 파일, URL, agent 검색…", "90eb94dc48": "http:// 또는 https:// URL을 입력하세요.", "5553b283ce": "URL 또는 파일 경로를 입력하세요.", "queryTooLarge": "검색 텍스트가 너무 큽니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 96a748a9898..0605c965926 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2999,7 +2999,13 @@ "d62d63b807": "创建文件", "25dc1cd653": "打开文件", "7cdf8ee0c8": "打开网址", - "b27864279e": "启动智能体" + "b27864279e": "启动智能体", + "0e5b7a3f16": "搜索打开的标签页、文件、URL 和智能体…", + "8f0a1c4d92": "切换到标签页", + "2c38630a01": "工作区已不存在", + "4f0d9a71c2": "标签页已不存在", + "d7d496a451": "浏览器页面已不存在", + "7726ce9970": "移动模拟器标签页已不存在" }, "TabBarQuickCommandsButton": { "a2c7a33831": "命令", @@ -3033,6 +3039,7 @@ "classifier": { "42e6262ae9": "没有可用的操作。", "097a982ee0": "正在加载文件...", + "c41f8d20b7": "搜索打开的标签页、文件、URL 和智能体…", "90eb94dc48": "输入 http:// 或 https:// URL。", "5553b283ce": "输入 URL 或文件路径。", "queryTooLarge": "搜索文本过长。", diff --git a/src/renderer/src/lib/browser-page-palette-activation.test.ts b/src/renderer/src/lib/browser-page-palette-activation.test.ts index 71c3f9922b3..d2264698c02 100644 --- a/src/renderer/src/lib/browser-page-palette-activation.test.ts +++ b/src/renderer/src/lib/browser-page-palette-activation.test.ts @@ -215,7 +215,7 @@ describe('activateBrowserPagePaletteResult', () => { }) }) - it('reports a missing page, workspace or worktree as a stale target', () => { + it('reports a missing page or workspace as a stale target', () => { seedStore({ browserPagesByWorkspace: {} }) expect(activateBrowserPagePaletteResult(target)).toEqual({ status: 'failed', @@ -228,12 +228,33 @@ describe('activateBrowserPagePaletteResult', () => { reason: 'missing-page' }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + // A live page in a dead workspace is a different story than a dead page. + it('reports an absent worktree as a missing workspace', () => { seedStore({ worktreesByRepo: {} }) + expect(activateBrowserPagePaletteResult(target)).toEqual({ status: 'failed', - reason: 'missing-page' + reason: 'missing-worktree' + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + // Deleting a worktree purges its browser workspaces and pages too, so the + // worktree check must win or a dead workspace reads as a stale page. + it('reports a deleted worktree as a missing workspace once its pages are purged', () => { + seedStore({ + worktreesByRepo: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {} }) + expect(activateBrowserPagePaletteResult(target)).toEqual({ + status: 'failed', + reason: 'missing-worktree' + }) expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/browser-page-palette-activation.ts b/src/renderer/src/lib/browser-page-palette-activation.ts index 896657fdd8a..3dc175e56bb 100644 --- a/src/renderer/src/lib/browser-page-palette-activation.ts +++ b/src/renderer/src/lib/browser-page-palette-activation.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import type { ExecutionHostId } from '../../../shared/execution-host' import { isBlankBrowserUrl } from './browser-palette-search' import { activateAndRevealWorktree } from './worktree-activation' @@ -11,12 +12,14 @@ export type BrowserPagePaletteActivationResult = | { status: 'failed'; reason: BrowserPagePaletteActivationFailure } export type BrowserPagePaletteActivationTarget = { + executionHostId?: ExecutionHostId pageId: string workspaceId: string worktreeId: string } export function activateBrowserPagePaletteResult({ + executionHostId, pageId, workspaceId, worktreeId @@ -28,8 +31,13 @@ export function activateBrowserPagePaletteResult({ const workspace = (initialState.browserTabsByWorktree[worktreeId] ?? []).find( (candidate) => candidate.id === workspaceId ) - const worktree = initialState.getKnownWorktreeById(worktreeId) - if (!page || !workspace || !worktree) { + const worktree = initialState.getKnownWorktreeById(worktreeId, executionHostId) + // Why worktree first: removing a worktree also purges its browser workspaces + // and pages, so a page-first check would report a dead workspace as a stale page. + if (!worktree) { + return { status: 'failed', reason: 'missing-worktree' } + } + if (!page || !workspace) { return { status: 'failed', reason: 'missing-page' } } @@ -39,9 +47,10 @@ export function activateBrowserPagePaletteResult({ ? 'address-bar' : 'webview' + const targetHostId = executionHostId ?? worktree.hostId const activated = activateAndRevealWorktree( worktree.id, - worktree.hostId ? { executionHostId: worktree.hostId } : {} + targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index 52393f15422..989f70554f6 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -96,6 +96,40 @@ describe('simulator-palette-search', () => { ]) }) + it('stamps each row with its own execution host when worktree ids collide', () => { + // Two hosts can serve the same worktree id, so activation needs the host + // that owns the row rather than the first id match in the store. + const entries = [ + { + tab: makeTab({ id: 'sim-local' }), + worktree: makeWorktree(), + repoName: 'repo/mobile', + worktreeSortIndex: 0, + isCurrentTab: false, + isCurrentWorktree: false + }, + { + tab: makeTab({ id: 'sim-remote' }), + worktree: makeWorktree({ hostId: 'ssh:host-1' }), + repoName: 'repo/mobile', + worktreeSortIndex: 1, + isCurrentTab: false, + isCurrentWorktree: false + } + ] + + expect( + searchSimulatorTabs(entries, 'emulator').map((result) => [ + result.tabId, + result.worktreeId, + result.executionHostId + ]) + ).toEqual([ + ['sim-local', 'wt-1', undefined], + ['sim-remote', 'wt-1', 'ssh:host-1'] + ]) + }) + it('matches mobile emulator and simulator aliases', () => { const entries = [ { diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 1c2f7efc89e..d1c7b47779b 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' @@ -14,6 +15,8 @@ export type SearchableSimulatorTab = { } export type SimulatorPaletteSearchResult = { + /** Worktree ids collide across hosts; activation must not resolve by id alone. */ + executionHostId?: ExecutionHostId tabId: string worktreeId: string groupId: string @@ -198,6 +201,7 @@ export function searchSimulatorTabs( // Why: a cleared display name leaves this undefined at runtime; findRange would throw. const worktreeName = resolveWorktreeDisplayName(entry.worktree) const baseResult = { + executionHostId: entry.worktree.hostId, tabId: entry.tab.id, worktreeId: entry.worktree.id, groupId: entry.tab.groupId, diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts index 924f0c18b77..07c1090f622 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts @@ -110,6 +110,22 @@ describe('activateSimulatorTabPaletteResult', () => { }) }) + it('picks the host that owns the row when the worktree id exists on two hosts', () => { + seedStore({ + worktreesByRepo: { + 'repo-1': [makeWorktree({ hostId: 'ssh:host-1' })], + 'repo-2': [makeWorktree({ repoId: 'repo-2', hostId: 'ssh:host-2', path: '/tmp/wt-1-b' })] + } + }) + + expect( + activateSimulatorTabPaletteResult({ ...target, executionHostId: 'ssh:host-2' }).status + ).toBe('activated') + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + executionHostId: 'ssh:host-2' + }) + }) + it('reports an unknown worktree without activating', () => { seedStore({ worktreesByRepo: {} }) diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.ts b/src/renderer/src/lib/simulator-tab-palette-activation.ts index 959bdd29c9d..abae54d775d 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' export type SimulatorTabPaletteActivationFailure = 'missing-tab' | 'missing-worktree' @@ -8,11 +9,13 @@ export type SimulatorTabPaletteActivationResult = | { status: 'failed'; reason: SimulatorTabPaletteActivationFailure } export type SimulatorTabPaletteActivationTarget = { + executionHostId?: ExecutionHostId tabId: string worktreeId: string } export function activateSimulatorTabPaletteResult({ + executionHostId, tabId, worktreeId }: SimulatorTabPaletteActivationTarget): SimulatorTabPaletteActivationResult { @@ -24,16 +27,15 @@ export function activateSimulatorTabPaletteResult({ return { status: 'failed', reason: 'missing-tab' } } - // Why thread hostId: activateAndRevealWorktree resolves the worktree and stores - // activeWorkspaceExecutionHostId from it, so remote-hosted worktrees need it. - const worktree = initialState.getKnownWorktreeById(worktreeId) + const worktree = initialState.getKnownWorktreeById(worktreeId, executionHostId) if (!worktree) { return { status: 'failed', reason: 'missing-worktree' } } + const targetHostId = executionHostId ?? worktree.hostId const activated = activateAndRevealWorktree( worktree.id, - worktree.hostId ? { executionHostId: worktree.hostId } : {} + targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 5d0def8b8ba..386fb25d958 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -175,6 +175,17 @@ describe('activateWorkspaceTabPaletteResult', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1') }) + it('scopes activation to the host carried by the search result', () => { + const executionHostId = 'runtime:host-1' as const + + expect(activateWorkspaceTabPaletteResult({ ...makeResult(), executionHostId })).toEqual({ + status: 'activated' + }) + + expect(mocks.store.getKnownWorktreeById).toHaveBeenCalledWith('wt-1', executionHostId) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { executionHostId }) + }) + it('activates tabs in known folder or detected workspaces', () => { mocks.store.worktreesByRepo = {} mocks.store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1' }) diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 0b75ec1db81..688dae2fe7d 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -6,6 +6,7 @@ import { } from '@/runtime/web-runtime-session' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search' @@ -23,7 +24,7 @@ export type WorkspaceTabPaletteActivationResult = export type WorkspaceTabPaletteActivationTarget = Pick< WorkspaceTabPaletteSearchResult, 'contentType' | 'entityId' | 'groupId' | 'tabId' | 'worktreeId' -> +> & { executionHostId?: ExecutionHostId } type WorkspaceTabPaletteActivationState = Pick< AppState, @@ -42,7 +43,7 @@ function validateTarget( state: WorkspaceTabPaletteActivationState, result: WorkspaceTabPaletteActivationTarget ): WorkspaceTabPaletteActivationFailure | null { - if (!state.getKnownWorktreeById(result.worktreeId)) { + if (!state.getKnownWorktreeById(result.worktreeId, result.executionHostId)) { return 'missing-worktree' } const group = (state.groupsByWorktree[result.worktreeId] ?? []).find( @@ -82,7 +83,11 @@ export function activateWorkspaceTabPaletteResult( return { status: 'failed', reason: initialFailure } } - const activated = activateAndRevealWorktree(result.worktreeId) + const executionHostId = + result.executionHostId ?? initialState.getKnownWorktreeById(result.worktreeId)?.hostId + const activated = executionHostId + ? activateAndRevealWorktree(result.worktreeId, { executionHostId }) + : activateAndRevealWorktree(result.worktreeId) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } } diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 5e3ce6dd4de..398de1913a0 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isCaseInsensitiveRuntimeRoot, isPathInsideOrEqual, isRuntimePathAbsolute, normalizeRuntimePathForComparison, @@ -7,6 +8,21 @@ import { resolveRuntimePath } from './cross-platform-path' +describe('isCaseInsensitiveRuntimeRoot', () => { + it('folds Windows drive and plain UNC roots', () => { + expect(isCaseInsensitiveRuntimeRoot('C:\\repos\\app')).toBe(true) + expect(isCaseInsensitiveRuntimeRoot('c:/repos/app')).toBe(true) + expect(isCaseInsensitiveRuntimeRoot('\\\\server\\share\\app')).toBe(true) + }) + + it('keeps WSL UNC and POSIX roots case-sensitive', () => { + expect(isCaseInsensitiveRuntimeRoot('\\\\wsl.localhost\\Ubuntu\\home\\ada\\app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('//wsl$/Ubuntu/home/ada/app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('/home/ada/app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('/Users/ada/app')).toBe(false) + }) +}) + describe('cross-platform path containment', () => { it('keeps POSIX sibling prefixes outside the root', () => { expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index de59442d990..3ef49e310e0 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -1,9 +1,25 @@ +import { isWslUncPath } from './wsl-paths' + const SLASH_CHAR_CODE = '/'.charCodeAt(0) export function isWindowsAbsolutePathLike(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') } +/** + * Whether names under `rootPath` compare case-insensitively. + * + * Decided by path SYNTAX, never by the client platform — a Windows client can + * drive a case-sensitive SSH or WSL workspace. Windows drive/UNC roots fold + * case; the WSL UNC aliases front a case-sensitive Linux filesystem, as do + * POSIX roots. macOS stays case-sensitive here, matching + * `normalizeRuntimePathForComparison`: folding a case-sensitive root would + * merge distinct files, which is worse than missing a case-only duplicate. + */ +export function isCaseInsensitiveRuntimeRoot(rootPath: string): boolean { + return isWindowsAbsolutePathLike(rootPath) && !isWslUncPath(rootPath) +} + export function normalizeRuntimePathSeparators(value: string): string { const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/') if (value.startsWith('\\\\') || value.startsWith('//')) {