mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
Fix WSL agent setup to use the distro login shell (#5285)
This commit is contained in:
@@ -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) })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PreflightWslCommandResult> {
|
||||
const distroArgs = target.distro ? ['-d', target.distro] : []
|
||||
return execFileAsync(
|
||||
'wsl.exe',
|
||||
[
|
||||
...distroArgs,
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs
|
||||
}
|
||||
) as Promise<PreflightWslCommandResult>
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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/<drive>/... and enter a login
|
||||
* bash inside the default distro.
|
||||
* - wsl.exe: translate the Windows cwd to /mnt/<drive>/... and enter the
|
||||
* distro user's login shell.
|
||||
* - anything else: no args, same cwd. */
|
||||
export function resolveWindowsShellLaunchArgs(
|
||||
shellPath: string,
|
||||
|
||||
+456
-453
@@ -1776,42 +1776,42 @@ function App(): React.JSX.Element {
|
||||
<ConfirmationDialogProvider>
|
||||
<LinkRoutingPreferenceDialogProvider>
|
||||
<WorkspacePortScanner enabled={workspaceSessionReady} />
|
||||
{/* 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. */}
|
||||
<RetainedAgentsSyncGate />
|
||||
{/* Why: workspace activation is a hot path; including activeWorktreeId
|
||||
<RetainedAgentsSyncGate />
|
||||
{/* Why: workspace activation is a hot path; including activeWorktreeId
|
||||
in reset keys remounts whole surfaces during wake. */}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="app.workspace-shell"
|
||||
surface="workspace-shell"
|
||||
resetKey={activeView}
|
||||
title={translate('auto.App.df1d56bf87', 'The workspace shell hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8504ddf267',
|
||||
'The app is still running. Retry the shell or use the menu to report the crash details.'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{/* Why: the non-workspace titlebar lives inside this left+center
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="app.workspace-shell"
|
||||
surface="workspace-shell"
|
||||
resetKey={activeView}
|
||||
title={translate('auto.App.df1d56bf87', 'The workspace shell hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8504ddf267',
|
||||
'The app is still running. Retry the shell or use the menu to report the crash details.'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{/* Why: in workspace view (split groups always enabled), the
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{/* 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 ? (
|
||||
<div className="titlebar">
|
||||
<div className="flex items-center shrink-0 mr-2">{titlebarLeftControls}</div>
|
||||
{titlebarMainStrip}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{showSidebar ? (
|
||||
workspaceActive || stackedSidebarOpen ? (
|
||||
/* Why: left column wraps the sidebar with a titlebar-height
|
||||
{!workspaceActive && !stackedSidebarOpen ? (
|
||||
<div className="titlebar">
|
||||
<div className="flex items-center shrink-0 mr-2">{titlebarLeftControls}</div>
|
||||
{titlebarMainStrip}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{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. */
|
||||
<div
|
||||
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
|
||||
>
|
||||
<div
|
||||
// Why: when the sidebar is collapsed, titlebar-left floats
|
||||
// absolutely on top of the center column's own `border-l`
|
||||
// (see TabGroupSplitLayout), occluding that seam. Add a
|
||||
// `border-r` in the floating state so the vertical line
|
||||
// between the traffic-light/nav cluster and the tab strip
|
||||
// stays visible in both states. w-max keeps the floating
|
||||
// header sized to its own controls instead of the w-0
|
||||
// sidebar wrapper.
|
||||
className={`titlebar-left${
|
||||
sidebarOpen
|
||||
? ''
|
||||
: ' titlebar-left-floating absolute top-0 left-0 z-10 w-max border-r border-border'
|
||||
}`}
|
||||
style={{
|
||||
// Why: the Sidebar resize hook updates the sidebar DOM width
|
||||
// directly during drag and only persists to Zustand on
|
||||
// mouseup. In workspace view, size this header from the
|
||||
// wrapper's live width so it tracks those in-flight resizes
|
||||
// instead of leaving a stale-width gap until the drag ends.
|
||||
width: sidebarOpen ? '100%' : undefined
|
||||
}}
|
||||
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Why: the workspace-view wrapper adds a fixed 36px header
|
||||
<div
|
||||
// Why: when the sidebar is collapsed, titlebar-left floats
|
||||
// absolutely on top of the center column's own `border-l`
|
||||
// (see TabGroupSplitLayout), occluding that seam. Add a
|
||||
// `border-r` in the floating state so the vertical line
|
||||
// between the traffic-light/nav cluster and the tab strip
|
||||
// stays visible in both states. w-max keeps the floating
|
||||
// header sized to its own controls instead of the w-0
|
||||
// sidebar wrapper.
|
||||
className={`titlebar-left${
|
||||
sidebarOpen
|
||||
? ''
|
||||
: ' titlebar-left-floating absolute top-0 left-0 z-10 w-max border-r border-border'
|
||||
}`}
|
||||
style={{
|
||||
// Why: the Sidebar resize hook updates the sidebar DOM width
|
||||
// directly during drag and only persists to Zustand on
|
||||
// mouseup. In workspace view, size this header from the
|
||||
// wrapper's live width so it tracks those in-flight resizes
|
||||
// instead of leaving a stale-width gap until the drag ends.
|
||||
width: sidebarOpen ? '100%' : undefined
|
||||
}}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* 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. */}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="sidebar.worktrees"
|
||||
surface="sidebar"
|
||||
resetKey={activeView}
|
||||
title={translate(
|
||||
'auto.App.1468601e7b',
|
||||
'The workspace list hit an error.'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.App.bdc71dddc9',
|
||||
'The active workspace remains open. Retry the list or switch views.'
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
|
||||
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="sidebar.worktrees"
|
||||
surface="sidebar"
|
||||
resetKey={activeView}
|
||||
title={translate(
|
||||
'auto.App.1468601e7b',
|
||||
'The workspace list hit an error.'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.App.bdc71dddc9',
|
||||
'The active workspace remains open. Retry the list or switch views.'
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
|
||||
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="sidebar.worktrees"
|
||||
surface="sidebar"
|
||||
resetKey={activeView}
|
||||
title={translate('auto.App.1468601e7b', 'The workspace list hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.cba0fafda5',
|
||||
'The active page remains open. Retry the list or switch views.'
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
|
||||
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
) : null}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{stackedSidebarOpen ? (
|
||||
<div className="titlebar">{titlebarMainStrip}</div>
|
||||
) : (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="sidebar.worktrees"
|
||||
surface="sidebar"
|
||||
resetKey={activeView}
|
||||
title={translate(
|
||||
'auto.App.1468601e7b',
|
||||
'The workspace list hit an error.'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.App.cba0fafda5',
|
||||
'The active page remains open. Retry the list or switch views.'
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
|
||||
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
)
|
||||
) : null}
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Why: right sidebar toggle floats at the top-right of the center
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{stackedSidebarOpen ? (
|
||||
<div className="titlebar">{titlebarMainStrip}</div>
|
||||
) : null}
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="absolute top-0 z-10 flex items-center h-[36px]"
|
||||
style={
|
||||
{
|
||||
// Why: right: var(--window-controls-width) is the single
|
||||
// mechanism that keeps the toggle clear of the
|
||||
// fixed-position window-controls overlay on Windows (138px)
|
||||
// and sits at the right edge on non-Windows (0px). No
|
||||
// internal spacer needed — adding one would push the button
|
||||
// a further 138px to the left and cover the pane-actions
|
||||
// Ellipsis button with an un-clickable div.
|
||||
right: 'var(--window-controls-width)',
|
||||
WebkitAppRegion: 'no-drag'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{rightSidebarToggle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
|
||||
{shouldMountTerminalWorkbench ? (
|
||||
{workspaceActive && !rightSidebarOpen && (
|
||||
<div
|
||||
className={
|
||||
activeView !== 'terminal' ||
|
||||
!activeWorktreeId ||
|
||||
activeCreationLoaderVisible
|
||||
? 'hidden flex-1 min-w-0 min-h-0'
|
||||
: 'flex flex-1 min-w-0 min-h-0'
|
||||
className="absolute top-0 z-10 flex items-center h-[36px]"
|
||||
style={
|
||||
{
|
||||
// Why: right: var(--window-controls-width) is the single
|
||||
// mechanism that keeps the toggle clear of the
|
||||
// fixed-position window-controls overlay on Windows (138px)
|
||||
// and sits at the right edge on non-Windows (0px). No
|
||||
// internal spacer needed — adding one would push the button
|
||||
// a further 138px to the left and cover the pane-actions
|
||||
// Ellipsis button with an un-clickable div.
|
||||
right: 'var(--window-controls-width)',
|
||||
WebkitAppRegion: 'no-drag'
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="terminal.workbench"
|
||||
surface="terminal-workbench"
|
||||
resetKey="terminal"
|
||||
title={translate(
|
||||
'auto.App.5a9519aef0',
|
||||
'The workspace workbench hit an error.'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.App.98d4ea2823',
|
||||
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
|
||||
)}
|
||||
>
|
||||
<Terminal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
{rightSidebarToggle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
|
||||
{shouldMountTerminalWorkbench ? (
|
||||
<div
|
||||
className={
|
||||
activeView !== 'terminal' ||
|
||||
!activeWorktreeId ||
|
||||
activeCreationLoaderVisible
|
||||
? 'hidden flex-1 min-w-0 min-h-0'
|
||||
: 'flex flex-1 min-w-0 min-h-0'
|
||||
}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="terminal.workbench"
|
||||
surface="terminal-workbench"
|
||||
resetKey="terminal"
|
||||
title={translate(
|
||||
'auto.App.5a9519aef0',
|
||||
'The workspace workbench hit an error.'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.App.98d4ea2823',
|
||||
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
|
||||
)}
|
||||
>
|
||||
<Terminal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId={`page.${activeView}`}
|
||||
surface="page"
|
||||
resetKey={activeView}
|
||||
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.03a14f6b5b',
|
||||
'Retry the page or navigate to another Orca surface.'
|
||||
)}
|
||||
>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
{activeView === 'skills' ? <SkillsPage /> : null}
|
||||
{activeView === 'tasks' ? <TaskPage /> : null}
|
||||
{activeView === 'automations' ? <AutomationsPage /> : null}
|
||||
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
|
||||
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
|
||||
{activeView === 'mobile' ? <MobilePage /> : null}
|
||||
{activeView === 'terminal' &&
|
||||
activeCreationLoaderVisible &&
|
||||
activePendingCreationId ? (
|
||||
<WorktreeCreationPanel creationId={activePendingCreationId} />
|
||||
) : null}
|
||||
{activeView === 'terminal' &&
|
||||
!activeWorktreeId &&
|
||||
!activeCreationLoaderVisible ? (
|
||||
<Landing />
|
||||
) : null}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
{showFloatingTerminalButton ? (
|
||||
<FloatingTerminalToggleButton
|
||||
open={floatingTerminalOpen}
|
||||
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId={`page.${activeView}`}
|
||||
surface="page"
|
||||
resetKey={activeView}
|
||||
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.03a14f6b5b',
|
||||
'Retry the page or navigate to another Orca surface.'
|
||||
)}
|
||||
>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
{activeView === 'skills' ? <SkillsPage /> : null}
|
||||
{activeView === 'tasks' ? <TaskPage /> : null}
|
||||
{activeView === 'automations' ? <AutomationsPage /> : null}
|
||||
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
|
||||
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
|
||||
{activeView === 'mobile' ? <MobilePage /> : null}
|
||||
{activeView === 'terminal' &&
|
||||
activeCreationLoaderVisible &&
|
||||
activePendingCreationId ? (
|
||||
<WorktreeCreationPanel creationId={activePendingCreationId} />
|
||||
) : null}
|
||||
{activeView === 'terminal' &&
|
||||
!activeWorktreeId &&
|
||||
!activeCreationLoaderVisible ? (
|
||||
<Landing />
|
||||
) : null}
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
{showFloatingTerminalButton ? (
|
||||
<FloatingTerminalToggleButton
|
||||
open={floatingTerminalOpen}
|
||||
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="right-sidebar"
|
||||
surface="right-sidebar"
|
||||
resetKey={
|
||||
rightSidebarTab === 'explorer'
|
||||
? `${rightSidebarTab}:${rightSidebarExplorerView}`
|
||||
: rightSidebarTab
|
||||
}
|
||||
title={translate('auto.App.ed6b168d00', 'The right sidebar hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8d1e160ed1',
|
||||
'Retry the sidebar or switch tabs to reload this surface.'
|
||||
)}
|
||||
>
|
||||
<RightSidebar />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{shouldMountFloatingTerminalPanel ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="right-sidebar"
|
||||
surface="right-sidebar"
|
||||
resetKey={
|
||||
rightSidebarTab === 'explorer'
|
||||
? `${rightSidebarTab}:${rightSidebarExplorerView}`
|
||||
: rightSidebarTab
|
||||
}
|
||||
title={translate('auto.App.ed6b168d00', 'The right sidebar hit an error.')}
|
||||
boundaryId="overlay.floating-workspace"
|
||||
surface="overlay"
|
||||
resetKey={floatingTerminalOpen}
|
||||
compact
|
||||
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8d1e160ed1',
|
||||
'Retry the sidebar or switch tabs to reload this surface.'
|
||||
'auto.App.7cbfbf622f',
|
||||
'Retry the floating workspace or close and reopen it.'
|
||||
)}
|
||||
>
|
||||
<RightSidebar />
|
||||
<FloatingTerminalPanel
|
||||
open={floatingTerminalOpen}
|
||||
onOpenChange={setFloatingTerminalOpenWithFocus}
|
||||
tourInteractionSnapshot={floatingWorkspaceTourInteractionSnapshotRef.current}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{statusBarVisible ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
|
||||
}
|
||||
>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.status-bar"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8a023cea1f',
|
||||
'Retry the status bar to remount its controls.'
|
||||
)}
|
||||
>
|
||||
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
|
||||
the shared provider so lazy surfaces mount safely from any entry point. */}
|
||||
<Suspense fallback={null}>
|
||||
{resolvedMountedLazyModalIds.has('new-workspace-composer') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.new-workspace-composer"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'new-workspace-composer'}
|
||||
compact
|
||||
>
|
||||
<NewWorkspaceComposerModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{shouldMountFloatingTerminalPanel ? (
|
||||
{resolvedMountedLazyModalIds.has('workspace-cleanup') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.workspace-cleanup"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'workspace-cleanup'}
|
||||
compact
|
||||
>
|
||||
<WorkspaceCleanupDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.floating-workspace"
|
||||
surface="overlay"
|
||||
resetKey={floatingTerminalOpen}
|
||||
compact
|
||||
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.7cbfbf622f',
|
||||
'Retry the floating workspace or close and reopen it.'
|
||||
)}
|
||||
>
|
||||
<FloatingTerminalPanel
|
||||
open={floatingTerminalOpen}
|
||||
onOpenChange={setFloatingTerminalOpenWithFocus}
|
||||
tourInteractionSnapshot={floatingWorkspaceTourInteractionSnapshotRef.current}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{resolvedMountedLazyModalIds.has('quick-open') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.quick-open"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'quick-open'}
|
||||
compact
|
||||
>
|
||||
<QuickOpen />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('worktree-palette') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.worktree-palette"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'worktree-palette'}
|
||||
compact
|
||||
>
|
||||
<WorktreeJumpPalette />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('setup-guide') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.setup-guide"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'setup-guide'}
|
||||
compact
|
||||
>
|
||||
<SetupGuideModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('feature-wall') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.feature-wall"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'feature-wall'}
|
||||
compact
|
||||
>
|
||||
<FeatureWallModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('feature-tips') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.feature-tips"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'feature-tips'}
|
||||
compact
|
||||
>
|
||||
<FeatureTipsModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
) : null}
|
||||
{statusBarVisible ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
|
||||
}
|
||||
>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.status-bar"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.8a023cea1f',
|
||||
'Retry the status bar to remount its controls.'
|
||||
)}
|
||||
>
|
||||
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
|
||||
the shared provider so lazy surfaces mount safely from any entry point. */}
|
||||
<Suspense fallback={null}>
|
||||
{resolvedMountedLazyModalIds.has('new-workspace-composer') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.new-workspace-composer"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'new-workspace-composer'}
|
||||
compact
|
||||
>
|
||||
<NewWorkspaceComposerModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{shouldMountSetupGuideTelemetryObserver ? (
|
||||
<Suspense fallback={null}>
|
||||
<SetupGuideTelemetryObserver />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('workspace-cleanup') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.workspace-cleanup"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'workspace-cleanup'}
|
||||
compact
|
||||
>
|
||||
<WorkspaceCleanupDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{shouldMountContextualTourOverlay ? (
|
||||
<Suspense fallback={null}>
|
||||
<ContextualTourOverlay />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{resolvedMountedLazyModalIds.has('quick-open') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.quick-open"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'quick-open'}
|
||||
compact
|
||||
>
|
||||
<QuickOpen />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('worktree-palette') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.worktree-palette"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'worktree-palette'}
|
||||
compact
|
||||
>
|
||||
<WorktreeJumpPalette />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('setup-guide') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.setup-guide"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'setup-guide'}
|
||||
compact
|
||||
>
|
||||
<SetupGuideModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('feature-wall') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.feature-wall"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'feature-wall'}
|
||||
compact
|
||||
>
|
||||
<FeatureWallModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
{resolvedMountedLazyModalIds.has('feature-tips') ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.feature-tips"
|
||||
surface="modal"
|
||||
resetKey={activeModal === 'feature-tips'}
|
||||
compact
|
||||
>
|
||||
<FeatureTipsModal />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{shouldMountSetupGuideTelemetryObserver ? (
|
||||
<Suspense fallback={null}>
|
||||
<SetupGuideTelemetryObserver />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountContextualTourOverlay ? (
|
||||
<Suspense fallback={null}>
|
||||
<ContextualTourOverlay />
|
||||
</Suspense>
|
||||
) : 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 ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.pet"
|
||||
surface="overlay"
|
||||
resetKey={petVisible}
|
||||
compact
|
||||
>
|
||||
<PetOverlay />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountUpdateCard ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.update-card"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<UpdateCard />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.star-nag"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<StarNagCard />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{/* Why: the existing-user opt-in banner mounts at App root so it
|
||||
{renderPetOverlay ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.pet"
|
||||
surface="overlay"
|
||||
resetKey={petVisible}
|
||||
compact
|
||||
>
|
||||
<PetOverlay />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountUpdateCard ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.update-card"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<UpdateCard />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.star-nag"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<StarNagCard />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{/* 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. */}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.telemetry-first-launch"
|
||||
surface="overlay"
|
||||
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
|
||||
compact
|
||||
>
|
||||
<TelemetryFirstLaunchSurface />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.zoom"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<ZoomOverlay />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
{activeModal === 'delete-worktree' ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.delete-worktree"
|
||||
surface="modal"
|
||||
resetKey
|
||||
compact
|
||||
>
|
||||
<DeleteWorktreeDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.telemetry-first-launch"
|
||||
surface="overlay"
|
||||
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
|
||||
compact
|
||||
>
|
||||
<TelemetryFirstLaunchSurface />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.zoom"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<ZoomOverlay />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
{activeModal === 'delete-worktree' ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.delete-worktree"
|
||||
surface="modal"
|
||||
resetKey
|
||||
compact
|
||||
>
|
||||
<DeleteWorktreeDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{hasSshCredentialRequest ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.ssh-passphrase"
|
||||
surface="modal"
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
>
|
||||
<SshPassphraseDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Suspense>
|
||||
{hasSshCredentialRequest ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.ssh-passphrase"
|
||||
surface="modal"
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
>
|
||||
<SshPassphraseDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.markdown-template-picker"
|
||||
surface="modal"
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
>
|
||||
<MarkdownTemplatePicker />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.crash-report"
|
||||
surface="modal"
|
||||
reportAsCrash={false}
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
title={translate('auto.App.722d03aa62', 'The crash report dialog hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.acd66311dc',
|
||||
'Use the Help menu after retrying if you still need diagnostics.'
|
||||
)}
|
||||
>
|
||||
<CrashReportDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.onboarding"
|
||||
surface="modal"
|
||||
resetKey={onboardingSettingsDetourActive}
|
||||
title={translate('auto.App.f02d37278a', 'Onboarding hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.221a95ba38',
|
||||
'Retry onboarding or close it and continue in the app.'
|
||||
)}
|
||||
>
|
||||
<OnboardingFlow
|
||||
onboarding={onboarding}
|
||||
onOnboardingChange={setOnboarding}
|
||||
onSettingsDetourStart={beginOnboardingSettingsDetour}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountDictationController ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.dictation"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<DictationController />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.recent-tab-switcher"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<RecentTabSwitcher />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.markdown-template-picker"
|
||||
surface="modal"
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
>
|
||||
<MarkdownTemplatePicker />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.crash-report"
|
||||
surface="modal"
|
||||
reportAsCrash={false}
|
||||
resetKey={activeModal}
|
||||
compact
|
||||
title={translate('auto.App.722d03aa62', 'The crash report dialog hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.acd66311dc',
|
||||
'Use the Help menu after retrying if you still need diagnostics.'
|
||||
)}
|
||||
>
|
||||
<CrashReportDialog />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
{onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="modal.onboarding"
|
||||
surface="modal"
|
||||
resetKey={onboardingSettingsDetourActive}
|
||||
title={translate('auto.App.f02d37278a', 'Onboarding hit an error.')}
|
||||
description={translate(
|
||||
'auto.App.221a95ba38',
|
||||
'Retry onboarding or close it and continue in the app.'
|
||||
)}
|
||||
>
|
||||
<OnboardingFlow
|
||||
onboarding={onboarding}
|
||||
onOnboardingChange={setOnboarding}
|
||||
onSettingsDetourStart={beginOnboardingSettingsDetour}
|
||||
/>
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{shouldMountDictationController ? (
|
||||
<Suspense fallback={null}>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.dictation"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<DictationController />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.recent-tab-switcher"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<RecentTabSwitcher />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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: <Accessibility className="size-4" />
|
||||
},
|
||||
{
|
||||
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: <Camera className="size-4" />
|
||||
}
|
||||
]
|
||||
|
||||
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<NodeJS.Platform | null>(null)
|
||||
const [states, setStates] = useState<ComputerUsePermissionState[]>([])
|
||||
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<string | null>(null)
|
||||
const currentPlatform =
|
||||
props.currentPlatform ??
|
||||
(typeof navigator === 'undefined'
|
||||
? 'other'
|
||||
: getDesktopPlatformFromUserAgent(navigator.userAgent))
|
||||
const skillRuntime = useMemo(() => getComputerUseSkillRuntime(props), [props])
|
||||
const skillDiscoveryTarget = useMemo<SkillDiscoveryTarget | undefined>(
|
||||
() => 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 {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
|
||||
{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 {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{permission.label}</span>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${getComputerUsePermissionStatusClass(
|
||||
status
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
{getComputerUsePermissionStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
@@ -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={<MonitorCog className="size-5" />}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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<string | null>(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<SkillDiscoveryTarget | undefined>(
|
||||
() => 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={<Workflow className="size-5" />}
|
||||
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={
|
||||
<p className="text-[12px] leading-snug text-muted-foreground">
|
||||
@@ -113,7 +172,7 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
/>
|
||||
|
||||
<OrchestrationSkillPromptDialog
|
||||
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
|
||||
command={skillInstallCommand}
|
||||
open={skillPromptOpen}
|
||||
onOpenChange={setSkillPromptOpen}
|
||||
/>
|
||||
|
||||
@@ -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<SkillDiscoveryTarget | undefined>(
|
||||
() => 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') ? <OrchestrationPane /> : null}
|
||||
{isSectionMounted('orchestration') ? (
|
||||
<OrchestrationPane
|
||||
currentPlatform={currentPlatform}
|
||||
settings={settings}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{showDesktopOnlySettings ? (
|
||||
@@ -1058,7 +1093,15 @@ function Settings(): React.JSX.Element {
|
||||
)}
|
||||
searchEntries={getSectionSearchEntries('computer-use')}
|
||||
>
|
||||
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
|
||||
{isSectionMounted('computer-use') ? (
|
||||
<ComputerUsePane
|
||||
currentPlatform={currentPlatform}
|
||||
settings={settings}
|
||||
wslSupportedPlatform={wslSupportedPlatform}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Accessibility, Camera } from 'lucide-react'
|
||||
import type {
|
||||
ComputerUsePermissionId,
|
||||
ComputerUsePermissionStatus
|
||||
} from '../../../../shared/computer-use-permissions-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type PermissionDefinition = {
|
||||
id: ComputerUsePermissionId
|
||||
label: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const COMPUTER_USE_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: <Accessibility className="size-4" />
|
||||
},
|
||||
{
|
||||
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: <Camera className="size-4" />
|
||||
}
|
||||
]
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user