diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index a9a5fef3253..a339cae9185 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1015,12 +1015,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '--', - 'bash', - '-c', - `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` - ], + ['--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -1057,14 +1052,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Debian', - '--', - 'bash', - '-c', - `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` - ], + ['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -1092,14 +1080,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -1127,14 +1108,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ env: expect.not.objectContaining({ CODEX_HOME: expect.anything(), @@ -1219,14 +1193,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - `cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home', @@ -1260,14 +1227,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")], expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' }) }) @@ -1350,14 +1310,7 @@ describe('createPtySubprocess', () => { ) expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index 098a930aa48..d008220517c 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,6 +1,10 @@ import { execFile } from 'child_process' import { promisify } from 'util' import path from 'path' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' const execFileAsync = promisify(execFile) const WSL_AGENT_DETECTION_TIMEOUT_MS = 10000 @@ -30,8 +34,8 @@ export async function detectWslCommandsOnPath( try { // Why: WSL cold-start plus many parallel wsl.exe probes can timeout and - // cache an empty result. One interactive probe matches user terminals and - // gives the distro a single startup path. + // cache an empty result. One probe through the distro user's login shell + // matches zsh/bash PATH customizations from their normal terminals. const { stdout } = await execWslAgentDetectionCommand(wslTarget, script) return parseWslDetectedCommands(stdout) } catch { @@ -50,7 +54,13 @@ async function execWslAgentDetectionCommand( const distroArgs = target.distro ? ['-d', target.distro] : [] const commandPromise = execFileAsync( 'wsl.exe', - [...distroArgs, '--exec', 'bash', '-ic', command], + [ + ...distroArgs, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ], { encoding: 'utf-8', timeout: WSL_AGENT_DETECTION_TIMEOUT_MS diff --git a/src/main/ipc/preflight-wsl-command.ts b/src/main/ipc/preflight-wsl-command.ts new file mode 100644 index 00000000000..9e98c387451 --- /dev/null +++ b/src/main/ipc/preflight-wsl-command.ts @@ -0,0 +1,33 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' +import type { WslPreflightTarget } from './preflight-wsl-agent-detection' + +const execFileAsync = promisify(execFile) + +export type PreflightWslCommandResult = { stdout: string; stderr: string } + +export function runPreflightCommandInWsl( + target: WslPreflightTarget, + command: string, + timeoutMs: number +): Promise { + const distroArgs = target.distro ? ['-d', target.distro] : [] + return execFileAsync( + 'wsl.exe', + [ + ...distroArgs, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ], + { + encoding: 'utf-8', + timeout: timeoutMs + } + ) as Promise +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 09f57f1ceff..1f845f01603 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -278,10 +278,10 @@ describe('preflight', () => { } if (command === 'wsl.exe') { const script = String(args[5]) - if (script === "'gh' --version") { + if (script.includes('gh') && script.includes('--version')) { return { stdout: 'gh version 2.0.0\n' } } - if (script === "'gh' auth status") { + if (script.includes('gh') && script.includes('auth status')) { return { stdout: 'github.com\n - Active account: true\n' } } throw new Error(`unexpected WSL script ${script}`) @@ -294,12 +294,12 @@ describe('preflight', () => { expect(status.gh).toEqual({ installed: true, authenticated: true }) expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' --version"], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)], { encoding: 'utf-8', timeout: 5000 } ) expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' auth status"], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)], { encoding: 'utf-8', timeout: 5000 } ) }) @@ -318,10 +318,18 @@ describe('preflight', () => { if (command === 'gh' || command === 'glab') { return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) } - if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'gh' --version") { + if ( + command === 'wsl.exe' && + Array.isArray(args) && + String(args.at(-1)).includes("'gh' --version") + ) { return new Promise(() => {}) } - if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'glab' --version") { + if ( + command === 'wsl.exe' && + Array.isArray(args) && + String(args.at(-1)).includes("'glab' --version") + ) { return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) } throw new Error(`unexpected command ${String(command)}`) @@ -606,9 +614,9 @@ describe('preflight', () => { expect.arrayContaining([ '-d', 'Ubuntu', - '--exec', - 'bash', - '-ic', + '--', + 'sh', + '-c', expect.stringContaining("'claude'") ]), { encoding: 'utf-8', timeout: 10000 } @@ -637,7 +645,7 @@ describe('preflight', () => { expect(resolveCliCommandsMock).not.toHaveBeenCalled() expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - expect.arrayContaining(['--exec', 'bash', '-ic', expect.stringContaining("'codex'")]), + expect.arrayContaining(['--', 'sh', '-c', expect.stringContaining("'codex'")]), { encoding: 'utf-8', timeout: 10000 } ) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 3f7e4877ce5..15591d81750 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -11,6 +11,7 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' +import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -102,11 +103,7 @@ async function execCommandInWsl( target: WslPreflightTarget, command: string ): Promise<{ stdout: string; stderr: string }> { - const distroArgs = target.distro ? ['-d', target.distro] : [] - const commandPromise = execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { - encoding: 'utf-8', - timeout: PREFLIGHT_COMMAND_TIMEOUT_MS - }) as Promise<{ stdout: string; stderr: string }> + const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) return withPreflightTimeout('wsl.exe', commandPromise) } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 381c4a214e6..5a92c2cefdd 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -345,10 +345,11 @@ describe('LocalPtyProvider', () => { '-d', 'Debian', '--', - 'bash', + 'sh', '-c', - 'cd \'/mnt/c/Users/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + expect.stringContaining("cd '/mnt/c/Users/jin/repo'") ]) + expect(spawnCall[1][5]).toContain('exec "\\$_orca_wsl_shell" -l') }) it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { @@ -517,14 +518,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ], + ['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index f44ad922e19..ad8edd14e06 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -3,8 +3,18 @@ import { encodePowerShellCommand, getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' +import { + buildWslInteractiveLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' +function expectedWslArgs(linuxCwd: string, distro?: string): string[] { + const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}` + const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)] + return distro ? ['-d', distro, ...shellArgs] : shellArgs +} + describe('resolveWindowsShellLaunchArgs', () => { it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') @@ -95,12 +105,7 @@ describe('resolveWindowsShellLaunchArgs', () => { 'C:\\Users\\alice\\code', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual([ - '--', - 'bash', - '-c', - 'cd \'/mnt/c/Users/alice/code\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ]) + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) // Why: WSL cannot cd into a Windows path, so node-pty must start from the // user's Windows home and we inject the Linux cd into the shellArgs above. expect(result.effectiveCwd).toBe('C:\\Users\\alice') @@ -109,17 +114,16 @@ describe('resolveWindowsShellLaunchArgs', () => { it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') - // The injected bash cmd must not break out of the surrounding single - // quotes when the path contains a ' character. - expect(result.shellArgs[3]).toBe( - "cd '/mnt/c/weird'\\''path' && export PATH=\"$HOME/.local/bin:$PATH\" && exec bash -l" - ) + // The injected sh cmd must not break out of the surrounding single quotes + // when the path contains a ' character. + expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'") + expect(result.shellArgs[3]).toContain('exec "\\$_orca_wsl_shell" -l') }) it('falls back to /mnt/c when cwd is not a drive-letter path', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice') - expect(result.shellArgs[3]).toBe( - 'cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' + expect(result.shellArgs[3]).toContain( + 'cd \'/mnt/c\' && export PATH="\\$HOME/.local/bin:\\$PATH"' ) }) @@ -136,14 +140,7 @@ describe('resolveWindowsShellLaunchArgs', () => { '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual([ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/alice/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ]) + expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu')) expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo') } finally { @@ -162,14 +159,7 @@ describe('resolveWindowsShellLaunchArgs', () => { { distro: 'Ubuntu', treatPosixCwdAsWsl: true } ) - expect(result.shellArgs).toEqual([ - '-d', - 'Ubuntu', - '--', - 'bash', - '-c', - 'cd \'/home/alice/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l' - ]) + expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu')) expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir') }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 0e41f9caa83..b41fbd55788 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -1,6 +1,11 @@ import { win32 as pathWin32 } from 'path' import { isWindowsGitBashShellPath } from '../git-bash' import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl' +import { + buildWslInteractiveLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -32,15 +37,14 @@ export type WindowsShellWslContext = { } function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { - const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''") - // Why: Orca's WSL bridge is installed under ~/.local/bin, but distro login - // files do not consistently include that directory before agent commands run. - const shellArgs = [ - '--', - 'bash', - '-c', - `cd '${escapedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l` - ] + const setupCommand = [ + `cd ${quotePosixShell(linuxCwd)}`, + 'export PATH="$HOME/.local/bin:$PATH"', + buildWslInteractiveLoginShellCommand() + ].join(' && ') + // Why: WSL users often customize zsh rather than bash; launch the distro's + // login shell so terminal PATH matches the environment Orca detects. + const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(setupCommand)] return distro ? ['-d', distro, ...shellArgs] : shellArgs } @@ -50,8 +54,8 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { * - powershell.exe / pwsh.exe: dot-source $PROFILE and force UTF-8 I/O so * oh-my-posh / starship / PSReadLine keep working. `-NoExit` alone would * skip the profile. - * - wsl.exe: translate the Windows cwd to /mnt//... and enter a login - * bash inside the default distro. + * - wsl.exe: translate the Windows cwd to /mnt//... and enter the + * distro user's login shell. * - anything else: no args, same cwd. */ export function resolveWindowsShellLaunchArgs( shellPath: string, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f797cf4fc64..d7fcded84af 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1776,42 +1776,42 @@ function App(): React.JSX.Element { - {/* Why: leaf-mounted retention sync keeps agent-status retention + {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} - - {/* Why: workspace activation is a hot path; including activeWorktreeId + + {/* Why: workspace activation is a hot path; including activeWorktreeId in reset keys remounts whole surfaces during wake. */} - -
- {/* Why: the non-workspace titlebar lives inside this left+center + +
+ {/* Why: the non-workspace titlebar lives inside this left+center wrapper so it does not span over the right-sidebar column — when the right sidebar is open, its own header anchors at the top alongside the titlebar instead of being pushed below it. */} -
- {/* Why: in workspace view (split groups always enabled), the +
+ {/* Why: in workspace view (split groups always enabled), the full-width titlebar is removed so tab groups + terminal extend to the top of the window. Left titlebar controls move to a header above the sidebar. Settings, landing, and the tasks page keep the titlebar. */} - {!workspaceActive && !stackedSidebarOpen ? ( -
-
{titlebarLeftControls}
- {titlebarMainStrip} -
- ) : null} -
- {showSidebar ? ( - workspaceActive || stackedSidebarOpen ? ( - /* Why: left column wraps the sidebar with a titlebar-height + {!workspaceActive && !stackedSidebarOpen ? ( +
+
{titlebarLeftControls}
+ {titlebarMainStrip} +
+ ) : null} +
+ {showSidebar ? ( + workspaceActive || stackedSidebarOpen ? ( + /* Why: left column wraps the sidebar with a titlebar-height header above it. The header holds the same controls (traffic lights, sidebar toggle, "Orca" title, agent badge) that the full-width titlebar held while the center and right @@ -1819,476 +1819,479 @@ function App(): React.JSX.Element { When the sidebar is collapsed, take this header out of flex layout so the terminal/editor reclaim the left edge instead of leaving behind a content-width blank strip. */ -
- {titlebarLeftControls} -
-
- {/* Why: the workspace-view wrapper adds a fixed 36px header +
+ {titlebarLeftControls} +
+
+ {/* Why: the workspace-view wrapper adds a fixed 36px header above the sidebar. Without a flex-1/min-h-0 slot here, the sidebar falls back to its content height, so the worktree list loses its scroll viewport and the fixed bottom toolbar (including Add Project) gets pushed offscreen. */} - - - + + + +
-
- ) : ( - - - - ) - ) : null} -
- {stackedSidebarOpen ? ( -
{titlebarMainStrip}
+ ) : ( + + + + ) ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center +
+ {stackedSidebarOpen ? ( +
{titlebarMainStrip}
+ ) : null} +
+ {/* Why: right sidebar toggle floats at the top-right of the center column so it's always accessible whether the right sidebar is open or closed. Match the RightSidebar header's 36px height and top-0 anchor so the icon's vertical center is identical between open and closed states — otherwise toggling makes the icon jump a few pixels, which reads as layout jitter. */} - {workspaceActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} -
- )} -
- {shouldMountTerminalWorkbench ? ( + {workspaceActive && !rightSidebarOpen && (
- - - - - + {rightSidebarToggle}
+ )} +
+ {shouldMountTerminalWorkbench ? ( +
+ + + + + +
+ ) : null} + + + {activeView === 'settings' ? : null} + {activeView === 'skills' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} + {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} + {activeView === 'mobile' ? : null} + {activeView === 'terminal' && + activeCreationLoaderVisible && + activePendingCreationId ? ( + + ) : null} + {activeView === 'terminal' && + !activeWorktreeId && + !activeCreationLoaderVisible ? ( + + ) : null} + + +
+ {showFloatingTerminalButton ? ( + setFloatingTerminalOpenWithFocus((open) => !open)} + /> ) : null} - - - {activeView === 'settings' ? : null} - {activeView === 'skills' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'automations' ? : null} - {activeView === 'activity' ? : null} - {activeView === 'space' ? : null} - {activeView === 'mobile' ? : null} - {activeView === 'terminal' && - activeCreationLoaderVisible && - activePendingCreationId ? ( - - ) : null} - {activeView === 'terminal' && - !activeWorktreeId && - !activeCreationLoaderVisible ? ( - - ) : null} - -
- {showFloatingTerminalButton ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null}
-
- {/* Why: keep the right-sidebar shell mounted for layout stability. + {/* Why: keep the right-sidebar shell mounted for layout stability. Its heavy panels disconnect while closed so workspace wake stays responsive. Unmount on the tasks view since that surface is intentionally distraction-free. */} - {showRightSidebarControls ? ( + {showRightSidebarControls ? ( + + + + ) : null} +
+ + {shouldMountFloatingTerminalPanel ? ( + - + + + + ) : null} + {statusBarVisible ? ( + + } + > + + + + + ) : null} + {/* Why: root overlays can render Radix s; keep them inside + the shared provider so lazy surfaces mount safely from any entry point. */} + + {resolvedMountedLazyModalIds.has('new-workspace-composer') ? ( + + ) : null} -
- - {shouldMountFloatingTerminalPanel ? ( + {resolvedMountedLazyModalIds.has('workspace-cleanup') ? ( + + + + ) : null} + - - - + {resolvedMountedLazyModalIds.has('quick-open') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('worktree-palette') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('setup-guide') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('feature-wall') ? ( + + + + ) : null} + {resolvedMountedLazyModalIds.has('feature-tips') ? ( + + + + ) : null} - ) : null} - {statusBarVisible ? ( - - } - > - - - - - ) : null} - {/* Why: root overlays can render Radix s; keep them inside - the shared provider so lazy surfaces mount safely from any entry point. */} - - {resolvedMountedLazyModalIds.has('new-workspace-composer') ? ( - - - + {shouldMountSetupGuideTelemetryObserver ? ( + + + ) : null} - {resolvedMountedLazyModalIds.has('workspace-cleanup') ? ( - - - + {shouldMountContextualTourOverlay ? ( + + + ) : null} - - - {resolvedMountedLazyModalIds.has('quick-open') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('worktree-palette') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('setup-guide') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('feature-wall') ? ( - - - - ) : null} - {resolvedMountedLazyModalIds.has('feature-tips') ? ( - - - - ) : null} - - {shouldMountSetupGuideTelemetryObserver ? ( - - - - ) : null} - {shouldMountContextualTourOverlay ? ( - - - - ) : null} - {/* Why: mount PetOverlay only after persisted UI hydration, with + {/* Why: mount PetOverlay only after persisted UI hydration, with both independent pet toggles allowing it; otherwise a hidden pet flashes while the store still has default visibility. */} - {renderPetOverlay ? ( - - - - - - ) : null} - {shouldMountUpdateCard ? ( - - - - - - ) : null} - - - - {/* Why: the existing-user opt-in banner mounts at App root so it + {renderPetOverlay ? ( + + + + + + ) : null} + {shouldMountUpdateCard ? ( + + + + + + ) : null} + + + + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates internally on the cohort markers populated by the migration, so it only shows for users who installed before the telemetry release and have not yet resolved consent. New users get no first-launch surface — see telemetry-plan.md §First-launch experience. */} - - - - - - - - {activeModal === 'delete-worktree' ? ( - - - + + + + + + + + {activeModal === 'delete-worktree' ? ( + + + + ) : null} + + {hasSshCredentialRequest ? ( + + + + + ) : null} - - {hasSshCredentialRequest ? ( - - - - - - ) : null} - - - - - - - {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( - - - - - - ) : null} - {shouldMountDictationController ? ( - - - - - - ) : null} - - - + + + + + + + {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( + + + + + + ) : null} + {shouldMountDictationController ? ( + + + + + + ) : null} + + + diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx new file mode 100644 index 00000000000..e4103d51bd0 --- /dev/null +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + buildSkillInstallCommandForRuntime, + getSkillDiscoveryTargetForRuntime +} from './CliSkillRuntimeSetup' + +describe('CliSkillRuntimeSetup runtime helpers', () => { + it('wraps WSL skill installs in the selected distro login shell', () => { + const command = buildSkillInstallCommandForRuntime('npx skills add orchestration --global', { + runtime: 'wsl', + wslDistro: 'Ubuntu', + label: 'WSL Ubuntu' + }) + + expect(command).toContain("wsl.exe -d 'Ubuntu' -- sh -c") + expect(command).toContain('getent passwd') + expect(command).toContain('npx skills add orchestration --global') + }) + + it('preserves the selected WSL distro for skill discovery', () => { + expect( + getSkillDiscoveryTargetForRuntime({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + label: 'WSL Ubuntu' + }) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) +}) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 9172331d53e..330cef2be47 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -1,4 +1,8 @@ import type { GlobalSettings } from '../../../../shared/types' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../../../shared/wsl-login-shell-command' import { toast } from 'sonner' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import { @@ -67,7 +71,16 @@ export function buildSkillInstallCommandForRuntime( const distroArg = runtime.wslDistro?.trim() ? ` -d ${quotePowerShellSingle(runtime.wslDistro.trim())}` : '' - return `wsl.exe${distroArg} -- bash -lc ${quotePowerShellSingle(command)}` + const wslCommand = escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + return `wsl.exe${distroArg} -- sh -c ${quotePowerShellSingle(wslCommand)}` +} + +export function getSkillDiscoveryTargetForRuntime( + runtime: LocalAgentRuntime +): { runtime: 'wsl'; wslDistro?: string | null } | undefined { + return runtime.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: runtime.wslDistro ?? null } + : undefined } export function getAgentSkillTerminalShellOverride( diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 281a6e769ee..01164d6657b 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -1,17 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { - Accessibility, - Camera, - ExternalLink, - MonitorCog, - RefreshCw, - ShieldCheck -} from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ExternalLink, MonitorCog, RefreshCw, ShieldCheck } from 'lucide-react' import { toast } from 'sonner' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' +import type { GlobalSettings } from '../../../../shared/types' import type { ComputerUsePermissionId, - ComputerUsePermissionState, - ComputerUsePermissionStatus + ComputerUsePermissionState } from '../../../../shared/computer-use-permissions-types' import { COMPUTER_USE_SKILL_INSTALL_COMMAND, @@ -19,7 +13,8 @@ import { } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal + ensureOrcaCliAvailableForAgentSkillTerminal, + isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, @@ -29,65 +24,32 @@ import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Badge } from '../ui/badge' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getAgentSkillTerminalShellOverride, + getSkillDiscoveryTargetForRuntime +} from './CliSkillRuntimeSetup' +import { getDesktopPlatformFromUserAgent } from './GeneralPane' +import { + COMPUTER_USE_PERMISSIONS, + getComputerUsePermissionStatusClass, + getComputerUsePermissionStatusLabel +} from './computer-use-permission-definitions' +import { getComputerUseSkillRuntime } from './computer-use-skill-runtime' +import { getComputerUseSummary } from './computer-use-summary' import { translate } from '@/i18n/i18n' export { getComputerUsePaneSearchEntries } from './computer-use-search' -type PermissionDefinition = { - id: ComputerUsePermissionId - label: string - description: string - icon: ReactNode +type ComputerUsePaneProps = { + currentPlatform?: string + settings?: GlobalSettings | null + wslSupportedPlatform?: boolean + wslAvailable?: boolean + wslCapabilitiesLoading?: boolean } -const PERMISSIONS: PermissionDefinition[] = [ - { - id: 'accessibility', - get label() { - return translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility') - }, - get description() { - return translate( - 'auto.components.settings.ComputerUsePane.4d03dec2d0', - 'Read app interface trees and perform requested actions.' - ) - }, - icon: - }, - { - id: 'screenshots', - get label() { - return translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots') - }, - get description() { - return translate( - 'auto.components.settings.ComputerUsePane.0c9a33f468', - 'Capture app windows so agents can inspect visual state.' - ) - }, - icon: - } -] - -function statusLabel(status: ComputerUsePermissionStatus | undefined): string { - switch (status) { - case 'granted': - return 'Granted' - case 'unsupported': - return 'macOS only' - case 'not-granted': - case undefined: - return 'Not enabled' - } -} - -function statusClass(status: ComputerUsePermissionStatus | undefined): string { - if (status === 'granted') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' - } - return 'border-border bg-muted text-muted-foreground' -} - -export function ComputerUsePane(): React.JSX.Element { +export function ComputerUsePane(props: ComputerUsePaneProps = {}): React.JSX.Element { const [platform, setPlatform] = useState(null) const [states, setStates] = useState([]) const [loading, setLoading] = useState(true) @@ -98,12 +60,35 @@ export function ComputerUsePane(): React.JSX.Element { const permissionOperationSequence = useRef(0) const mountedRef = useRef(true) const [helperUnavailableReason, setHelperUnavailableReason] = useState(null) + const currentPlatform = + props.currentPlatform ?? + (typeof navigator === 'undefined' + ? 'other' + : getDesktopPlatformFromUserAgent(navigator.userAgent)) + const skillRuntime = useMemo(() => getComputerUseSkillRuntime(props), [props]) + const skillDiscoveryTarget = useMemo( + () => getSkillDiscoveryTargetForRuntime(skillRuntime), + [skillRuntime] + ) + const skillInstallCommand = buildSkillInstallCommandForRuntime( + COMPUTER_USE_SKILL_INSTALL_COMMAND, + skillRuntime + ) + const skillTerminalShellOverride = props.settings + ? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime) + : undefined + const getSkillPrerequisiteStatus = () => + skillRuntime.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus() + : window.api.cli.getInstallStatus() + const { installed: computerUseSkillDetected, loading: computerUseSkillLoading, error: computerUseSkillError, refresh: refreshComputerUseSkill } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { + discoveryTarget: skillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -111,30 +96,21 @@ export function ComputerUsePane(): React.JSX.Element { () => new Map(states.map((state) => [state.id, state.status] as const)), [states] ) - const grantedCount = PERMISSIONS.filter( + const grantedCount = COMPUTER_USE_PERMISSIONS.filter( (permission) => stateById.get(permission.id) === 'granted' ).length - const allGranted = grantedCount === PERMISSIONS.length + const allGranted = grantedCount === COMPUTER_USE_PERMISSIONS.length const checking = loading && states.length === 0 const setupUnavailable = helperUnavailableReason !== null const resetAccessDisabled = resetting || loading || states.length === 0 || pendingId !== null || setupUnavailable - const summaryTitle = checking - ? 'Checking Computer Use access.' - : setupUnavailable - ? 'Computer Use is unavailable.' - : allGranted - ? 'Computer Use is ready.' - : 'Finish setup to use local apps.' - const summaryDescription = checking - ? 'Orca is checking macOS privacy permissions for the Computer Use helper.' - : setupUnavailable - ? `Computer Use permissions are unavailable because ${helperUnavailableReason}.` - : allGranted - ? 'Agents can inspect and operate app windows when you ask.' - : `${PERMISSIONS.length - grantedCount} permission${ - PERMISSIONS.length - grantedCount === 1 ? '' : 's' - } required before agents can operate app windows.` + const { title: summaryTitle, description: summaryDescription } = getComputerUseSummary({ + checking, + setupUnavailable, + allGranted, + helperUnavailableReason, + requiredPermissionCount: COMPUTER_USE_PERMISSIONS.length - grantedCount + }) useEffect(() => { mountedRef.current = true @@ -323,7 +299,7 @@ export function ComputerUsePane(): React.JSX.Element {
- {PERMISSIONS.map((permission) => { + {COMPUTER_USE_PERMISSIONS.map((permission) => { const status = stateById.get(permission.id) const pending = pendingId === permission.id @@ -338,11 +314,11 @@ export function ComputerUsePane(): React.JSX.Element {
{permission.label} - {statusLabel(status)} + {getComputerUsePermissionStatusLabel(status)}

{permission.description}

@@ -395,18 +371,23 @@ export function ComputerUsePane(): React.JSX.Element { 'auto.components.settings.ComputerUsePane.1735461723', 'Enables agents to inspect and operate local desktop apps.' )} - command={COMPUTER_USE_SKILL_INSTALL_COMMAND} + command={skillInstallCommand} terminalTitle="Computer Use setup" terminalAriaLabel="Computer Use skill install terminal" - terminalWorktreeId="settings-computer-use-skill-terminal" + terminalWorktreeId={`settings-computer-use-skill-terminal-${skillRuntime.runtime}`} + terminalShellOverride={skillTerminalShellOverride} installed={computerUseSkillDetected} loading={computerUseSkillLoading} error={computerUseSkillError} icon={} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={getSkillPrerequisiteStatus} + isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('computer-use-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (skillRuntime.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal() + : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} /> diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index 2c9f2012d99..4633d96d998 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { ArrowRightLeft, GitBranch, ListChecks, Workflow } from 'lucide-react' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' +import type { GlobalSettings } from '../../../../shared/types' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal + ensureOrcaCliAvailableForAgentSkillTerminal, + isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples' @@ -20,6 +23,15 @@ import { OrchestrationSkillAgentCoverage } from './OrchestrationSkillAgentCovera import { OrchestrationExampleDialog } from './OrchestrationExamplesDialog' import { OrchestrationSkillPromptDialog } from './OrchestrationSkillPromptDialog' import { translate } from '@/i18n/i18n' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getAgentSkillTerminalShellOverride, + getSelectedAgentRuntime, + getSkillDiscoveryTargetForRuntime, + type LocalAgentRuntime +} from './CliSkillRuntimeSetup' +import { getDesktopPlatformFromUserAgent } from './GeneralPane' const EXAMPLE_ICONS = { handoff: ArrowRightLeft, @@ -29,11 +41,52 @@ const EXAMPLE_ICONS = { 'child-worktrees': Workflow } as const -export function OrchestrationPane(): React.JSX.Element { +type OrchestrationPaneProps = { + currentPlatform?: string + settings?: GlobalSettings | null + wslSupportedPlatform?: boolean + wslAvailable?: boolean + wslCapabilitiesLoading?: boolean +} + +function getOrchestrationSkillRuntime(props: OrchestrationPaneProps): LocalAgentRuntime { + if (!props.settings) { + return { runtime: 'host', label: 'This device' } + } + return getSelectedAgentRuntime( + props.settings, + props.wslSupportedPlatform ?? false, + props.wslAvailable ?? false, + props.wslCapabilitiesLoading ?? false + ) +} + +export function OrchestrationPane(props: OrchestrationPaneProps = {}): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const showOrchestration = matchesSettingsSearch(searchQuery, getOrchestrationPaneSearchEntries()) const [selectedExampleId, setSelectedExampleId] = useState(null) const [skillPromptOpen, setSkillPromptOpen] = useState(false) + const currentPlatform = + props.currentPlatform ?? + (typeof navigator === 'undefined' + ? 'other' + : getDesktopPlatformFromUserAgent(navigator.userAgent)) + const skillRuntime = useMemo(() => getOrchestrationSkillRuntime(props), [props]) + const skillDiscoveryTarget = useMemo( + () => getSkillDiscoveryTargetForRuntime(skillRuntime), + [skillRuntime] + ) + const skillInstallCommand = buildSkillInstallCommandForRuntime( + ORCHESTRATION_SKILL_INSTALL_COMMAND, + skillRuntime + ) + const skillTerminalShellOverride = props.settings + ? getAgentSkillTerminalShellOverride(currentPlatform, props.settings, skillRuntime) + : undefined + const getSkillPrerequisiteStatus = () => + skillRuntime.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus() + : window.api.cli.getInstallStatus() const { installed: orchestrationSkillDetected, @@ -42,6 +95,7 @@ export function OrchestrationPane(): React.JSX.Element { skills: discoveredSkills, refresh: refreshOrchestrationSkill } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + discoveryTarget: skillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -71,18 +125,23 @@ export function OrchestrationPane(): React.JSX.Element { 'auto.components.settings.OrchestrationPane.9bedd2a6e5', 'Enables agents to hand off context and coordinate work through Orca.' )} - command={ORCHESTRATION_SKILL_INSTALL_COMMAND} + command={skillInstallCommand} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" - terminalWorktreeId="settings-orchestration-skill-terminal" + terminalWorktreeId={`settings-orchestration-skill-terminal-${skillRuntime.runtime}`} + terminalShellOverride={skillTerminalShellOverride} installed={orchestrationSkillDetected} loading={orchestrationSkillLoading} error={orchestrationSkillError} icon={} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={getSkillPrerequisiteStatus} + isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (skillRuntime.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal() + : ensureOrcaCliAvailableForAgentSkillTerminal()) }} actionHint={

@@ -113,7 +172,7 @@ export function OrchestrationPane(): React.JSX.Element { /> diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index cc4d57cd6d0..8daae3a380a 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' import { toast } from 'sonner' import type { GlobalSettings, OrcaHooks } from '../../../../shared/types' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' import type { SpeechModelState } from '../../../../shared/speech-types' import type { SourceControlAiSettings, @@ -16,7 +17,7 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' -import { GeneralPane } from './GeneralPane' +import { GeneralPane, getDesktopPlatformFromUserAgent } from './GeneralPane' import { BrowserPane } from './BrowserPane' import { AppearancePane } from './AppearancePane' import { InputPane } from './InputPane' @@ -85,6 +86,11 @@ import { getRuntimeTargetIdentity } from './settings-load-performance' import { translate } from '@/i18n/i18n' +import { + getSelectedAgentRuntime, + getSkillDiscoveryTargetForRuntime, + type LocalAgentRuntime +} from './CliSkillRuntimeSetup' const SETTINGS_NAV_GROUPS = [ { @@ -161,6 +167,16 @@ function getSkillNavInstallStatus(skill: { return skill.installed ? 'installed' : 'install' } +function getSettingsAgentSkillRuntime(args: { + settings: GlobalSettings | null + isWindows: boolean +}): LocalAgentRuntime { + if (!args.settings) { + return { runtime: 'host', label: 'This device' } + } + return getSelectedAgentRuntime(args.settings, args.isWindows, args.isWindows, false) +} + function hasReadyVoiceModel( settings: GlobalSettings, modelStates: readonly SpeechModelState[] @@ -256,10 +272,21 @@ function Settings(): React.JSX.Element { const isMac = isMacUserAgent() const isWebClient = isWebClientLocation() const showDesktopOnlySettings = !isWebClient + const currentPlatform = getDesktopPlatformFromUserAgent(navigator.userAgent) + const agentSkillRuntime = useMemo( + () => getSettingsAgentSkillRuntime({ settings, isWindows }), + [settings, isWindows] + ) + const agentSkillDiscoveryTarget = useMemo( + () => getSkillDiscoveryTargetForRuntime(agentSkillRuntime), + [agentSkillRuntime] + ) const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + discoveryTarget: agentSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const computerUseSkill = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { + discoveryTarget: agentSkillDiscoveryTarget, enabled: showDesktopOnlySettings, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -1041,7 +1068,15 @@ function Settings(): React.JSX.Element { )} searchEntries={getSectionSearchEntries('orchestration')} > - {isSectionMounted('orchestration') ? : null} + {isSectionMounted('orchestration') ? ( + + ) : null} {showDesktopOnlySettings ? ( @@ -1058,7 +1093,15 @@ function Settings(): React.JSX.Element { )} searchEntries={getSectionSearchEntries('computer-use')} > - {isSectionMounted('computer-use') ? : null} + {isSectionMounted('computer-use') ? ( + + ) : null} + }, + { + id: 'screenshots', + get label() { + return translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots') + }, + get description() { + return translate( + 'auto.components.settings.ComputerUsePane.0c9a33f468', + 'Capture app windows so agents can inspect visual state.' + ) + }, + icon: + } +] + +export function getComputerUsePermissionStatusLabel( + status: ComputerUsePermissionStatus | undefined +): string { + switch (status) { + case 'granted': + return 'Granted' + case 'unsupported': + return 'macOS only' + case 'not-granted': + case undefined: + return 'Not enabled' + } +} + +export function getComputerUsePermissionStatusClass( + status: ComputerUsePermissionStatus | undefined +): string { + if (status === 'granted') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + } + return 'border-border bg-muted text-muted-foreground' +} diff --git a/src/renderer/src/components/settings/computer-use-skill-runtime.ts b/src/renderer/src/components/settings/computer-use-skill-runtime.ts new file mode 100644 index 00000000000..1ebb1ba75b5 --- /dev/null +++ b/src/renderer/src/components/settings/computer-use-skill-runtime.ts @@ -0,0 +1,21 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { getSelectedAgentRuntime, type LocalAgentRuntime } from './CliSkillRuntimeSetup' + +export type ComputerUseSkillRuntimeInput = { + settings?: GlobalSettings | null + wslSupportedPlatform?: boolean + wslAvailable?: boolean + wslCapabilitiesLoading?: boolean +} + +export function getComputerUseSkillRuntime(input: ComputerUseSkillRuntimeInput): LocalAgentRuntime { + if (!input.settings) { + return { runtime: 'host', label: 'This device' } + } + return getSelectedAgentRuntime( + input.settings, + input.wslSupportedPlatform ?? false, + input.wslAvailable ?? false, + input.wslCapabilitiesLoading ?? false + ) +} diff --git a/src/renderer/src/components/settings/computer-use-summary.ts b/src/renderer/src/components/settings/computer-use-summary.ts new file mode 100644 index 00000000000..d14eb9f4144 --- /dev/null +++ b/src/renderer/src/components/settings/computer-use-summary.ts @@ -0,0 +1,45 @@ +import { translate } from '@/i18n/i18n' + +type ComputerUseSummaryInput = { + checking: boolean + setupUnavailable: boolean + allGranted: boolean + helperUnavailableReason: string | null + requiredPermissionCount: number +} + +export function getComputerUseSummary({ + checking, + setupUnavailable, + allGranted, + helperUnavailableReason, + requiredPermissionCount +}: ComputerUseSummaryInput): { title: string; description: string } { + if (checking) { + return { + title: 'Checking Computer Use access.', + description: 'Orca is checking macOS privacy permissions for the Computer Use helper.' + } + } + if (setupUnavailable) { + return { + title: 'Computer Use is unavailable.', + description: `Computer Use permissions are unavailable because ${helperUnavailableReason}.` + } + } + if (allGranted) { + return { + title: 'Computer Use is ready.', + description: 'Agents can inspect and operate app windows when you ask.' + } + } + return { + title: 'Finish setup to use local apps.', + description: translate( + 'auto.components.settings.computerUseSummary.permissionsRequired', + `${requiredPermissionCount} permission${ + requiredPermissionCount === 1 ? '' : 's' + } required before agents can operate app windows.` + ) + } +} diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts new file mode 100644 index 00000000000..510200ac9a0 --- /dev/null +++ b/src/shared/wsl-login-shell-command.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + buildWslInteractiveLoginShellCommand, + buildWslLoginShellCommand, + quotePosixShell +} from './wsl-login-shell-command' + +describe('wsl login shell command helpers', () => { + it('quotes single quotes for POSIX shell arguments', () => { + expect(quotePosixShell("a'b")).toBe("'a'\\''b'") + }) + + it('runs commands through the distro user login shell', () => { + const command = buildWslLoginShellCommand("printf 'hello'") + + expect(command).toContain('getent passwd') + expect(command).toContain('exec "$_orca_wsl_shell" -ilc') + expect(command).toContain("printf '\\''hello'\\''") + }) + + it('starts an interactive login shell without assuming bash', () => { + const command = buildWslInteractiveLoginShellCommand() + + expect(command).toContain('getent passwd') + expect(command).toContain('if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then') + expect(command).toContain('exec "$_orca_wsl_shell" -l') + }) +}) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts new file mode 100644 index 00000000000..a27393a871a --- /dev/null +++ b/src/shared/wsl-login-shell-command.ts @@ -0,0 +1,37 @@ +export function quotePosixShell(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export function escapeWslShCommandForWindows(command: string): string { + return command.replace(/\$/g, '\\$') +} + +export function buildWslLoginShellCommand(command: string): string { + const quotedCommand = quotePosixShell(command) + return [ + '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell="${SHELL:-/bin/bash}"', + 'fi', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell=/bin/sh', + 'fi', + 'case "$(basename "$_orca_wsl_shell")" in', + ` sh|dash) exec "$_orca_wsl_shell" -lc ${quotedCommand} ;;`, + ` *) exec "$_orca_wsl_shell" -ilc ${quotedCommand} ;;`, + 'esac' + ].join('\n') +} + +export function buildWslInteractiveLoginShellCommand(): string { + return [ + '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell="${SHELL:-/bin/bash}"', + 'fi', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell=/bin/sh', + 'fi', + 'exec "$_orca_wsl_shell" -l' + ].join('\n') +}