From 4d7d306f1cf6e679a84ced33ce84ab2fc6918bfd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 19:17:05 -0700 Subject: [PATCH] Consolidate tab bar host platform capability --- .../src/components/tab-bar/TabBar.tsx | 46 ++++--------- .../TabBar.windows-shell-launch.test.ts | 21 +++--- .../lib/windows-terminal-capabilities.test.ts | 68 +++++++++++++------ .../src/lib/windows-terminal-capabilities.ts | 15 ++-- 4 files changed, 84 insertions(+), 66 deletions(-) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 6213b35aa14..736e8aa8b36 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -216,41 +216,23 @@ function TabBarInner({ ), [agentCmdOverrides, defaultAgent, detectedIds] ) - const [runtimeHostPlatform, setRuntimeHostPlatform] = useState(null) - useEffect(() => { - if ( - !(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ || - !activeRuntimeEnvironmentId - ) { - setRuntimeHostPlatform(null) - return - } - let cancelled = false - void window.api.runtime - .getStatus() - .then((status) => { - if (!cancelled) { - setRuntimeHostPlatform(status.hostPlatform ?? null) - } - }) - .catch(() => { - if (!cancelled) { - setRuntimeHostPlatform(null) - } - }) - return () => { - cancelled = true - } - }, [activeRuntimeEnvironmentId]) + const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true + const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( + activeRuntimeEnvironmentId + ) + const shouldProbeWindowsShellCapabilities = + (isWindows || (isWebClient && activeRuntimeEnvironmentId !== null)) && + !worktreeHasRemoteConnection + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + shouldProbeWindowsShellCapabilities, + false, + windowsTerminalCapabilityOwnerKey + ) // Why: SSH-backed PTYs ignore local Windows shell overrides; showing these // entries there promises PowerShell/CMD/Git Bash but opens the remote shell. const shouldShowWindowsShellMenu = - (isWindows || runtimeHostPlatform === 'win32') && !worktreeHasRemoteConnection - const windowsTerminalCapabilities = useWindowsTerminalCapabilities( - shouldShowWindowsShellMenu, - false, - getWindowsTerminalCapabilityOwnerKey(activeRuntimeEnvironmentId) - ) + (isWindows || windowsTerminalCapabilities.hostPlatform === 'win32') && + !worktreeHasRemoteConnection const resolvedGroupId = groupId ?? worktreeId const statusByRelativePath = useMemo(() => buildStatusMap(gitStatusEntries), [gitStatusEntries]) diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index b2ebd692348..e47c3c78710 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -16,7 +16,6 @@ const appStoreSnapshot: { repos: [], worktreesByRepo: {} } -let runtimeHostPlatformState: NodeJS.Platform | null | undefined const useAppStoreMock = vi.fn( ( @@ -58,9 +57,6 @@ vi.mock('react', async () => { useRef: (current: T) => ({ current }), useState: (initial: T | (() => T)) => { const value = typeof initial === 'function' ? (initial as () => T)() : initial - if (value === null && runtimeHostPlatformState !== undefined) { - return [runtimeHostPlatformState as T, vi.fn()] as const - } return [value, vi.fn()] as const } } @@ -251,7 +247,6 @@ describe('TabBar PowerShell launch wiring', () => { appStoreSnapshot.activeRuntimeEnvironmentId = null appStoreSnapshot.repos = [] appStoreSnapshot.worktreesByRepo = {} - runtimeHostPlatformState = undefined vi.stubGlobal('navigator', { userAgent: 'Windows' }) }) @@ -267,7 +262,8 @@ describe('TabBar PowerShell launch wiring', () => { listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) const capabilities = await import('@/lib/windows-terminal-capabilities') @@ -318,7 +314,8 @@ describe('TabBar PowerShell launch wiring', () => { listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) const capabilities = await import('@/lib/windows-terminal-capabilities') @@ -364,11 +361,11 @@ describe('TabBar PowerShell launch wiring', () => { listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) appStoreSnapshot.activeRuntimeEnvironmentId = 'web-env-1' - runtimeHostPlatformState = 'win32' const capabilities = await import('@/lib/windows-terminal-capabilities') await capabilities.loadWindowsTerminalCapabilities({ force: true, @@ -419,7 +416,8 @@ describe('TabBar PowerShell launch wiring', () => { listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(true) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(true) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) const capabilities = await import('@/lib/windows-terminal-capabilities') @@ -474,7 +472,8 @@ describe('TabBar PowerShell launch wiring', () => { listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(true) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(true) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) const capabilities = await import('@/lib/windows-terminal-capabilities') diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index da218e5d1c1..7e19ba5da98 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -12,26 +12,32 @@ function stubTerminalCapabilityApi(args: { pwshAvailable: boolean wslDistros?: string[] gitBashAvailable?: boolean + hostPlatform?: NodeJS.Platform | null }): { wslIsAvailable: ReturnType wslListDistros: ReturnType pwshIsAvailable: ReturnType isGitBashAvailable: ReturnType + runtimeGetStatus: ReturnType } { const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable) const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? []) const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) const isGitBashAvailable = vi.fn().mockResolvedValue(args.gitBashAvailable ?? false) + const runtimeGetStatus = vi + .fn() + .mockResolvedValue({ hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32' }) vi.stubGlobal('window', { api: { wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, pwsh: { isAvailable: pwshIsAvailable }, - gitBash: { isAvailable: isGitBashAvailable } + gitBash: { isAvailable: isGitBashAvailable }, + runtime: { getStatus: runtimeGetStatus } } }) - return { wslIsAvailable, wslListDistros, pwshIsAvailable, isGitBashAvailable } + return { wslIsAvailable, wslListDistros, pwshIsAvailable, isGitBashAvailable, runtimeGetStatus } } describe('windows terminal capabilities', () => { @@ -41,19 +47,25 @@ describe('windows terminal capabilities', () => { }) it('shares WSL, PowerShell, and Git Bash availability between terminal UI consumers', async () => { - const { wslIsAvailable, wslListDistros, pwshIsAvailable, isGitBashAvailable } = - stubTerminalCapabilityApi({ - wslAvailable: true, - pwshAvailable: true, - wslDistros: ['Ubuntu'], - gitBashAvailable: true - }) + const { + wslIsAvailable, + wslListDistros, + pwshIsAvailable, + isGitBashAvailable, + runtimeGetStatus + } = stubTerminalCapabilityApi({ + wslAvailable: true, + pwshAvailable: true, + wslDistros: ['Ubuntu'], + gitBashAvailable: true + }) expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: false, wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + hostPlatform: null, isLoading: false }) @@ -62,6 +74,7 @@ describe('windows terminal capabilities', () => { wslDistros: ['Ubuntu'], pwshAvailable: true, gitBashAvailable: true, + hostPlatform: 'win32', isLoading: false } await expect(loadWindowsTerminalCapabilities()).resolves.toEqual(expected) @@ -72,6 +85,7 @@ describe('windows terminal capabilities', () => { expect(wslListDistros).toHaveBeenCalledTimes(1) expect(pwshIsAvailable).toHaveBeenCalledTimes(1) expect(isGitBashAvailable).toHaveBeenCalledTimes(1) + expect(runtimeGetStatus).toHaveBeenCalledTimes(1) }) it('keeps WSL available when the PowerShell version probe fails', async () => { @@ -81,7 +95,8 @@ describe('windows terminal capabilities', () => { api: { wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) @@ -90,6 +105,7 @@ describe('windows terminal capabilities', () => { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + hostPlatform: 'win32', isLoading: false }) }) @@ -101,7 +117,8 @@ describe('windows terminal capabilities', () => { api: { wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) @@ -125,7 +142,8 @@ describe('windows terminal capabilities', () => { api: { wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(false) } + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) @@ -144,6 +162,10 @@ describe('windows terminal capabilities', () => { it('does not reuse capability cache between runtime owners', async () => { const isGitBashAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const runtimeGetStatus = vi + .fn() + .mockResolvedValueOnce({ hostPlatform: 'win32' }) + .mockResolvedValueOnce({ hostPlatform: 'linux' }) vi.stubGlobal('window', { api: { wsl: { @@ -151,24 +173,28 @@ describe('windows terminal capabilities', () => { listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, - gitBash: { isAvailable: isGitBashAvailable } + gitBash: { isAvailable: isGitBashAvailable }, + runtime: { getStatus: runtimeGetStatus } } }) await expect( loadWindowsTerminalCapabilities({ ownerKey: 'runtime:host-a' }) - ).resolves.toMatchObject({ gitBashAvailable: true }) + ).resolves.toMatchObject({ gitBashAvailable: true, hostPlatform: 'win32' }) await expect( loadWindowsTerminalCapabilities({ ownerKey: 'runtime:host-b' }) - ).resolves.toMatchObject({ gitBashAvailable: false }) + ).resolves.toMatchObject({ gitBashAvailable: false, hostPlatform: 'linux' }) expect(getCachedWindowsTerminalCapabilities('runtime:host-a')).toMatchObject({ - gitBashAvailable: true + gitBashAvailable: true, + hostPlatform: 'win32' }) expect(getCachedWindowsTerminalCapabilities('runtime:host-b')).toMatchObject({ - gitBashAvailable: false + gitBashAvailable: false, + hostPlatform: 'linux' }) expect(isGitBashAvailable).toHaveBeenCalledTimes(2) + expect(runtimeGetStatus).toHaveBeenCalledTimes(2) }) it('does not select the previous owner capabilities while a new owner loads', async () => { @@ -180,7 +206,8 @@ describe('windows terminal capabilities', () => { listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, - gitBash: { isAvailable: isGitBashAvailable } + gitBash: { isAvailable: isGitBashAvailable }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) @@ -197,6 +224,7 @@ describe('windows terminal capabilities', () => { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + hostPlatform: null, isLoading: false }) }) @@ -209,7 +237,8 @@ describe('windows terminal capabilities', () => { api: { wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue([]) }, pwsh: { isAvailable: pwshIsAvailable }, - gitBash: { isAvailable: isGitBashAvailable } + gitBash: { isAvailable: isGitBashAvailable }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } } }) @@ -218,6 +247,7 @@ describe('windows terminal capabilities', () => { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + hostPlatform: 'win32', isLoading: false }) }) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index d00fa7a35c1..91bc93b934d 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -5,6 +5,7 @@ export type WindowsTerminalCapabilities = { wslDistros: string[] pwshAvailable: boolean gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null isLoading: boolean } @@ -13,6 +14,7 @@ const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + hostPlatform: null, isLoading: false } @@ -81,22 +83,27 @@ export function loadWindowsTerminalCapabilities( return pendingCapabilities } - // Why: Settings and the tab bar need one shared answer. Separate probes can - // leave Settings rendering without WSL while the "+" menu already shows it. + // Why: Settings, status bar, and paired web tab bars need one shared answer. + // Separate probes can leave one surface showing stale Windows shell choices. const requestId = ++nextCapabilityRequestId latestCapabilityRequestIdByOwnerKey.set(ownerKey, requestId) const nextPendingCapabilities = Promise.all([ window.api.wsl.isAvailable().catch(() => false), window.api.wsl.listDistros().catch(() => []), window.api.pwsh.isAvailable().catch(() => false), - window.api.gitBash.isAvailable().catch(() => false) + window.api.gitBash.isAvailable().catch(() => false), + window.api.runtime + .getStatus() + .then((status) => status.hostPlatform ?? null) + .catch(() => null) ]) - .then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable]) => { + .then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform]) => { const capabilities = { wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, + hostPlatform, isLoading: false } if (requestId === latestCapabilityRequestIdByOwnerKey.get(ownerKey)) {