Fix Windows setup split terminal behavior

This commit is contained in:
Jinwoo Hong
2026-05-17 17:04:06 -04:00
parent 102ca09a38
commit 83c26a3c23
26 changed files with 890 additions and 119 deletions
+45
View File
@@ -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,
+15
View File
@@ -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
),
+86 -1
View File
@@ -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.
+11 -3
View File
@@ -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(
-9
View File
@@ -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<string | null>(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}
@@ -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<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
@@ -257,7 +257,6 @@ function Settings(): React.JSX.Element {
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const terminalCapabilitiesLoadedRef = useRef(false)
const pendingNavSectionRef = useRef<string | null>(null)
const pendingScrollTargetRef = useRef<string | null>(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}
</SettingsSection>
@@ -894,13 +894,6 @@ export function TerminalPane({
size="sm"
className="h-8 flex-wrap"
>
<ToggleGroupItem
value="new-tab"
className="h-8 px-3 text-xs"
aria-label="Run in a new tab"
>
New Tab
</ToggleGroupItem>
<ToggleGroupItem
value="split-vertical"
className="h-8 px-3 text-xs"
@@ -915,6 +908,13 @@ export function TerminalPane({
>
Split Horizontally
</ToggleGroupItem>
<ToggleGroupItem
value="new-tab"
className="h-8 px-3 text-xs"
aria-label="Run in a new tab"
>
New Tab
</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs text-muted-foreground">
&quot;New Tab&quot; opens the setup command in a background tab titled &quot;Setup&quot;
@@ -28,7 +28,8 @@ vi.mock('react', async () => {
useLayoutEffect: () => {},
useMemo: <T>(factory: () => T) => factory(),
useRef: <T>(current: T) => ({ current }),
useState: <T>(initial: T) => [initial, vi.fn()] as const
useState: <T>(initial: T | (() => T)) =>
[typeof initial === 'function' ? (initial as () => T)() : initial, vi.fn()] as const
}
})
@@ -181,7 +182,6 @@ async function renderTabBar(props: Record<string, unknown>): Promise<unknown> {
onSetCustomTitle: () => {},
onSetTabColor: () => {},
onTogglePaneExpand: () => {},
wslAvailable: false,
...props
})
}
+7 -16
View File
@@ -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
)
)
}}
@@ -28,7 +28,8 @@ vi.mock('react', async () => {
useLayoutEffect: () => {},
useMemo: <T>(factory: () => T) => factory(),
useRef: <T>(current: T) => ({ current }),
useState: <T>(initial: T) => [initial, vi.fn()] as const
useState: <T>(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<string, unknown>) => 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()
})
})
@@ -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}
@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type SetupSplit = {
command: string
env?: Record<string, string>
direction: 'vertical' | 'horizontal'
}
const mockStateValues: unknown[] = []
const mockStateInitialized: boolean[] = []
let mockStateIndex = 0
const lifecycleCalls: { setupSplit: SetupSplit | null | undefined }[] = []
const mockStore = {
pendingStartupByTabId: {} as Record<string, unknown>,
pendingSetupSplitByTabId: {} as Record<string, SetupSplit>,
pendingIssueCommandSplitByTabId: {} as Record<string, unknown>,
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<typeof import('react')>('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)
})
})
@@ -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
@@ -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<number, IDisposable>())
const osc7DisposablesRef = useRef(new Map<number, IDisposable>())
const mouseHideDisposablesRef = useRef(new Map<number, IDisposable>())
const setupSplitPtyDepsRef = useRef<PtyConnectionDeps | null>(null)
const handledSetupSplitRef = useRef<{
command: string
env?: Record<string, string>
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) {
@@ -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}
+11 -1
View File
@@ -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'
)
})
})
+5 -2
View File
@@ -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)
)
}
@@ -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<typeof vi.fn>
pwshIsAvailable: ReturnType<typeof vi.fn>
} {
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
})
})
})
@@ -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<WindowsTerminalCapabilities> | 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<WindowsTerminalCapabilities> {
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()
}
@@ -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<string, unknown> = {}) {
@@ -32,7 +32,7 @@ function createMockStore(overrides: Record<string, unknown> = {}) {
}
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()
+14 -5
View File
@@ -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
+2 -1
View File
@@ -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: [],
+16 -4
View File
@@ -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)}`
}
+7 -4
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ type TestPage = Parameters<typeof getActiveWorktreeId>[0]
const SETUP_HOOK_CONTENT = 'scripts:\n setup: echo SETUP_COMPLETE\n'
async function ensureSetupHookCommitted(page: TestPage): Promise<void> {
export async function ensureSetupHookCommitted(page: TestPage): Promise<void> {
const activeWorktreePath = await page.evaluate(() => {
const state = window.__store?.getState()
if (!state?.activeWorktreeId) {
+69
View File
@@ -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)
})
})