From 83c26a3c23eeaf875384b5167151fd9faedf9ae8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 17 May 2026 16:25:26 -0400 Subject: [PATCH] Fix Windows setup split terminal behavior --- src/main/persistence.test.ts | 45 +++ src/main/persistence.ts | 15 + src/main/runtime/orca-runtime.test.ts | 87 ++++- src/main/runtime/orca-runtime.ts | 14 +- src/renderer/src/components/Terminal.tsx | 9 - .../src/components/settings/Settings.tsx | 37 +-- .../src/components/settings/TerminalPane.tsx | 14 +- .../tab-bar/TabBar.context-menu.test.ts | 4 +- .../src/components/tab-bar/TabBar.tsx | 23 +- .../TabBar.windows-shell-launch.test.ts | 46 ++- .../components/tab-group/TabGroupPanel.tsx | 8 +- ...rminalPane.setup-split-late-queue.test.tsx | 303 ++++++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 14 +- .../use-terminal-pane-lifecycle.ts | 33 ++ .../src/components/terminal/TerminalShell.tsx | 3 - src/renderer/src/lib/setup-runner.test.ts | 12 +- src/renderer/src/lib/setup-runner.ts | 7 +- .../lib/windows-terminal-capabilities.test.ts | 88 +++++ .../src/lib/windows-terminal-capabilities.ts | 82 +++++ .../src/lib/worktree-activation.test.ts | 41 ++- src/renderer/src/lib/worktree-activation.ts | 19 +- src/shared/constants.ts | 3 +- src/shared/setup-runner-command.ts | 20 +- src/shared/types.ts | 11 +- tests/e2e/helpers/dead-terminal.ts | 2 +- tests/e2e/worktree.spec.ts | 69 ++++ 26 files changed, 890 insertions(+), 119 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/TerminalPane.setup-split-late-queue.test.tsx create mode 100644 src/renderer/src/lib/windows-terminal-capabilities.test.ts create mode 100644 src/renderer/src/lib/windows-terminal-capabilities.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d74124435e0..2d3e719e8b6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -679,6 +679,51 @@ describe('Store', () => { expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true) }) + it('defaults missing setup-script launch mode to split-vertical on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().setupScriptLaunchMode).toBe('split-vertical') + expect(store.getSettings().setupScriptLaunchModeDefaultedToSplit).toBe(true) + + store.flush() + const persisted = readDataFile() as { + settings: { + setupScriptLaunchMode: string + setupScriptLaunchModeDefaultedToSplit?: boolean + } + } + expect(persisted.settings.setupScriptLaunchMode).toBe('split-vertical') + expect(persisted.settings.setupScriptLaunchModeDefaultedToSplit).toBe(true) + }) + + it('preserves a post-migration setup-script new-tab choice', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + setupScriptLaunchMode: 'new-tab', + setupScriptLaunchModeDefaultedToSplit: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().setupScriptLaunchMode).toBe('new-tab') + expect(store.getSettings().setupScriptLaunchModeDefaultedToSplit).toBe(true) + }) + it('preserves custom notification sound paths from persisted settings', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 9ba04be5016..ff16c3c3d35 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1210,6 +1210,19 @@ export class Store { const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers ? (parsed.settings?.floatingTerminalEnabled ?? true) : true + const setupScriptLaunchModeDefaultedToSplit = + parsed.settings?.setupScriptLaunchModeDefaultedToSplit === true + // Why: early setup-location builds persisted the old new-tab default. + // Missing values can safely take the new split default; persisted + // choices are preserved because they may be intentional on macOS/Linux. + const migratedSetupScriptLaunchMode = setupScriptLaunchModeDefaultedToSplit + ? (parsed.settings?.setupScriptLaunchMode ?? defaults.settings.setupScriptLaunchMode) + : parsed.settings?.setupScriptLaunchMode === undefined + ? 'split-vertical' + : parsed.settings.setupScriptLaunchMode + if (!setupScriptLaunchModeDefaultedToSplit) { + this.loadNeedsSave = true + } result = { ...defaults, ...parsed, @@ -1229,6 +1242,8 @@ export class Store { terminalMacOptionAsAltMigrated: true, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, + setupScriptLaunchMode: migratedSetupScriptLaunchMode, + setupScriptLaunchModeDefaultedToSplit: true, terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a34ef8c00f9..c112490da77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3569,6 +3569,87 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) + it('does not pre-spawn setup as a standalone terminal when activated workspace setup uses split mode', async () => { + const splitModeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + setupScriptLaunchMode: 'split-vertical' as const + }) + } + const runtime = new OrcaRuntimeService(splitModeStore) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-startup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-split-startup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-split-startup') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-split-startup' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-hook-split-startup', + head: 'def', + branch: 'runtime-hook-split-startup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-split-startup', + runHooks: true, + activate: true, + startup: { + command: 'codex', + env: { ORCA_AGENT: 'codex' } + } + }) + + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-hook-split-startup', + command: 'codex', + env: expect.objectContaining({ ORCA_AGENT: 'codex' }) + }) + ) + expect(revealTerminalSession).toHaveBeenCalledTimes(1) + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + }) + it('follows normal setup policy for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -3658,11 +3739,15 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) + const expectedSetupCommand = + process.platform === 'win32' + ? 'cmd.exe /c "/tmp/repo/.git/orca/setup-runner.sh"' + : 'bash /tmp/repo/.git/orca/setup-runner.sh' expect(spawn).toHaveBeenNthCalledWith( 2, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-hook-skip', - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + command: expectedSetupCommand, // Why: createTerminal stamps ORCA_PANE_KEY/TAB_ID/WORKTREE_ID into the // PTY env on top of the caller-supplied env so hook-based agent status // can attribute hook events to a stable pane. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 632bfc1ba6a..5e31fe75df4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -309,6 +309,7 @@ type RuntimeStore = { refreshLocalBaseRefOnWorktreeCreate: boolean branchPrefix: string branchPrefixCustom: string + setupScriptLaunchMode?: GlobalSettings['setupScriptLaunchMode'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null voice?: VoiceSettings @@ -5422,6 +5423,7 @@ export class OrcaRuntimeService { const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false let didSpawnSetup = false + const setupScriptLaunchMode = settings.setupScriptLaunchMode ?? 'split-vertical' if (args.startup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane @@ -5440,11 +5442,17 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } - if (didSpawnStartup && setup && this.ptyController?.spawn) { + if ( + didSpawnStartup && + setup && + this.ptyController?.spawn && + (!shouldActivate || setupScriptLaunchMode === 'new-tab') + ) { try { // Why: reveal-on-adopt can create the startup tab before renderer - // activation handles setup. Spawn setup in runtime too so startup+setup - // cannot be skipped by the renderer's "terminal already exists" guard. + // activation handles setup. For split-mode setup, let activation queue + // the split against the adopted startup tab instead of creating a + // standalone Setup tab. await this.createTerminal(`path:${worktree.path}`, { title: 'Setup', command: buildSetupRunnerCommand( diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4ba40358159..fb4c0402e64 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -166,14 +166,6 @@ function Terminal(): React.JSX.Element | null { ? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',') : '' - const [wslAvailable, setWslAvailable] = useState(false) - useEffect(() => { - // Why: wsl:isAvailable is synchronous on the main-process side but we - // call it asynchronously so the renderer doesn't block on startup. The - // result only gates UI options, so a brief false→true transition is fine. - void window.api.wsl.isAvailable().then(setWslAvailable) - }, []) - // Save confirmation dialog state const [saveDialogFileId, setSaveDialogFileId] = useState(null) const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null @@ -1217,7 +1209,6 @@ function Terminal(): React.JSX.Element | null { onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} onNewFileTab={handleNewFile} - wslAvailable={wslAvailable} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} expandedPaneByTabId={expandedPaneByTabId} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 7407f0c057f..2527ebb54e5 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -78,6 +78,7 @@ import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { deriveNeededRepoIds, deriveNeededSectionIds, @@ -237,8 +238,7 @@ function Settings(): React.JSX.Element { // the import trigger as a headerAction. The modal itself still lives inside // TerminalPane, driven by this shared state. const ghostty = useGhosttyImport(updateSettings, settings) - const [wslAvailable, setWslAvailable] = useState(false) - const [pwshAvailable, setPwshAvailable] = useState(false) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows) const [fontSuggestions, setFontSuggestions] = useState( Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()])) ) @@ -257,7 +257,6 @@ function Settings(): React.JSX.Element { const contentScrollRef = useRef(null) const searchInputRef = useRef(null) const terminalFontsLoadedRef = useRef(false) - const terminalCapabilitiesLoadedRef = useRef(false) const pendingNavSectionRef = useRef(null) const pendingScrollTargetRef = useRef(null) const repoHooksRequestSeqRef = useRef(0) @@ -659,34 +658,6 @@ function Settings(): React.JSX.Element { } }, [neededSectionIds]) - useEffect(() => { - if (!isWindows) { - setWslAvailable(false) - setPwshAvailable(false) - terminalCapabilitiesLoadedRef.current = true - return - } - if (!neededSectionIds.has('terminal') || terminalCapabilitiesLoadedRef.current) { - return - } - - let stale = false - terminalCapabilitiesLoadedRef.current = true - void window.api.wsl.isAvailable().then((available) => { - if (!stale) { - setWslAvailable(available) - } - }) - void window.api.pwsh.isAvailable().then((available) => { - if (!stale) { - setPwshAvailable(available) - } - }) - return () => { - stale = true - } - }, [isWindows, neededSectionIds]) - const neededRepoIds = useMemo( () => deriveNeededRepoIds(repos, neededSectionIds), [neededSectionIds, repos] @@ -1090,8 +1061,8 @@ function Settings(): React.JSX.Element { scrollbackMode={scrollbackMode} setScrollbackMode={setScrollbackMode} ghostty={ghostty} - wslAvailable={wslAvailable} - pwshAvailable={pwshAvailable} + wslAvailable={windowsTerminalCapabilities.wslAvailable} + pwshAvailable={windowsTerminalCapabilities.pwshAvailable} /> ) : null} diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 582f7bdaad5..9a614589a1a 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -894,13 +894,6 @@ export function TerminalPane({ size="sm" className="h-8 flex-wrap" > - - New Tab - Split Horizontally + + New Tab +

"New Tab" opens the setup command in a background tab titled "Setup" diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 1170506a349..575da0c1028 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -28,7 +28,8 @@ vi.mock('react', async () => { useLayoutEffect: () => {}, useMemo: (factory: () => T) => factory(), useRef: (current: T) => ({ current }), - useState: (initial: T) => [initial, vi.fn()] as const + useState: (initial: T | (() => T)) => + [typeof initial === 'function' ? (initial as () => T)() : initial, vi.fn()] as const } }) @@ -181,7 +182,6 @@ async function renderTabBar(props: Record): Promise { onSetCustomTitle: () => {}, onSetTabColor: () => {}, onTogglePaneExpand: () => {}, - wslAvailable: false, ...props }) } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 08613a5ae64..27116391698 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -26,6 +26,7 @@ import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { ShellIcon } from './shell-icons' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { DropdownMenu, DropdownMenuContent, @@ -58,9 +59,6 @@ type TabBarProps = { terminalOnly?: boolean showAgentLaunchItems?: boolean onNewFileTab?: () => void - /** Whether WSL is installed on this Windows machine. When true, the "+" - * dropdown shows a WSL option under the terminal submenu. */ - wslAvailable?: boolean onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePaneExpand: (tabId: string) => void @@ -142,8 +140,7 @@ function TabBarInner({ onPinFile, tabBarOrder, onCreateSplitGroup, - hoveredTabInsertion, - wslAvailable + hoveredTabInsertion }: TabBarProps): React.JSX.Element { const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) const defaultWindowsShell = useAppStore( @@ -152,15 +149,7 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) - const [pwshAvailable, setPwshAvailable] = useState(false) - useEffect(() => { - if (!isWindows) { - setPwshAvailable(false) - return - } - - void window.api.pwsh.isAvailable().then(setPwshAvailable) - }, []) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows) const resolvedGroupId = groupId ?? worktreeId const statusByRelativePath = useMemo( @@ -497,7 +486,9 @@ function TabBarInner({ }[] = [ { label: 'PowerShell', shell: 'powershell.exe' }, { label: 'CMD Prompt', shell: 'cmd.exe' }, - ...(wslAvailable ? ([{ label: 'WSL', shell: 'wsl.exe' }] as const) : []) + ...(windowsTerminalCapabilities.wslAvailable + ? ([{ label: 'WSL', shell: 'wsl.exe' }] as const) + : []) ] const defaultEntry = allShells.find((s) => s.shell === defaultWindowsShell) ?? allShells[0] @@ -520,7 +511,7 @@ function TabBarInner({ resolveWindowsShellLaunchTarget( entry.shell, defaultWindowsPowerShellImplementation, - pwshAvailable + windowsTerminalCapabilities.pwshAvailable ) ) }} 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 c431ea4378e..5a9fda09f9a 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 @@ -28,7 +28,8 @@ vi.mock('react', async () => { useLayoutEffect: () => {}, useMemo: (factory: () => T) => factory(), useRef: (current: T) => ({ current }), - useState: (initial: T) => [initial, vi.fn()] as const + useState: (initial: T | (() => T)) => + [typeof initial === 'function' ? (initial as () => T)() : initial, vi.fn()] as const } }) @@ -230,8 +231,7 @@ describe('TabBar PowerShell launch wiring', () => { onNewBrowserTab: () => {}, onSetCustomTitle: () => {}, onSetTabColor: () => {}, - onTogglePaneExpand: () => {}, - wslAvailable: false + onTogglePaneExpand: () => {} }) const item = findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell') @@ -242,4 +242,44 @@ describe('TabBar PowerShell launch wiring', () => { expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') }) + + it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => { + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: vi.fn().mockResolvedValue(true) }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) } + } + }) + const capabilities = await import('@/lib/windows-terminal-capabilities') + await capabilities.loadWindowsTerminalCapabilities() + + const tabBarModule = await import('./TabBar') + const candidate = tabBarModule.default ?? tabBarModule + const TabBar = + typeof candidate === 'function' + ? candidate + : typeof (candidate as { type?: unknown }).type === 'function' + ? (candidate as { type: (props: Record) => unknown }).type + : null + expect(TabBar).not.toBeNull() + + const element = TabBar!({ + tabs: [], + activeTabId: null, + worktreeId: 'wt-1', + expandedPaneByTabId: {}, + onActivate: () => {}, + onClose: () => {}, + onCloseOthers: () => {}, + onCloseToRight: () => {}, + onNewTerminalTab: () => {}, + onNewTerminalWithShell: () => {}, + onNewBrowserTab: () => {}, + onSetCustomTitle: () => {}, + onSetTabColor: () => {}, + onTogglePaneExpand: () => {} + }) + + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index bd86341ad33..602e826d8b1 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react' +import { lazy, Suspense, useMemo } from 'react' import { useDroppable } from '@dnd-kit/core' import { Columns2, Ellipsis, Rows2, X } from 'lucide-react' import { useAppStore } from '../../store' @@ -50,11 +50,6 @@ export default function TabGroupPanel({ const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen) const sidebarOpen = useAppStore((state) => state.sidebarOpen) - const [wslAvailable, setWslAvailable] = useState(false) - useEffect(() => { - void window.api.wsl.isAvailable().then(setWslAvailable) - }, []) - const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model const { setNodeRef: setBodyDropRef } = useDroppable({ @@ -116,7 +111,6 @@ export default function TabGroupPanel({ }} onNewTerminalTab={commands.newTerminalTab} onNewTerminalWithShell={commands.newTerminalWithShell} - wslAvailable={wslAvailable} onNewBrowserTab={commands.newBrowserTab} onNewFileTab={commands.newFileTab} onSetCustomTitle={commands.setTabCustomTitle} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.setup-split-late-queue.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.setup-split-late-queue.test.tsx new file mode 100644 index 00000000000..849e9d18c15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPane.setup-split-late-queue.test.tsx @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type SetupSplit = { + command: string + env?: Record + direction: 'vertical' | 'horizontal' +} + +const mockStateValues: unknown[] = [] +const mockStateInitialized: boolean[] = [] +let mockStateIndex = 0 +const lifecycleCalls: { setupSplit: SetupSplit | null | undefined }[] = [] + +const mockStore = { + pendingStartupByTabId: {} as Record, + pendingSetupSplitByTabId: {} as Record, + pendingIssueCommandSplitByTabId: {} as Record, + pendingCodexPaneRestartIds: {}, + terminalLayoutsByTabId: {}, + worktreesByRepo: {}, + repos: [], + settings: { + terminalQuickCommands: [] + }, + setTabPaneExpanded: vi.fn(), + setTabCanExpandPane: vi.fn(), + suppressPtyExit: vi.fn(), + consumePendingCodexPaneRestart: vi.fn(), + clearCodexRestartNotice: vi.fn(), + setTabLayout: vi.fn(), + updateTabTitle: vi.fn(), + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabPtyId: vi.fn(), + clearTabPtyId: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + clearWorktreeUnread: vi.fn(), + clearTerminalTabUnread: vi.fn(), + consumeTabStartupCommand: vi.fn(), + consumeTabSetupSplit: vi.fn(), + consumeTabIssueCommandSplit: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + consumeSuppressedPtyExit: vi.fn() +} + +function resetRenderCursor() { + mockStateIndex = 0 +} + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useState: (initial: unknown) => { + const i = mockStateIndex++ + if (!mockStateInitialized[i]) { + mockStateValues[i] = typeof initial === 'function' ? (initial as () => unknown)() : initial + mockStateInitialized[i] = true + } + const setter = (value: unknown) => { + mockStateValues[i] = + typeof value === 'function' + ? (value as (prev: unknown) => unknown)(mockStateValues[i]) + : value + } + return [mockStateValues[i], setter] + }, + useRef: (initial: unknown) => ({ current: initial }), + useEffect: () => undefined, + useLayoutEffect: () => undefined, + useCallback: (fn: unknown) => fn + } +}) + +vi.mock('../../store', () => { + const useAppStore = (selector: (state: typeof mockStore) => unknown) => selector(mockStore) + useAppStore.getState = () => mockStore + return { useAppStore } +}) + +vi.mock('@/lib/terminal-theme', () => ({ + DEFAULT_TERMINAL_DIVIDER_DARK: '#000', + normalizeColor: (value: string | undefined, fallback: string) => value ?? fallback, + resolveEffectiveTerminalAppearance: () => ({ dividerColor: '#000' }) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: () => null +})) + +vi.mock('./terminal-drop-handler', () => ({ + resolveTerminalDropTargetShell: () => 'bash' +})) + +vi.mock('./layout-serialization', () => ({ + EMPTY_LAYOUT: {}, + serializeTerminalLayout: () => ({}) +})) + +vi.mock('../../../../shared/stable-pane-id', () => ({ + makePaneKey: (tabId: string, leafId: string) => `${tabId}:${leafId}` +})) + +vi.mock('./expand-collapse', () => ({ + applyExpandedLayoutTo: vi.fn(), + restoreExpandedLayoutFrom: vi.fn(), + createExpandCollapseActions: () => ({ + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + syncExpandedLayout: vi.fn(), + toggleExpandPane: vi.fn() + }) +})) + +vi.mock('./keyboard-handlers', () => ({ + useTerminalKeyboardShortcuts: vi.fn() +})) + +vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ + useEffectiveMacOptionAsAlt: () => 'true' +})) + +vi.mock('./useTerminalFontZoom', () => ({ + useTerminalFontZoom: vi.fn() +})) + +vi.mock('./use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => true +})) + +vi.mock('./use-terminal-pane-global-effects', () => ({ + useTerminalPaneGlobalEffects: vi.fn() +})) + +vi.mock('./use-terminal-pane-lifecycle', () => ({ + useTerminalPaneLifecycle: (deps: { setupSplit?: SetupSplit | null }) => { + lifecycleCalls.push({ setupSplit: deps.setupSplit }) + } +})) + +vi.mock('./use-terminal-pane-context-menu', () => ({ + useTerminalPaneContextMenu: () => ({ + open: false, + setOpen: vi.fn(), + point: null, + menuOpenedAtRef: { current: 0 }, + paneCount: 1, + menuPaneId: null, + onContextMenuCapture: vi.fn(), + onCopy: vi.fn(), + onPaste: vi.fn(), + onSplitRight: vi.fn(), + onSplitDown: vi.fn(), + onEqualizePaneSizes: vi.fn(), + onClosePane: vi.fn(), + onClearScreen: vi.fn(), + onQuickCommand: vi.fn(), + onToggleExpand: vi.fn(), + onSetTitle: vi.fn() + }) +})) + +vi.mock('./use-notification-dispatch', () => ({ + useNotificationDispatch: () => vi.fn() +})) + +vi.mock('./pty-connection', () => ({ + connectPanePty: vi.fn() +})) + +vi.mock('../../../../shared/workspace-session-terminal-buffers', () => ({ + shouldPreserveTerminalScrollbackBuffers: () => true +})) + +vi.mock('@/lib/pane-manager/mobile-fit-overrides', () => ({ + getFitOverrideForPty: () => null, + onOverrideChange: () => vi.fn() +})) + +vi.mock('@/lib/pane-manager/mobile-driver-state', () => ({ + getDriverForPty: () => ({ kind: 'none' }), + onDriverChange: () => vi.fn() +})) + +vi.mock('@/lib/pane-manager/pane-key-resolution', () => ({ + resolvePaneKeyForManager: () => ({ status: 'missing' }) +})) + +vi.mock('@/lib/pane-manager/pane-tree-ops', () => ({ + safeFit: vi.fn() +})) + +vi.mock('./terminal-shutdown-layout-capture', () => ({ + captureTerminalShutdownLayout: () => ({}) +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + getRemoteRuntimePtyEnvironmentId: () => null, + getRemoteRuntimeTerminalHandle: () => null +})) + +vi.mock('@/lib/primary-selection', () => ({ + isPrimarySelectionEnabled: () => false, + readPrimarySelectionText: vi.fn() +})) + +vi.mock('./shutdown-buffer-captures', () => ({ + shutdownBufferCaptures: new Map() +})) + +vi.mock('./merge-captured-leaf-state', () => ({ + mergeCapturedLeafState: () => ({}) +})) + +vi.mock('./pane-helpers', () => ({ + fitPanes: vi.fn(), + isWindowsUserAgent: () => false, + shellEscapePath: (value: string) => value +})) + +vi.mock('@/lib/pane-manager/pane-manager', () => ({ + PaneManager: class {} +})) + +vi.mock('./TerminalSearch', () => ({ + default: function TerminalSearch() { + return null + } +})) + +vi.mock('./CloseTerminalDialog', () => ({ + default: function CloseTerminalDialog() { + return null + } +})) + +vi.mock('./MobileDriverOverlay', () => ({ + MobileDriverOverlay: function MobileDriverOverlay() { + return null + } +})) + +vi.mock('./TerminalErrorToast', () => ({ + TerminalErrorToast: function TerminalErrorToast() { + return null + } +})) + +vi.mock('./TerminalContextMenu', () => ({ + default: function TerminalContextMenu() { + return null + } +})) + +import TerminalPane from './TerminalPane' + +function renderPane() { + resetRenderCursor() + TerminalPane({ + tabId: 'tab-1', + worktreeId: 'wt-1', + cwd: 'C:\\repo', + isActive: true, + onPtyExit: vi.fn(), + onCloseTab: vi.fn() + }) +} + +describe('TerminalPane late setup split queueing', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStateValues.length = 0 + mockStateInitialized.length = 0 + lifecycleCalls.length = 0 + mockStore.pendingSetupSplitByTabId = {} + }) + + it('passes a setup split queued after the first render into the lifecycle', () => { + renderPane() + expect(lifecycleCalls.at(-1)?.setupSplit).toBeUndefined() + + const setupSplit: SetupSplit = { + command: 'bash .git/orca/setup-runner.sh', + env: { ORCA_ROLE: 'setup' }, + direction: 'vertical' + } + mockStore.pendingSetupSplitByTabId = { 'tab-1': setupSplit } + + renderPane() + + expect(lifecycleCalls.at(-1)?.setupSplit).toEqual(setupSplit) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 31242072b53..9b16051b6b6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -266,7 +266,7 @@ export default function TerminalPane({ const rightClickToPaste = isWindowsUserAgent() && (settings?.terminalRightClickToPaste ?? true) const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId]) const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) - const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) + const setupSplit = useAppStore((store) => store.pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) const [issueCommandSplit] = useState( () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] @@ -279,12 +279,6 @@ export default function TerminalPane({ } }, [startup, tabId, consumeTabStartupCommand]) - useEffect(() => { - if (setupSplit) { - consumeTabSetupSplit(tabId) - } - }, [setupSplit, tabId, consumeTabSetupSplit]) - // Clear the queued issue-command split once this tab has captured it for initial mount. useEffect(() => { if (issueCommandSplit) { @@ -554,6 +548,12 @@ export default function TerminalPane({ setPaneCount }) + useEffect(() => { + if (setupSplit) { + consumeTabSetupSplit(tabId) + } + }, [setupSplit, tabId, consumeTabSetupSplit]) + // Why (Activity-only pane isolation): when this TerminalPane is being // portaled into the Activity page for a specific agent pane, hide the // other split siblings so the user only sees that agent's pane. Uses diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 9200ffa3709..9cd0c6f1a69 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -39,6 +39,7 @@ import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' +import type { PtyConnectionDeps } from './pty-connection-types' import type { PtyTransport } from './pty-transport' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' @@ -245,6 +246,12 @@ export function useTerminalPaneLifecycle({ const osc52DisposablesRef = useRef(new Map()) const osc7DisposablesRef = useRef(new Map()) const mouseHideDisposablesRef = useRef(new Map()) + const setupSplitPtyDepsRef = useRef(null) + const handledSetupSplitRef = useRef<{ + command: string + env?: Record + direction: SetupSplitDirection + } | null>(null) const applyAppearance = (manager: PaneManager): void => { const currentSettings = settingsRef.current @@ -392,6 +399,7 @@ export function useTerminalPaneLifecycle({ syncPanePtyLayoutBinding, restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {} } + setupSplitPtyDepsRef.current = ptyDeps const unregisterRuntimeTab = registerRuntimeTerminalTab({ tabId, @@ -866,6 +874,7 @@ export function useTerminalPaneLifecycle({ if (setupSplit) { if (initialPane) { + handledSetupSplitRef.current = setupSplit const setupPane = splitPaneWithOneShotStartup( ptyDeps, { command: setupSplit.command, env: setupSplit.env }, @@ -1012,6 +1021,10 @@ export function useTerminalPaneLifecycle({ paneTransports.clear() manager.destroy() managerRef.current = null + if (setupSplitPtyDepsRef.current === ptyDeps) { + setupSplitPtyDepsRef.current = null + handledSetupSplitRef.current = null + } if (e2eConfig.exposeStore) { window.__paneManagers?.delete(tabId) } @@ -1021,6 +1034,26 @@ export function useTerminalPaneLifecycle({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabId, cwd]) + useEffect(() => { + if (!setupSplit || handledSetupSplitRef.current === setupSplit) { + return + } + const manager = managerRef.current + const ptyDeps = setupSplitPtyDepsRef.current + const initialPane = manager?.getActivePane() ?? manager?.getPanes()[0] ?? null + if (!manager || !ptyDeps || !initialPane) { + return + } + // Why: runtime-created startup terminals can mount before worktree + // activation queues the setup split. Process that late payload against the + // existing manager instead of waiting for a remount that may never happen. + handledSetupSplitRef.current = setupSplit + splitPaneWithOneShotStartup(ptyDeps, { command: setupSplit.command, env: setupSplit.env }, () => + manager.splitPane(initialPane.id, setupSplit.direction) + ) + manager.setActivePane(initialPane.id, { focus: isActiveRef.current }) + }, [setupSplit, managerRef, isActiveRef]) + useEffect(() => { const manager = managerRef.current if (!manager || !settings) { diff --git a/src/renderer/src/components/terminal/TerminalShell.tsx b/src/renderer/src/components/terminal/TerminalShell.tsx index 5c3de92848e..9a5dca520a4 100644 --- a/src/renderer/src/components/terminal/TerminalShell.tsx +++ b/src/renderer/src/components/terminal/TerminalShell.tsx @@ -32,7 +32,6 @@ type TerminalShellProps = { onNewTerminalWithShell?: (shell: string) => void onNewBrowserTab: () => void onNewFileTab?: () => void - wslAvailable?: boolean onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePaneExpand: (tabId: string) => void @@ -70,7 +69,6 @@ export function TerminalShell({ onNewTerminalWithShell, onNewBrowserTab, onNewFileTab, - wslAvailable, onSetCustomTitle, onSetTabColor, onTogglePaneExpand, @@ -110,7 +108,6 @@ export function TerminalShell({ onNewTerminalWithShell={onNewTerminalWithShell} onNewBrowserTab={onNewBrowserTab} onNewFileTab={onNewFileTab} - wslAvailable={wslAvailable} onSetCustomTitle={onSetCustomTitle} onSetTabColor={onSetTabColor} expandedPaneByTabId={expandedPaneByTabId} diff --git a/src/renderer/src/lib/setup-runner.test.ts b/src/renderer/src/lib/setup-runner.test.ts index f78591d1141..bbe86a1ad03 100644 --- a/src/renderer/src/lib/setup-runner.test.ts +++ b/src/renderer/src/lib/setup-runner.test.ts @@ -16,7 +16,7 @@ describe('buildSetupRunnerCommand', () => { buildSetupRunnerCommand( '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh' ) - ).toBe("bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh") + ).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') }) it('uses cmd.exe for native Windows runner scripts', () => { @@ -28,4 +28,14 @@ describe('buildSetupRunnerCommand', () => { 'cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"' ) }) + + it('uses bash for POSIX runner scripts even on a Windows client', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + }) + + expect(buildSetupRunnerCommand('/home/jin/repo/.git/orca/setup-runner.sh')).toBe( + 'bash /home/jin/repo/.git/orca/setup-runner.sh' + ) + }) }) diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index b4e57fdc0ca..e56882837ef 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -1,8 +1,11 @@ -import { buildSetupRunnerCommand as buildSharedSetupRunnerCommand } from '../../../shared/setup-runner-command' +import { + buildSetupRunnerCommand as buildSharedSetupRunnerCommand, + inferSetupRunnerCommandPlatform +} from '../../../shared/setup-runner-command' export function buildSetupRunnerCommand(runnerScriptPath: string): string { return buildSharedSetupRunnerCommand( runnerScriptPath, - navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + inferSetupRunnerCommandPlatform(runnerScriptPath) ) } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts new file mode 100644 index 00000000000..357e5875d53 --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getCachedWindowsTerminalCapabilities, + loadWindowsTerminalCapabilities, + resetWindowsTerminalCapabilitiesForTests +} from './windows-terminal-capabilities' + +function stubTerminalCapabilityApi(args: { wslAvailable: boolean; pwshAvailable: boolean }): { + wslIsAvailable: ReturnType + pwshIsAvailable: ReturnType +} { + const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable) + const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) + + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable }, + pwsh: { isAvailable: pwshIsAvailable } + } + }) + + return { wslIsAvailable, pwshIsAvailable } +} + +describe('windows terminal capabilities', () => { + afterEach(() => { + resetWindowsTerminalCapabilitiesForTests() + vi.unstubAllGlobals() + }) + + it('shares WSL and PowerShell availability between terminal UI consumers', async () => { + const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({ + wslAvailable: true, + pwshAvailable: true + }) + + expect(getCachedWindowsTerminalCapabilities()).toEqual({ + wslAvailable: false, + pwshAvailable: false + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ + wslAvailable: true, + pwshAvailable: true + }) + expect(getCachedWindowsTerminalCapabilities()).toEqual({ + wslAvailable: true, + pwshAvailable: true + }) + + await loadWindowsTerminalCapabilities() + expect(wslIsAvailable).toHaveBeenCalledTimes(1) + expect(pwshIsAvailable).toHaveBeenCalledTimes(1) + }) + + it('coalesces simultaneous availability requests', async () => { + const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({ + wslAvailable: true, + pwshAvailable: false + }) + + const [first, second] = await Promise.all([ + loadWindowsTerminalCapabilities(), + loadWindowsTerminalCapabilities() + ]) + + expect(first).toEqual({ wslAvailable: true, pwshAvailable: false }) + expect(second).toEqual(first) + expect(wslIsAvailable).toHaveBeenCalledTimes(1) + expect(pwshIsAvailable).toHaveBeenCalledTimes(1) + }) + + it('keeps WSL available when the PowerShell version probe fails', async () => { + const wslIsAvailable = vi.fn().mockResolvedValue(true) + const pwshIsAvailable = vi.fn().mockRejectedValue(new Error('pwsh probe failed')) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable }, + pwsh: { isAvailable: pwshIsAvailable } + } + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({ + wslAvailable: true, + pwshAvailable: false + }) + }) +}) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts new file mode 100644 index 00000000000..89c0f5f30ef --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react' + +export type WindowsTerminalCapabilities = { + wslAvailable: boolean + pwshAvailable: boolean +} + +const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = { + wslAvailable: false, + pwshAvailable: false +} + +let cachedCapabilities: WindowsTerminalCapabilities | null = null +let pendingCapabilities: Promise | null = null +const subscribers = new Set<(capabilities: WindowsTerminalCapabilities) => void>() + +function publish(capabilities: WindowsTerminalCapabilities): void { + cachedCapabilities = capabilities + for (const subscriber of subscribers) { + subscriber(capabilities) + } +} + +export function getCachedWindowsTerminalCapabilities(): WindowsTerminalCapabilities { + return cachedCapabilities ?? UNAVAILABLE_CAPABILITIES +} + +export function loadWindowsTerminalCapabilities(): Promise { + if (cachedCapabilities) { + return Promise.resolve(cachedCapabilities) + } + if (pendingCapabilities) { + 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. + pendingCapabilities = Promise.all([ + window.api.wsl.isAvailable().catch(() => false), + window.api.pwsh.isAvailable().catch(() => false) + ]) + .then(([wslAvailable, pwshAvailable]) => { + const capabilities = { wslAvailable, pwshAvailable } + pendingCapabilities = null + publish(capabilities) + return capabilities + }) + .catch(() => { + pendingCapabilities = null + publish(UNAVAILABLE_CAPABILITIES) + return UNAVAILABLE_CAPABILITIES + }) + + return pendingCapabilities +} + +export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTerminalCapabilities { + const [capabilities, setCapabilities] = useState(getCachedWindowsTerminalCapabilities) + + useEffect(() => { + if (!enabled) { + setCapabilities(UNAVAILABLE_CAPABILITIES) + return + } + + setCapabilities(getCachedWindowsTerminalCapabilities()) + subscribers.add(setCapabilities) + void loadWindowsTerminalCapabilities().then(setCapabilities) + + return () => { + subscribers.delete(setCapabilities) + } + }, [enabled]) + + return enabled ? capabilities : UNAVAILABLE_CAPABILITIES +} + +export function resetWindowsTerminalCapabilitiesForTests(): void { + cachedCapabilities = null + pendingCapabilities = null + subscribers.clear() +} diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 26199221f59..4720da80dcb 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { useAppStore.setState((state) => ({ settings: state.settings - ? { ...state.settings, setupScriptLaunchMode: mode ?? 'new-tab' } + ? { ...state.settings, setupScriptLaunchMode: mode ?? 'split-vertical' } : mode !== null ? ({ setupScriptLaunchMode: mode } as unknown as typeof state.settings) : state.settings @@ -14,7 +14,7 @@ function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { } afterEach(() => { - setSetupScriptLaunchMode('new-tab') + setSetupScriptLaunchMode('split-vertical') }) function createMockStore(overrides: Record = {}) { @@ -32,7 +32,7 @@ function createMockStore(overrides: Record = {}) { } describe('ensureWorktreeHasInitialTerminal', () => { - it('creates a background Setup tab for newly created worktrees by default', () => { + it('queues a vertical setup split for newly created worktrees by default', () => { let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab }) @@ -45,18 +45,18 @@ describe('ensureWorktreeHasInitialTerminal', () => { } }) - expect(createTab).toHaveBeenCalledTimes(2) - expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1') - expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') - expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup') - expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(store.setTabCustomTitle).not.toHaveBeenCalled() + expect(store.queueTabStartupCommand).not.toHaveBeenCalled() + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { command: 'bash /tmp/repo/.git/orca/setup-runner.sh', env: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' - } + }, + direction: 'vertical' }) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('creates a single tab without setup split when no setup is provided', () => { @@ -89,6 +89,27 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('queues setup split on an existing terminal when runtime pre-created the startup tab', () => { + setSetupScriptLaunchMode('split-vertical') + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-startup' }] }, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setActiveTab).not.toHaveBeenCalled() + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-startup', { + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { ORCA_ROOT_PATH: '/tmp/repo' }, + direction: 'vertical' + }) + }) + it('queues a startup command when agent launch is provided', () => { const store = createMockStore() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 1593901075b..037c9ef9224 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -190,10 +190,22 @@ export function ensureWorktreeHasInitialTerminal( issueCommand?: IssueCommandLaunch ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) + const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'split-vertical' + // Why: activation can now restore editor- or browser-only worktrees from the // reconciled tab-group model. Creating a terminal just because the legacy // terminal slice is empty would reopen worktrees with an unexpected extra tab. if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { + if (setup && mode !== 'new-tab') { + const targetTab = store.tabsByWorktree[worktreeId]?.[0] + if (targetTab) { + store.queueTabSetupSplit(targetTab.id, { + command: buildSetupRunnerCommand(setup.runnerScriptPath), + env: setup.envVars, + direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical' + }) + } + } return null } @@ -215,12 +227,9 @@ export function ensureWorktreeHasInitialTerminal( } // Why: the setup script launch location is user-configurable. The default - // 'new-tab' creates a separate background tab titled "Setup" without - // stealing focus from the main terminal, so setup output never crowds the - // primary pane; 'split-vertical' and 'split-horizontal' keep the setup - // output adjacent to the main terminal via a split. + // split keeps setup output next to the initial agent in one terminal tab; + // 'new-tab' is still available for users who want setup unattended. if (setup) { - const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' const setupCommand = { command: buildSetupRunnerCommand(setup.runnerScriptPath), env: setup.envVars diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7ce34db456d..28138771d2f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -207,7 +207,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { windowBackgroundBlur: false, terminalClipboardOnSelect: false, terminalAllowOsc52Clipboard: false, - setupScriptLaunchMode: 'new-tab', + setupScriptLaunchMode: 'split-vertical', + setupScriptLaunchModeDefaultedToSplit: true, terminalScrollbackBytes: 10_000_000, openLinksInApp: true, openInApplications: [], diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index 69f3406c12d..c6a82b7e2c4 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -1,14 +1,26 @@ export type SetupRunnerCommandPlatform = 'windows' | 'posix' +export function inferSetupRunnerCommandPlatform( + runnerScriptPath: string +): SetupRunnerCommandPlatform { + if (isWslUncPath(runnerScriptPath)) { + return 'posix' + } + return /^[A-Za-z]:[\\/]/.test(runnerScriptPath) || /^\\\\(?!wsl[.$\\])/i.test(runnerScriptPath) + ? 'windows' + : 'posix' +} + export function buildSetupRunnerCommand( runnerScriptPath: string, platform: SetupRunnerCommandPlatform ): string { + if (isWslUncPath(runnerScriptPath)) { + const linuxPath = wslUncToLinuxPath(runnerScriptPath) + return `bash ${quotePosixArg(linuxPath)}` + } + if (platform === 'windows') { - if (isWslUncPath(runnerScriptPath)) { - const linuxPath = wslUncToLinuxPath(runnerScriptPath) - return `bash ${quotePosixArg(linuxPath)}` - } return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` } diff --git a/src/shared/types.ts b/src/shared/types.ts index c537f64806f..1a82582933b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1213,8 +1213,8 @@ export type TuiAgent = export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' /** Where the repo setup script runs when a worktree is created. - * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab (default). - * - 'split-vertical': split the initial terminal pane with a vertical divider. + * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab. + * - 'split-vertical': split the initial terminal pane with a vertical divider (default). * - 'split-horizontal': split the initial terminal pane with a horizontal divider. */ export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab' @@ -1350,9 +1350,12 @@ export type GlobalSettings = { * conservative default while making the capability one toggle away. */ terminalAllowOsc52Clipboard: boolean /** Where the repo setup script runs on workspace create. Defaults to a - * background "Setup" tab so the user's main terminal stays immediately - * usable without the setup output crowding the initial pane. */ + * vertical split so the agent and setup output stay in one terminal tab. */ setupScriptLaunchMode: SetupScriptLaunchMode + /** One-shot migration flag for the split-by-default rollout. Before this + * landed, profiles could persist the inherited "new-tab" default. Once + * migrated, explicit future choices stick. */ + setupScriptLaunchModeDefaultedToSplit?: boolean terminalScrollbackBytes: number /** Why: opening arbitrary links inside Orca uses an isolated guest browser surface. * The setting stays opt-in so existing workflows continue to use the system browser diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts index 510e40faff6..5347e91732d 100644 --- a/tests/e2e/helpers/dead-terminal.ts +++ b/tests/e2e/helpers/dead-terminal.ts @@ -15,7 +15,7 @@ type TestPage = Parameters[0] const SETUP_HOOK_CONTENT = 'scripts:\n setup: echo SETUP_COMPLETE\n' -async function ensureSetupHookCommitted(page: TestPage): Promise { +export async function ensureSetupHookCommitted(page: TestPage): Promise { const activeWorktreePath = await page.evaluate(() => { const state = window.__store?.getState() if (!state?.activeWorktreeId) { diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 38eb07dd2e9..fae67052be7 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -28,6 +28,8 @@ import { ensureTerminalVisible, worktreeExists } from './helpers/store' +import { ensureSetupHookCommitted } from './helpers/dead-terminal' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' test.describe('Create Workspace', () => { test.beforeEach(async ({ orcaPage }) => { @@ -154,4 +156,71 @@ test.describe('Create Workspace', () => { }) } }) + + test('creates setup as a split pane through the composer UI by default', async ({ orcaPage }) => { + await ensureSetupHookCommitted(orcaPage) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Store unavailable') + } + const repoId = state.activeRepoId + if (repoId) { + state.markOrcaHookRepoAlwaysTrusted(repoId) + } + }) + + const workspaceName = `e2e-setup-split-${Date.now()}` + + await orcaPage.evaluate(() => { + window.__store?.getState().openModal('new-workspace-composer') + }) + + const dialog = orcaPage.getByRole('dialog', { name: /Create Workspace/i }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('combobox').first()).toBeVisible() + + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await expect(nameInput).toBeVisible() + await nameInput.fill(workspaceName) + + const createButton = dialog.getByRole('button', { name: /Create Workspace/i }) + await expect(createButton).toBeEnabled() + await createButton.click() + + await expect(dialog).toBeHidden({ timeout: 15_000 }) + const trustDialog = orcaPage.getByRole('dialog', { name: /Run setup script/i }) + try { + await expect(trustDialog).toBeVisible({ timeout: 5_000 }) + await trustDialog.getByRole('button', { name: /Run hooks/i }).click() + await expect(trustDialog).toBeHidden({ timeout: 15_000 }) + } catch { + // The setup hook may already be trusted in this isolated profile. + } + + await expect + .poll(async () => worktreeExists(orcaPage, workspaceName), { + timeout: 10_000, + message: `Worktree "${workspaceName}" did not appear in the store` + }) + .toBe(true) + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + + await expect(orcaPage.getByRole('button', { name: /^Setup\b/i })).toHaveCount(0) + + const activeTerminalTabs = orcaPage.locator( + '[data-testid="sortable-tab"][data-active="true"]:visible' + ) + await expect(activeTerminalTabs).toHaveCount(1) + await expect(activeTerminalTabs.first()).not.toHaveAttribute('data-tab-title', 'Setup') + + const activeTabId = await activeTerminalTabs.first().getAttribute('data-tab-id') + expect(activeTabId).toBeTruthy() + const activeTerminal = orcaPage.locator(`[data-terminal-tab-id="${activeTabId}"]`) + await expect(activeTerminal.locator('.pane[data-pane-id]')).toHaveCount(2) + }) })