[Tabs] Preserve host routing and reduce search churn (#13114)

* fix tab search host routing and churn

* Fix open-tab search to resolve hosts from worktree when active host unkn

- Use worktree.hostId to resolve execution host instead of defaulting to LOCAL_EXECUTION_HOST_ID
- Correctly populate search results for remote-only worktrees when activeWorkspaceExecutionHostId is null
- Remove automatic focus of terminal tabs after search activation

* Prevent stale tab results when user keeps typing ahead of deferred searc

- useOpenTabSearch now returns {query, results} to track which query the results describe
- Gate tab results on query match so stale results don't appear on user's screen
- Add live region (role=status) for accessibility of tab switch error messages
- Distinguish missing-worktree from missing-page errors in browser page activation
- Improve host resolution to prefer active host when worktree and repo don't specify one

* Re-pin entry to deferred tab results that rank higher

Track whether selection auto-follows the top-ranked result or was
manually positioned. Re-pin entry to tabs when they rank higher,
but preserve manual selection.

* Consolidate browser focus requests and simplify selection state

- Extract requestBrowserFocus to handle queueing + event dispatch atomically
- Simplify omnibox selection tracking with single pinnedOptionId state
- Optimize host resolution in tab search to compute once per query

* Report dead browser workspaces correctly and fold dedupe case by host

Two readiness-checklist fixes for open-tab search:

- Browser page activation checked page/workspace before the worktree, but
  deleting a worktree purges its browser workspaces and pages too, so a dead
  workspace surfaced as "Browser page no longer exists". Check the worktree
  first; routing already maps missing-worktree to the workspace wording.

- Editor-tab/file dedupe compared paths with separator normalization only, so
  a Windows worktree offered both "Switch to tab" and "Open file" for the same
  path in different case. Fold by the worktree path's syntax via the new
  isCaseInsensitiveRuntimeRoot, keeping WSL, POSIX and SSH roots case-sensitive,
  and add NFC so a macOS NFD listing matches an editor's composed path.

* Fix tab deduplication and resolve worktree host collisions

- Only editor tabs should suppress file entries; check contentType instead
  of relying on path being empty for non-editor tabs.
- Add executionHostId to simulator search results to disambiguate when
  the same worktree id exists on multiple execution hosts.
This commit is contained in:
Jinjing
2026-08-08 17:46:11 -07:00
committed by GitHub
parent 8a773a5e3f
commit 17eefef502
26 changed files with 625 additions and 142 deletions
@@ -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' })
@@ -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
@@ -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<string, unknown[]>
}))
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> = {}): 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<string, unknown> = {}): 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.
@@ -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<string | null>(null)
const [switchError, setSwitchError] = useState<string | null>(null)
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null)
const [selectedOptionQuery, setSelectedOptionQuery] = useState(query)
// null = follow ranking (deferred tabs can prepend); set on arrow keys only.
const [pinnedOptionId, setPinnedOptionId] = useState<string | null>(null)
const [lastMenuOpen, setLastMenuOpen] = useState(menuOpen)
const inputRef = useRef<HTMLInputElement>(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({
/>
</div>
{/* Above the list, not instead of it: a stale switch target must not wipe
the rows the user can still act on. */}
{switchError ? (
<div className="mt-1 px-1">
<EntryStatusRow message={switchError} />
</div>
) : 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. */}
<div role="status">
{switchError ? (
<div className="mt-1 px-1">
<EntryStatusRow message={switchError} />
</div>
) : null}
</div>
{error || activeOptions.length > 0 || hasQuery ? (
<div
className="mt-1 space-y-0.5 px-1"
@@ -14,6 +14,7 @@ function editorTab(
relativePath: string | null
): Extract<OpenTabSearchResult, { source: 'workspace' }> {
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)
})
})
@@ -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<string>()
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))
)
}
@@ -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<Repo, 'displayName' | 'id'> | null
worktree: Worktree | null
repo: Pick<Repo, 'connectionId' | 'displayName' | 'executionHostId' | 'id'> | 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,
@@ -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,
@@ -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<OpenTabSearchResult, { source: 'workspace' }> = {
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'
@@ -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,
@@ -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([])
})
})
@@ -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]
)
}
+8 -1
View File
@@ -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.",
+8 -1
View File
@@ -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": "検索テキストが大きすぎます。",
+8 -1
View File
@@ -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": "검색 텍스트가 너무 큽니다.",
+8 -1
View File
@@ -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": "搜索文本过长。",
@@ -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()
})
@@ -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' }
@@ -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 = [
{
@@ -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,
@@ -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: {} })
@@ -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' }
@@ -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' })
@@ -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' }
}
+16
View File
@@ -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)
+16
View File
@@ -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('//')) {