mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Fix Windows setup split terminal behavior
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
"New Tab" opens the setup command in a background tab titled "Setup"
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+303
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user