From a278a30b1cacfbe71cf8ace287cf68d8c6b2ed5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:50:00 -0700 Subject: [PATCH 01/23] Let agents wait for setup when requested (#6298) Co-authored-by: Orca --- src/main/hooks.test.ts | 34 ++ src/main/hooks.ts | 13 +- src/main/ipc/pty.test.ts | 61 +++ src/main/ipc/pty.ts | 15 +- src/main/ipc/worktree-remote.ts | 73 ++- src/main/ipc/worktrees.test.ts | 63 ++- src/main/providers/ssh-pty-provider.test.ts | 22 + src/main/providers/ssh-pty-provider.ts | 8 +- src/main/providers/types.ts | 1 + src/main/runtime/orca-runtime.test.ts | 352 ++++++++++++++- src/main/runtime/orca-runtime.ts | 211 +++++++-- src/relay/pty-handler.test.ts | 277 +++++++++++- src/relay/pty-handler.ts | 149 +++++- src/relay/relay.ts | 7 +- .../NewWorkspaceComposerCard.test.tsx | 54 +++ .../components/NewWorkspaceComposerCard.tsx | 43 ++ .../settings/RepositoryHooksSection.test.ts | 87 +++- .../settings/RepositoryHooksSection.tsx | 76 +++- .../repository-git-hooks-search-entries.ts | 4 + .../terminal-pane/pty-connection.test.ts | 59 +++ .../terminal-pane/pty-connection.ts | 7 +- ...poserState-host-context-boundaries.test.ts | 30 ++ src/renderer/src/hooks/useComposerState.ts | 154 +++++++ src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- src/renderer/src/lib/setup-runner.test.ts | 10 + src/renderer/src/lib/setup-runner.ts | 12 +- .../src/lib/worktree-activation.test.ts | 238 ++++++++++ src/renderer/src/lib/worktree-activation.ts | 109 ++++- src/shared/constants.ts | 2 + src/shared/setup-agent-sequencing.test.ts | 425 ++++++++++++++++++ src/shared/setup-agent-sequencing.ts | 257 +++++++++++ src/shared/setup-agent-startup-policy.ts | 11 + src/shared/setup-runner-command.test.ts | 58 ++- src/shared/setup-runner-command.ts | 73 ++- src/shared/types.ts | 4 + 39 files changed, 2879 insertions(+), 175 deletions(-) create mode 100644 src/shared/setup-agent-sequencing.test.ts create mode 100644 src/shared/setup-agent-sequencing.ts create mode 100644 src/shared/setup-agent-startup-policy.ts diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 4ead1dc4d2e..87b7b590549 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1020,6 +1020,40 @@ describe('runHook', () => { }) }) +describe('createSetupRunnerScript', () => { + const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { + mode: 'auto', + setupAgentStartupPolicy, + scripts: { setup: '', archive: '' } + } + }) as unknown as Repo + + it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') + const { createSetupRunnerScript } = await import('./hooks') + + expect( + createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBeUndefined() + expect( + createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup') + .waitForAgentStartup + ).toBe(true) + }) +}) + describe('shouldRunSetupForCreate', () => { const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 17a87757f52..7cb4ba5a1e9 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -6,6 +6,7 @@ import { parse } from 'yaml' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' +import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy' import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { @@ -517,7 +518,8 @@ export function createSetupRunnerScript( worktreePath, script, 'setup-runner', - getHookRuntimeTarget(projectRuntime) + getHookRuntimeTarget(projectRuntime), + shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) ) } @@ -575,7 +577,8 @@ function createWorktreeRunnerScript( worktreePath: string, script: string, runnerBaseName: 'setup-runner' | 'issue-command-runner', - runtimeTarget?: HookRuntimeTarget + runtimeTarget?: HookRuntimeTarget, + waitForAgentStartup?: boolean ): WorktreeSetupLaunch { const envVars = getSetupEnvVars(repo, worktreePath) // Why: WSL worktrees run on a Linux filesystem even though process.platform @@ -618,7 +621,11 @@ function createWorktreeRunnerScript( } } - return { runnerScriptPath, envVars } + return { + runnerScriptPath, + envVars, + ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) + } } /** diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4b9ce9508b7..add52d56301 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ })) import { LocalPtyProvider } from '../providers/local-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, registerSshPtyProvider, @@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => { } ) + it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' }, + undefined, + undefined, + undefined, + 'bash -lc wait-wrapper' + ) + + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + }) + it('does not inject MiMo overlay for non-mimo launches', async () => { await spawnAndGetEnv() @@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/user-omp-agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + 'powershell wait-wrapper' + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/tmp/user-omp-agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/tmp/user-omp-agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => { const env = await spawnAndGetEnv({ PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', @@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) + it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => { + const env = await daemonSpawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/user/.omp/agent', + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' + }, + undefined, + undefined, + undefined, + { command: 'powershell wait-wrapper' } + ) + + expect(piBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/user/.omp/agent', + 'omp' + ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( + '/user/.omp/agent/extensions/orca-agent-status.ts' + ) + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('injects the selected Codex home on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 65290d01177..09fb8f6906e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { assertFolderWorkspacePathUsable, @@ -751,9 +752,10 @@ export function buildPtyHostEnv( // in lock-step across spawn paths without pushing process.env onto the // IPC wire unnecessarily. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint) const hasLaunchCommand = - typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0 + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand // Why: source shadows are agent-scoped. Trusting the other kind's source // would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes. @@ -783,7 +785,7 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } - if (isMimoLaunchCommand(opts.launchCommand)) { + if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) if (baseEnv.MIMOCODE_HOME) { @@ -1880,6 +1882,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } @@ -2256,6 +2261,7 @@ export function registerPtyHandlers( env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -2557,6 +2563,9 @@ export function registerPtyHandlers( if (args.command !== undefined) { spawnOptions.command = args.command } + if (args.commandDelivery !== undefined) { + spawnOptions.commandDelivery = args.commandDelivery + } if (args.startupCommandDelivery !== undefined) { spawnOptions.startupCommandDelivery = args.startupCommandDelivery } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 74dfb7db9f8..ae445927837 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' +import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' import { markCodexProjectTrusted, @@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = { type StagedStartupResult = { startupTerminal?: CreateWorktreeResult['startupTerminal'] + activationSetup?: CreateWorktreeResult['setup'] didSpawnSetup: boolean warning?: string } @@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: { let startupTerminalHandle: string | null = null let startupTerminal: CreateWorktreeResult['startupTerminal'] + let sequencedStartup = startup + let wrappedSetupCommandStr: string | undefined + if (startup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: startup.command, + platform + }) + sequencedStartup = { + ...startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + try { // Why: after `git worktree add` and metadata registration, a runtime-owned // PTY can begin booting the selected agent while setup runs in a sibling @@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: { } } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: startup.command, - env: startup.env, - ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), - startupCommandDelivery: startup.startupCommandDelivery, - telemetry: startup.telemetry, + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, activate: true }) startupTerminalHandle = terminal.handle @@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { let didSpawnSetup = false if (setup) { try { - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (settings as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: { } return { + ...(setup && !didSpawnSetup + ? { + activationSetup: { + ...setup, + ...(startupTerminalHandle && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + } + : {}), ...(startupTerminal ? { startupTerminal } : {}), didSpawnSetup, ...(warning ? { warning } : {}) @@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript( ) return { runnerScriptPath, - envVars: getSetupRunnerEnvVars(repo, worktreePath) + envVars: getSetupRunnerEnvVars(repo, worktreePath), + ...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) + ? { waitForAgentStartup: true } + : {}) } } @@ -2390,7 +2435,11 @@ export async function createLocalWorktree( return { worktree: { ...worktree, workspaceLineage }, ...(workspaceLineage ? { workspaceLineage } : {}), - ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}), + ...(stagedStartup.activationSetup + ? { setup: stagedStartup.activationSetup } + : setup && !stagedStartup.didSpawnSetup + ? { setup } + : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 51324c45c89..5ce5d0baba6 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => { 1, 'id:repo-1::/workspace/improve-dashboard', { + claudeAgentTeamsSourceCommand: 'claude --prefill test', command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, launchAgent: 'claude', @@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => { 'id:repo-1::/workspace/improve-dashboard', { title: 'Setup', - command: 'bash /workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'), env: { ORCA_ROOT_PATH: '/workspace/repo', ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' @@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => { activate: false } ) + const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] + const setupCreateCall = runtimeStub.createTerminal.mock.calls[1] + if (!startupCreateCall || !setupCreateCall) { + throw new Error('expected startup and setup terminal calls') + } + const startupCommand = (startupCreateCall[1] as { command: string }).command + const setupCommand = (setupCreateCall[1] as { command: string }).command + expect(startupCommand).toBe('claude --prefill test') + expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh') expect(result.setup).toBeUndefined() expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( @@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => { ) }) + it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + head: 'def', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + createSetupRunnerScriptMock.mockReturnValueOnce({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/workspace/repo', + ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + }, + waitForAgentStartup: true + }) + runtimeStub.createTerminal + .mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' }) + .mockRejectedValueOnce(new Error('setup creation failed')) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + createdWithAgent: 'claude', + startup: { + command: 'claude --prefill test', + env: { ORCA_AGENT_MODE: 'direct' }, + telemetry: { + agent_kind: 'claude', + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + } + })) as { setup?: { command?: string; runnerScriptPath: string } } + + expect(result.setup).toEqual( + expect.objectContaining({ + runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh') + }) + ) + expect(result.setup?.command).toContain('printf') + }) + it('checks out a selected existing local branch exactly', async () => { listWorktreesMock .mockResolvedValueOnce([ diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 5adfdecf23b..7a6af3da39a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -102,6 +102,28 @@ describe('SshPtyProvider', () => { }) }) + it('forwards provider command delivery to the relay', async () => { + mux.request.mockResolvedValue({ id: 'pty-provider-command' }) + + await provider.spawn({ + cols: 120, + rows: 40, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, + command: 'echo from-runtime', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + }) + it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => { mux.request.mockResolvedValue({ id: 'pty-bridge' }) provider = new SshPtyProvider('conn-1', mux as never, { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 54c2ce5824f..c33910bad95 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -133,12 +133,10 @@ export class SshPtyProvider implements IPtyProvider { cwd: opts.cwd, env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), // Why: the relay's plugin-overlay env augmenter needs to know which - // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors - // the right `~/./agent` source dir on the remote disk. The - // relay does not execute `command` itself — the user types it into - // the shell — but receiving it as a hint lets overlay resolution be - // per-launch instead of always-Pi. + // Pi-compatible agent is being launched, while commandDelivery tells it + // whether to submit the command itself for runtime-owned background PTYs. ...(opts.command ? { command: opts.command } : {}), + ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), ...(opts.startupCommandDelivery ? { startupCommandDelivery: opts.startupCommandDelivery } : {}) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 3a3ebdd94a4..6561d120240 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -30,6 +30,7 @@ export type PtySpawnOptions = { env?: Record envToDelete?: string[] command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3487ea9830c..5f8cd7fc52b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -65,6 +65,7 @@ import { } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { makePaneKey } from '../../shared/stable-pane-id' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' @@ -2908,7 +2909,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const parent = { path: '/remote/repo-parent', @@ -3075,7 +3082,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3285,7 +3298,13 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } } const metaById: Record = {} const remoteStore = { @@ -3372,14 +3391,12 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(result.setup).toMatchObject({ - runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' - }) + expect(result.setup).toBeUndefined() expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'claude', + command: expect.stringContaining('exec claude'), worktreeId: result.worktree.id }) ) @@ -3387,10 +3404,21 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/remote/mobile-setup', - command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh', + command: expect.stringContaining( + 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' + ), worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ @@ -6420,6 +6448,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ cwd: TEST_WORKTREE_PATH, command: 'codex', + commandDelivery: 'provider', worktreeId: TEST_WORKTREE_ID, preAllocatedHandle: expect.stringMatching(/^term_/) }) @@ -6700,6 +6729,49 @@ describe('OrcaRuntimeService', () => { }) }) + it('preserves Claude Agent Teams for sequenced Claude launches', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + claudeAgentTeamsMode: 'in-process' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'', + claudeAgentTeamsSourceCommand: 'claude "hello"', + launchAgent: 'claude', + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { CLAUDE_PROFILE: 'captured' } + } + }) + + const sequencedClaude = spawn.mock.calls[0]?.[0] as { + command?: string + env?: Record + } + + expect(sequencedClaude.command).toBe( + 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'' + ) + expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe( + 'claude --teammate-mode in-process "hello"' + ) + }) + it('restores captured native Claude Agent Teams mode with fresh service env', async () => { setPlatform('linux') const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) @@ -18700,15 +18772,9 @@ describe('OrcaRuntimeService', () => { repoId: 'repo-1', path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' - }), - setup: { - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' - } - } + }) }) + expect(result.setup).toBeUndefined() expect(activateWorktree).not.toHaveBeenCalled() expect(spawn).toHaveBeenNthCalledWith( 1, @@ -18749,6 +18815,162 @@ describe('OrcaRuntimeService', () => { }) }) + it('sequences setup before startup for opted-in local headless worktree creates', async () => { + const waitRepo = { + ...store.getRepo('repo-1')!, + hookSettings: { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + scripts: { setup: '', archive: '' } + } + } + const runtimeStore = { + ...store, + getRepos: () => [waitRepo], + getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-startup-setup', + head: 'def', + branch: 'runtime-headless-startup-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-startup-setup', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(createSetupRunnerScript).toHaveBeenCalled() + expect(runHook).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(2) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + expect(startupCommand).toContain('exec claude') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(result.setup).toBeUndefined() + }) + + it('starts setup and startup side by side by default for local headless worktree creates', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' }) + .mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-headless-parallel', + head: 'def', + branch: 'runtime-headless-parallel', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-headless-parallel', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' }) + ) + }) + it('creates the first terminal for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -19356,7 +19578,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('honors split setup placement for local startup-draft worktrees', async () => { + it('honors split setup placement for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { ...store, @@ -19413,7 +19635,8 @@ describe('OrcaRuntimeService', () => { envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split' - } + }, + waitForAgentStartup: true }) vi.mocked(listWorktrees).mockResolvedValue([ { @@ -19438,7 +19661,7 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: expect.stringContaining('codex'), worktreeId: result.worktree.id }) ) @@ -19446,7 +19669,7 @@ describe('OrcaRuntimeService', () => { 2, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), env: expect.objectContaining({ ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split', @@ -19455,8 +19678,19 @@ describe('OrcaRuntimeService', () => { worktreeId: result.worktree.id }) ) + const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) + expect(nonceMatch?.[1]).toBeTruthy() + const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done` + expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox') + expect(setupCommand).toContain('printf') + expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) + expect(startupCommand).toContain(markerPath) + expect(setupCommand).toContain(markerPath) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} + expect(result.setup).toBeUndefined() expect(mainEnv.ORCA_TAB_ID).toBeDefined() expect(mainEnv.ORCA_PANE_KEY).toBeDefined() expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) @@ -19473,6 +19707,84 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => { + const runtime = new OrcaRuntimeService(store) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-startup-main' }) + .mockRejectedValueOnce(new Error('setup spawn failed')) + const activateWorktree = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' + }, + waitForAgentStartup: true + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-startup-setup-retry', + head: 'def', + branch: 'runtime-startup-setup-retry', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-startup-setup-retry', + setupDecision: 'run', + activate: true, + startup: { command: 'claude' } + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + expect.objectContaining({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }), + undefined, + undefined + ) + const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined + expect(activationSetup?.command).toContain('printf') + }) + it('lets explicit startup draft agents override the desktop default', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 94716e89400..f8dc05fcfa7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -154,7 +154,14 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../shared/setup-runner-command' +import { + createSequencedSetupAgentCommands, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' @@ -991,6 +998,7 @@ type RuntimePtyController = { rows: number cwd?: string command?: string + commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] @@ -12717,12 +12725,11 @@ export class OrcaRuntimeService { } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) if (shouldRunSetup && hooks?.scripts.setup) { - if (this.authoritativeWindowId !== null) { + const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) + if (shouldUseSetupRunner) { try { - // Why: CLI-created worktrees must use the same runner-script path as the - // renderer create flow so repo-committed `orca.yaml` setup hooks run in - // the visible first terminal instead of a hidden background shell with - // different failure and prompt behavior. + // Why: setup+startup must share the terminal runner path even without + // a renderer window, so the startup shell can wait on setup completion. setup = createSetupRunnerScript( repo, worktreePath, @@ -12770,7 +12777,30 @@ export class OrcaRuntimeService { let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (effectiveStartup && this.ptyController?.spawn) { + + let sequencedStartup = effectiveStartup + let wrappedSetupCommandStr: string | undefined + if (effectiveStartup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: effectiveStartup.command, + platform + }) + sequencedStartup = { + ...effectiveStartup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv + ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } + : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the @@ -12780,12 +12810,15 @@ export class OrcaRuntimeService { this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: effectiveStartup.command, - env: effectiveStartup.env, - ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}), + command: sequencedStartup.command, + ...(setup && effectiveStartup + ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -12811,10 +12844,15 @@ export class OrcaRuntimeService { // Why: reveal-on-adopt can create the startup tab before renderer // activation handles setup. Honor the same split-vs-tab setting here // because renderer activation will skip setup once the startup tab exists. - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -12848,7 +12886,16 @@ export class OrcaRuntimeService { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. - const activationSetup = didSpawnSetup ? undefined : setup + const activationSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, @@ -12868,10 +12915,15 @@ export class OrcaRuntimeService { initialTerminalHandle = terminal.handle } if (setup && !didSpawnSetup) { - const setupCommand = buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -12900,6 +12952,16 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } + const returnedSetup = didSpawnSetup + ? undefined + : setup + ? { + ...setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined return { worktree: { ...worktree, @@ -12910,7 +12972,7 @@ export class OrcaRuntimeService { git: created }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), - ...(setup ? { setup } : {}), + ...(returnedSetup ? { setup: returnedSetup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh @@ -13036,7 +13098,25 @@ export class OrcaRuntimeService { let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null - if (args.startup && this.ptyController?.spawn) { + + let sequencedStartup = args.startup + let wrappedSetupCommandStr: string | undefined + if (args.startup && result.setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: result.setup.runnerScriptPath, + startupCommand: args.startup.command, + platform + }) + sequencedStartup = { + ...args.startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + if (sequencedStartup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent if (startupTrustAgent) { @@ -13047,12 +13127,15 @@ export class OrcaRuntimeService { ) } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { - command: args.startup.command, - env: args.startup.env, - ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), + command: sequencedStartup.command, + ...(result.setup && args.startup + ? { claudeAgentTeamsSourceCommand: args.startup.command } + : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), - startupCommandDelivery: args.startup.startupCommandDelivery, - telemetry: args.startup.telemetry + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -13078,10 +13161,12 @@ export class OrcaRuntimeService { // Why: remote/mobile task creates spawn the agent terminal in runtime, // so renderer activation never receives the setup payload. Runtime // must apply the same user-selected split-vs-tab setup placement. - const setupCommand = buildSetupRunnerCommand( - result.setup.runnerScriptPath, - isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' - ) + const setupCommand = + wrappedSetupCommandStr ?? + buildSetupRunnerCommand( + result.setup.runnerScriptPath, + getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + ) const setupLaunchMode = (this.store.getSettings() as Partial>) .setupScriptLaunchMode ?? 'new-tab' @@ -13113,7 +13198,16 @@ export class OrcaRuntimeService { const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { - const activationSetup = didSpawnSetup ? undefined : result.setup + const activationSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, @@ -13139,7 +13233,7 @@ export class OrcaRuntimeService { if (result.setup && !didSpawnSetup) { const setupCommand = buildSetupRunnerCommand( result.setup.runnerScriptPath, - isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' + getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') ) const setupLaunchMode = (this.store.getSettings() as Partial>) @@ -13166,10 +13260,27 @@ export class OrcaRuntimeService { } } + const returnedSetup = didSpawnSetup + ? undefined + : result.setup + ? { + ...result.setup, + ...(didSpawnStartup && wrappedSetupCommandStr + ? { command: wrappedSetupCommandStr } + : {}) + } + : undefined + const resultForRenderer = returnedSetup + ? { ...result, setup: returnedSetup } + : (() => { + const { setup: _setup, ...resultWithoutSetup } = result + return resultWithoutSetup + })() + const resultWithStartupTerminal = didSpawnStartup && startupTerminalHandle ? { - ...result, + ...resultForRenderer, startupTerminal: { spawned: true, handle: startupTerminalHandle, @@ -13179,7 +13290,7 @@ export class OrcaRuntimeService { surface: 'background' as const } } - : result + : resultForRenderer return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } @@ -14678,6 +14789,7 @@ export class OrcaRuntimeService { worktreeSelector?: string, opts: { command?: string + claudeAgentTeamsSourceCommand?: string env?: Record launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string @@ -14739,14 +14851,16 @@ export class OrcaRuntimeService { ...opts.env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) } + const claudeAgentTeamsSourceCommand = + opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( opts.launchConfig, - opts.command, + claudeAgentTeamsSourceCommand, claudeAgentTeamsMode ) const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({ - command: opts.command, + command: claudeAgentTeamsSourceCommand, mode: effectiveClaudeAgentTeamsMode, baseEnv: { ...process.env, @@ -14763,6 +14877,13 @@ export class OrcaRuntimeService { shimBin }).env }) + const sequencedStartupCommand = + agentTeamsPlan && + claudeAgentTeamsSourceCommand && + opts.command && + claudeAgentTeamsSourceCommand !== opts.command + ? agentTeamsPlan.command + : undefined const effectiveLaunchConfig = opts.launchConfig && agentTeamsPlan ? { @@ -14778,9 +14899,17 @@ export class OrcaRuntimeService { } } : opts.launchConfig + // Why: setup/agent sequencing wraps the PTY launch in a wait shell before + // Claude Agent Teams runs. Preserve the direct Claude command separately + // so the wrapper can exec the teammate-mode variant after setup completes. const env = this.buildTerminalWorkspaceEnv( workspace, - baseEnv, + { + ...baseEnv, + ...(sequencedStartupCommand + ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } + : {}) + }, paneKey, tabId, agentTeamsPlan?.env @@ -14789,7 +14918,8 @@ export class OrcaRuntimeService { cols: 120, rows: 40, cwd: workspace.path, - command: agentTeamsPlan?.command ?? opts.command, + command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command), + commandDelivery: 'provider', startupCommandDelivery: opts.startupCommandDelivery, env, envToDelete: agentTeamsPlan?.envToDelete, @@ -15634,6 +15764,7 @@ export class OrcaRuntimeService { rows: 40, cwd: workspace.path, command: opts.command, + commandDelivery: 'provider', env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), envToDelete: opts.envToDelete, connectionId: workspace.connectionId, diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 983ac234848..3c9b0597030 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -4,6 +4,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { + resolveSetupAgentSequenceLaunchCommand, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from '../shared/setup-agent-sequencing' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -165,8 +169,34 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { + await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) + + vi.advanceTimersByTime(50) + + const term = mockPtySpawn.mock.results[0]?.value + expect(term.write).not.toHaveBeenCalled() + }) + + it('submits provider-delivered spawn commands to the relay shell', async () => { + await dispatcher.callRequest('pty.spawn', { + command: 'echo provider-owned', + commandDelivery: 'provider' + }) + + vi.advanceTimersByTime(49) + const term = mockPtySpawn.mock.results[0]?.value + expect(handler.retainedStartupCommandCount).toBe(1) + expect(term.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + const submit = process.platform === 'win32' ? '\r' : '\n' + expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`) + expect(handler.retainedStartupCommandCount).toBe(0) + }) + it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for delivery-hinted startup commands', + 'emits shell-ready markers for renderer-delivered startup commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -177,6 +207,7 @@ describe('PtyHandler', () => { try { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir }, + command: 'echo renderer-owned', startupCommandDelivery: 'shell-ready' }) } finally { @@ -197,11 +228,12 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) } ) it.skipIf(process.platform === 'win32')( - 'enables shell-ready marker env for Codex native prefill commands', + 'emits shell-ready markers for renderer-delivered Codex native prefill commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME @@ -232,6 +264,197 @@ describe('PtyHandler', () => { | { env?: Record } | undefined expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + expect(handler.retainedStartupCommandCount).toBe(0) + } + ) + + it.skipIf(process.platform === 'win32')( + 'enables shell-ready marker env for provider-delivered startup commands', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo provider-owned', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the sequenced startup command hint for provider shell-ready detection', + async () => { + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { + HOME: homeDir, + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + }, + command: 'bash -lc wait-for-setup-wrapper', + commandDelivery: 'provider' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as + | { env?: Record } + | undefined + expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1') + } + ) + + it.skipIf(process.platform === 'win32')( + 'waits for the shell-ready marker before provider-delivered startup commands', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo after-ready', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + vi.advanceTimersByTime(1499) + expect(term.write).not.toHaveBeenCalled() + + dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + vi.advanceTimersByTime(49) + expect(term.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(term.write).toHaveBeenCalledWith('echo after-ready\n') + expect(handler.retainedStartupCommandCount).toBe(0) + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: 'user@remote $ ' + }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'flushes held shell-ready marker bytes when provider delivery falls back', + async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + const oldShell = process.env.SHELL + const oldHome = process.env.HOME + const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-')) + + process.env.SHELL = '/bin/bash' + process.env.HOME = homeDir + try { + await dispatcher.callRequest('pty.spawn', { + env: { HOME: homeDir }, + command: 'echo fallback', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + } finally { + if (oldShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = oldShell + } + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + rmSync(homeDir, { recursive: true, force: true }) + } + + dataCallback?.('\x1b]777;orca-shell-ready') + vi.advanceTimersByTime(1500) + + expect(term.write).toHaveBeenCalledWith('echo fallback\n') + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '\x1b]777;orca-shell-ready' + }) + + const result = await dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' }) } ) @@ -252,6 +475,42 @@ describe('PtyHandler', () => { expect(killSpy).toHaveBeenCalledWith('SIGKILL') }) + it('does not submit provider-delivered commands for stale spawn responses', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest( + 'pty.spawn', + { command: 'echo stale', commandDelivery: 'provider' }, + { isStale: () => true } + ) + + vi.advanceTimersByTime(50) + expect(term.write).not.toHaveBeenCalled() + expect(handler.retainedStartupCommandCount).toBe(0) + expect(killSpy).toHaveBeenCalledWith('SIGTERM') + }) + + it('releases pending provider-delivered commands on shutdown before delivery', async () => { + const killSpy = vi.fn() + const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } + mockPtySpawn.mockReturnValue(term) + + await dispatcher.callRequest('pty.spawn', { + command: 'echo stop-before-run', + commandDelivery: 'provider' + }) + expect(handler.retainedStartupCommandCount).toBe(1) + + await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) + vi.advanceTimersByTime(50) + + expect(handler.retainedStartupCommandCount).toBe(0) + expect(term.write).not.toHaveBeenCalled() + expect(killSpy).toHaveBeenCalledWith('SIGKILL') + }) + it('increments PTY ids on each spawn', async () => { const r1 = await dispatcher.callRequest('pty.spawn', {}) const r2 = await dispatcher.callRequest('pty.spawn', {}) @@ -759,6 +1018,20 @@ describe('PtyHandler', () => { expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { + handler.addEnvAugmenter((ctx) => ({ + SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' + })) + + await dispatcher.callRequest('pty.spawn', { + command: 'powershell wait-wrapper', + env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' } + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume') + }) + it.skipIf(process.platform === 'win32')( 'wraps bash spawns to restore overlay env after remote startup files', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b1419e2facb..8ac4259d3ee 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -13,6 +13,13 @@ import { import { getRelayShellLaunchConfig } from './pty-shell-launch' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { + createShellReadyScanState, + drainShellReadyHeldBytes, + scanForShellReady, + type ShellReadyScanState +} from '../main/shell-ready-marker-scanner' // Why: node-pty is a native addon that may not be installed on the remote. // Dynamic import keeps the require() lazy so loadPty() returns null gracefully @@ -53,12 +60,21 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + startupCommand?: ManagedStartupCommand } type PendingPtyOutput = { data: string } +type ManagedStartupCommand = { + command: string + delivered: boolean + waitForShellReady: boolean + scanState: ShellReadyScanState | null + timer: ReturnType | null +} + function disposeManagedPty(managed: ManagedPty): void { if (managed.disposed) { return @@ -98,6 +114,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +const STARTUP_COMMAND_WRITE_DELAY_MS = 50 +const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 const ALLOWED_SIGNALS = new Set([ 'SIGINT', 'SIGTERM', @@ -222,14 +240,71 @@ export class PtyHandler { return { ...baseEnv, ...augmented } } + private clearStartupCommandTimer(managed: ManagedPty): void { + if (managed.startupCommand?.timer) { + clearTimeout(managed.startupCommand.timer) + managed.startupCommand.timer = null + } + } + + private appendReplayBuffer(managed: ManagedPty, data: string): void { + managed.buffered += data + if (managed.buffered.length > REPLAY_BUFFER_MAX) { + managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + } + } + + private releaseStartupCommand(managed: ManagedPty): void { + this.clearStartupCommandTimer(managed) + managed.startupCommand = undefined + } + + private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + this.clearStartupCommandTimer(managed) + startup.timer = setTimeout(() => { + startup.timer = null + this.deliverStartupCommand(managed) + }, delayMs) + } + + private deliverStartupCommand(managed: ManagedPty): void { + const startup = managed.startupCommand + if (!startup || startup.delivered || managed.disposed) { + return + } + startup.delivered = true + this.clearStartupCommandTimer(managed) + if (startup.scanState) { + const heldBytes = drainShellReadyHeldBytes(startup.scanState) + if (heldBytes) { + this.appendReplayBuffer(managed, heldBytes) + this.enqueuePtyOutput(managed.id, heldBytes) + } + } + const submit = process.platform === 'win32' ? '\r' : '\n' + const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n') + const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}` + managed.startupCommand = undefined + managed.pty.write(payload) + } + /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { this.ptys.set(managed.id, managed) managed.pty.onData((data: string) => { - managed.buffered += data - if (managed.buffered.length > REPLAY_BUFFER_MAX) { - managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) + const startup = managed.startupCommand + if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { + const scanned = scanForShellReady(startup.scanState, data) + data = scanned.output + if (scanned.matched) { + this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) + } } + this.appendReplayBuffer(managed, data) this.enqueuePtyOutput(managed.id, data) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { @@ -253,6 +328,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) this.flushPtyOutput(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) this.notifyExitListener(managed) @@ -428,20 +504,26 @@ export class PtyHandler { // dirs) override renderer-supplied env so live remote paths and hook coords // win over local userData paths. The context lets overlay augmenters derive // per-PTY OpenCode/Pi directories from the stable paneKey when present. - // `command` is forwarded by ssh-pty-provider.ts only as a hint for - // overlay resolution — the relay still launches a login shell and the - // command is typed in via pty.data writes. + // `command` is usually forwarded by ssh-pty-provider.ts only as a hint + // for overlay resolution; runtime-owned PTYs opt into relay delivery + // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined + const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' + const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) - // Why: only explicit shell-ready hints are trusted here; native Codex - // prefill detection still auto-enables readiness through the predicate. - const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { - emitReadyMarker: shouldUseShellReadyStartupDelivery({ - command, + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) + const shouldEmitShellReadyMarker = + launchCommandHint !== undefined && + shouldUseShellReadyStartupDelivery({ + command: launchCommandHint, startupCommandDelivery: params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined }) + // Why: renderer- and provider-delivered startup commands both use this + // marker; the side responsible for delivery also strips it from output. + const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + emitReadyMarker: shouldEmitShellReadyMarker }) // Why: SSH exec channels give the relay a minimal environment without @@ -454,7 +536,9 @@ export class PtyHandler { cols, rows, cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) // Why: capture the renderer-supplied paneKey on the managed entry so the @@ -470,13 +554,28 @@ export class PtyHandler { buffered: '', paneKey, tabId, - worktreeId + worktreeId, + ...(shouldProviderDeliverCommand + ? { + startupCommand: { + command, + delivered: false, + waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1', + scanState: + shellLaunch.env.ORCA_SHELL_READY_MARKER === '1' + ? createShellReadyScanState() + : null, + timer: null + } + } + : {}) } this.wireAndStore(managed) if (context?.isStale()) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down // immediately so it does not linger as an unreachable remote shell. + this.releaseStartupCommand(managed) term.kill('SIGTERM') managed.killTimer = setTimeout(() => { const still = this.ptys.get(id) @@ -491,6 +590,13 @@ export class PtyHandler { this.ptys.delete(id) } }, 5000) + } else if (managed.startupCommand) { + this.scheduleStartupCommandDelivery( + managed, + managed.startupCommand.waitForShellReady + ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS + : STARTUP_COMMAND_WRITE_DELAY_MS + ) } return { id } } @@ -562,6 +668,7 @@ export class PtyHandler { } if (immediate) { + this.releaseStartupCommand(managed) this.flushPtyOutput(id) managed.pty.kill('SIGKILL') // Why: SIGKILL has already reaped the child; release the ptmx fd on the @@ -581,6 +688,7 @@ export class PtyHandler { this.ptys.delete(id) this.clearPtyFlowState(id) } else { + this.releaseStartupCommand(managed) managed.pty.kill('SIGTERM') // Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal @@ -761,7 +869,9 @@ export class PtyHandler { cols: entry.cols, rows: entry.rows, cwd: entry.cwd, - env: { ...spawnEnv, ...shellLaunch.env } + // Why: revived shells should not inherit an ambient shell-ready marker + // because no provider-delivered startup command is waiting on it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) this.wireAndStore({ id: entry.id, @@ -821,6 +931,7 @@ export class PtyHandler { clearTimeout(managed.killTimer) managed.killTimer = undefined } + this.clearStartupCommandTimer(managed) // Why: SIGKILL (not SIGTERM) before destroy. The relay process is // exiting; any SIGTERM-ignoring remote shell (editor with unsaved // buffers, a hung child with a bad handler, a process in @@ -843,6 +954,16 @@ export class PtyHandler { return this.ptys.size } + get retainedStartupCommandCount(): number { + let count = 0 + for (const managed of this.ptys.values()) { + if (managed.startupCommand) { + count += 1 + } + } + return count + } + get graceTimerActive(): boolean { return this.graceTimer !== null } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 356d778125b..7f1e6212019 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -57,6 +57,7 @@ import { import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -536,8 +537,10 @@ async function main(): Promise { // Why: source-dir defaulting is keyed on which Pi-compatible agent is // being launched (Pi vs OMP). Install Orca's guarded extension into that // real remote agent dir without redirecting PI_CODING_AGENT_DIR. - const kind = detectPiAgentKindFromCommand(ctx.command) - const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0 + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) + const kind = detectPiAgentKindFromCommand(launchCommandHint) + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 899524a11dc..43d07dc285a 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -150,6 +150,8 @@ function renderCard( requiresExplicitSetupChoice={false} setupDecision={null} onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} shouldWaitForSetupCheck={false} resolvedSetupDecision={null} createError={null} @@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(onChanges).toEqual([true]) }) + it('shows the setup startup policy toggle only when setup is available', () => { + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: '# defaultTabs[1]\npnpm dev', + kind: 'default-tabs' + } + }) + expect(current.container.textContent).not.toContain( + 'Wait for setup to complete before starting agent' + ) + + act(() => current?.root.unmount()) + current?.container.remove() + + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + } + }) + expect(current.container.textContent).toContain( + 'Wait for setup to complete before starting agent' + ) + }) + + it('emits the setup startup policy toggle value', () => { + const changes: string[] = [] + current = renderCard({ + advancedOpen: true, + setupControlsEnabled: true, + setupConfig: { + source: 'yaml', + command: 'pnpm install', + kind: 'setup' + }, + onSetupAgentStartupPolicyChange: (next) => changes.push(next) + }) + + const waitSwitch = current.container.querySelector( + '[role="switch"][aria-label="Wait for setup to complete before starting agent"]' + ) + expect(waitSwitch).toBeTruthy() + act(() => waitSwitch?.click()) + expect(changes).toEqual(['wait-for-setup']) + }) + it('does not disable folder workspace creation when only source lookup needs SSH', () => { current = renderCard({ eligibleRepos: [ diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6f68d168bb2..aa3bd8a60c2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -35,6 +36,7 @@ import type { GitHubWorkItem, GitLabWorkItem, LinearIssue, + SetupAgentStartupPolicy, SparsePreset, TuiAgent } from '../../../shared/types' @@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = { requiresExplicitSetupChoice: boolean setupDecision: 'run' | 'skip' | null onSetupDecisionChange: (value: 'run' | 'skip') => void + setupAgentStartupPolicy: SetupAgentStartupPolicy + onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: WorkspaceCreateErrorDisplay | null @@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({ requiresExplicitSetupChoice, setupDecision, onSetupDecisionChange, + setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange, shouldWaitForSetupCheck, resolvedSetupDecision, createError, @@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({ ? 'Run commands now' : 'Run setup now' const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands' + // Why: defaultTabs launch commands can be long-running too, but they are not + // the setup command this setting gates agent startup on. + const showSetupAgentStartupPolicy = + setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs' const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({ ) : null} ) : null} + + {showSetupAgentStartupPolicy ? ( +
+ + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + + + {translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} + + + + onSetupAgentStartupPolicyChange( + setupAgentStartupPolicy === 'wait-for-setup' + ? 'start-immediately' + : 'wait-for-setup' + ) + } + ariaLabel={translate( + 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} + /> +
+ ) : null} ) : null} diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index ac4bb19b878..c1f4390e5a8 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -1,5 +1,66 @@ -import { describe, expect, it } from 'vitest' -import { getLocalCommandSourcePolicyNotice } from './RepositoryHooksSection' +// @vitest-environment happy-dom + +import React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getLocalCommandSourcePolicyNotice, RepositoryHooksSection } from './RepositoryHooksSection' + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + settings: {}, + settingsSearchQuery: '' + }) +})) + +vi.mock('@/runtime/runtime-hooks-client', () => ({ + readRuntimeIssueCommand: vi.fn().mockResolvedValue({ command: '', exists: false }), + writeRuntimeIssueCommand: vi.fn().mockResolvedValue(undefined) +})) + +const repo: Repo = { + id: 'repo-1', + kind: 'git', + path: '/workspace/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + gitUsername: '' +} + +function renderRepositoryHooksSection(args: { + onUpdateHookSettings: (settings: NonNullable) => void +}): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + React.createElement(RepositoryHooksSection, { + repo, + yamlHooks: null, + hasHooksFile: false, + hooksInspectionReady: true, + mayNeedUpdate: false, + copiedTemplate: false, + forceVisible: true, + onCopyTemplate: () => {}, + onUpdateHookSettings: args.onUpdateHookSettings + }) + ) + }) + return { container, root } +} + +let rendered: { container: HTMLDivElement; root: Root } | null = null + +afterEach(() => { + act(() => rendered?.root.unmount()) + rendered?.container.remove() + rendered = null +}) describe('getLocalCommandSourcePolicyNotice', () => { it('does not show a notice when no local scripts are saved', () => { @@ -72,3 +133,25 @@ describe('getLocalCommandSourcePolicyNotice', () => { ).toEqual({ kind: 'action', policy: 'run-both', label: 'Run both' }) }) }) + +describe('RepositoryHooksSection setup startup policy', () => { + it('persists wait-for-setup when the repository toggle is checked', () => { + const updates: NonNullable[] = [] + rendered = renderRepositoryHooksSection({ + onUpdateHookSettings: (settings) => updates.push(settings) + }) + + const waitSwitch = rendered.container.querySelector( + '[role="switch"][aria-label="Wait for setup to complete before starting agent"]' + ) + expect(waitSwitch).toBeTruthy() + + act(() => waitSwitch?.click()) + + expect(updates.at(-1)).toMatchObject({ + setupAgentStartupPolicy: 'wait-for-setup', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 3231f845878..c10516dfd57 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -6,6 +6,7 @@ import type { OrcaHooks, Repo, RepoHookSettings, + SetupAgentStartupPolicy, SetupRunPolicy } from '../../../../shared/types' import { AlertTriangle, ChevronRight, Plus } from 'lucide-react' @@ -14,6 +15,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitch } from './SettingsFormControls' import { useAppStore } from '@/store' import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' @@ -39,7 +41,7 @@ type PolicyOption

= { policy: P; label: string; description: string } const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number] type HookSettingsPolicyDraft = Partial< - Pick + Pick > // Why: this is a literal issue-command template token, not app data for i18next to fill. @@ -75,6 +77,7 @@ function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): b return ( a.mode === b.mode && a.setupRunPolicy === b.setupRunPolicy && + a.setupAgentStartupPolicy === b.setupAgentStartupPolicy && a.commandSourcePolicy === b.commandSourcePolicy && a.scripts.setup === b.scripts.setup && a.scripts.archive === b.scripts.archive @@ -770,6 +773,8 @@ export function RepositoryHooksSection({ const selectedSetupRunPolicy: SetupRunPolicy = hookSettingsDraft.setupRunPolicy ?? 'run-by-default' + const selectedSetupAgentStartupPolicy: SetupAgentStartupPolicy = + hookSettingsDraft.setupAgentStartupPolicy ?? 'start-immediately' const setupRunPolicyOptions = getSetupRunPolicyOptions() const commandSourcePolicyOptions = getCommandSourcePolicyOptions() const localHookFields = getLocalHookFields() @@ -1043,26 +1048,59 @@ export function RepositoryHooksSection({ forceVisible={forceVisible} keywords={['setup run policy', 'ask', 'run by default', 'skip by default']} > -

-
-
- {translate( - 'auto.components.settings.RepositoryHooksSection.793dcee97d', - 'When to run' +
+
+
+
+ {translate( + 'auto.components.settings.RepositoryHooksSection.793dcee97d', + 'When to run' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.21fb607a87', + 'Default behavior when a new worktree is created.' + )} +

+
+ updateHookSettingsPolicyDraft({ setupRunPolicy: policy })} + /> +
+
+
+
+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp', + 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' + )} +

+
+ + updateHookSettingsPolicyDraft({ + setupAgentStartupPolicy: + selectedSetupAgentStartupPolicy === 'wait-for-setup' + ? 'start-immediately' + : 'wait-for-setup' + }) + } + ariaLabel={translate( + 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', + 'Wait for setup to complete before starting agent' )} -
-

- {translate( - 'auto.components.settings.RepositoryHooksSection.21fb607a87', - 'Default behavior when a new worktree is created.' - )} -

+ />
- updateHookSettingsPolicyDraft({ setupRunPolicy: policy })} - />
diff --git a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts index 96146826bd7..a5323265f5b 100644 --- a/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-hooks-search-entries.ts @@ -117,6 +117,10 @@ export function getRepositoryGitHooksSearchEntries(repo: Repo): SettingsSearchEn 'auto.components.settings.repository.search.f9d84b7971', 'setup run policy' ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.waitForSetupBeforeAgent', + 'wait for setup before starting agent' + ), ...translateSearchKeyword('auto.components.settings.repository.search.80c490b012', 'ask'), ...translateSearchKeyword( 'auto.components.settings.repository.search.a69c5cbe90', diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ef0430160cf..1d7ba1281b2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" @@ -2879,6 +2880,64 @@ describe('connectPanePty', () => { } }) + it('uses the sequenced startup command hint for SSH shell-ready detection', async () => { + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + + const capturedDataCallback: { current: ((data: string) => void) | null } = { + current: null + } + const transport = createMockTransport('pty-id') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-ssh-1' + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + } + + const wrapperCommand = 'bash -lc wait-for-setup-wrapper' + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: wrapperCommand, + env: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + } + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + capturedDataCallback.current?.('user@remote $ ') + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + expect(transport.sendInput).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b]777;orca-shell-ready\x07user@remote $ ') + for (const fn of pendingTimeouts.splice(0)) { + fn() + } + + expect(transport.sendInput).toHaveBeenCalledWith(`${wrapperCommand}\r`) + } finally { + globalThis.setTimeout = originalSetTimeout + } + }) + it('drops agent status without retaining when OSC 133 reports the command finished', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7475a1d2634..eac18e483ab 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -46,6 +46,7 @@ import { } from './layout-serialization' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { mode2031SequenceFor, @@ -2198,10 +2199,14 @@ export function connectPanePty( ? { command: paneStartup.command } : null : null + const startupShellReadyCommandHint = resolveSetupAgentSequenceLaunchCommand( + paneStartup?.env ?? {}, + paneStartup?.command + ) const shouldWaitForSshShellReady = Boolean(connectionId) && shouldUseShellReadyStartupDelivery({ - command: paneStartup?.command, + command: startupShellReadyCommandHint, startupCommandDelivery: paneStartup?.startupCommandDelivery }) && !shouldDeliverStartupViaTerminalPaste diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index e4546a8ed47..5a8bdf55ae5 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -187,6 +187,36 @@ describe('useComposerState host-context boundaries', () => { ) }) + it('saves setup startup policy before creating a workspace', () => { + const persistSection = sourceBetween( + HOOK_SOURCE, + 'const persistSetupAgentStartupPolicy = useCallback', + 'const handleSetupAgentStartupPolicyChange' + ) + expect(persistSection).toContain('setupAgentStartupPolicySaveRef.current') + expect(persistSection).toContain('pendingSave?.repoId === currentRepo.id') + expect(persistSection).toContain('pendingSave.policy === policy') + expect(persistSection).toContain('await pendingSave.promise') + expect(persistSection).toContain('continue') + expect(HOOK_SOURCE).toContain('setupAgentStartupPolicyDraftRef.current') + + const fullSubmit = sourceBetween( + HOOK_SOURCE, + 'const submit = useCallback', + 'const submitQuick = useCallback' + ) + const fullPolicySave = fullSubmit.indexOf('await persistSetupAgentStartupPolicy()') + const fullCreate = fullSubmit.indexOf('const result = await createWorktree(') + expect(fullPolicySave).toBeGreaterThanOrEqual(0) + expect(fullCreate).toBeGreaterThan(fullPolicySave) + + const quickSubmit = sourceBetween(HOOK_SOURCE, 'const submitQuick = useCallback', 'return {') + const quickPolicySave = quickSubmit.indexOf('await persistSetupAgentStartupPolicy()') + const quickCreate = quickSubmit.indexOf('const request: WorktreeCreationRequest = {') + expect(quickPolicySave).toBeGreaterThanOrEqual(0) + expect(quickCreate).toBeGreaterThan(quickPolicySave) + }) + it('resolves submit-time GitHub smart input when folder child repos exist', () => { expect( canResolveFolderSmartGitHubSubmit({ diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 486cbd1e45c..8dfce31d814 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { getDefaultRepoHookSettings } from '../../../shared/constants' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -39,6 +40,8 @@ import type { GitLabWorkItem, LinearIssue, OrcaHooks, + RepoHookSettings, + SetupAgentStartupPolicy, SetupDecision, SetupRunPolicy, SparsePreset, @@ -331,6 +334,8 @@ export type ComposerCardProps = { requiresExplicitSetupChoice: boolean setupDecision: 'run' | 'skip' | null onSetupDecisionChange: (value: 'run' | 'skip') => void + setupAgentStartupPolicy: SetupAgentStartupPolicy + onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: WorkspaceCreateErrorDisplay | null @@ -368,6 +373,30 @@ export type InitialWorkspaceRunSeedInput = { > | null } +function getRepoSetupAgentStartupPolicy(repo?: { + hookSettings?: Pick +}): SetupAgentStartupPolicy { + return repo?.hookSettings?.setupAgentStartupPolicy ?? 'start-immediately' +} + +function buildSetupAgentStartupHookSettings( + current: RepoHookSettings | undefined, + setupAgentStartupPolicy: SetupAgentStartupPolicy +): RepoHookSettings { + const defaults = getDefaultRepoHookSettings() + return { + ...defaults, + ...current, + setupRunPolicy: current?.setupRunPolicy ?? defaults.setupRunPolicy, + setupAgentStartupPolicy, + commandSourcePolicy: current?.commandSourcePolicy ?? defaults.commandSourcePolicy, + scripts: { + ...defaults.scripts, + ...current?.scripts + } + } +} + export function resolveInitialWorkspaceRunSeed({ draftProjectId, draftHostId, @@ -424,6 +453,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setNewWorkspaceDraft: s.setNewWorkspaceDraft, clearNewWorkspaceDraft: s.clearNewWorkspaceDraft, createWorktree: s.createWorktree, + updateRepo: s.updateRepo, updateWorktreeMeta: s.updateWorktreeMeta, createFolderWorkspace: s.createFolderWorkspace, setSidebarOpen: s.setSidebarOpen, @@ -439,6 +469,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setNewWorkspaceDraft, clearNewWorkspaceDraft, createWorktree, + updateRepo, updateWorktreeMeta, createFolderWorkspace, setSidebarOpen, @@ -900,6 +931,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [issueCommandTemplate, setIssueCommandTemplate] = useState('') const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) + const [setupAgentStartupPolicy, setSetupAgentStartupPolicy] = useState( + () => getRepoSetupAgentStartupPolicy(selectedRepo) + ) + const setupAgentStartupPolicyRef = useRef(setupAgentStartupPolicy) + setupAgentStartupPolicyRef.current = setupAgentStartupPolicy + const setupAgentStartupPolicySaveRef = useRef<{ + repoId: string + policy: SetupAgentStartupPolicy + promise: Promise + } | null>(null) + const setupAgentStartupPolicyDraftRef = useRef<{ + repoId: string + policy: SetupAgentStartupPolicy + } | null>(null) const [creating, setCreating] = useState(false) const [createError, setCreateError] = useState(null) // Why: when checked, a successful worktree create keeps the modal open and @@ -982,6 +1027,93 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const selectedRepoSettingsRef = useRef(selectedRepoSettings) selectedRepoSettingsRef.current = selectedRepoSettings + useEffect(() => { + const nextPolicy = getRepoSetupAgentStartupPolicy(selectedRepo) + const draft = setupAgentStartupPolicyDraftRef.current + if (draft?.repoId === repoId && draft.policy !== nextPolicy) { + return + } + setupAgentStartupPolicyRef.current = nextPolicy + setSetupAgentStartupPolicy(nextPolicy) + }, [repoId, selectedRepo]) + + const persistSetupAgentStartupPolicy = useCallback( + async ( + policy: SetupAgentStartupPolicy = setupAgentStartupPolicyRef.current + ): Promise => { + while (true) { + const currentRepo = useAppStore.getState().repos.find((repo) => repo.id === repoId) + if (!currentRepo || !isGitRepoKind(currentRepo)) { + return true + } + const pendingSave = setupAgentStartupPolicySaveRef.current + if (pendingSave?.repoId === currentRepo.id) { + if (pendingSave.policy === policy) { + const saved = await pendingSave.promise + if ( + saved && + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return saved + } + await pendingSave.promise + continue + } + if (getRepoSetupAgentStartupPolicy(currentRepo) === policy) { + if ( + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return true + } + const promise = updateRepo(currentRepo.id, { + hookSettings: buildSetupAgentStartupHookSettings(currentRepo.hookSettings, policy) + }).finally(() => { + if (setupAgentStartupPolicySaveRef.current?.promise === promise) { + setupAgentStartupPolicySaveRef.current = null + } + }) + setupAgentStartupPolicySaveRef.current = { repoId: currentRepo.id, policy, promise } + const saved = await promise + if ( + saved && + setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id && + setupAgentStartupPolicyDraftRef.current.policy === policy + ) { + setupAgentStartupPolicyDraftRef.current = null + } + return saved + } + }, + [repoId, updateRepo] + ) + + const handleSetupAgentStartupPolicyChange = useCallback( + (policy: SetupAgentStartupPolicy) => { + setupAgentStartupPolicyRef.current = policy + if (repoId) { + setupAgentStartupPolicyDraftRef.current = { repoId, policy } + } + setSetupAgentStartupPolicy(policy) + void persistSetupAgentStartupPolicy(policy).then((saved) => { + if (!saved) { + toast.error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } + }) + }, + [persistSetupAgentStartupPolicy, repoId] + ) + const cancelPromptCaretFrame = useCallback((): void => { if (promptCaretFrameRef.current === null) { return @@ -3090,6 +3222,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS telemetry: composerTelemetry } : undefined + if (!(await persistSetupAgentStartupPolicy())) { + throw new Error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } const result = await createWorktree( repoId, workspaceName, @@ -3215,6 +3355,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, onCreated, parsedLinkedIssueNumber, + persistSetupAgentStartupPolicy, persistDraft, pushTarget, repoId, @@ -3511,6 +3652,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) } : undefined + if (!(await persistSetupAgentStartupPolicy())) { + throw new Error( + translate( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } const request: WorktreeCreationRequest = { repoId, worktreeCreateProgressMode: @@ -3615,6 +3764,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note, onCreated, parsedLinkedIssueNumber, + persistSetupAgentStartupPolicy, persistDraft, pushTarget, repoId, @@ -3763,6 +3913,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS requiresExplicitSetupChoice: isProjectGroupTarget ? false : requiresExplicitSetupChoice, setupDecision: isProjectGroupTarget ? null : setupDecision, onSetupDecisionChange: isProjectGroupTarget ? () => {} : setSetupDecision, + setupAgentStartupPolicy: isProjectGroupTarget ? 'start-immediately' : setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange: isProjectGroupTarget + ? () => {} + : handleSetupAgentStartupPolicyChange, shouldWaitForSetupCheck: isProjectGroupTarget ? false : shouldWaitForSetupCheck, resolvedSetupDecision: isProjectGroupTarget ? null : resolvedSetupDecision, createError, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 474f51919cf..14b4834a441 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -534,7 +534,8 @@ "ba6cb77082": "Failed to connect to project.", "chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.", "folderWorkspaceCreateFailedTitle": "Folder workspace creation failed", - "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again." + "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "Failed to upload dropped files.", @@ -1123,7 +1124,9 @@ "reconnectingSsh": "Reconnecting SSH...", "sshReconnectionFailed": "SSH reconnection failed", "notConnected": "Not connected", - "notePasteTooLarge": "Paste is too large for the note field." + "notePasteTooLarge": "Paste is too large for the note field.", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "Create worktree", @@ -5683,7 +5686,9 @@ "2b6356e744": "Saved", "81057d5f71": "Saving...", "da37d6f10e": "Copy", - "3149964b66": "Copied" + "3149964b66": "Copied", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "Use {{value0}} repo color", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8fa1768628b..5247ced7988 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -534,7 +534,8 @@ "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", "folderWorkspaceCreateFailedTitle": "No se pudo crear el espacio de trabajo de carpeta", - "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo." + "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "No se pudieron cargar los archivos eliminados.", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "El contenido pegado es demasiado grande para el campo de nota.", "reuseExistingBranch": "Reutilizar rama", "reuseExistingBranchHint": "Hacer checkout de la rama existente en lugar de crear una nueva a partir de ella.", - "createMultiple": "Crear más" + "createMultiple": "Crear más", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "Crear árbol de trabajo", @@ -5646,7 +5649,9 @@ "2b6356e744": "Guardado", "81057d5f71": "Ahorro...", "da37d6f10e": "Copiar", - "3149964b66": "copiado" + "3149964b66": "copiado", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "Utilice el color del repo {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bc1e1766aaa..7c12bb98c9d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -534,7 +534,8 @@ "ba6cb77082": "プロジェクトへの接続に失敗しました。", "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加。", "folderWorkspaceCreateFailedTitle": "フォルダーワークスペースを作成できませんでした", - "folderWorkspaceCreateFailedMessage": "フォルダーワークスペースを作成できませんでした。上のエラー詳細を確認して、もう一度お試しください。" + "folderWorkspaceCreateFailedMessage": "フォルダーワークスペースを作成できませんでした。上のエラー詳細を確認して、もう一度お試しください。", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "ドロップされたファイルのアップロードに失敗しました。", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "メモ欄への貼り付け内容が大きすぎます。", "reuseExistingBranch": "ブランチを再利用", "reuseExistingBranchHint": "既存のブランチから新しいブランチを作成せず、そのブランチをチェックアウトします。", - "createMultiple": "さらに作成" + "createMultiple": "さらに作成", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "ワークツリーを作成する", @@ -5668,7 +5671,9 @@ "2b6356e744": "保存されました", "81057d5f71": "保存中...", "da37d6f10e": "コピー", - "3149964b66": "コピーされました" + "3149964b66": "コピーされました", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo カラーを使用する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3abd871fcdf..2304381ddcf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -534,7 +534,8 @@ "ba6cb77082": "프로젝트에 연결하지 못했습니다.", "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", "folderWorkspaceCreateFailedTitle": "폴더 워크스페이스를 만들지 못했습니다", - "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스를 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요." + "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스를 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요.", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.", "reuseExistingBranch": "브랜치 재사용", "reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.", - "createMultiple": "더 만들기" + "createMultiple": "더 만들기", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "작업 트리 만들기", @@ -5631,7 +5634,9 @@ "2b6356e744": "저장됨", "81057d5f71": "저장 중...", "da37d6f10e": "복사", - "3149964b66": "복사됨" + "3149964b66": "복사됨", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo 색상 사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9fc99821736..98467e2c391 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -534,7 +534,8 @@ "ba6cb77082": "无法连接到项目。", "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", "folderWorkspaceCreateFailedTitle": "文件夹工作区创建失败", - "folderWorkspaceCreateFailedMessage": "无法创建文件夹工作区。请查看上方错误详情,然后重试。" + "folderWorkspaceCreateFailedMessage": "无法创建文件夹工作区。请查看上方错误详情,然后重试。", + "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior." }, "useGlobalFileDrop": { "38c9f034ff": "无法上传删除的文件。", @@ -1123,7 +1124,9 @@ "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", "reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。", - "createMultiple": "创建更多" + "createMultiple": "创建更多", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "NewWorkspaceComposerModal": { "createWorktree": "创建工作树", @@ -5631,7 +5634,9 @@ "2b6356e744": "已保存", "81057d5f71": "保存中...", "da37d6f10e": "复制", - "3149964b66": "已复制" + "3149964b66": "已复制", + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", + "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." }, "RepositoryIconPicker": { "2b7d27b93c": "使用 {{value0}} 仓库颜色", diff --git a/src/renderer/src/lib/setup-runner.test.ts b/src/renderer/src/lib/setup-runner.test.ts index a2491d8d2fc..c8e8da93f5a 100644 --- a/src/renderer/src/lib/setup-runner.test.ts +++ b/src/renderer/src/lib/setup-runner.test.ts @@ -38,4 +38,14 @@ describe('buildSetupRunnerCommand', () => { 'bash /home/dev/repo/.git/orca/setup-runner.sh' ) }) + + it('uses cmd.exe for native Windows runner scripts on non-Windows clients', () => { + vi.stubGlobal('navigator', { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + }) + + expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd')).toBe( + 'cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"' + ) + }) }) diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index b4e57fdc0ca..1779f567703 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -1,8 +1,16 @@ -import { buildSetupRunnerCommand as buildSharedSetupRunnerCommand } from '../../../shared/setup-runner-command' +import { + buildSetupRunnerCommand as buildSharedSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../../shared/setup-runner-command' export function buildSetupRunnerCommand(runnerScriptPath: string): string { + // Why: the runner may live on a remote/WSL filesystem, so the shell follows + // the runner path format rather than the local renderer OS. return buildSharedSetupRunnerCommand( runnerScriptPath, - navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + getSetupRunnerCommandPlatformForPath( + runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) ) } diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 7321641de5c..a2afa59fc06 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -176,6 +176,53 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.setActiveTab).not.toHaveBeenCalled() }) + it('queues returned setup fallback on an existing web runtime tab', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + settings: { activeRuntimeEnvironmentId: 'web-runtime-1' }, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings @@ -214,6 +261,71 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('queues returned setup on an existing terminal tab when startup was already adopted', () => { + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + command: 'bash -lc wrapped-setup', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash -lc wrapped-setup', + env: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + + it('queues wrapped setup on an existing terminal tab when setup gates startup', () => { + let createdIndex = 1 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + createTab, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })) + }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenCalledTimes(1) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + it('queues a startup command when agent launch is provided', () => { const store = createMockStore() @@ -236,6 +348,108 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) + it('gates startup behind setup completion when both are provided in new-tab mode', () => { + setSetupScriptLaunchMode('new-tab') + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('Timed out waiting for setup before starting agent.') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('exec claude') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('printf') + }) + ) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-2', + expect.objectContaining({ + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabSetupSplit).not.toHaveBeenCalled() + }) + + it('starts setup and agent side by side by default', () => { + setSetupScriptLaunchMode('new-tab') + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { + command: 'claude' + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + }) + + it('gates startup behind setup completion when setup is a split', () => { + setSetupScriptLaunchMode('split-vertical') + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' }, + waitForAgentStartup: true + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('exec claude') + }) + ) + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), + env: { ORCA_ROOT_PATH: '/tmp/repo' }, + direction: 'vertical' + }) + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + command: expect.stringContaining('printf'), + env: { ORCA_ROOT_PATH: '/tmp/repo' }, + direction: 'vertical' + }) + }) + it('forwards telemetry on the queued startup so main can fire agent_started', () => { const store = createMockStore() @@ -268,6 +482,30 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('stamps the tab agent from startup launchAgent without telemetry', () => { + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { + command: 'codex', + launchAgent: 'codex' + }, + undefined, + undefined + ) + + expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + pendingActivationSpawn: true, + launchAgent: 'codex' + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { + command: 'codex', + launchAgent: 'codex' + }) + }) + it('does not create a terminal just because the legacy terminal slice is empty', () => { const store = createMockStore({ tabsByWorktree: { 'wt-1': [] }, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 818ed7959f6..451575469de 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -12,6 +12,8 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { buildSetupRunnerCommand } from './setup-runner' +import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' +import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' import { buildAgentStartupPlan } from './tui-agent-startup' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { CLIENT_PLATFORM } from './new-workspace' @@ -440,26 +442,76 @@ export function ensureWorktreeHasInitialTerminal( // Why: activation can now restore editor- or browser-only worktrees from the // reconciled tab-group model. Creating a terminal just because the legacy // terminal slice is empty would reopen worktrees with an unexpected extra tab. - if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { - return null - } - // Why: remote web clients mirror the runtime server's session tabs. A local - // activation fallback can spawn a second host terminal before the mirror lands. const ownerState = store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined ? store : useAppStore.getState() + let sequencedStartup = startup + let wrappedSetupCommandStr: string | undefined + + if (startup && setup?.waitForAgentStartup === true) { + const platform = getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) + const sequenced = createSequencedSetupAgentCommands({ + runnerScriptPath: setup.runnerScriptPath, + startupCommand: startup.command, + platform + }) + sequencedStartup = { + ...startup, + command: sequenced.startupCommand, + ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {}) + } + wrappedSetupCommandStr = sequenced.setupCommand + } + + // Why: remote web clients mirror the runtime server's session tabs. A local + // activation fallback can spawn a second host terminal before the mirror lands, + // but returned setup fallbacks still need to run on an already mirrored tab. if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { + const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id + if (existingTerminalTabId && (setup || issueCommand)) { + queueSetupAndIssueCommands( + store, + worktreeId, + existingTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) + return existingTerminalTabId + } + return null + } + + if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { + const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id + if (existingTerminalTabId && (setup || issueCommand)) { + // Why: main may have already adopted the startup tab but failed to spawn + // setup; renderer activation must still launch the returned fallback setup. + queueSetupAndIssueCommands( + store, + worktreeId, + existingTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) + return existingTerminalTabId + } return null } const templatedTabId = applyDefaultTerminalTabs( store, worktreeId, - startup, + sequencedStartup, setup, issueCommand, - defaultTabs + defaultTabs, + wrappedSetupCommandStr ) if (templatedTabId) { return templatedTabId @@ -472,11 +524,13 @@ export function ensureWorktreeHasInitialTerminal( // // Why: the initial terminal can be seeded with a coding agent (new-workspace // flow, or reopening an empty worktree created with an agent). The startup - // payload only carries telemetry's agent_kind, so reverse it back to a - // TuiAgent to stamp the tab — giving it the provider icon before any hook. - const launchAgent = startup?.telemetry - ? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined) - : undefined + // payload may carry explicit launchAgent; older flows only carry telemetry's + // agent_kind, so reverse that back to a TuiAgent when needed for the icon. + const launchAgent = + sequencedStartup?.launchAgent ?? + (sequencedStartup?.telemetry + ? (agentKindToTuiAgent(sequencedStartup.telemetry.agent_kind) ?? undefined) + : undefined) const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, ...(launchAgent ? { launchAgent } : {}) @@ -487,10 +541,17 @@ export function ensureWorktreeHasInitialTerminal( // coding agent and user prompt. Queue that startup command on the initial // pane so the main terminal begins in the requested agent session instead of // opening to an idle shell and forcing the user to repeat the same prompt. - if (startup) { - store.queueTabStartupCommand(terminalTab.id, startup) + if (sequencedStartup) { + store.queueTabStartupCommand(terminalTab.id, sequencedStartup) } - queueSetupAndIssueCommands(store, worktreeId, terminalTab.id, setup, issueCommand) + queueSetupAndIssueCommands( + store, + worktreeId, + terminalTab.id, + setup, + issueCommand, + wrappedSetupCommandStr + ) return terminalTab.id } @@ -501,7 +562,8 @@ function applyDefaultTerminalTabs( startup: WorktreeStartupPayload | undefined, setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, - defaultTabs: WorktreeDefaultTabsLaunch | undefined + defaultTabs: WorktreeDefaultTabsLaunch | undefined, + wrappedSetupCommandStr?: string ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -539,7 +601,14 @@ function applyDefaultTerminalTabs( if (startup) { store.queueTabStartupCommand(firstTabId, startup) } - queueSetupAndIssueCommands(store, worktreeId, firstTabId, setup, issueCommand) + queueSetupAndIssueCommands( + store, + worktreeId, + firstTabId, + setup, + issueCommand, + wrappedSetupCommandStr + ) return firstTabId } @@ -548,7 +617,8 @@ function queueSetupAndIssueCommands( worktreeId: string, terminalTabId: string, setup: WorktreeSetupLaunch | undefined, - issueCommand: IssueCommandLaunch | undefined + issueCommand: IssueCommandLaunch | undefined, + wrappedSetupCommandStr?: string ): void { // Why: the setup script launch location is user-configurable. The default // 'new-tab' creates a separate background tab titled "Setup" without @@ -558,7 +628,8 @@ function queueSetupAndIssueCommands( if (setup) { const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' const setupCommand = { - command: buildSetupRunnerCommand(setup.runnerScriptPath), + command: + wrappedSetupCommandStr ?? setup.command ?? buildSetupRunnerCommand(setup.runnerScriptPath), env: setup.envVars } if (mode === 'new-tab') { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ca2e5deb364..95c3871e419 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,6 +29,7 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' +import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -397,6 +398,7 @@ export function getDefaultRepoHookSettings(): RepoHookSettings { return { mode: 'auto', setupRunPolicy: 'run-by-default', + setupAgentStartupPolicy: DEFAULT_SETUP_AGENT_STARTUP_POLICY, scripts: { setup: '', archive: '' diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts new file mode 100644 index 00000000000..9b599a87cf6 --- /dev/null +++ b/src/shared/setup-agent-sequencing.test.ts @@ -0,0 +1,425 @@ +import { spawn } from 'child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getDefaultRepoHookSettings } from './constants' +import { + createSequencedSetupAgentCommands, + createSetupAgentSequenceNonce, + getSetupAgentSequenceShellForTests, + resolveSetupAgentSequenceLaunchCommand, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from './setup-agent-sequencing' +import { + DEFAULT_SETUP_AGENT_STARTUP_POLICY, + shouldWaitForSetupBeforeAgentStartup +} from './setup-agent-startup-policy' + +const TEMP_DIRS: string[] = [] + +afterEach(() => { + for (const dir of TEMP_DIRS.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('createSequencedSetupAgentCommands', () => { + it('defaults agent startup to immediate unless the wait policy is explicit', () => { + expect(DEFAULT_SETUP_AGENT_STARTUP_POLICY).toBe('start-immediately') + expect(getDefaultRepoHookSettings().setupAgentStartupPolicy).toBe('start-immediately') + expect(shouldWaitForSetupBeforeAgentStartup(undefined)).toBe(false) + expect(shouldWaitForSetupBeforeAgentStartup('start-immediately')).toBe(false) + expect(shouldWaitForSetupBeforeAgentStartup('wait-for-setup')).toBe(true) + }) + + it('uses the original sequenced startup command as the launch hint when present', () => { + expect( + resolveSetupAgentSequenceLaunchCommand( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }, + 'powershell wait-wrapper' + ) + ).toBe('omp --resume') + expect( + resolveSetupAgentSequenceLaunchCommand( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: ' ' }, + 'powershell wait-wrapper' + ) + ).toBe('powershell wait-wrapper') + }) + + it('wraps POSIX setup and startup commands with a matching nonce marker', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: "codex 'fix bug'", + platform: 'posix', + nonce: 'nonce-123', + waitTimeoutSeconds: 9 + }) + + expect(result.setupCommand).toMatch(/^bash -lc /) + expect(result.setupCommand).toContain('bash /repo/.git/orca/setup-runner.sh') + expect(result.setupCommand).toContain('printf') + expect(result.setupCommand).toContain('nonce-123 "$status"') + expect(result.setupCommand).toContain( + 'mv -f /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp' + ) + expect(result.startupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).toContain('deadline=$((SECONDS + 9))') + expect(result.startupCommand).not.toContain('date +%s') + expect(result.startupCommand).toContain('Waiting for setup to finish before starting agent...') + expect(result.startupCommand).toContain('[ "$seen" = nonce-123 ]') + expect(result.startupCommand).toContain( + 'rm -f /repo/.git/orca/setup-runner.sh.nonce-123.done /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp' + ) + expect(result.startupCommand).toContain('exec codex') + expect(result.startupCommand).toContain('fix bug') + expect(result.startupEnv).toEqual( + expect.objectContaining({ + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'fix bug'" + }) + ) + }) + + it('uses launch-specific marker paths for overlapping setup gates', () => { + const first = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'claude', + platform: 'posix', + nonce: 'first-launch' + }) + const second = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'codex', + platform: 'posix', + nonce: 'second-launch' + }) + + expect(first.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + expect(first.startupCommand).toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + expect(second.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(second.startupCommand).toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(first.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.second-launch.done') + expect(second.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.first-launch.done') + }) + + it('keeps simple POSIX startup commands eligible for exec when quoted text has separators', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: "codex 'fix this; then test'", + platform: 'posix', + nonce: 'nonce-quoted', + waitTimeoutSeconds: 9 + }) + + expect(result.startupCommand).toContain("exec codex '\\''fix this; then test'\\''") + expect(result.startupCommand).not.toContain('eval codex') + }) + + it('preserves POSIX inline environment assignment startup commands', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/repo/.git/orca/setup-runner.sh', + startupCommand: 'FOO=bar claude', + platform: 'posix', + nonce: 'nonce-env', + waitTimeoutSeconds: 9 + }) + + expect(result.startupCommand).toContain('FOO=bar claude') + expect(result.startupCommand).toContain('exit "$?"') + expect(result.startupCommand).not.toContain('exec FOO=bar claude') + }) + + it('uses the converted Linux marker path for WSL UNC runners on Windows', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + startupCommand: 'claude', + platform: 'windows', + nonce: 'nonce-wsl' + }) + + expect(getSetupAgentSequenceShellForTests(resultPathWsl(), 'windows')).toBe('posix') + expect(result.setupCommand).toContain( + 'bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh' + ) + expect(result.setupCommand).toContain( + '/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh.nonce-wsl.done' + ) + expect(result.setupCommand).not.toContain('wsl.localhost') + }) + + it('keeps remote POSIX runners in bash even from a Windows client', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: '/remote/repo/.git/worktrees/feature/orca/setup-runner.sh', + startupCommand: 'claude', + platform: 'windows', + nonce: 'nonce-remote' + }) + + expect(result.setupCommand).toContain( + 'bash /remote/repo/.git/worktrees/feature/orca/setup-runner.sh' + ) + expect(result.startupCommand).toContain('[ "$seen" = nonce-remote ]') + }) + + it('wraps native Windows runners in a cmd-pinned setup and startup gate', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + startupCommand: "codex --model gpt-5 'fix !PATH! & test'", + platform: 'windows', + nonce: 'nonce-win', + waitTimeoutSeconds: 3 + }) + + expect(result.setupCommand).toContain('cmd.exe /d /s /v:on /c') + expect(result.setupCommand).toContain('cmd.exe /c ""C:\\repo\\.git\\orca\\setup-runner.cmd""') + expect(result.setupCommand).toContain('echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!') + expect(result.startupCommand.match(/powershell\.exe/g)).toHaveLength(1) + expect(result.startupCommand).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass') + expect(result.startupCommand).toContain('AddSeconds(3)') + expect(result.startupCommand).toContain('!ORCA_SETUP_STATUS!') + expect(result.startupCommand).toContain('Timed out waiting for setup before starting agent.') + expect(result.startupCommand).toContain('Setup failed; skipping agent startup.') + expect(result.startupCommand).toContain( + 'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue' + ) + expect(result.startupCommand).not.toContain('%ERRORLEVEL%') + expect(result.startupCommand).not.toContain(' & ) else') + expect(result.startupCommand).not.toContain('if ""!ORCA_SETUP_STATUS!""==""124""') + expect(result.startupCommand).not.toContain('if not ""!ORCA_SETUP_STATUS!""==""0""') + expect(result.startupCommand).not.toContain( + `call !${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}!` + ) + expect(result.startupCommand).toContain('Invoke-Expression') + expect(result.startupCommand).not.toContain('fix !PATH! & test') + expect(result.startupEnv).toEqual({ + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --model gpt-5 'fix !PATH! & test'" + }) + }) + + it.skipIf(process.platform === 'win32')( + 'ignores stale markers until the matching setup run finishes, even when startup launches first', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const startupScriptPath = join(tempDir, 'startup.sh') + const logPath = join(tempDir, 'sequence.log') + const markerPath = `${runnerScriptPath}.fresh-sequence.done` + + writeExecutable( + runnerScriptPath, + [ + '#!/bin/sh', + `printf 'setup-start\\n' >> ${quoteSh(logPath)}`, + 'sleep 1', + `printf 'setup-done\\n' >> ${quoteSh(logPath)}` + ].join('\n') + ) + writeExecutable( + startupScriptPath, + ['#!/bin/sh', `printf 'agent-start\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + writeFileSync(markerPath, 'stale:0\n', 'utf8') + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `bash ${quoteSh(startupScriptPath)}`, + platform: 'posix', + nonce: 'fresh-sequence', + waitTimeoutSeconds: 5 + }) + + const startupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + await sleep(250) + expect(readIfExists(logPath)).toBe('') + expect(readFileSync(markerPath, 'utf8')).toBe('stale:0\n') + + const setupExit = await waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + expect(setupExit.code).toBe(0) + + const startupExit = await startupExitPromise + expect(startupExit.code).toBe(0) + + expect(readFileSync(logPath, 'utf8')).toBe('setup-start\nsetup-done\nagent-start\n') + expect(readIfExists(markerPath)).toBe('') + expect(readIfExists(`${markerPath}.tmp`)).toBe('') + } + ) + + it.skipIf(process.platform === 'win32')( + 'runs compound POSIX startup cleanup commands after setup succeeds', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const logPath = join(tempDir, 'sequence.log') + + writeExecutable( + runnerScriptPath, + ['#!/bin/sh', `printf 'setup-done\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `printf 'agent-start\\n' >> ${quoteSh(logPath)}; printf 'cleanup\\n' >> ${quoteSh(logPath)}`, + platform: 'posix', + nonce: 'compound-sequence', + waitTimeoutSeconds: 5 + }) + + const setupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + const setupExit = await setupExitPromise + + expect(setupExit.code).toBe(0) + expect(startupExit.code).toBe(0) + expect(readFileSync(logPath, 'utf8')).toBe('setup-done\nagent-start\ncleanup\n') + expect(commands.startupCommand).toContain('eval') + expect(commands.startupCommand).not.toContain('exec printf') + } + ) + + it.skipIf(process.platform === 'win32')( + 'prefers the env-provided startup command after setup succeeds', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + const startupScriptPath = join(tempDir, 'startup.sh') + const logPath = join(tempDir, 'sequence.log') + + writeExecutable( + runnerScriptPath, + ['#!/bin/sh', `printf 'setup-done\\n' >> ${quoteSh(logPath)}`].join('\n') + ) + writeExecutable( + startupScriptPath, + [ + '#!/bin/sh', + 'if [ "$FOO" = "bar" ]; then', + ` printf 'env-start\\n' >> ${quoteSh(logPath)}`, + 'fi' + ].join('\n') + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `printf 'inline-start\\n' >> ${quoteSh(logPath)}`, + platform: 'posix', + nonce: 'env-sequence', + waitTimeoutSeconds: 5 + }) + + const setupExitPromise = waitForExit( + spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe' }) + ) + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { + stdio: 'pipe', + env: { + ...process.env, + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: `FOO=bar bash ${quoteSh(startupScriptPath)}; printf 'env-cleanup\\n' >> ${quoteSh(logPath)}` + } + }) + ) + const setupExit = await setupExitPromise + + expect(setupExit.code).toBe(0) + expect(startupExit.code).toBe(0) + expect(readFileSync(logPath, 'utf8')).toBe('setup-done\nenv-start\nenv-cleanup\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'times out instead of hanging forever when setup never writes a matching marker', + async () => { + const tempDir = makeTempDir() + const runnerScriptPath = join(tempDir, 'setup-runner.sh') + + writeExecutable(runnerScriptPath, '#!/bin/sh\nexit 0\n') + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: 'printf ready', + platform: 'posix', + nonce: 'timeout-sequence', + waitTimeoutSeconds: 1 + }) + + const startupExit = await waitForExit( + spawn('bash', ['-lc', commands.startupCommand], { stdio: 'pipe' }) + ) + + expect(startupExit.code).toBe(124) + expect(startupExit.stderr).toContain('Timed out waiting for setup before starting agent.') + } + ) +}) + +describe('createSetupAgentSequenceNonce', () => { + it('prefers crypto.randomUUID when available', () => { + const originalCrypto = globalThis.crypto + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' }) + + expect(createSetupAgentSequenceNonce()).toBe('uuid-1') + + vi.stubGlobal('crypto', originalCrypto) + }) +}) + +function resultPathWsl(): string { + return '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh' +} + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-setup-sequencing-')) + TEMP_DIRS.push(dir) + return dir +} + +function writeExecutable(path: string, contents: string): void { + writeFileSync(path, contents, 'utf8') + chmodSync(path, 0o755) +} + +function quoteSh(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function readIfExists(path: string): string { + try { + return readFileSync(path, 'utf8') + } catch { + return '' + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function waitForExit( + child: ReturnType +): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + let stderr = '' + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString() + }) + child.once('error', reject) + child.once('close', (code) => { + resolve({ code, stderr }) + }) + }) +} diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts new file mode 100644 index 00000000000..33a5f96a63f --- /dev/null +++ b/src/shared/setup-agent-sequencing.ts @@ -0,0 +1,257 @@ +import { + resolveSetupRunnerCommand, + type SetupRunnerCommandPlatform, + type SetupRunnerCommandShell +} from './setup-runner-command' + +const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60 +export const SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV = 'ORCA_SEQUENCED_STARTUP_COMMAND' + +export type SequencedSetupAgentCommands = { + setupCommand: string + startupCommand: string + startupEnv?: Record +} + +export function resolveSetupAgentSequenceLaunchCommand( + env: Record, + fallbackCommand: string | undefined +): string | undefined { + const sequencedStartup = env[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]?.trim() + return sequencedStartup || fallbackCommand +} + +export function createSetupAgentSequenceNonce(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +export function createSequencedSetupAgentCommands(args: { + runnerScriptPath: string + startupCommand: string + platform: SetupRunnerCommandPlatform + nonce?: string + waitTimeoutSeconds?: number +}): SequencedSetupAgentCommands { + const nonce = args.nonce ?? createSetupAgentSequenceNonce() + const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform) + // Why: overlapping gated launches of the same setup runner must not race on + // a shared completion marker. + const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` + const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS + + if (resolution.shell === 'windows') { + return { + setupCommand: buildWindowsSetupCommand(resolution.command, markerPath, nonce), + startupCommand: buildWindowsStartupCommand(markerPath, nonce, waitTimeoutSeconds), + startupEnv: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand + } + } + } + + return { + setupCommand: buildPosixSetupCommand(resolution.command, markerPath, nonce), + startupCommand: buildPosixStartupCommand( + args.startupCommand, + markerPath, + nonce, + waitTimeoutSeconds + ), + startupEnv: { + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand + } + } +} + +function buildPosixSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { + const marker = quotePosixArg(markerPath) + const tmp = quotePosixArg(`${markerPath}.tmp`) + const nonceValue = quotePosixArg(nonce) + + const script = [ + `rm -f ${marker} ${tmp} 2>/dev/null`, + `( ${setupCommand} )`, + 'status=$?', + `printf '%s:%s\\n' ${nonceValue} "$status" > ${tmp}`, + `mv -f ${tmp} ${marker}`, + 'exit "$status"' + ].join('; ') + + return `bash -lc ${quotePosixArg(script)}` +} + +function buildPosixStartupCommand( + startupCommand: string, + markerPath: string, + nonce: string, + waitTimeoutSeconds: number +): string { + const marker = quotePosixArg(markerPath) + const tmp = quotePosixArg(`${markerPath}.tmp`) + const nonceValue = quotePosixArg(nonce) + const timeout = Math.max(1, Math.floor(waitTimeoutSeconds)) + const startupSuccessCommand = buildPosixStartupSuccessCommand(startupCommand) + // Why: the PTY launch path feeds this command through an interactive shell, + // so keeping the wrapper on one line avoids visible `quote>` continuation + // prompts while still preserving valid `while`/`if` shell syntax. + const script = [ + `deadline=$((SECONDS + ${timeout}));`, + 'echo "Waiting for setup to finish before starting agent..." >&2;', + 'while :; do', + `if [ -f ${marker} ]; then`, + `IFS=: read -r seen status < ${marker} || true;`, + `if [ "$seen" = ${nonceValue} ]; then`, + `rm -f ${marker} ${tmp} 2>/dev/null;`, + `if [ "$status" = "0" ]; then if [ -n "\${${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}:-}" ]; then eval "\$${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}"; exit "$?"; else ${startupSuccessCommand}; fi; fi;`, + 'echo "Setup failed; skipping agent startup." >&2;', + 'exit "${status:-1}";', + 'fi;', + 'fi;', + 'if [ "$SECONDS" -ge "$deadline" ]; then', + 'echo "Timed out waiting for setup before starting agent." >&2;', + 'exit 124;', + 'fi;', + 'sleep 1;', + 'done' + ].join(' ') + + return `bash -lc ${quotePosixArg(script)}` +} + +function buildPosixStartupSuccessCommand(startupCommand: string): string { + if ( + hasUnquotedPosixCommandSeparator(startupCommand) || + hasLeadingPosixEnvAssignment(startupCommand) + ) { + return `eval ${quotePosixArg(startupCommand)}; exit "$?"` + } + return `exec ${startupCommand}` +} + +function hasLeadingPosixEnvAssignment(command: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(command.trimStart()) +} + +function hasUnquotedPosixCommandSeparator(command: string): boolean { + let quote: "'" | '"' | null = null + let escaped = false + for (const char of command) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') { + return true + } + } + return false +} + +function buildWindowsSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { + return wrapCmd([ + `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, + `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, + 'del /f /q "!ORCA_SETUP_MARKER!" "!ORCA_SETUP_MARKER!.tmp" 2>nul', + `call ${setupCommand}`, + 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', + '> "!ORCA_SETUP_MARKER!.tmp" echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!', + 'move /y "!ORCA_SETUP_MARKER!.tmp" "!ORCA_SETUP_MARKER!" >nul', + 'exit /b !ORCA_SETUP_STATUS!' + ]) +} + +function buildWindowsStartupCommand( + markerPath: string, + nonce: string, + waitTimeoutSeconds: number +): string { + const timeout = Math.max(1, Math.floor(waitTimeoutSeconds)) + // Why: native Windows setup runners launch through cmd.exe, but PowerShell + // gives us safe bounded file polling/parsing without a fragile batch label loop. + const script = [ + '$marker = $env:ORCA_SETUP_MARKER', + '$tmp = $marker + ".tmp"', + '$nonce = $env:ORCA_SETUP_NONCE', + `$deadline = (Get-Date).AddSeconds(${timeout})`, + 'while ($true) {', + ' if (Test-Path -LiteralPath $marker) {', + ' $content = Get-Content -LiteralPath $marker -TotalCount 1', + ' if ($content -match "^([0-9A-Za-z_-]+):([0-9]+)$" -and $Matches[1] -eq $nonce) {', + ' $setupStatus = [int]$Matches[2]', + ' Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue', + ' if ($setupStatus -ne 0) {', + ' [Console]::Error.WriteLine("Setup failed; skipping agent startup.")', + ' exit $setupStatus', + ' }', + ` $startup = $env:${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}`, + ' if ([string]::IsNullOrWhiteSpace($startup)) {', + ' [Console]::Error.WriteLine("Missing sequenced startup command.")', + ' exit 1', + ' }', + ' Invoke-Expression $startup', + ' if ($global:LASTEXITCODE -ne $null) { exit $global:LASTEXITCODE }', + ' if (-not $?) { exit 1 }', + ' exit 0', + ' }', + ' }', + ' if ((Get-Date) -ge $deadline) {', + ' [Console]::Error.WriteLine("Timed out waiting for setup before starting agent.")', + ' exit 124', + ' }', + ' Start-Sleep -Seconds 1', + '}' + ].join('; ') + + return wrapCmd([ + `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, + `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, + 'echo Waiting for setup to finish before starting agent... 1>&2', + `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${quoteWindowsArg(script)}`, + 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', + 'exit /b !ORCA_SETUP_STATUS!' + ]) +} + +function wrapCmd(parts: string[]): string { + return `cmd.exe /d /s /v:on /c ${quoteWindowsArg(parts.join(' & '))}` +} + +function quotePosixArg(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value + } + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function quoteWindowsArg(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function escapeCmdSetValue(value: string): string { + return value.replace(/"/g, '""').replace(/[%!^]/g, (char) => `^${char}`) +} + +export function getSetupAgentSequenceShellForTests( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform +): SetupRunnerCommandShell { + return resolveSetupRunnerCommand(runnerScriptPath, platform).shell +} diff --git a/src/shared/setup-agent-startup-policy.ts b/src/shared/setup-agent-startup-policy.ts new file mode 100644 index 00000000000..4c4fe2485e2 --- /dev/null +++ b/src/shared/setup-agent-startup-policy.ts @@ -0,0 +1,11 @@ +import type { SetupAgentStartupPolicy } from './types' + +// Why: existing repos should keep launching setup and agents side by side unless +// the user explicitly opts into waiting for setup completion. +export const DEFAULT_SETUP_AGENT_STARTUP_POLICY: SetupAgentStartupPolicy = 'start-immediately' + +export function shouldWaitForSetupBeforeAgentStartup( + policy: SetupAgentStartupPolicy | undefined +): boolean { + return policy === 'wait-for-setup' +} diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 3362e890c2b..1d2dd5c3662 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildSetupRunnerCommand } from './setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from './setup-runner-command' describe('buildSetupRunnerCommand', () => { it('uses bash for WSL UNC runner scripts regardless of host casing', () => { @@ -10,4 +13,57 @@ describe('buildSetupRunnerCommand', () => { ) ).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') }) + + it('uses bash with Linux paths for forward-slash WSL UNC runner scripts', () => { + expect( + buildSetupRunnerCommand( + '//wsl.localhost/Ubuntu/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh', + 'windows' + ) + ).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh') + }) + + it('keeps generic forward-slash UNC runner scripts on cmd.exe', () => { + expect( + buildSetupRunnerCommand('//server/share/repo/.git/orca/setup-runner.cmd', 'windows') + ).toBe('cmd.exe /c "//server/share/repo/.git/orca/setup-runner.cmd"') + }) +}) + +describe('getSetupRunnerCommandPlatformForPath', () => { + it('prefers POSIX for absolute POSIX runner paths even from Windows clients', () => { + expect( + getSetupRunnerCommandPlatformForPath('/remote/repo/.git/orca/setup-runner.sh', 'windows') + ).toBe('posix') + }) + + it('prefers Windows for native Windows runner paths even from POSIX clients', () => { + expect( + getSetupRunnerCommandPlatformForPath('C:\\repo\\.git\\orca\\setup-runner.cmd', 'posix') + ).toBe('windows') + }) + + it('keeps WSL UNC paths on the Windows resolver so they can be converted', () => { + expect( + getSetupRunnerCommandPlatformForPath( + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\orca\\setup-runner.sh', + 'posix' + ) + ).toBe('windows') + }) + + it('keeps forward-slash UNC paths on the Windows resolver', () => { + expect( + getSetupRunnerCommandPlatformForPath( + '//wsl.localhost/Ubuntu/home/jin/repo/.git/orca/setup-runner.sh', + 'posix' + ) + ).toBe('windows') + expect( + getSetupRunnerCommandPlatformForPath( + '//server/share/repo/.git/orca/setup-runner.cmd', + 'posix' + ) + ).toBe('windows') + }) }) diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index 95f12d73724..d750f5b9e51 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -1,29 +1,74 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' + export type SetupRunnerCommandPlatform = 'windows' | 'posix' +export type SetupRunnerCommandShell = 'posix' | 'windows' + +export type SetupRunnerCommandResolution = { + command: string + runnerScriptPathForShell: string + shell: SetupRunnerCommandShell +} export function buildSetupRunnerCommand( runnerScriptPath: string, platform: SetupRunnerCommandPlatform ): string { - if (platform === 'windows') { - if (runnerScriptPath.startsWith('/')) { - return `bash ${quotePosixArg(runnerScriptPath)}` - } - if (isWslUncPath(runnerScriptPath)) { - const linuxPath = wslUncToLinuxPath(runnerScriptPath) - return `bash ${quotePosixArg(linuxPath)}` - } - return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` - } - - return `bash ${quotePosixArg(runnerScriptPath)}` + return resolveSetupRunnerCommand(runnerScriptPath, platform).command } -function isWslUncPath(path: string): boolean { +export function getSetupRunnerCommandPlatformForPath( + runnerScriptPath: string, + fallbackPlatform: SetupRunnerCommandPlatform +): SetupRunnerCommandPlatform { + if (isWindowsAbsolutePathLike(runnerScriptPath)) { + return 'windows' + } + if (runnerScriptPath.startsWith('/')) { + return 'posix' + } + return fallbackPlatform +} + +export function resolveSetupRunnerCommand( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform +): SetupRunnerCommandResolution { + if (platform === 'windows') { + if (isWslUncPath(runnerScriptPath)) { + const linuxPath = wslUncToLinuxPath(runnerScriptPath) + return { + command: `bash ${quotePosixArg(linuxPath)}`, + runnerScriptPathForShell: linuxPath, + shell: 'posix' + } + } + if (runnerScriptPath.startsWith('/') && !isWindowsAbsolutePathLike(runnerScriptPath)) { + return { + command: `bash ${quotePosixArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'posix' + } + } + return { + command: `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'windows' + } + } + + return { + command: `bash ${quotePosixArg(runnerScriptPath)}`, + runnerScriptPathForShell: runnerScriptPath, + shell: 'posix' + } +} + +export function isWslUncPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return /^\/\/(wsl\.localhost|wsl\$)\//i.test(normalized) } -function wslUncToLinuxPath(windowsPath: string): string { +export function wslUncToLinuxPath(windowsPath: string): string { const normalized = windowsPath.replace(/\\/g, '/') const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/i) return match?.[2] || '/' diff --git a/src/shared/types.ts b/src/shared/types.ts index a6c498da541..8338a5999da 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -389,6 +389,7 @@ export type ProjectGroupImportResult = { } export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' +export type SetupAgentStartupPolicy = 'start-immediately' | 'wait-for-setup' export type SetupDecision = 'inherit' | 'run' | 'skip' export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both' @@ -1926,6 +1927,7 @@ export type RepoHookSettings = { // hook UI. Keep it in the shape so existing local state reads without a migration. mode: 'auto' | 'override' setupRunPolicy?: SetupRunPolicy + setupAgentStartupPolicy?: SetupAgentStartupPolicy commandSourcePolicy?: HookCommandSourcePolicy scripts: { setup: string @@ -1936,6 +1938,8 @@ export type RepoHookSettings = { export type WorktreeSetupLaunch = { runnerScriptPath: string envVars: Record + command?: string + waitForAgentStartup?: boolean } export type WorktreeStartupLaunch = { From 8430a10a1097dc8f5861a0cdcb6fe4f0a2502909 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:56:44 -0700 Subject: [PATCH 02/23] fix(terminal): reconcile PTY size after spawn to fix first-mount column desync --- .../terminal-pane/pty-connection.ts | 34 ++ .../e2e/terminal-column-desync-repro.spec.ts | 334 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 tests/e2e/terminal-column-desync-repro.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index eac18e483ab..66f6931f0df 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2110,6 +2110,37 @@ export function connectPanePty( if (geometryReportObserver && pane.container instanceof Element) { geometryReportObserver.observe(pane.container) } + + // Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's + // real (e.g. split/narrower) layout has not settled by the first frame — the + // PTY is born at the wide window width while xterm later reflows to the pane + // width. The corrective onResize is then dropped (cols already matched at fit + // time, or isRendererPtyResizeAuthoritative() was false mid-mount), pinning + // process.stdout.columns forever and garbling TUIs. Re-fit once layout has + // settled and force the PTY to xterm's dimensions; the initial spawn-time sync + // is authoritative by definition, so it bypasses the visibility gate (but not + // the mobile-fit override, which legitimately parks the PTY at phone dims). + const reconcilePtySizeAfterSpawn = ( + ptyId: string, + spawnCols: number, + spawnRows: number + ): void => { + requestAnimationFrame(() => { + if (disposed || transport.getPtyId() !== ptyId) { + return + } + if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { + return + } + safeFit(pane) + const cols = pane.terminal.cols + const rows = pane.terminal.rows + if (cols > 0 && rows > 0 && (cols !== spawnCols || rows !== spawnRows)) { + transport.resize(cols, rows) + } + }) + } + // Defer PTY spawn/attach to next frame so FitAddon has time to calculate // the correct terminal dimensions from the laid-out container. connectFrame = requestAnimationFrame(() => { @@ -2489,6 +2520,9 @@ export function connectPanePty( // viable delivery target and must not wait for a future pane. clearRegisteredStartupLaunchConfig() } + if (resolvedPtyId) { + reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) + } const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { if (!isRemoteRuntimePtyId(resolvedPtyId)) { diff --git a/tests/e2e/terminal-column-desync-repro.spec.ts b/tests/e2e/terminal-column-desync-repro.spec.ts new file mode 100644 index 00000000000..f4c06de93e2 --- /dev/null +++ b/tests/e2e/terminal-column-desync-repro.spec.ts @@ -0,0 +1,334 @@ +/** + * Repro: xterm <-> PTY column desync. + * + * Symptom (observed live, macOS, build 1.4.103): an interactive TUI (Claude + * Code) renders garbled — ~1 char per line, overlapping text. Ruled out: + * xterm version, font/letter-spacing, pane width, GPU (DOM renderer). + * + * Hypothesis: xterm reflows to the visible width but the PTY's + * process.stdout.columns is pinned to a stale/tiny value, so the program keeps + * emitting output sized for the wrong width and xterm faithfully paints + * garbage. The suspected gate is isRendererPtyResizeAuthoritative() in + * pty-connection.ts: while a pane is hidden it returns false, so xterm reflows + * that happen off-screen never reach the PTY, and the resume-time correction + * (safeFit + transport.resize, pty-connection.ts ~3316) is the only thing that + * can re-sync. If that correction is missed, the PTY stays stale. + * + * The proof is a direct comparison: process.stdout.columns inside the PTY must + * equal terminal.cols in xterm. This spec drives several scenarios that mimic + * the real usage and asserts the two match. + * + * Reproduces reliably (always on the first mount) when run in isolation: + * SKIP_BUILD=1 npx playwright test --config tests/playwright.config.ts \ + * tests/e2e/terminal-column-desync-repro.spec.ts --project electron-headless \ + * -g "during initial mount" + * Observed: the first terminal spawns its PTY at the full window width + * (e.g. 203 cols) while xterm fits the pane to the real layout width (79 cols); + * the gap never closes. Under heavy parallel load the timing can shift, so the + * `-g "during initial mount"` test (serial, reload-looped) is the golden repro. + */ + +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + splitActiveTerminalPane, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { waitForPtyColumnsAtMost } from './terminal-column-probes' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +// Why: a generous ceiling so the PTY column probe returns the actual current +// process.stdout.columns instead of waiting for it to drop below a target. +const READ_ANY_COLS = 100_000 + +/** Read xterm's authoritative column count for the active terminal pane. */ +async function readRenderedTerminalCols(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal?.cols ?? 0 + }) +} + +/** Read process.stdout.columns as seen by the program inside the PTY. */ +async function readPtyCols(page: Page, ptyId: string): Promise { + return waitForPtyColumnsAtMost(page, ptyId, READ_ANY_COLS, 30_000) +} + +/** Read xterm cols for the pane bound to a specific PTY id. */ +async function readRenderedColsForPty(page: Page, ptyId: string): Promise { + return page.evaluate((ptyId) => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === ptyId) { + return pane.terminal?.cols ?? 0 + } + } + } + return 0 + }, ptyId) +} + +type ColumnSnapshot = { xtermCols: number; ptyCols: number } + +async function readColumnSnapshot(page: Page, ptyId: string): Promise { + const xtermCols = await readRenderedTerminalCols(page) + const ptyCols = await readPtyCols(page, ptyId) + return { xtermCols, ptyCols } +} + +async function closeRightSidebarAndFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + return + } + store.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store.getState().rightSidebarOpen) { + store.getState().setRightSidebarOpen(false) + } + }) +} + +async function settleTerminal(page: Page): Promise { + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page) + await waitForPtyShellEcho(page, ptyId, 15_000) + return ptyId +} + +test.describe('Terminal column desync repro', () => { + test('PTY columns stay in sync with xterm across a visible resize', async ({ orcaPage }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await closeRightSidebarAndFeatureTips(orcaPage) + await ensureTerminalVisible(orcaPage) + const ptyId = await settleTerminal(orcaPage) + + // Baseline: a freshly fit terminal should agree with its PTY. + const baseline = await readColumnSnapshot(orcaPage, ptyId) + expect( + baseline.ptyCols, + `baseline PTY cols (${baseline.ptyCols}) should equal xterm cols (${baseline.xtermCols})` + ).toBe(baseline.xtermCols) + + // Shrink the window while the terminal is visible, then widen it. xterm + // reflows via the ResizeObserver; the PTY must follow. + await orcaPage.setViewportSize({ width: 760, height: 800 }) + await orcaPage.waitForTimeout(400) + const narrow = await readColumnSnapshot(orcaPage, ptyId) + expect( + narrow.ptyCols, + `after shrink, PTY cols (${narrow.ptyCols}) should equal xterm cols (${narrow.xtermCols})` + ).toBe(narrow.xtermCols) + + await orcaPage.setViewportSize({ width: 1280, height: 800 }) + await orcaPage.waitForTimeout(400) + const wide = await readColumnSnapshot(orcaPage, ptyId) + expect( + wide.ptyCols, + `after widen, PTY cols (${wide.ptyCols}) should equal xterm cols (${wide.xtermCols})` + ).toBe(wide.xtermCols) + }) + + test('PTY columns re-sync after the terminal is resized while hidden', async ({ orcaPage }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + const otherWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== homeWorktreeId) + test.skip(!otherWorktreeId, 'hidden-resize repro needs the seeded secondary worktree') + if (!otherWorktreeId) { + return + } + + await closeRightSidebarAndFeatureTips(orcaPage) + await ensureTerminalVisible(orcaPage) + const ptyId = await settleTerminal(orcaPage) + await orcaPage.setViewportSize({ width: 1280, height: 800 }) + await orcaPage.waitForTimeout(400) + + const baseline = await readColumnSnapshot(orcaPage, ptyId) + expect(baseline.ptyCols).toBe(baseline.xtermCols) + + // Hide the terminal by switching worktrees, resize the window narrow while + // it is in the background (so isRendererPtyResizeAuthoritative() is false + // and the off-screen reflow's pty:resize is dropped), then return. + await switchToWorktree(orcaPage, otherWorktreeId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.setViewportSize({ width: 720, height: 800 }) + await orcaPage.waitForTimeout(500) + await switchToWorktree(orcaPage, homeWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(600) + + const afterReturn = await readColumnSnapshot(orcaPage, ptyId) + expect( + afterReturn.ptyCols, + `after hidden resize + return, PTY cols (${afterReturn.ptyCols}) should equal xterm cols ` + + `(${afterReturn.xtermCols}); a stale PTY width is the column-desync bug` + ).toBe(afterReturn.xtermCols) + }) + + test('PTY columns re-sync after repeated background resizes', async ({ orcaPage }) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + const otherWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== homeWorktreeId) + test.skip( + !otherWorktreeId, + 'repeated background-resize repro needs the seeded secondary worktree' + ) + if (!otherWorktreeId) { + return + } + + await closeRightSidebarAndFeatureTips(orcaPage) + await ensureTerminalVisible(orcaPage) + const ptyId = await settleTerminal(orcaPage) + + // Several hide/resize/show cycles at different widths. Terminal timing bugs + // need repetition: each cycle is a fresh chance for the resume-time + // correction to miss and leave the PTY pinned at a stale column count. + const widths = [700, 1320, 640, 1180, 600] + for (const [index, width] of widths.entries()) { + await switchToWorktree(orcaPage, otherWorktreeId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.setViewportSize({ width, height: 800 }) + await orcaPage.waitForTimeout(350) + await switchToWorktree(orcaPage, homeWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(500) + + const snapshot = await readColumnSnapshot(orcaPage, ptyId) + expect( + snapshot.ptyCols, + `cycle ${index} (width ${width}): PTY cols (${snapshot.ptyCols}) should equal xterm cols ` + + `(${snapshot.xtermCols})` + ).toBe(snapshot.xtermCols) + } + }) + + test('both panes keep PTY columns synced after a vertical split reparent', async ({ + orcaPage + }) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await closeRightSidebarAndFeatureTips(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.setViewportSize({ width: 1280, height: 800 }) + await orcaPage.waitForTimeout(300) + const firstPtyId = await settleTerminal(orcaPage) + + const baseline = await readColumnSnapshot(orcaPage, firstPtyId) + expect(baseline.ptyCols).toBe(baseline.xtermCols) + + // Splitting halves the width of the original pane: xterm reflows to ~half + // the columns. The PTY must follow, otherwise the existing shell keeps + // emitting full-width output into a half-width pane. + await splitActiveTerminalPane(orcaPage, 'vertical') + await expect + .poll( + async () => { + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + return snapshot.panes + .map((pane) => pane.ptyId) + .filter((ptyId): ptyId is string => Boolean(ptyId)) + }, + { timeout: 30_000, message: 'vertical split should produce two PTY-backed panes' } + ) + .toHaveLength(2) + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + + for (const pane of snapshot.panes) { + const ptyId = pane.ptyId + expect(ptyId, 'split pane should be bound to a PTY').toBeTruthy() + if (!ptyId) { + continue + } + const ptyCols = await readPtyCols(orcaPage, ptyId) + const xtermCols = await readRenderedColsForPty(orcaPage, ptyId) + expect( + ptyCols, + `after split, pane ${ptyId} PTY cols (${ptyCols}) should equal its xterm cols (${xtermCols})` + ).toBe(xtermCols) + } + }) + + // Why: this is the tightest isolation of the real bug. A viewport resize that + // lands in the terminal's initial mount window — after xterm exists but + // before the PTY binding/visibility settle — reflows xterm to the new width, + // but forwardPtyResize drops it (isRendererPtyResizeAuthoritative()===false, + // or the spawn captures the pre-resize cols and no later resize fires). The + // PTY stays pinned at the startup width while xterm shows the new width, and + // nothing re-syncs. A long-output program then prints sized for the stale + // PTY width into the narrower pane → the garbled "1 char per line" render. + test('PTY columns stay synced when the window is resized during initial mount', async ({ + orcaPage + }) => { + test.setTimeout(240_000) + + // Why: the desync is a race in the *initial* mount window — the first + // terminal spawns its PTY at the wide default window width, and a resize + // landing before the PTY binding/visibility settle reflows xterm but is + // dropped by forwardPtyResize, with no later correction. A single attempt + // only trips it ~1 in 3 runs, so reload the renderer to re-run the full + // first-mount sequence each attempt and resize mid-mount. We assert none of + // the attempts desynced, so a single stale PTY fails the test. + const MOUNT_ATTEMPTS = 8 + const desyncs: { attempt: number; ptyCols: number; xtermCols: number }[] = [] + for (let attempt = 0; attempt < MOUNT_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + // Re-run the first-mount path: a wide window, then reload so the + // terminal remounts and spawns its PTY at the wide width. + await orcaPage.setViewportSize({ width: 1440, height: 900 }) + await orcaPage.reload() + await orcaPage.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + } + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await closeRightSidebarAndFeatureTips(orcaPage) + await ensureTerminalVisible(orcaPage) + + // Resize down while the terminal is mounting / the PTY is spawning. + await orcaPage.setViewportSize({ width: 1280, height: 800 }) + await orcaPage.waitForTimeout(300) + + const ptyId = await settleTerminal(orcaPage) + await orcaPage.waitForTimeout(700) + + const snapshot = await readColumnSnapshot(orcaPage, ptyId) + if (snapshot.ptyCols !== snapshot.xtermCols) { + desyncs.push({ attempt, ptyCols: snapshot.ptyCols, xtermCols: snapshot.xtermCols }) + } + } + + expect( + desyncs, + `PTY columns desynced from xterm on ${desyncs.length}/${MOUNT_ATTEMPTS} mount attempts. ` + + `A PTY pinned at the wider startup width while xterm reflowed narrower is the ` + + `column-desync bug that garbles interactive TUIs: ${JSON.stringify(desyncs)}` + ).toEqual([]) + }) +}) From d3e64493737cda3d1af2fd29ec62fd13408dc004 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:58:59 +0000 Subject: [PATCH 03/23] release: v1.4.104-rc.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88f68d40de0..22e17d96b67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.104-rc.5", + "version": "1.4.104-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 11a0a36087ff31189cb50799294b2b32bb06ccec Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:44:01 -0700 Subject: [PATCH 04/23] fix(terminal): poll across frames in post-spawn PTY reconcile (#6649) Follow-up to #6644. The merged fix used a single post-spawn requestAnimationFrame, which did not fully close the first-mount column race: the pane's real layout can keep changing for several frames (split equalize, sidebar/title reflow), so a one-shot re-fit could still measure a stale width and leave the PTY pinned. The golden 'during initial mount' e2e test failed ~1 in 9 runs with the single-frame version. Poll for up to 12 frames, forwarding the settled size to the PTY only when it differs from what the PTY was last told (mirroring the existing ResizeObserver stability loop). Each resize is gated on an actual change, so a TUI sees at most a couple of SIGWINCH during startup, not a loop. Verified: golden test passes 6/6 with repeat-each + retries; full spec 15/15. Without this change the single-frame version is intermittently flaky. Co-authored-by: Orca --- .../terminal-pane/pty-connection.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 66f6931f0df..a921d341026 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2125,20 +2125,41 @@ export function connectPanePty( spawnCols: number, spawnRows: number ): void => { - requestAnimationFrame(() => { + // Why: a single post-spawn frame is not enough — the pane's real layout can + // keep changing for several frames (split equalize, sidebar/title reflow), + // so a one-shot re-fit can still measure a stale width and leave the PTY + // pinned. Poll across frames and forward the settled size to the PTY + // whenever it differs from what the PTY was last told, mirroring the + // ResizeObserver stability loop. Each resize is gated on an actual change, + // so a TUI sees at most a couple of SIGWINCH during startup, not a loop. + const MAX_RECONCILE_FRAMES = 12 + let lastSentCols = spawnCols + let lastSentRows = spawnRows + let frame = 0 + const tick = (): void => { if (disposed || transport.getPtyId() !== ptyId) { return } + // Mobile legitimately parks the PTY at phone dims; never fight that. if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { return } safeFit(pane) const cols = pane.terminal.cols const rows = pane.terminal.rows - if (cols > 0 && rows > 0 && (cols !== spawnCols || rows !== spawnRows)) { + if (cols > 0 && rows > 0 && (cols !== lastSentCols || rows !== lastSentRows)) { + // Initial spawn-time sync is authoritative, so it bypasses the + // visibility gate that onResize honors (but not the mobile guards above). transport.resize(cols, rows) + lastSentCols = cols + lastSentRows = rows } - }) + frame += 1 + if (frame < MAX_RECONCILE_FRAMES) { + requestAnimationFrame(tick) + } + } + requestAnimationFrame(tick) } // Defer PTY spawn/attach to next frame so FitAddon has time to calculate From 1b8e7c5bb5d59cbd7a52851a2b42e2624931b385 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:46:28 -0700 Subject: [PATCH 05/23] Improve sidebar search button styling and shortcut rendering (#6601) * Redesign the search button in the sidebar to look like a standard input field with a border, background, and absolute icon positioning. * Replace the basic `` label with `ShortcutKeyCombo` elements driven by `useShortcutKeyComboDetails`. * Show the shortcut combinations when the search container is focused as well as hovered. --- .../components/sidebar/SidebarNav.test.tsx | 19 +++++++++++++- .../src/components/sidebar/SidebarNav.tsx | 26 +++++++++++++------ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 5558a512055..3b919f7f8b6 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -36,7 +36,7 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: () => '⌘P' + useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'J'], doubleTap: false }] })) vi.mock('./mobile-sidebar-onboarding-badge', () => ({ @@ -273,6 +273,23 @@ describe('SidebarNav', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ showMobileButton: false }) }) + it('hides the worktree palette shortcut until the search field is hovered or focused', async () => { + const container = await renderSidebarNav() + + const searchButton = container.querySelector( + 'button[aria-label="Search worktrees and browser tabs"]' + ) + expect(searchButton).not.toBeNull() + + const shortcuts = searchButton?.querySelector('span.hidden') + expect(shortcuts?.className).toContain('hidden') + expect(shortcuts?.className).toContain('group-hover:inline-flex') + expect(shortcuts?.className).toContain('group-focus-within:inline-flex') + expect(shortcuts?.textContent).toContain('⌘') + expect(shortcuts?.textContent).toContain('J') + expect(searchButton?.querySelector('kbd')).toBeNull() + }) + it('hides task source shortcuts until the Tasks row is hovered or focused', async () => { const container = await renderSidebarNav() diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d099f0d83dc..7474259a9e8 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -4,7 +4,8 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { useMobileSidebarOnboardingBadge } from './mobile-sidebar-onboarding-badge' import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' @@ -33,7 +34,7 @@ export function shouldShowAutomationsButton( } const SidebarNav = React.memo(function SidebarNav() { - const worktreePaletteShortcut = useShortcutLabel('worktree.palette') + const worktreePaletteShortcutCombos = useShortcutKeyComboDetails('worktree.palette') const openAutomationsPage = useAppStore((s) => s.openAutomationsPage) const openActivityPage = useAppStore((s) => s.openActivityPage) const openMobilePage = useAppStore((s) => s.openMobilePage) @@ -164,18 +165,27 @@ const SidebarNav = React.memo(function SidebarNav() { 'auto.components.sidebar.SidebarNav.0c3395fd32', 'Search worktrees and browser tabs' )} - className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight text-worktree-sidebar-foreground/60 transition-colors hover:bg-worktree-sidebar-foreground/8" + className="group relative flex h-7 w-full items-center rounded-md border border-worktree-sidebar-border/70 bg-worktree-sidebar-foreground/5 pl-7 pr-1.5 text-left text-[12px] font-medium tracking-tight text-worktree-sidebar-foreground/45 transition-colors hover:border-worktree-sidebar-border hover:bg-worktree-sidebar-foreground/8 hover:text-worktree-sidebar-foreground/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50" > - + {translate('auto.components.sidebar.SidebarNav.80611a8b10', 'Search')} - - {worktreePaletteShortcut} - + + {worktreePaletteShortcutCombos.map((combo) => ( + + ))} + ) From 60baa449d4f58b94495fcff22d343a5846291af8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:50:52 -0700 Subject: [PATCH 06/23] Fix sidebar shortcut combo spacing (#6651) --- src/renderer/src/components/sidebar/SidebarNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 7474259a9e8..aa33aac261c 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -174,7 +174,7 @@ const SidebarNav = React.memo(function SidebarNav() { {translate('auto.components.sidebar.SidebarNav.80611a8b10', 'Search')} - + {worktreePaletteShortcutCombos.map((combo) => ( Date: Sun, 28 Jun 2026 18:08:13 -0400 Subject: [PATCH 07/23] fix(terminal): support Windows shell selection for SSH hosts (#6466) * fix(terminal): support Windows shell selection for SSH hosts * fix(runtime): register SSH Windows capability preflight RPC * fix(terminal): honor remote Git Bash and split preflight IPC seams * fix(terminal): keep SSH Windows shell state tied to the selected host * fix(terminal): preserve Windows SSH shell selection --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/ipc/preflight-command-exec.ts | 93 +++++++ ...ht-remote-windows-terminal-capabilities.ts | 30 +++ src/main/ipc/preflight.test.ts | 29 ++ src/main/ipc/preflight.ts | 114 ++------ src/main/providers/ssh-pty-provider.test.ts | 20 ++ src/main/providers/ssh-pty-provider.ts | 4 + .../runtime/rpc/methods/preflight.test.ts | 35 +++ src/main/runtime/rpc/methods/preflight.ts | 9 + src/preload/api-types.ts | 7 + src/preload/index.ts | 11 +- src/relay/preflight-handler.test.ts | 64 ++++- src/relay/preflight-handler.ts | 28 ++ src/relay/pty-handler.test.ts | 151 +++++++++++ src/relay/pty-handler.ts | 36 ++- src/relay/pty-shell-launch.ts | 19 +- .../src/components/tab-bar/TabBar.tsx | 32 ++- .../TabBar.windows-shell-launch.test.ts | 105 +++++++- .../windows-shell-menu-visibility.test.ts | 13 +- .../tab-bar/windows-shell-menu-visibility.ts | 9 +- .../WorkspaceCleanupDialog.tsx | 35 ++- ...workspace-cleanup-presentation-fixtures.ts | 6 +- .../workspace-cleanup-presentation.ts | 17 +- src/renderer/src/i18n/locales/en.json | 27 +- src/renderer/src/i18n/locales/es.json | 27 +- src/renderer/src/i18n/locales/ja.json | 27 +- src/renderer/src/i18n/locales/ko.json | 27 +- src/renderer/src/i18n/locales/zh.json | 27 +- .../lib/windows-terminal-capabilities.test.ts | 253 +++++++++++++++++- .../src/lib/windows-terminal-capabilities.ts | 157 +++++------ .../lib/windows-terminal-capability-read.ts | 87 ++++++ .../src/store/slices/store-cascades.test.ts | 98 +++++++ src/renderer/src/store/slices/terminals.ts | 43 ++- src/renderer/src/web/web-preload-api.ts | 23 +- 33 files changed, 1437 insertions(+), 226 deletions(-) create mode 100644 src/main/ipc/preflight-command-exec.ts create mode 100644 src/main/ipc/preflight-remote-windows-terminal-capabilities.ts create mode 100644 src/renderer/src/lib/windows-terminal-capability-read.ts diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts new file mode 100644 index 00000000000..4b807ba8afb --- /dev/null +++ b/src/main/ipc/preflight-command-exec.ts @@ -0,0 +1,93 @@ +import { execFile } from 'child_process' +import path from 'path' +import { promisify } from 'util' +import { buildLocalPreflightEnv } from './preflight-local-env' +import { runPreflightCommandInWsl } from './preflight-wsl-command' +import type { WslPreflightTarget } from './preflight-wsl-agent-detection' + +const execFileAsync = promisify(execFile) +export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 + +export type PreflightCommandResult = { stdout: string; stderr: string } + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = Object.assign(new Error(`Timed out running ${command}`), { + code: 'ETIMEDOUT' + }) + reject(error) + }, PREFLIGHT_COMMAND_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +export async function execLocalPreflightCommand( + command: string, + args: string[] +): Promise { + const env = buildLocalPreflightEnv() + const commandPromise = execFileAsync(command, args, { + encoding: 'utf-8', + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, + ...(env ? { env } : {}) + }) as Promise + + return withPreflightTimeout(command, commandPromise) +} + +export async function execCommandInWsl( + target: WslPreflightTarget, + command: string +): Promise { + const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) + return withPreflightTimeout('wsl.exe', commandPromise) +} + +export async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { + try { + await (wslTarget + ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) + : execLocalPreflightCommand(command, ['--version'])) + return true + } catch { + return false + } +} + +export async function isCommandOnPath( + command: string, + wslTarget?: WslPreflightTarget +): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' + try { + const { stdout } = wslTarget + ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + : await execLocalPreflightCommand(finder, [command]) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => path.isAbsolute(line)) + } catch { + return false + } +} diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts new file mode 100644 index 00000000000..f28886cdaec --- /dev/null +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -0,0 +1,30 @@ +import { getActiveMultiplexer } from './ssh' + +export type RemoteWindowsTerminalCapabilities = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null +} + +const EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES: RemoteWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null +} + +export async function detectRemoteWindowsTerminalCapabilities(args: { + connectionId: string +}): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + return EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES + } + const result = (await mux.request('preflight.detectWindowsTerminalCapabilities', {})) as + | RemoteWindowsTerminalCapabilities + | undefined + return result ?? EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index d3ffef854ef..944ee1b4418 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -708,6 +708,35 @@ describe('preflight', () => { expect(request).not.toHaveBeenCalled() }) + it('sends remote Windows shell capability probes through the SSH preflight path', async () => { + const request = vi.fn().mockResolvedValue({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, { + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {}) + }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index fbe00db4605..cc14ca490b6 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,7 +1,4 @@ import { ipcMain } from 'electron' -import { execFile } from 'child_process' -import { promisify } from 'util' -import path from 'path' import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' @@ -11,13 +8,20 @@ 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' -import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' -const execFileAsync = promisify(execFile) -const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +import { + execCommandInWsl, + execLocalPreflightCommand, + isCommandAvailable, + isCommandOnPath, + shellQuote +} from './preflight-command-exec' +import { + detectRemoteWindowsTerminalCapabilities, + type RemoteWindowsTerminalCapabilities +} from './preflight-remote-windows-terminal-capabilities' export type PreflightStatus = { git: { installed: boolean } @@ -44,6 +48,9 @@ export type PreflightStatus = { } } +export { detectRemoteWindowsTerminalCapabilities } +export type { RemoteWindowsTerminalCapabilities } + // Why: cache the result so repeated Landing mounts don't re-spawn processes. // The check only runs once per app session — relaunch to re-check. let cached: PreflightStatus | null = null @@ -53,92 +60,6 @@ export function _resetPreflightCache(): void { cached = null } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -type PreflightCommandResult = { stdout: string; stderr: string } - -// Why: a broken PATH shim or auth helper should not keep startup/settings -// preflight IPC pending forever; WSL probes already use the same deadline. -async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { - let timeout: ReturnType | null = null - try { - return await Promise.race([ - commandPromise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - const error = Object.assign(new Error(`Timed out running ${command}`), { - code: 'ETIMEDOUT' - }) - reject(error) - }, PREFLIGHT_COMMAND_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) - ]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -async function execLocalPreflightCommand( - command: string, - args: string[] -): Promise { - const env = buildLocalPreflightEnv() - const commandPromise = execFileAsync(command, args, { - encoding: 'utf-8', - timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, - ...(env ? { env } : {}) - }) as Promise - - return withPreflightTimeout(command, commandPromise) -} - -async function execCommandInWsl( - target: WslPreflightTarget, - command: string -): Promise<{ stdout: string; stderr: string }> { - const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) - return withPreflightTimeout('wsl.exe', commandPromise) -} - -async function isCommandAvailable( - command: string, - wslTarget?: WslPreflightTarget -): Promise { - try { - await (wslTarget - ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) - : execLocalPreflightCommand(command, ['--version'])) - return true - } catch { - return false - } -} - -// Why: `which`/`where` is faster than spawning the agent binary itself and avoids -// triggering any agent-specific startup side-effects. This gives a reliable -// PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) - : await execLocalPreflightCommand(finder, [command]) - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => path.isAbsolute(line)) - } catch { - return false - } -} - const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => getTuiAgentDetectCommands(config).map((cmd) => ({ id, @@ -378,4 +299,11 @@ export function registerPreflightHandlers(): void { return detectRemoteAgents(args) } ) + + ipcMain.handle( + 'preflight:detectRemoteWindowsTerminalCapabilities', + async (_event, args: { connectionId: string }): Promise => { + return detectRemoteWindowsTerminalCapabilities(args) + } + ) } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 7a6af3da39a..1523f38f02f 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -67,6 +67,26 @@ describe('SshPtyProvider', () => { }) }) + it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => { + mux.request.mockResolvedValue({ id: 'pty-2' }) + + await provider.spawn({ + cols: 120, + rows: 40, + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + }) + it('preserves an explicit remote Powerlevel10k wizard env value', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index c33910bad95..7284f2353ba 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -136,6 +136,10 @@ export class SshPtyProvider implements IPtyProvider { // Pi-compatible agent is being launched, while commandDelivery tells it // whether to submit the command itself for runtime-owned background PTYs. ...(opts.command ? { command: opts.command } : {}), + ...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}), + ...(opts.terminalWindowsWslDistro !== undefined + ? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro } + : {}), ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}), ...(opts.startupCommandDelivery ? { startupCommandDelivery: opts.startupCommandDelivery } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index d28b97b8e53..e8f1ef82930 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -7,11 +7,13 @@ import { PREFLIGHT_METHODS } from './preflight' const { detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, + detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), + detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) @@ -19,6 +21,7 @@ const { vi.mock('../../../ipc/preflight', () => ({ detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, + detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock })) @@ -81,4 +84,36 @@ describe('preflight RPC methods', () => { expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) expect(response).toMatchObject({ ok: true, result: ['claude'] }) }) + + it('detects remote Windows terminal capabilities through runtime RPC', async () => { + detectRemoteWindowsTerminalCapabilitiesMock.mockResolvedValueOnce({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('preflight.detectRemoteWindowsTerminalCapabilities', { + connectionId: 'ssh-1' + }) + ) + + expect(detectRemoteWindowsTerminalCapabilitiesMock).toHaveBeenCalledWith({ + connectionId: 'ssh-1' + }) + expect(response).toMatchObject({ + ok: true, + result: { + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 4e0f06201e9..aafbb96319c 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, + detectRemoteWindowsTerminalCapabilities, detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck @@ -13,6 +14,9 @@ const PreflightCheck = z.object({ const PreflightDetectRemoteAgents = z.object({ connectionId: z.string().min(1) }) +const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ + connectionId: z.string().min(1) +}) export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ @@ -30,6 +34,11 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ params: PreflightDetectRemoteAgents, handler: async (params) => detectRemoteAgents(params) }), + defineMethod({ + name: 'preflight.detectRemoteWindowsTerminalCapabilities', + params: PreflightDetectRemoteWindowsTerminalCapabilities, + handler: async (params) => detectRemoteWindowsTerminalCapabilities(params) + }), defineMethod({ name: 'preflight.refreshAgents', params: null, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7a485016fe6..96429261e41 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -548,6 +548,13 @@ export type PreflightApi = { detectAgents: (args?: PreflightRuntimeContext) => Promise refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise + detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> } // Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion diff --git a/src/preload/index.ts b/src/preload/index.ts index d29a1b701c7..be4ba360c2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1771,7 +1771,16 @@ const api = { refreshAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args) + ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteWindowsTerminalCapabilities: (args: { + connectionId: string + }): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) }, notifications: { diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 6d2f9339b9e..668354b03db 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -4,6 +4,14 @@ const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() })) +const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } = + vi.hoisted(() => ({ + isPwshAvailableMock: vi.fn(), + isWslAvailableMock: vi.fn(), + listWslDistrosMock: vi.fn(), + isGitBashAvailableMock: vi.fn() + })) + vi.mock('child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock @@ -11,11 +19,19 @@ vi.mock('child_process', () => { return { execFile: execFileWithPromisify } }) +vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) +vi.mock('../main/wsl', () => ({ + isWslAvailable: isWslAvailableMock, + listWslDistros: listWslDistrosMock +})) +vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) + import { buildCommandLookupSpec, buildCommandLookupSpecs, hasAbsoluteCommandPath, - isCommandOnPathForRelay + isCommandOnPathForRelay, + PreflightHandler } from './preflight-handler' function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { @@ -43,6 +59,10 @@ function fishLookupArgs(command: string): string[] { beforeEach(() => { execFileAsyncMock.mockReset() + isPwshAvailableMock.mockReset() + isWslAvailableMock.mockReset() + listWslDistrosMock.mockReset() + isGitBashAvailableMock.mockReset() }) describe('buildCommandLookupSpec', () => { @@ -214,3 +234,45 @@ describe('hasAbsoluteCommandPath', () => { ).toBe(true) }) }) + +describe('PreflightHandler', () => { + it('reports remote Windows shell capabilities through the SSH preflight path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + isWslAvailableMock.mockReturnValue(true) + listWslDistrosMock.mockReturnValue(['Ubuntu']) + isPwshAvailableMock.mockReturnValue(true) + isGitBashAvailableMock.mockReturnValue(true) + + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + + try { + const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities') + expect(handler).toBeDefined() + await expect(handler!({})).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) +}) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a0382d77275..cdfc42c2b21 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -4,6 +4,9 @@ import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' import { buildRelayCommandEnv } from './relay-command-env' +import { isPwshAvailable } from '../main/pwsh' +import { isWslAvailable, listWslDistros } from '../main/wsl' +import { isGitBashAvailable } from '../main/git-bash' const execFileAsync = promisify(execFile) @@ -33,6 +36,9 @@ export class PreflightHandler { private registerHandlers(): void { this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p)) + this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () => + this.detectWindowsTerminalCapabilities() + ) } // Why: the client sends the command list rather than importing TUI_AGENT_CONFIG @@ -54,6 +60,28 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } + private async detectWindowsTerminalCapabilities(): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> { + const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([ + Promise.resolve(isWslAvailable()).catch(() => false), + Promise.resolve(isPwshAvailable()).catch(() => false), + Promise.resolve(isGitBashAvailable()).catch(() => false) + ]) + const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : [] + return { + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + hostPlatform: process.platform + } + } + // Why: SSH exec channels give the relay a minimal environment without shell // startup files sourced. Ask the user's configured shell so agent dirs added // by zsh/bash/fish startup hooks match the remote terminal experience. diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3c9b0597030..321458c65da 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import * as gitBash from '../main/git-bash' +import * as ptyShellUtils from './pty-shell-utils' import { resolveSetupAgentSequenceLaunchCommand, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV @@ -169,6 +171,155 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('uses an explicit shell override and falls back to the default shell otherwise', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveDefaultShellSpy = vi + .spyOn(ptyShellUtils, 'resolveDefaultShell') + .mockReturnValue('/default-shell') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + expect(mockPtySpawn).toHaveBeenCalledWith( + 'powershell.exe', + expect.any(Array), + expect.any(Object) + ) + + mockPtySpawn.mockClear() + + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + expect(mockPtySpawn).toHaveBeenCalledWith( + '/default-shell', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveDefaultShellSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('ignores Windows shell overrides on non-Windows relay hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' + }) + const resolveDefaultShellSpy = vi + .spyOn(ptyShellUtils, 'resolveDefaultShell') + .mockReturnValue('/default-shell') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'powershell.exe' + }) + + expect(mockPtySpawn).toHaveBeenCalledWith( + '/default-shell', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveDefaultShellSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('rejects unsupported shell overrides on Windows relay hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + await expect( + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'notepad.exe' + }) + ).rejects.toThrow('Unsupported Windows shell override') + expect(mockPtySpawn).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'git-bash' + }) + + expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash') + expect(mockPtySpawn).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('passes the selected WSL distro to relay launches on Windows', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu-24.04' + }) + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu-24.04'], + expect.any(Object) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => { await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 8ac4259d3ee..5f4fff86610 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' +import { resolveWindowsGitBashShellPath } from '../main/git-bash' +import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' import { resolveDefaultShell, @@ -128,6 +130,32 @@ const ALLOWED_SIGNALS = new Set([ 'SIGUSR2' ]) +const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([ + 'powershell.exe', + 'powershell', + 'pwsh.exe', + 'pwsh', + 'cmd.exe', + 'cmd', + 'wsl.exe', + 'wsl', + WINDOWS_GIT_BASH_SHELL +]) + +function resolvePtyShellOverride(shellOverride: string): string { + if (!shellOverride) { + return '' + } + if (process.platform !== 'win32') { + return '' + } + const normalized = shellOverride.toLowerCase() + if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) { + throw new Error(`Unsupported Windows shell override: ${shellOverride}`) + } + return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride +} + type SerializedPtyEntry = { id: string pid: number @@ -497,7 +525,10 @@ export class PtyHandler { const rows = (params.rows as number) || 24 const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined - const shell = resolveDefaultShell() + const shellOverride = + typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' + const resolvedShellOverride = resolvePtyShellOverride(shellOverride) + const shell = resolvedShellOverride || resolveDefaultShell() const id = `pty-${this.nextId++}` // Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay @@ -509,6 +540,8 @@ export class PtyHandler { // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined const command = typeof params.command === 'string' ? params.command : undefined + const terminalWindowsWslDistro = + typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) @@ -523,6 +556,7 @@ export class PtyHandler { // Why: renderer- and provider-delivered startup commands both use this // marker; the side responsible for delivery also strips it from output. const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { + terminalWindowsWslDistro, emitReadyMarker: shouldEmitShellReadyMarker }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index d2ee25e2d41..935f48d46d4 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -25,7 +25,10 @@ function shellBasename(shellPath: string): string { return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' } -function windowsShellArgs(shellName: string): string[] | null { +function windowsShellArgs( + shellName: string, + options: { terminalWindowsWslDistro?: string | null } = {} +): string[] | null { if (shellName === 'powershell.exe' || shellName === 'powershell') { return ['-NoLogo'] } @@ -35,6 +38,10 @@ function windowsShellArgs(shellName: string): string[] | null { if (shellName === 'cmd.exe' || shellName === 'cmd') { return [] } + if (shellName === 'wsl.exe' || shellName === 'wsl') { + const distro = options.terminalWindowsWslDistro?.trim() + return distro ? ['-d', distro] : [] + } return null } @@ -247,14 +254,20 @@ export function getRelayShellLaunchConfig( shellPath: string, env: Record, platform: NodeJS.Platform = process.platform, - options: { emitReadyMarker?: boolean } = {} + options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {} ): RelayShellLaunchConfig { const shellName = shellBasename(shellPath) const emitReadyMarker = options.emitReadyMarker === true if (platform === 'win32') { // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. - return { args: windowsShellArgs(shellName) ?? [], env: {} } + return { + args: + windowsShellArgs(shellName, { + terminalWindowsWslDistro: options.terminalWindowsWslDistro + }) ?? [], + env: {} + } } if (shellName !== 'zsh' && shellName !== 'bash') { diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index f61ff4aff33..18a0bb907bf 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -307,12 +307,18 @@ function TabBarInner({ const activeRuntimeEnvironmentId = useAppStore( (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null ) - const worktreeHasRemoteConnection = useAppStore((s) => { + const worktreeConnectionId = useAppStore((s) => { const worktree = Object.values(s.worktreesByRepo ?? {}) .flat() .find((entry) => entry.id === worktreeId) const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null - return Boolean(repo?.connectionId) + return repo?.connectionId?.trim() || null + }) + const worktreeRemotePlatform = useAppStore((s) => { + if (!worktreeConnectionId) { + return null + } + return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null }) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const agentCmdOverrides = useAppStore( @@ -361,29 +367,36 @@ function TabBarInner({ ) const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( - activeRuntimeEnvironmentId + activeRuntimeEnvironmentId, + worktreeConnectionId ) const runtimeTarget = useMemo( () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), [activeRuntimeEnvironmentId] ) const shouldProbeWindowsShellCapabilities = - (isWindows || Boolean(activeRuntimeEnvironmentId?.trim()) || isWebClient) && - !worktreeHasRemoteConnection + isWindows || + Boolean(activeRuntimeEnvironmentId?.trim()) || + isWebClient || + Boolean(worktreeConnectionId) const windowsTerminalCapabilities = useWindowsTerminalCapabilities( shouldProbeWindowsShellCapabilities, false, windowsTerminalCapabilityOwnerKey, - runtimeTarget + runtimeTarget, + worktreeConnectionId ) + const shellMenuHostPlatform = worktreeConnectionId + ? (worktreeRemotePlatform ?? windowsTerminalCapabilities.hostPlatform) + : windowsTerminalCapabilities.hostPlatform const showWindowsShellMenu = shouldShowWindowsShellMenu({ activeRuntimeEnvironmentId, - hostPlatform: windowsTerminalCapabilities.hostPlatform, + hostPlatform: shellMenuHostPlatform, isWindowsClient: isWindows, - worktreeHasRemoteConnection + worktreeHasRemoteConnection: Boolean(worktreeConnectionId) }) const localProjectRuntime = useMemo(() => { - if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim()) { + if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim() || worktreeConnectionId) { return undefined } return getLocalProjectExecutionRuntimeContext( @@ -414,6 +427,7 @@ function TabBarInner({ repos, settings, showWindowsShellMenu, + worktreeConnectionId, windowsTerminalCapabilities.isLoading, windowsTerminalCapabilities.wslAvailable, windowsTerminalCapabilities.wslDistros, diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 9c1073ac246..1fa6e5809a8 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -20,6 +20,7 @@ const appStoreSnapshot: { sourceRepoIds?: string[] }[] repos: { id: string; connectionId?: string | null }[] + sshConnectionStates: Map worktreesByRepo: Record< string, { id: string; repoId: string; path?: string; projectId?: string }[] @@ -38,6 +39,7 @@ const appStoreSnapshot: { activeWorktreeId: null, projects: [], repos: [], + sshConnectionStates: new Map(), worktreesByRepo: {}, unifiedTabsByWorktree: {}, activeGroupIdByWorktree: {}, @@ -59,6 +61,7 @@ const useAppStoreMock = vi.fn( gitStatusByWorktree: Record projects: typeof appStoreSnapshot.projects repos: { id: string; connectionId?: string | null }[] + sshConnectionStates: Map worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record @@ -84,6 +87,7 @@ const useAppStoreMock = vi.fn( gitStatusByWorktree: {}, projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, + sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, @@ -159,6 +163,7 @@ useAppStoreExport.getState = vi.fn(() => ({ gitStatusByWorktree: {}, projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, + sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree, @@ -339,6 +344,7 @@ describe('TabBar PowerShell launch wiring', () => { appStoreSnapshot.activeWorktreeId = null appStoreSnapshot.projects = [] appStoreSnapshot.repos = [] + appStoreSnapshot.sshConnectionStates = new Map() appStoreSnapshot.worktreesByRepo = {} appStoreSnapshot.unifiedTabsByWorktree = {} appStoreSnapshot.activeGroupIdByWorktree = {} @@ -701,24 +707,101 @@ describe('TabBar PowerShell launch wiring', () => { expect(onNewTerminalWithShell).toHaveBeenCalledWith('git-bash') }) - it('hides local Windows shell rows for SSH worktrees', async () => { + it('shows the Windows shell rows for an SSH Windows host', async () => { appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }] + appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'win32' }]]) appStoreSnapshot.worktreesByRepo = { 'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }] } + const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) vi.stubGlobal('window', { api: { - wsl: { - isAvailable: vi.fn().mockResolvedValue(true), - listDistros: vi.fn().mockResolvedValue(['Ubuntu']) - }, - pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }, - gitBash: { isAvailable: vi.fn().mockResolvedValue(true) }, - runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + preflight: { + detectRemoteWindowsTerminalCapabilities + } } }) const capabilities = await import('@/lib/windows-terminal-capabilities') - await capabilities.loadWindowsTerminalCapabilities() + await capabilities.loadWindowsTerminalCapabilities({ + ownerKey: 'ssh:ssh-1', + sshConnectionId: 'ssh-1' + }) + + const tabBarModule = await import('./TabBar') + const candidate = tabBarModule.default ?? tabBarModule + const TabBar = + typeof candidate === 'function' + ? candidate + : typeof (candidate as { type?: unknown }).type === 'function' + ? (candidate as { type: (props: Record) => unknown }).type + : null + expect(TabBar).not.toBeNull() + + const onNewTerminalWithShell = vi.fn() + const element = TabBar!({ + tabs: [], + activeTabId: null, + worktreeId: 'wt-ssh', + expandedPaneByTabId: {}, + onActivate: () => {}, + onClose: () => {}, + onCloseOthers: () => {}, + onCloseToRight: () => {}, + onNewTerminalTab: () => {}, + onNewTerminalWithShell, + onNewBrowserTab: () => {}, + onSetCustomTitle: () => {}, + onSetTabColor: () => {}, + onTogglePaneExpand: () => {} + }) + + const powerShellItem = findDropdownMenuItemByText( + expandNode(element), + 'New Terminal: PowerShell' + ) + expect(powerShellItem).not.toBeNull() + expect( + findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt') + ).not.toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).not.toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull() + + const onSelect = powerShellItem?.props.onSelect as (() => void) | undefined + onSelect?.() + expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') + }) + + it('keeps SSH Linux hosts on the generic new-terminal entry', async () => { + appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }] + appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'linux' }]]) + appStoreSnapshot.worktreesByRepo = { + 'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }] + } + const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({ + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: 'linux' + }) + vi.stubGlobal('window', { + api: { + preflight: { + detectRemoteWindowsTerminalCapabilities + } + } + }) + const capabilities = await import('@/lib/windows-terminal-capabilities') + await capabilities.loadWindowsTerminalCapabilities({ + ownerKey: 'ssh:ssh-1', + sshConnectionId: 'ssh-1' + }) const tabBarModule = await import('./TabBar') const candidate = tabBarModule.default ?? tabBarModule @@ -747,8 +830,10 @@ describe('TabBar PowerShell launch wiring', () => { onTogglePaneExpand: () => {} }) - expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull() expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')).toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')).toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).toBeNull() expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal')).not.toBeNull() }) diff --git a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts index fefc1ca44a4..e196f888138 100644 --- a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts +++ b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts @@ -46,7 +46,7 @@ describe('shouldShowWindowsShellMenu', () => { ).toBe(false) }) - it('hides local Windows shells for SSH worktrees', () => { + it('shows Windows shells for SSH worktrees when the remote host is Windows', () => { expect( shouldShowWindowsShellMenu({ activeRuntimeEnvironmentId: null, @@ -54,6 +54,17 @@ describe('shouldShowWindowsShellMenu', () => { isWindowsClient: true, worktreeHasRemoteConnection: true }) + ).toBe(true) + }) + + it('hides Windows shells for SSH worktrees when the remote host is Linux', () => { + expect( + shouldShowWindowsShellMenu({ + activeRuntimeEnvironmentId: null, + hostPlatform: 'linux', + isWindowsClient: true, + worktreeHasRemoteConnection: true + }) ).toBe(false) }) }) diff --git a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts index 48af2956238..26a4bad10d2 100644 --- a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts +++ b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts @@ -8,9 +8,8 @@ export function shouldShowWindowsShellMenu(args: { // to be Windows, local Windows shell choices would advertise the wrong target. const runtimeHostIsNotKnownWindows = Boolean(args.activeRuntimeEnvironmentId?.trim()) && args.hostPlatform !== 'win32' - return ( - (args.isWindowsClient || args.hostPlatform === 'win32') && - !args.worktreeHasRemoteConnection && - !runtimeHostIsNotKnownWindows - ) + if (args.worktreeHasRemoteConnection) { + return args.hostPlatform === 'win32' + } + return (args.isWindowsClient || args.hostPlatform === 'win32') && !runtimeHostIsNotKnownWindows } diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 11ca5d3c2f3..242a56f62cf 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -898,7 +898,10 @@ function WorkspaceCleanupFilterToolbar({ updateFilter('query', event.target.value)} - placeholder="Search workspaces" + placeholder={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4361f1534c', + 'Search workspaces' + )} className="h-8 pl-8 text-xs" /> @@ -941,7 +944,10 @@ function WorkspaceCleanupFilterToolbar({ )} - label="Age" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6cd8439929', + 'Age' + )} value={filters.time} options={[ ['all', 'Any age'], @@ -952,7 +958,10 @@ function WorkspaceCleanupFilterToolbar({ onChange={(value) => updateFilter('time', value)} /> - label="Review" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38422e7b8d', + 'Review' + )} value={filters.review} options={[ ['all', 'Any review'], @@ -964,7 +973,10 @@ function WorkspaceCleanupFilterToolbar({ onChange={(value) => updateFilter('review', value)} /> - label="Git" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6359dc079a', + 'Git' + )} value={filters.git} options={[ ['all', 'Any git'], @@ -976,7 +988,10 @@ function WorkspaceCleanupFilterToolbar({ onChange={(value) => updateFilter('git', value)} /> - label="Context" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7644af86b2', + 'Context' + )} value={filters.context} options={[ ['all', 'Any context'], @@ -993,7 +1008,10 @@ function WorkspaceCleanupFilterToolbar({ )} - label="Sort by" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e343b9e9ce', + 'Sort by' + )} value={sortKey} options={[ ['activity', 'Activity'], @@ -1005,7 +1023,10 @@ function WorkspaceCleanupFilterToolbar({ onChange={onSortKeyChange} /> - label="Direction" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.27acbc2efe', + 'Direction' + )} value={sortDirection} options={[ ['asc', 'Ascending'], diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index a9f6b5362da..3dd1fb6bc4d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -2,6 +2,7 @@ import type { AppState } from '@/store/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo, Worktree } from '../../../../shared/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation' export const NOW = 1_700_000_000_000 @@ -84,7 +85,10 @@ export function makeReview(overrides: Partial = {}): HostedRev return { provider: 'github', number: 42, - title: 'Review alpha cleanup', + title: translate( + 'auto.components.workspace.cleanup.presentationFixtures.5ed71d83ef', + 'Review alpha cleanup' + ), state: 'open', url: 'https://example.test/review/42', status: 'neutral', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index ad860e1f7e2..1dbbeb6e343 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -1,6 +1,7 @@ import { getWorktreeMapFromState } from '@/store/selectors' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import type { AppState } from '@/store/types' +import { translate } from '@/i18n/i18n' import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review' import type { Repo, Worktree } from '../../../../shared/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' @@ -110,10 +111,22 @@ function getLinkedReviewFallback(worktree: Worktree | null): { return null } if (worktree.linkedGitLabMR != null) { - return { label: `MR #${worktree.linkedGitLabMR}`, provider: 'gitlab' } + return { + label: `${translate( + 'auto.components.workspace.cleanup.presentation.0bb8d1aa02', + 'MR #' + )}${worktree.linkedGitLabMR}`, + provider: 'gitlab' + } } if (worktree.linkedPR != null) { - return { label: `PR #${worktree.linkedPR}`, provider: 'github' } + return { + label: `${translate( + 'auto.components.workspace.cleanup.presentation.b1f1a02943', + 'PR #' + )}${worktree.linkedPR}`, + provider: 'github' + } } return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14b4834a441..dc987fa1ea7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2237,7 +2237,32 @@ "2ddbd6fe8a": "checked", "ee81adfcef": "View", "4d0b72481c": "Ignore", - "9cc26c019d": "Remove" + "9cc26c019d": "Remove", + "0e2d235c63": "Inactive workspace scan ready", + "4a35c08764": "Review", + "47123d0108": "Scanning inactive workspaces. You can close this and come back.", + "9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.", + "3d957ff117": "No workspaces match these filters.", + "e94b1f8bb4": "Clear filters", + "efb3843e75": "Filter and sort workspaces", + "93b7381d50": "Filters", + "a615e24679": "Sort", + "4cc5b73efe": "Finding inactive workspaces...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", + "4361f1534c": "Search workspaces", + "6cd8439929": "Age", + "38422e7b8d": "Review", + "6359dc079a": "Git", + "7644af86b2": "Context", + "e343b9e9ce": "Sort by", + "27acbc2efe": "Direction" + }, + "presentation": { + "0bb8d1aa02": "MR #", + "b1f1a02943": "PR #" + }, + "presentationFixtures": { + "5ed71d83ef": "Review alpha cleanup" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5247ced7988..12eb041c162 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2237,7 +2237,32 @@ "2ddbd6fe8a": "comprobado", "ee81adfcef": "Vista", "4d0b72481c": "Ignorar", - "9cc26c019d": "Eliminar" + "9cc26c019d": "Eliminar", + "0e2d235c63": "Inactive workspace scan ready", + "4a35c08764": "Review", + "47123d0108": "Scanning inactive workspaces. You can close this and come back.", + "9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.", + "3d957ff117": "No workspaces match these filters.", + "e94b1f8bb4": "Clear filters", + "efb3843e75": "Filter and sort workspaces", + "93b7381d50": "Filters", + "a615e24679": "Sort", + "4cc5b73efe": "Finding inactive workspaces...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", + "4361f1534c": "Search workspaces", + "6cd8439929": "Age", + "38422e7b8d": "Review", + "6359dc079a": "Git", + "7644af86b2": "Context", + "e343b9e9ce": "Sort by", + "27acbc2efe": "Direction" + }, + "presentation": { + "0bb8d1aa02": "MR #", + "b1f1a02943": "PR #" + }, + "presentationFixtures": { + "5ed71d83ef": "Review alpha cleanup" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7c12bb98c9d..515a4873bc2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2237,7 +2237,32 @@ "2ddbd6fe8a": "チェック済み", "ee81adfcef": "表示", "4d0b72481c": "無視", - "9cc26c019d": "削除" + "9cc26c019d": "削除", + "0e2d235c63": "Inactive workspace scan ready", + "4a35c08764": "Review", + "47123d0108": "Scanning inactive workspaces. You can close this and come back.", + "9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.", + "3d957ff117": "No workspaces match these filters.", + "e94b1f8bb4": "Clear filters", + "efb3843e75": "Filter and sort workspaces", + "93b7381d50": "Filters", + "a615e24679": "Sort", + "4cc5b73efe": "Finding inactive workspaces...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", + "4361f1534c": "Search workspaces", + "6cd8439929": "Age", + "38422e7b8d": "Review", + "6359dc079a": "Git", + "7644af86b2": "Context", + "e343b9e9ce": "Sort by", + "27acbc2efe": "Direction" + }, + "presentation": { + "0bb8d1aa02": "MR #", + "b1f1a02943": "PR #" + }, + "presentationFixtures": { + "5ed71d83ef": "Review alpha cleanup" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2304381ddcf..d0ca9068824 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2237,7 +2237,32 @@ "2ddbd6fe8a": "확인됨", "ee81adfcef": "보기", "4d0b72481c": "무시", - "9cc26c019d": "제거" + "9cc26c019d": "제거", + "0e2d235c63": "Inactive workspace scan ready", + "4a35c08764": "Review", + "47123d0108": "Scanning inactive workspaces. You can close this and come back.", + "9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.", + "3d957ff117": "No workspaces match these filters.", + "e94b1f8bb4": "Clear filters", + "efb3843e75": "Filter and sort workspaces", + "93b7381d50": "Filters", + "a615e24679": "Sort", + "4cc5b73efe": "Finding inactive workspaces...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", + "4361f1534c": "Search workspaces", + "6cd8439929": "Age", + "38422e7b8d": "Review", + "6359dc079a": "Git", + "7644af86b2": "Context", + "e343b9e9ce": "Sort by", + "27acbc2efe": "Direction" + }, + "presentation": { + "0bb8d1aa02": "MR #", + "b1f1a02943": "PR #" + }, + "presentationFixtures": { + "5ed71d83ef": "Review alpha cleanup" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 98467e2c391..cf8639fed03 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2237,7 +2237,32 @@ "2ddbd6fe8a": "检查过", "ee81adfcef": "查看", "4d0b72481c": "忽略", - "9cc26c019d": "移除" + "9cc26c019d": "移除", + "0e2d235c63": "Inactive workspace scan ready", + "4a35c08764": "Review", + "47123d0108": "Scanning inactive workspaces. You can close this and come back.", + "9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.", + "3d957ff117": "No workspaces match these filters.", + "e94b1f8bb4": "Clear filters", + "efb3843e75": "Filter and sort workspaces", + "93b7381d50": "Filters", + "a615e24679": "Sort", + "4cc5b73efe": "Finding inactive workspaces...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", + "4361f1534c": "Search workspaces", + "6cd8439929": "Age", + "38422e7b8d": "Review", + "6359dc079a": "Git", + "7644af86b2": "Context", + "e343b9e9ce": "Sort by", + "27acbc2efe": "Direction" + }, + "presentation": { + "0bb8d1aa02": "MR #", + "b1f1a02943": "PR #" + }, + "presentationFixtures": { + "5ed71d83ef": "Review alpha cleanup" } } }, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 55af965a537..18f00b5b44c 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -1,11 +1,17 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { getCachedWindowsTerminalCapabilities, + getWindowsTerminalCapabilityOwnerKey, hasCachedWindowsTerminalCapabilities, loadWindowsTerminalCapabilities, refreshWindowsTerminalCapabilities, resetWindowsTerminalCapabilitiesForTests, - selectWindowsTerminalCapabilitiesForOwner + selectWindowsTerminalCapabilitiesForOwner, + useWindowsTerminalCapabilities } from './windows-terminal-capabilities' function stubTerminalCapabilityApi(args: { @@ -42,7 +48,12 @@ function stubTerminalCapabilityApi(args: { } describe('windows terminal capabilities', () => { + const hookRoots: Root[] = [] + afterEach(() => { + for (const root of hookRoots.splice(0)) { + act(() => root.unmount()) + } resetWindowsTerminalCapabilitiesForTests() vi.unstubAllGlobals() }) @@ -258,6 +269,246 @@ describe('windows terminal capabilities', () => { ) }) + it('loads SSH Windows host capabilities through the SSH preflight bridge', async () => { + const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + vi.stubGlobal('window', { + api: { + preflight: { + detectRemoteWindowsTerminalCapabilities + } + } + }) + + await expect( + loadWindowsTerminalCapabilities({ + ownerKey: 'ssh:ssh-1', + sshConnectionId: 'ssh-1' + }) + ).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32', + isLoading: false + }) + + expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledWith({ + connectionId: 'ssh-1' + }) + expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32', + isLoading: false + }) + }) + + it('derives the SSH owner cache key when callers omit ownerKey', async () => { + const detectRemoteWindowsTerminalCapabilities = vi + .fn() + .mockResolvedValueOnce({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + .mockResolvedValueOnce({ + wslAvailable: true, + wslDistros: ['Ubuntu', 'Debian'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32' + }) + vi.stubGlobal('window', { + api: { + preflight: { + detectRemoteWindowsTerminalCapabilities + } + } + }) + + const sshOwnerKey = getWindowsTerminalCapabilityOwnerKey(null, 'ssh-1') + await expect(loadWindowsTerminalCapabilities({ sshConnectionId: 'ssh-1' })).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32', + isLoading: false + }) + + expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32', + isLoading: false + }) + expect(getCachedWindowsTerminalCapabilities()).toEqual({ + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null, + isLoading: false + }) + + await expect( + refreshWindowsTerminalCapabilities(undefined, { kind: 'local' }, 'ssh-1') + ).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu', 'Debian'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32', + isLoading: false + }) + + expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu', 'Debian'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32', + isLoading: false + }) + }) + + it('loads runtime-owned SSH capabilities through runtime RPC with a scoped cache key', async () => { + const detectRemoteWindowsTerminalCapabilities = vi.fn() + const runtimeEnvironmentCall = vi.fn(async (args: { selector: string; method: string }) => { + const resultByMethod: Record = { + 'status.get': { + hostPlatform: 'linux', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2 + }, + 'preflight.detectRemoteWindowsTerminalCapabilities': { + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32' + } + } + return { + id: args.method, + ok: true, + result: resultByMethod[args.method] + } + }) + vi.stubGlobal('window', { + api: { + preflight: { + detectRemoteWindowsTerminalCapabilities + }, + runtimeEnvironments: { + call: runtimeEnvironmentCall + } + } + }) + + const ownerKey = getWindowsTerminalCapabilityOwnerKey('env-1', 'ssh-1') + expect(ownerKey).toBe('runtime:env-1:ssh:ssh-1') + + await expect( + loadWindowsTerminalCapabilities({ + target: { kind: 'environment', environmentId: 'env-1' }, + sshConnectionId: 'ssh-1' + }) + ).resolves.toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32', + isLoading: false + }) + + expect(detectRemoteWindowsTerminalCapabilities).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'preflight.detectRemoteWindowsTerminalCapabilities', + params: { connectionId: 'ssh-1' } + }) + ) + expect(getCachedWindowsTerminalCapabilities(ownerKey)).toEqual({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: false, + hostPlatform: 'win32', + isLoading: false + }) + expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({ + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null, + isLoading: false + }) + }) + + it('does not re-probe on parent rerenders with the same capability target', async () => { + const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({ + wslAvailable: true, + wslDistros: ['Ubuntu'], + pwshAvailable: true, + gitBashAvailable: true, + hostPlatform: 'win32' + }) + vi.stubGlobal('window', { + api: { + preflight: { + detectRemoteWindowsTerminalCapabilities + } + } + }) + + function HookProbe(): null { + useWindowsTerminalCapabilities(true, false, undefined, { kind: 'local' }, 'ssh-1') + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + await act(async () => { + root.render(createElement(HookProbe)) + }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) + + await act(async () => { + root.render(createElement(HookProbe)) + }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) + }) + it('prunes expired runtime owner capability caches', async () => { stubTerminalCapabilityApi({ wslAvailable: false, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 6af6037e0f8..ba8da74c86a 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -1,6 +1,8 @@ -import { useEffect, useState } from 'react' -import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import type { RuntimeStatus } from '../../../shared/runtime-types' +import { useEffect, useMemo, useState } from 'react' +import { + readWindowsTerminalCapabilities, + type WindowsTerminalCapabilityLoadTarget +} from './windows-terminal-capability-read' export type WindowsTerminalCapabilities = { wslAvailable: boolean @@ -39,14 +41,33 @@ type WindowsTerminalCapabilityHookState = { capabilities: WindowsTerminalCapabilities } -type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget +function resolveWindowsTerminalCapabilityCacheKey(args: { + ownerKey?: string + target?: WindowsTerminalCapabilityLoadTarget + sshConnectionId?: string | null +}): string { + const explicitOwnerKey = args.ownerKey?.trim() + if (explicitOwnerKey) { + return explicitOwnerKey + } + const environmentId = args.target?.kind === 'environment' ? args.target.environmentId : null + return getWindowsTerminalCapabilityOwnerKey(environmentId, args.sshConnectionId) +} export function getWindowsTerminalCapabilityOwnerKey( - activeRuntimeEnvironmentId?: string | null + activeRuntimeEnvironmentId?: string | null, + sshConnectionId?: string | null ): string { // Why: remote desktop and paired web clients can switch hosts; Git Bash/WSL availability is // host-owned, so a previous runtime's answer must not bleed into the next. + const connectionId = sshConnectionId?.trim() const environmentId = activeRuntimeEnvironmentId?.trim() + if (connectionId && environmentId) { + return `runtime:${environmentId}:ssh:${connectionId}` + } + if (connectionId) { + return `ssh:${connectionId}` + } return environmentId ? `runtime:${environmentId}` : 'local' } @@ -105,11 +126,17 @@ export function loadWindowsTerminalCapabilities( now?: number ownerKey?: string target?: WindowsTerminalCapabilityLoadTarget + sshConnectionId?: string | null } = {} ): Promise { const now = options.now ?? Date.now() - const ownerKey = options.ownerKey ?? 'local' + const sshConnectionId = options.sshConnectionId?.trim() || null const target = options.target ?? { kind: 'local' } + const ownerKey = resolveWindowsTerminalCapabilityCacheKey({ + ownerKey: options.ownerKey, + target, + sshConnectionId + }) pruneExpiredCapabilityOwners(now) const cached = cachedCapabilitiesByOwnerKey.get(ownerKey) if (cached && !options.force && now - cached.loadedAt < CAPABILITY_CACHE_TTL_MS) { @@ -124,16 +151,8 @@ export function loadWindowsTerminalCapabilities( // Separate probes can leave one surface showing stale Windows shell choices. const requestId = ++nextCapabilityRequestId latestCapabilityRequestIdByOwnerKey.set(ownerKey, requestId) - const nextPendingCapabilities = Promise.all(readWindowsTerminalCapabilityPromises(target)) - .then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform]) => { - const capabilities = { - wslAvailable, - wslDistros, - pwshAvailable, - gitBashAvailable, - hostPlatform, - isLoading: false - } + const nextPendingCapabilities = readWindowsTerminalCapabilities(target, sshConnectionId) + .then((capabilities) => { if (requestId === latestCapabilityRequestIdByOwnerKey.get(ownerKey)) { pendingCapabilitiesByOwnerKey.delete(ownerKey) publish(capabilities, ownerKey, now) @@ -155,10 +174,11 @@ export function loadWindowsTerminalCapabilities( } export function refreshWindowsTerminalCapabilities( - ownerKey = 'local', - target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' } + ownerKey: string | undefined = undefined, + target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, + sshConnectionId?: string | null ): Promise { - return loadWindowsTerminalCapabilities({ force: true, ownerKey, target }) + return loadWindowsTerminalCapabilities({ force: true, ownerKey, target, sshConnectionId }) } export function selectWindowsTerminalCapabilitiesForOwner( @@ -171,107 +191,76 @@ export function selectWindowsTerminalCapabilitiesForOwner( } return state.ownerKey === ownerKey ? state.capabilities - : (cachedCapabilitiesByOwnerKey.get(ownerKey)?.capabilities ?? UNAVAILABLE_CAPABILITIES) + : getCachedWindowsTerminalCapabilities(ownerKey) } export function useWindowsTerminalCapabilities( enabled: boolean, forceRefreshOnMount = false, - ownerKey = 'local', - target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' } + ownerKey: string | undefined = undefined, + target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, + sshConnectionId?: string | null ): WindowsTerminalCapabilities { const targetKind = target.kind const targetEnvironmentId = target.kind === 'environment' ? target.environmentId : null - const [state, setState] = useState(() => ({ + const sshConnectionIdKey = sshConnectionId?.trim() || null + const resolvedTarget: WindowsTerminalCapabilityLoadTarget = useMemo( + () => + targetKind === 'environment' && targetEnvironmentId + ? { kind: 'environment', environmentId: targetEnvironmentId } + : { kind: 'local' }, + [targetKind, targetEnvironmentId] + ) + const resolvedOwnerKey = resolveWindowsTerminalCapabilityCacheKey({ ownerKey, - capabilities: getCachedWindowsTerminalCapabilities(ownerKey) + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey + }) + const [state, setState] = useState(() => ({ + ownerKey: resolvedOwnerKey, + capabilities: getCachedWindowsTerminalCapabilities(resolvedOwnerKey) })) useEffect(() => { if (!enabled) { - setState({ ownerKey, capabilities: UNAVAILABLE_CAPABILITIES }) + setState({ ownerKey: resolvedOwnerKey, capabilities: UNAVAILABLE_CAPABILITIES }) return } - const loadTarget: WindowsTerminalCapabilityLoadTarget = - targetKind === 'environment' && targetEnvironmentId - ? { kind: 'environment', environmentId: targetEnvironmentId } - : { kind: 'local' } - let cancelled = false - const cached = getCachedWindowsTerminalCapabilities(ownerKey) - const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(ownerKey) + const cached = getCachedWindowsTerminalCapabilities(resolvedOwnerKey) + const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(resolvedOwnerKey) setState({ - ownerKey, + ownerKey: resolvedOwnerKey, capabilities: hasOwnerCache ? cached : { ...cached, isLoading: true } }) const setCapabilities = (capabilities: WindowsTerminalCapabilities): void => { - setState({ ownerKey, capabilities }) + setState({ ownerKey: resolvedOwnerKey, capabilities }) } - const subscribers = subscribersByOwnerKey.get(ownerKey) ?? new Set() + const subscribers = subscribersByOwnerKey.get(resolvedOwnerKey) ?? new Set() subscribers.add(setCapabilities) - subscribersByOwnerKey.set(ownerKey, subscribers) + subscribersByOwnerKey.set(resolvedOwnerKey, subscribers) void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount, - ownerKey, - target: loadTarget + ownerKey: resolvedOwnerKey, + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey }).then((nextCapabilities) => { if (!cancelled) { - setState({ ownerKey, capabilities: nextCapabilities }) + setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) } }) return () => { cancelled = true - const currentSubscribers = subscribersByOwnerKey.get(ownerKey) + const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey) currentSubscribers?.delete(setCapabilities) if (currentSubscribers?.size === 0) { - subscribersByOwnerKey.delete(ownerKey) + subscribersByOwnerKey.delete(resolvedOwnerKey) } } - }, [enabled, forceRefreshOnMount, ownerKey, targetKind, targetEnvironmentId]) + }, [enabled, forceRefreshOnMount, resolvedOwnerKey, resolvedTarget, sshConnectionIdKey]) - return selectWindowsTerminalCapabilitiesForOwner(state, enabled, ownerKey) -} - -function readWindowsTerminalCapabilityPromises( - target: WindowsTerminalCapabilityLoadTarget -): [ - Promise, - Promise, - Promise, - Promise, - Promise -] { - if (target.kind === 'local') { - return [ - window.api.wsl.isAvailable().catch(() => false), - window.api.wsl.listDistros().catch(() => []), - window.api.pwsh.isAvailable().catch(() => false), - window.api.gitBash.isAvailable().catch(() => false), - window.api.runtime - .getStatus() - .then((status) => status.hostPlatform ?? null) - .catch(() => null) - ] - } - - return [ - callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { timeoutMs: 15_000 }).catch( - () => false - ), - callRuntimeRpc(target, 'host.wsl.listDistros', undefined, { - timeoutMs: 15_000 - }).catch(() => []), - callRuntimeRpc(target, 'host.pwsh.isAvailable', undefined, { - timeoutMs: 15_000 - }).catch(() => false), - callRuntimeRpc(target, 'host.gitBash.isAvailable', undefined, { - timeoutMs: 15_000 - }).catch(() => false), - callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) - .then((status) => status.hostPlatform ?? null) - .catch(() => null) - ] + return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey) } export function resetWindowsTerminalCapabilitiesForTests(): void { diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts new file mode 100644 index 00000000000..e73336b424f --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -0,0 +1,87 @@ +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { WindowsTerminalCapabilities } from './windows-terminal-capabilities' + +export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget + +export async function readWindowsTerminalCapabilities( + target: WindowsTerminalCapabilityLoadTarget, + sshConnectionId?: string | null +): Promise { + if (sshConnectionId) { + const remoteCapabilityPromise = + target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'preflight.detectRemoteWindowsTerminalCapabilities', + { connectionId: sshConnectionId }, + { timeoutMs: 15_000 } + ) + : window.api.preflight.detectRemoteWindowsTerminalCapabilities({ + connectionId: sshConnectionId + }) + return remoteCapabilityPromise + .then((capabilities) => ({ + ...capabilities, + wslDistros: capabilities.wslDistros ?? [], + isLoading: false + })) + .catch(() => ({ + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null, + isLoading: false + })) + } + + if (target.kind === 'local') { + const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] = + await Promise.all([ + window.api.wsl.isAvailable().catch(() => false), + window.api.wsl.listDistros().catch(() => []), + window.api.pwsh.isAvailable().catch(() => false), + window.api.gitBash.isAvailable().catch(() => false), + window.api.runtime + .getStatus() + .then((status) => status.hostPlatform ?? null) + .catch(() => null) + ]) + return { + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + hostPlatform, + isLoading: false + } + } + + const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] = + await Promise.all([ + callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'host.wsl.listDistros', undefined, { + timeoutMs: 15_000 + }).catch(() => []), + callRuntimeRpc(target, 'host.pwsh.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'host.gitBash.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) + .then((status) => status.hostPlatform ?? null) + .catch(() => null) + ]) + return { + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + hostPlatform, + isLoading: false + } +} diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 4f2d06848e0..9e70b9136c0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1342,6 +1342,104 @@ describe('setActiveWorktree', () => { } }) + it('preserves explicit Windows shell selections for Windows SSH terminal tabs', () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + const store = createTestStore() + const wt = 'remote-repo::/path/wt1' + + seedStore(store, { + repos: [ + { + id: 'remote-repo', + path: '/remote/repo', + displayName: 'Remote Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'win32' + } + ] + ]), + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' }, + worktreesByRepo: { + 'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })] + } + }) + + const terminal = store.getState().createTab(wt, undefined, 'cmd.exe') + expect(terminal.shellOverride).toBe('cmd.exe') + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } + }) + + it('drops explicit Windows shell selections for non-Windows SSH terminal tabs', () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + const store = createTestStore() + const wt = 'remote-repo::/path/wt1' + + seedStore(store, { + repos: [ + { + id: 'remote-repo', + path: '/remote/repo', + displayName: 'Remote Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' + } + ] + ]), + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' }, + worktreesByRepo: { + 'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })] + } + }) + + const terminal = store.getState().createTab(wt, undefined, 'cmd.exe') + expect(terminal.shellOverride).toBeUndefined() + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } + }) + it('does not offer Git Bash as a local shell override for SSH terminal tabs', () => { const originalNavigator = globalThis.navigator Object.defineProperty(globalThis, 'navigator', { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b99b248a503..4911236964e 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -27,6 +27,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' +import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' @@ -169,14 +170,28 @@ function isWindowsRendererRuntime(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') } +function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean { + return ( + shell === 'powershell.exe' || + shell === 'pwsh.exe' || + shell === 'cmd.exe' || + shell === 'wsl.exe' || + shell === WINDOWS_GIT_BASH_SHELL + ) +} + function resolveCreatedTabShellOverride( explicitShellOverride: string | undefined, defaultWindowsShell: string | undefined, isRemoteWorktree: boolean, + remotePlatform: NodeJS.Platform | null, isWslWorktree: boolean, projectRuntime: ProjectExecutionRuntimeResolution | undefined ): string | undefined { if (isRemoteWorktree) { + if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) { + return explicitShellOverride + } return undefined } if (isWindowsRendererRuntime()) { @@ -231,6 +246,27 @@ export function worktreeUsesRemoteConnection( return Boolean(repo?.connectionId) } +function getRemoteConnectionIdForWorktree( + state: Pick, + worktreeId: string +): string | null { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null + } + const directRepoId = getRepoIdFromWorktreeId(worktreeId) + const directRepo = state.repos.find((repo) => repo.id === directRepoId) + if (directRepo) { + return directRepo.connectionId?.trim() || null + } + + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + return repo?.connectionId?.trim() || null +} + function resolveTerminalStopRuntimeEnvironmentId( state: Pick, worktreeId: string @@ -686,7 +722,8 @@ export const createTerminalSlice: StateCreator const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` const quickCommandLabel = options?.quickCommandLabel?.trim() - const isRemoteWorktree = worktreeUsesRemoteConnection(s, worktreeId) + const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) + const isRemoteWorktree = Boolean(remoteConnectionId) const isWslWorktree = worktreeUsesWslPath(s, worktreeId) const createdShellOverride = resolveCreatedTabShellOverride( shellOverride, @@ -694,6 +731,10 @@ export const createTerminalSlice: StateCreator // Why: SSH PTYs ignore local Windows shell selection; persisting a // local shell icon would mislabel a remote terminal. isRemoteWorktree, + remoteConnectionId + ? ((s.sshConnectionStates.get(remoteConnectionId) + ?.remotePlatform as NodeJS.Platform | null) ?? null) + : null, // Why: WSL UNC worktrees are repo-scoped WSL environments. New default // terminals should enter that distro even when the global Windows shell // preference is PowerShell or cmd.exe. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index eee02f7627c..aff6f472e12 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2179,6 +2179,20 @@ function createPreflightApi(): NonNullable['preflight']> { pathSource: 'sync_seed_only', pathFailureReason: 'spawn_error' } + type WindowsTerminalCapabilityBridgeResult = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + } + const fallbackWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null + } return { check: async (args) => { if (!requireActiveEnvironmentOrNull()) { @@ -2201,7 +2215,14 @@ function createPreflightApi(): NonNullable['preflight']> { detectRemoteAgents: async (args) => requireActiveEnvironmentOrNull() ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) - : [] + : [], + detectRemoteWindowsTerminalCapabilities: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteWindowsTerminalCapabilities', + args + ).catch(() => fallbackWindowsTerminalCapabilities) + : Promise.resolve(fallbackWindowsTerminalCapabilities) } } From db2ce7a10de68609244fa4d783bced088a0c831a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:20:56 -0700 Subject: [PATCH 08/23] Fix remote-host automation ownership (#6636) Co-authored-by: Orca --- .../automations/AutomationsPage.tsx | 35 ++++++++--- .../automation-host-client.test.ts | 40 ++++++++++++- .../automations/automation-host-client.ts | 25 +++++--- .../automation-target-availability.test.ts | 60 +++++++++++++++++++ .../automation-target-availability.ts | 56 ++++++++++++++--- 5 files changed, 192 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index eec62ea8413..935b112d44f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -167,6 +167,16 @@ function getAutomationHostTargetKey(target: AutomationHostTarget): string { return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' } +function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } @@ -547,6 +557,10 @@ export default function AutomationsPage(): React.JSX.Element { () => worktreesByRepo[draft.projectId] ?? [], [draft.projectId, worktreesByRepo] ) + const automationHostTarget = useMemo( + () => getAutomationHostTargetFromKey(automationHostTargetKey), + [automationHostTargetKey] + ) useEffect(() => { for (const [workspaceId, worktree] of worktreeMap) { @@ -801,6 +815,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) }) : null @@ -969,7 +984,7 @@ export default function AutomationsPage(): React.JSX.Element { pendingAutomationRunNavigation.hostId ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) : selected - ? getAutomationOwnerTarget(selected) + ? getAutomationOwnerTarget(selected, automationHostTarget) : getAutomationListTarget(settings) void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { if (!cancelled) { @@ -979,7 +994,7 @@ export default function AutomationsPage(): React.JSX.Element { return () => { cancelled = true } - }, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) + }, [automationHostTarget, pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { @@ -1478,7 +1493,7 @@ export default function AutomationsPage(): React.JSX.Element { } const automation = editingAutomationId ? currentAutomation - ? await updateAutomationForTarget(currentAutomation, updates) + ? await updateAutomationForTarget(currentAutomation, updates, automationHostTarget) : await window.api.automations.update({ id: editingAutomationId, updates @@ -1539,12 +1554,16 @@ export default function AutomationsPage(): React.JSX.Element { } const toggleAutomation = async (automation: Automation): Promise => { - await updateAutomationForTarget(automation, { enabled: !automation.enabled }) + await updateAutomationForTarget( + automation, + { enabled: !automation.enabled }, + automationHostTarget + ) await refresh() } const deleteAutomation = async (automation: Automation): Promise => { - await deleteAutomationForTarget(automation) + await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) } @@ -1616,13 +1635,14 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) if (!availability.canRunNow) { toast.error(availability.message) return } - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) useAppStore.getState().recordFeatureInteraction('automation-run') await hydratePersistedUIState() await refresh() @@ -1640,7 +1660,7 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) await hydratePersistedUIState() await refresh() toast.message( @@ -2217,6 +2237,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) const workspaceLabel = diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index e2073cd04d4..e77ee46ff62 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -4,7 +4,8 @@ import { createAutomationForTarget, getAutomationListTarget, listAutomationsForTarget, - runAutomationNowForTarget + runAutomationNowForTarget, + updateAutomationForTarget } from './automation-host-client' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -126,4 +127,41 @@ describe('automation host client', () => { { timeoutMs: 15_000 } ) }) + + it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-devbox', + repoId: 'repo-1', + path: '/srv/orca' + } + }) + const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' } + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } }) + .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + + await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget) + await runAutomationNowForTarget(automation, sourceTarget) + + expect(mockApi.automations.update).not.toHaveBeenCalled() + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + sourceTarget, + 'automation.update', + { id: automation.id, updates: { name: 'Updated' } }, + { timeoutMs: 15_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + sourceTarget, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + }) }) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index a657c88ad8f..b3569b4b7b2 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -43,8 +43,12 @@ export function getAutomationListTarget( } export function getAutomationOwnerTarget( - automation: Pick + automation: Pick, + sourceTarget?: AutomationHostTarget | null ): AutomationHostTarget { + if (sourceTarget?.kind === 'environment') { + return sourceTarget + } return getAutomationTargetFromHostId(automation.runContext?.hostId) } @@ -121,9 +125,10 @@ export async function createAutomationForTarget(input: AutomationCreateInput): P export async function updateAutomationForTarget( automation: Automation, - updates: AutomationUpdateInput + updates: AutomationUpdateInput, + sourceTarget?: AutomationHostTarget | null ): Promise { - const target = getAutomationOwnerTarget(automation) + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.update({ id: automation.id, updates }) } @@ -136,8 +141,11 @@ export async function updateAutomationForTarget( return result.automation } -export async function deleteAutomationForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function deleteAutomationForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { await window.api.automations.delete({ id: automation.id }) return @@ -145,8 +153,11 @@ export async function deleteAutomationForTarget(automation: Automation): Promise await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) } -export async function runAutomationNowForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function runAutomationNowForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.runNow({ id: automation.id }) } diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index a640de7d7b5..cbc9b029c92 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -140,6 +140,66 @@ describe('automation target availability', () => { ).toBe('host-mismatch') }) + it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'devbox', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ + connectionId: 'devbox', + executionHostId: 'runtime:gpu' + }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'connected' }]]), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('allows remote-listed server-local automations whose setup is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ executionHostId: 'runtime:gpu' }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'runtime:gpu', + executionHostId: 'runtime:gpu' + }) + ], + sshConnectionStates: new Map(), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + it('blocks saved run contexts whose project host setup is missing or not ready', () => { const automation = makeAutomation({ runContext: { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index f2e994758db..008b4c9eb48 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -8,6 +8,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { AutomationHostTarget } from './automation-host-client' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { RuntimeStatus } from '../../../../shared/runtime-types' @@ -52,6 +53,7 @@ type AutomationTargetAvailabilityArgs = { string, { status: RuntimeStatus | null; checkedAt: number } > + automationHostTarget?: AutomationHostTarget | null sourceHostAvailability?: readonly TaskSourceHostAvailability[] } @@ -62,6 +64,7 @@ export function getAutomationTargetAvailability({ projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability }: AutomationTargetAvailabilityArgs): AutomationTargetAvailability { if (!repo) { @@ -93,15 +96,16 @@ export function getAutomationTargetAvailability({ `Project setup on the selected automation host is ${setup.setupState}.` ) } - if ( - setup.projectId !== automation.runContext.projectId || - setup.hostId !== automation.runContext.hostId || - setup.repoId !== automation.runContext.repoId || - setup.path !== automation.runContext.path || - automation.runContext.repoId !== repo.id || - automation.runContext.path !== repo.path || - automation.runContext.hostId !== getRepoExecutionHostId(repo) - ) { + const setupMatchesContext = + setup.projectId === automation.runContext.projectId && + setup.repoId === automation.runContext.repoId && + setup.path === automation.runContext.path && + setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget) + const repoMatchesContext = + automation.runContext.repoId === repo.id && + automation.runContext.path === repo.path && + repoHostMatchesRunContext(repo, automation.runContext.hostId, automationHostTarget) + if (!setupMatchesContext || !repoMatchesContext) { return unavailable( 'host-mismatch', 'The saved run host no longer matches this project setup.' @@ -142,6 +146,40 @@ export function getAutomationTargetAvailability({ } } +function getRuntimeTargetHostId(target: AutomationHostTarget | null | undefined): string | null { + return target?.kind === 'environment' + ? `runtime:${encodeURIComponent(target.environmentId)}` + : null +} + +function setupHostMatchesRunContext( + setupHostId: string, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (setupHostId === runHostId) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: remote-runtime project lists project the server-local host as runtime:, + // while CLI-created automations can preserve the server's durable local run host. + return targetHostId !== null && setupHostId === targetHostId && runHostId === 'local' +} + +function repoHostMatchesRunContext( + repo: Repo, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (runHostId === getRepoExecutionHostId(repo)) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: repos fetched from a remote runtime are owned by runtime: in the + // renderer, but saved automations still target the host setup that runs there. + return targetHostId !== null && getRepoExecutionHostId(repo) === targetHostId +} + function getAutomationSourceAvailability( sourceContext: TaskSourceContext | null | undefined, sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined From ef6e64303107142aa80d6f5af878959c797fbbdd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:23:00 -0700 Subject: [PATCH 09/23] Update mobile 0.0.17 release links (#6654) Co-authored-by: Orca --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 00989a01325..b449142d82d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -229,7 +229,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [Download APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index cb78aa38095..e4bed124354 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index e2d93abd1f8..68f74264921 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4a63f512b9f..7c8de89e9fe 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index b3e97dd77cf..b43cbb37c6e 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index a959cda5ce1..7070b9b5b97 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index d6ffbc05461..5bf378a1ea6 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_APK_URL = - 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk' + 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk' export function MobileSettingsPane(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) From 710d567e828cc81a4419522beba43a82046509ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:25:06 -0700 Subject: [PATCH 10/23] Suppress Codex auto-approval notifications (#6230) Co-authored-by: Orca --- src/main/index.ts | 42 ++- src/main/runtime/orca-runtime.ts | 31 +++ .../agent-completion-coordinator-types.ts | 1 + .../agent-completion-coordinator.test.ts | 134 ++++++++++ .../agent-completion-coordinator.ts | 8 + ...-approval-notification-suppression.test.ts | 240 +++++++++++++++++ ...-auto-approval-notification-suppression.ts | 82 ++++++ .../terminal-pane/pty-connection.test.ts | 181 +++++++++++++ .../terminal-pane/pty-connection.ts | 29 +- ...gent-hook-completion-notifications.test.ts | 145 +++++++++- .../agent-hook-completion-notifications.ts | 4 +- src/renderer/src/hooks/useIpcEvents.test.ts | 251 ++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 15 ++ src/renderer/src/store/slices/agent-status.ts | 44 ++- src/shared/tui-agent-permissions.test.ts | 37 +++ src/shared/tui-agent-permissions.ts | 75 ++++-- 16 files changed, 1285 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts create mode 100644 src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts diff --git a/src/main/index.ts b/src/main/index.ts index f8ab73b4aa2..6f27111f841 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -147,6 +147,7 @@ import { type SyntheticAgentTitleProfile } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' +import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -855,7 +856,20 @@ function openMainWindow(): BrowserWindow { // Why: some native OSC titles miss terminal idle/permission frames. // Inject hook-derived frames so the renderer title tracker updates too. const profile = getSyntheticAgentTitleProfile(payload.agentType) - if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) { + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { driveSyntheticTitleFromHook(paneKey, payload.state, profile) } } @@ -1282,6 +1296,32 @@ function driveSyntheticTitleFromHook( }) } +function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { + agentArgs?: string | null + agentEnv?: Record | null + } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + app.whenReady().then(async () => { logStartupMilestone('app-ready') electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f8dc05fcfa7..2f816d61505 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17939,6 +17939,20 @@ export class OrcaRuntimeService { return this.getTerminalHandleForPaneKey(paneKey) ?? undefined } + getAgentStatusLaunchConfigForPaneKey( + paneKey: string, + args?: { launchToken?: string } + ): SleepingAgentLaunchConfig | undefined { + const pty = this.getPtyRecordForPaneKey(paneKey) + if (!pty?.launchConfig) { + return undefined + } + if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { + return undefined + } + return copySleepingAgentLaunchConfig(pty.launchConfig) + } + private buildAgentOrchestrationByPaneKey(): | Record | undefined { @@ -18049,6 +18063,23 @@ export class OrcaRuntimeService { return null } + private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) + const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + if (pty) { + return pty + } + } + for (const pty of this.ptysById.values()) { + if (pty.paneKey === paneKey) { + return pty + } + } + return null + } + private getPaneKeyForTerminalHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.paneKey) { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 8846afd6386..f0b4830fc2a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -29,6 +29,7 @@ export type AgentCompletionCoordinatorOptions = { dispatchAttention?: (title: string, meta: AgentAttentionDispatchMeta) => void isLive: () => boolean shouldPollProcessCadence?: () => boolean + shouldSuppressHookCompletion?: (payload: AgentCompletionStatusSnapshot) => boolean } export type AgentCompletionCoordinator = { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 41d02a6fb64..bcdeeb1c42f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1109,6 +1109,47 @@ describe('agent completion coordinator', () => { ) }) + it('suppresses the attention dispatch when shouldSuppressHookCompletion matches', () => { + // Why: guards the merge seam where the suppressor must short-circuit before + // the attention path, so auto-approved Codex pauses never notify. + const dispatchCompletion = vi.fn() + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchAttention, + isLive: () => true, + shouldSuppressHookCompletion: (payload) => + payload.state === 'waiting' || payload.state === 'blocked' + }) + + const turn = { + prompt: 'implement notifications', + agentType: 'codex' as const + } + + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'git status' + }) + coordinator.observeHookStatus({ + state: 'blocked', + ...turn, + toolName: 'exec_command', + toolInput: 'rm file' + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchAttention).not.toHaveBeenCalled() + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + it('does not dispatch completion when a blocked state arrives mid-turn', () => { const dispatchCompletion = vi.fn() const dispatchAttention = vi.fn() @@ -1198,6 +1239,45 @@ describe('agent completion coordinator', () => { ) }) + it('cancels a pending done timer when a suppressed attention state arrives before the quiet window', () => { + // Why: a suppressed Codex auto-approval pause must still cancel a provisional + // 'done' so the quiet-window timer never fires a false completion notification. + const dispatchCompletion = vi.fn() + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchAttention, + isLive: () => true, + shouldSuppressHookCompletion: (payload) => + payload.state === 'waiting' || payload.state === 'blocked' + }) + + const turn = { + prompt: 'implement notifications', + agentType: 'codex' as const + } + + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) + + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'git status' + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(false) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).not.toHaveBeenCalled() + expect(dispatchAttention).not.toHaveBeenCalled() + }) + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { const inspection = createDeferred() const dispatchCompletion = vi.fn() @@ -1218,4 +1298,58 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability') }) + + it('does not mutate completion state when hook completion is suppressed', () => { + const dispatchCompletion = vi.fn() + const shouldSuppressHookCompletion = vi.fn( + (payload: { state: string }) => payload.state === 'waiting' || payload.state === 'blocked' + ) + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true, + shouldSuppressHookCompletion + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'implement notifications', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'waiting', + prompt: 'implement notifications', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + }) + + expect(dispatchCompletion).not.toHaveBeenCalled() + expect(shouldSuppressHookCompletion).toHaveBeenCalled() + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'implement notifications', + agentType: 'codex', + stateStartedAt: 1_700_000_010_000, + lastAssistantMessage: 'Done.' + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + 'codex', + expect.objectContaining({ + source: 'hook', + quietedHookDone: true, + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'codex' + }) + }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index a6e55415aae..f1aac9f3922 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -652,6 +652,14 @@ export function createAgentCompletionCoordinator( } function observeHookStatus(payload: AgentCompletionStatusSnapshot): void { + if (options.shouldSuppressHookCompletion?.(payload)) { + // Why: a suppressed permission pause must still cancel a provisional 'done' + // so the quiet-window timer never fires a false completion notification. + if (isAttentionHookState(payload.state)) { + clearPendingHookDone() + } + return + } if (isRecognizedAgentType(payload.agentType)) { establishAgentEvidence() } diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts new file mode 100644 index 00000000000..43dd1c9cb2b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' +import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' +import type { AppState } from '../../store/types' +import { + createCodexAutoApprovalHookCompletionSuppressor, + shouldSuppressCodexAutoApprovalSyntheticTitle, + shouldSuppressCodexAutoApprovalStatus +} from './codex-auto-approval-notification-suppression' + +let testStore: ReturnType + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testStore.getState() + } +})) + +const paneKey = 'tab-1:leaf-1' +const launchToken = 'launch-token-1' +const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } + +function seedTab(): void { + testStore.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) +} + +function registerCodexLaunchConfig(args: { + agentArgs: string + launchToken?: string + providerSession?: typeof providerSession +}): void { + testStore.getState().registerAgentLaunchConfig( + paneKey, + { + agentArgs: args.agentArgs, + agentEnv: {} + }, + { + agentType: 'codex', + tabId: 'tab-1', + leafId: 'leaf-1', + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.providerSession ? { providerSession: args.providerSession } : {}) + } + ) +} + +describe('Codex auto-approval status suppression', () => { + beforeEach(() => { + testStore = createTestStore() + seedTab() + }) + + it('suppresses the first auto-approved Codex waiting status with matching launch token', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(true) + }) + + it('suppresses auto-approved Codex blocked statuses', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'blocked', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(true) + }) + + it('preserves manual Codex permission attention', () => { + registerCodexLaunchConfig({ agentArgs: '', launchToken }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('preserves mixed Codex permission attention', () => { + registerCodexLaunchConfig({ agentArgs: '--ask-for-approval on-request', launchToken }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('preserves missing-attribution Codex permission attention', () => { + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('fails open when a stale yolo launch token does not match', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken: 'manual-launch' } + ) + ).toBe(false) + }) + + it('fails open when a stale launch token conflicts with matching provider session', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken, + providerSession + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, + { + paneKey, + tabId: 'tab-1', + launchToken: 'manual-launch', + providerSession + } + ) + ).toBe(false) + }) + + it('fails open when launch token is missing from a token-registered launch', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken, + providerSession + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, + { + paneKey, + tabId: 'tab-1', + providerSession + } + ) + ).toBe(false) + }) + + it('matches provider session attribution when launch token is absent', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + providerSession + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', providerSession } + ) + ).toBe(true) + }) + + it('does not suppress non-Codex or done statuses', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'claude' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'done', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('uses the same predicate for hook-completion fallback suppression', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + const suppressor = createCodexAutoApprovalHookCompletionSuppressor(paneKey, () => ({ + tabId: 'tab-1', + launchToken + })) + + expect( + suppressor({ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }) + ).toBe(true) + }) + + it('suppresses synthetic Codex permission titles only when launch attribution is yolo', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('Codex - action required', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('Codex ready', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts new file mode 100644 index 00000000000..dadede174df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts @@ -0,0 +1,82 @@ +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title' +import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' +import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' +import { useAppStore } from '@/store' + +const CODEX_AUTO_APPROVED_PERMISSION_STATES = ['waiting', 'blocked'] as const + +export type CodexAutoApprovalStatusContext = { + paneKey: string + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata +} + +function isCodexAutoApprovedPermissionState( + state: AgentCompletionStatusSnapshot['state'] +): state is (typeof CODEX_AUTO_APPROVED_PERMISSION_STATES)[number] { + return CODEX_AUTO_APPROVED_PERMISSION_STATES.some((permissionState) => permissionState === state) +} + +export function shouldSuppressCodexAutoApprovalStatus( + payload: AgentCompletionStatusSnapshot, + context: CodexAutoApprovalStatusContext +): boolean { + if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) { + return false + } + + const state = useAppStore.getState() + if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') { + return false + } + + const launchConfig = state.getAgentLaunchConfigForStatusMetadata({ + paneKey: context.paneKey, + agentType: 'codex', + tabId: context.tabId, + terminalHandle: context.terminalHandle, + launchToken: context.launchToken, + providerSession: context.providerSession, + existingProviderSession: context.existingProviderSession + }) + if (!launchConfig) { + return false + } + + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: launchConfig.agentArgs, + agentEnv: launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitle( + title: string, + context: CodexAutoApprovalStatusContext +): boolean { + if (title !== getSyntheticAgentTitleProfile('codex')?.permissionLabel) { + return false + } + + return shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: '', agentType: 'codex' }, + context + ) +} + +export function createCodexAutoApprovalHookCompletionSuppressor( + paneKey: string, + getContext?: () => Omit +): (payload: AgentCompletionStatusSnapshot) => boolean { + return (payload) => + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey, + ...getContext?.() + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1d7ba1281b2..2dbf3c79753 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' // Repro command: @@ -115,6 +116,7 @@ type StoreState = { sleepingAgentSessionsByPaneKey: Record agentLaunchConfigByPaneKey: Record getAgentLaunchConfigForStatusEntry: ReturnType + getAgentLaunchConfigForStatusMetadata: ReturnType clearSleepingAgentSession: ReturnType registerAgentLaunchConfig: ReturnType clearAgentLaunchConfig: ReturnType @@ -552,6 +554,13 @@ describe('connectPanePty', () => { getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig }), + getAgentLaunchConfigForStatusMetadata: vi.fn( + (metadata: { paneKey: string; launchToken?: string }) => { + return metadata.launchToken + ? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig + : undefined + } + ), clearSleepingAgentSession: vi.fn((paneKey: string) => { delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] }), @@ -4323,6 +4332,7 @@ describe('connectPanePty', () => { } }, getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined), + getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined), sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -8937,6 +8947,177 @@ describe('connectPanePty', () => { ) }) + it('suppresses PTY-owned Codex auto-approved permission statuses before status or notification work', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + } + + const launchConfig = { + agentCommand: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-yolo', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'waiting' + prompt: string + agentType: 'codex' + toolName: string + toolInput: string + }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'waiting', + prompt: 'auto-approved permission', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + }) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(deps.dispatchNotification).not.toHaveBeenCalled() + }) + + it('suppresses synthetic Codex auto-approved permission titles before title work', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig: { + agentCommand: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + }, + launchToken: 'launch-yolo', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() + + titleHandler('Codex - action required', 'Codex - action required') + + expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledTimes(1) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.updateTabTitle).not.toHaveBeenCalled() + expect(manager.setPaneGpuRendering).not.toHaveBeenCalled() + }) + + it('does not resolve launch config for ordinary title changes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() + + for (let index = 0; index < 100; index += 1) { + titleHandler(`build output ${index}`, `build output ${index}`) + } + + expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).not.toHaveBeenCalled() + }) + + it('preserves synthetic Codex manual permission titles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: '', + agentEnv: {} + } + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: {} + }, + launchToken: 'launch-manual', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Codex - action required', 'Codex - action required') + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex - action required') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required') + }) + it('resolves synthetic terminal titles for remote hook status updates', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-devin') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index a921d341026..1d2c74c3e59 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -87,6 +87,11 @@ import { type AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + createCodexAutoApprovalHookCompletionSuppressor, + shouldSuppressCodexAutoApprovalSyntheticTitle, + shouldSuppressCodexAutoApprovalStatus +} from './codex-auto-approval-notification-suppression' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import { markTerminalBracketedPasteInterrupted, @@ -1301,7 +1306,11 @@ export function connectPanePty( return true } return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 - } + }, + shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey, () => ({ + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + })) }) // Why: the transport's own exit handler (pty-transport.ts) normally makes @@ -1375,6 +1384,15 @@ export function connectPanePty( let allowInitialIdleCacheSeed = false const onTitleChange = (title: string, rawTitle: string): void => { + if ( + shouldSuppressCodexAutoApprovalSyntheticTitle(title, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) deps.setRuntimePaneTitle(deps.tabId, pane.id, title) if (syncAgentTaskCompleteTrackingEnabled()) { @@ -1868,6 +1886,15 @@ export function connectPanePty( ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: (payload) => { + if ( + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } // Why: capture the store snapshot once so the title lookup and the // setAgentStatus call observe the same state. Re-reading getState() // between the two lines opens a brief window where the title could diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index d2d871abce5..5dc61b1e6bf 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: notification edge cases share one module-scoped coordinator, so keeping setup and regression cases together prevents brittle cross-file mock resets. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' const dispatchTerminalNotification = vi.fn() @@ -25,6 +26,32 @@ type MockStoreState = { ptyIdsByLeafId?: Record } > + agentLaunchConfigByPaneKey: Record< + string, + { + launchConfig: { agentArgs: string; agentEnv: Record } + launchToken?: string + } + > + agentStatusByPaneKey: Record< + string, + { + state: ParsedAgentStatusPayload['state'] + prompt: string + paneKey: string + updatedAt: number + stateStartedAt: number + agentType?: ParsedAgentStatusPayload['agentType'] + stateHistory: [] + } + > + getAgentLaunchConfigForStatusEntry: (entry: { + paneKey: string + }) => { agentArgs: string; agentEnv: Record } | undefined + getAgentLaunchConfigForStatusMetadata: (metadata: { + paneKey: string + launchToken?: string + }) => { agentArgs: string; agentEnv: Record } | undefined } let mockStoreState: MockStoreState @@ -49,6 +76,29 @@ function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatus } } +function seedCodexPaneLaunchConfig( + paneKey: string, + agentArgs: string, + launchToken = 'launch-token-1' +): void { + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs, + agentEnv: {} + }, + launchToken + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'implement notifications', + paneKey, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + stateHistory: [] + } +} + describe('agent hook completion notifications', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -71,7 +121,17 @@ describe('agent hook completion notifications', () => { tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] }, - terminalLayoutsByTabId: {} + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: (entry) => + mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig, + getAgentLaunchConfigForStatusMetadata: (metadata) => + metadata.launchToken && + metadata.launchToken === + mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchToken + ? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig + : undefined } }) @@ -460,6 +520,89 @@ describe('agent hook completion notifications', () => { ) }) + it('fails open for Codex auto-approved permission requests without launch proof', async () => { + seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state: 'waiting', + prompt: 'implement notifications', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + } + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + + it('still notifies for manual Codex permission requests', async () => { + seedCodexPaneLaunchConfig(paneKey, '') + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state: 'waiting', + prompt: 'implement notifications', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + } + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey, + terminalTitle: 'codex' + }) + ) + }) + + it('fails open for Codex auto-approved blocked permission requests without launch proof', async () => { + seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state: 'blocked', + prompt: 'implement notifications', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + } + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + it('does not notify on Grok routine permission prompt notifications during tool use', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 8f4364ce0f8..3e05ae7df67 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -8,6 +8,7 @@ import type { import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { createCodexAutoApprovalHookCompletionSuppressor } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' type CoordinatorEntry = { worktreeId: string @@ -170,7 +171,8 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion agentStatusSnapshot: meta.agentStatus }) }, - isLive: () => paneCanReceiveHookCompletion(paneKey) + isLive: () => paneCanReceiveHookCompletion(paneKey), + shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(paneKey) }) } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index a51291ed9d0..1b9c741e3ff 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -13,6 +13,7 @@ import { import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/types' import { makePaneKey } from '../../../shared/stable-pane-id' +import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' const { closeTerminalTabMock } = vi.hoisted(() => ({ closeTerminalTabMock: vi.fn() @@ -4082,6 +4083,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage?: string interrupted?: boolean terminalHandle?: string + launchToken?: string + providerSession?: { key: 'session_id'; id: string } orchestration?: { taskId?: string dispatchId?: string @@ -4149,6 +4152,7 @@ describe('useIpcEvents agent status snapshot integration', () => { runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined), recentlyClosedAgentStatusTabIds: {}, repos: [], worktreesByRepo: {}, @@ -4689,6 +4693,253 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('suppresses auto-approved Codex permission attention before status and title mutation', async () => { + const setAgentStatus = vi.fn() + const updateTabTitle = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => + metadata.launchToken === 'launch-yolo' + ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } + : undefined + ) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + updateTabTitle, + getAgentLaunchConfigForStatusMetadata, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'waiting', + prompt: 'auto-approved permission', + agentType: 'codex', + launchToken: 'launch-yolo', + receivedAt: 1_700_000_000_300, + stateStartedAt: 1_699_999_999_300 + }) + + expect(getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: FUTURE_PANE_KEY, launchToken: 'launch-yolo' }) + ) + expect(setAgentStatus).not.toHaveBeenCalled() + expect(updateTabTitle).not.toHaveBeenCalled() + expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + }) + + it('keeps manual or missing-attribution Codex permission attention actionable', async () => { + const setAgentStatus = vi.fn() + const updateTabTitle = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + updateTabTitle, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'waiting', + prompt: 'manual permission', + agentType: 'codex', + receivedAt: 1_700_000_000_400, + stateStartedAt: 1_699_999_999_400 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ + state: 'waiting', + prompt: 'manual permission', + agentType: 'codex' + }), + 'Codex - action required', + { updatedAt: 1_700_000_000_400, stateStartedAt: 1_699_999_999_400 }, + expectWorktreeRouting('wt-1'), + undefined + ) + expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Codex - action required') + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + }) + + it('keeps auto-approved Codex done statuses on the completion path', async () => { + const setAgentStatus = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => + metadata.launchToken === 'launch-yolo' + ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } + : undefined + ) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + getAgentLaunchConfigForStatusMetadata, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: {} + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'done', + prompt: 'auto-approved task', + agentType: 'codex', + launchToken: 'launch-yolo', + lastAssistantMessage: 'Done.', + receivedAt: 1_700_000_000_500, + stateStartedAt: 1_699_999_999_500 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + payload: expect.objectContaining({ state: 'done', agentType: 'codex' }) + }) + ) + }) + it('drops late push events for a recently closed terminal tab', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 94e815572fc..4f392c74e02 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -111,6 +111,7 @@ import { resetAgentHookCompletionNotificationCoordinators, syncAgentHookCompletionNotificationSettings } from './agent-hook-completion-notifications' +import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { titleHasAgentName } from '../../../shared/agent-detection' @@ -2879,6 +2880,20 @@ export function useIpcEvents(): void { // Keep the defensive store guard and completion notification path in sync. return 'dropped' } + if ( + shouldSuppressCodexAutoApprovalStatus(statusPayload, { + paneKey: data.paneKey, + tabId: data.tabId, + terminalHandle: data.terminalHandle, + launchToken: data.launchToken, + providerSession: data.providerSession, + existingProviderSession: existingStatus?.providerSession + }) + ) { + // Why: Codex yolo permission hooks are not user-actionable, and must + // not drive status, synthetic titles, unread badges, or notifications. + return 'dropped' + } const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 623bea5f5d5..affceaef623 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -66,6 +66,17 @@ type AgentLaunchConfigRegistrationMetadata = { providerSession?: AgentProviderSessionMetadata } +type AgentLaunchConfigStatusMetadata = { + paneKey: string + agentType?: AgentType + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata + providerSessionChanged?: boolean +} + type AgentLaunchConfigRegistryEntry = { launchConfig: SleepingAgentLaunchConfig registeredAt: number @@ -130,6 +141,9 @@ export type AgentStatusSlice = { getAgentLaunchConfigForStatusEntry: ( entry: AgentStatusEntry ) => SleepingAgentLaunchConfig | undefined + getAgentLaunchConfigForStatusMetadata: ( + metadata: AgentLaunchConfigStatusMetadata + ) => SleepingAgentLaunchConfig | undefined clearAgentLaunchConfig: (paneKey: string) => void setRuntimeAgentOrchestrationByPaneKey: ( @@ -640,15 +654,17 @@ function registryEntryMatchesStatus(args: { ) { return false } - if (identity.providerSession !== undefined) { - return providerSessionsEqual(identity.providerSession, args.providerSession) - } if ( identity.launchToken !== undefined && (args.launchToken === undefined || identity.launchToken !== args.launchToken) ) { + // Why: missing or mismatched launch tokens are stale launch proof even if a + // provider session id was reused by a later manual/mixed Codex run. return false } + if (identity.providerSession !== undefined) { + return providerSessionsEqual(identity.providerSession, args.providerSession) + } if (identity.launchToken !== undefined) { return true } @@ -691,6 +707,26 @@ function getLaunchConfigForEntry( : undefined } +function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -916,6 +952,8 @@ export const createAgentStatusSlice: StateCreator getLaunchConfigForEntry(get(), entry), + getAgentLaunchConfigForStatusMetadata: (metadata) => + getLaunchConfigForStatusMetadata(get(), metadata), clearAgentLaunchConfig: (paneKey) => { set((s) => { diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index 7ed1ff440c3..e1fa10e1993 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyAgentPermissionMode, resolveAgentPermissionModeSummary, + resolveTuiAgentPermissionMode, YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' @@ -48,4 +49,40 @@ describe('tui agent permissions', () => { }) ).toBe('mixed') }) + + it('resolves one Codex yolo launch as yolo', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex, + agentEnv: {} + }) + ).toBe('yolo') + }) + + it('resolves one empty Codex launch as manual', () => { + expect(resolveTuiAgentPermissionMode({ agent: 'codex', agentArgs: '', agentEnv: {} })).toBe( + 'manual' + ) + }) + + it('resolves custom Codex permission arguments as mixed', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: '--ask-for-approval on-request', + agentEnv: {} + }) + ).toBe('mixed') + }) + + it('resolves env-driven yolo launches', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'goose', + agentArgs: '', + agentEnv: YOLO_TUI_AGENT_ENV.goose + }) + ).toBe('yolo') + }) }) diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 09b2902bbd8..9833bfca193 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -71,37 +71,18 @@ function resolveAgentEnvPermissionMode( return sameEnv(env, yoloEnv) ? 'yolo' : 'mixed' } -export function resolveAgentPermissionModeSummary(args: { - agentDefaultArgs?: Partial> | null - agentDefaultEnv?: Partial>> | null -}): AgentPermissionMode { +function combinePermissionModes(modes: AgentPermissionMode[]): AgentPermissionMode { let sawYolo = false let sawManual = false let sawMixed = false - for (const agent of PERMISSION_AGENT_IDS) { - const modes: AgentPermissionMode[] = [] - if (agent in YOLO_TUI_AGENT_ARGS) { - modes.push( - resolveAgentPermissionMode( - normalizeArgs(args.agentDefaultArgs?.[agent]), - YOLO_TUI_AGENT_ARGS[agent] ?? '' - ) - ) - } - if (agent in YOLO_TUI_AGENT_ENV) { - modes.push( - resolveAgentEnvPermissionMode(args.agentDefaultEnv?.[agent], YOLO_TUI_AGENT_ENV[agent]) - ) - } - for (const mode of modes) { - if (mode === 'yolo') { - sawYolo = true - } else if (mode === 'manual') { - sawManual = true - } else { - sawMixed = true - } + for (const mode of modes) { + if (mode === 'yolo') { + sawYolo = true + } else if (mode === 'manual') { + sawManual = true + } else { + sawMixed = true } } @@ -111,6 +92,46 @@ export function resolveAgentPermissionModeSummary(args: { return sawYolo ? 'yolo' : 'manual' } +export function resolveTuiAgentPermissionMode(args: { + agent: TuiAgent + agentArgs?: string | null + agentEnv?: Record | null +}): AgentPermissionMode { + const modes: AgentPermissionMode[] = [] + if (args.agent in YOLO_TUI_AGENT_ARGS) { + modes.push( + resolveAgentPermissionMode( + normalizeArgs(args.agentArgs), + YOLO_TUI_AGENT_ARGS[args.agent] ?? '' + ) + ) + } + if (args.agent in YOLO_TUI_AGENT_ENV) { + modes.push(resolveAgentEnvPermissionMode(args.agentEnv, YOLO_TUI_AGENT_ENV[args.agent])) + } + + return combinePermissionModes(modes) +} + +export function resolveAgentPermissionModeSummary(args: { + agentDefaultArgs?: Partial> | null + agentDefaultEnv?: Partial>> | null +}): AgentPermissionMode { + const modes: AgentPermissionMode[] = [] + + for (const agent of PERMISSION_AGENT_IDS) { + modes.push( + resolveTuiAgentPermissionMode({ + agent, + agentArgs: args.agentDefaultArgs?.[agent], + agentEnv: args.agentDefaultEnv?.[agent] + }) + ) + } + + return combinePermissionModes(modes) +} + export function applyAgentPermissionMode(args: { mode: Exclude agentDefaultArgs?: Partial> | null From b847a9df89f01b507143342342c4242d307eb8f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:25:35 -0700 Subject: [PATCH 11/23] Run setup scripts for scheduled automation worktrees (#6236) Co-authored-by: Orca --- .../headless-workspace-create.test.ts | 17 +- .../automations/headless-workspace-create.ts | 2 +- src/main/persistence.test.ts | 45 +++ src/main/persistence.ts | 24 ++ .../runtime/orca-runtime-automations.test.ts | 13 +- src/main/runtime/orca-runtime.test.ts | 110 +++++- src/main/runtime/orca-runtime.ts | 373 +++++++++++------- .../runtime/rpc/methods/automations.test.ts | 10 +- src/main/runtime/rpc/methods/automations.ts | 3 + .../automations/AutomationEditorDialog.tsx | 23 +- .../AutomationEditorDialogFooter.tsx | 29 +- .../AutomationSetupDecisionField.tsx | 117 ++++++ .../automations/AutomationsPage.tsx | 169 +++++++- .../automation-host-client.test.ts | 2 + .../automation-setup-decision.test.ts | 278 +++++++++++++ .../automations/automation-setup-decision.ts | 102 +++++ .../terminal-pane/pty-dispatcher.ts | 157 +------- .../terminal-pane/pty-transport-types.ts | 109 +++++ .../terminal-pane/pty-transport.test.ts | 15 + .../components/terminal-pane/pty-transport.ts | 16 +- .../remote-runtime-pty-transport.ts | 2 +- .../hooks/useAutomationDispatchEvents.test.ts | 314 +++++++++++++++ .../src/hooks/useAutomationDispatchEvents.ts | 97 +++-- src/renderer/src/i18n/locales/en.json | 12 + src/renderer/src/i18n/locales/es.json | 12 + src/renderer/src/i18n/locales/ja.json | 12 + src/renderer/src/i18n/locales/ko.json | 12 + src/renderer/src/i18n/locales/zh.json | 12 + ...unch-worktree-background-terminals.test.ts | 291 ++++++++++++++ .../launch-worktree-background-terminals.ts | 294 ++++++++++++++ .../src/lib/worktree-activation.test.ts | 42 ++ src/renderer/src/lib/worktree-activation.ts | 46 ++- .../src/lib/worktree-creation-flow.test.ts | 3 +- .../src/lib/worktree-creation-flow.ts | 3 +- src/shared/automations-types.ts | 5 +- 35 files changed, 2400 insertions(+), 371 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationSetupDecisionField.tsx create mode 100644 src/renderer/src/components/automations/automation-setup-decision.test.ts create mode 100644 src/renderer/src/components/automations/automation-setup-decision.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-transport-types.ts create mode 100644 src/renderer/src/hooks/useAutomationDispatchEvents.test.ts create mode 100644 src/renderer/src/lib/launch-worktree-background-terminals.test.ts create mode 100644 src/renderer/src/lib/launch-worktree-background-terminals.ts diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts index 3e247af5046..b29496eb283 100644 --- a/src/main/automations/headless-workspace-create.test.ts +++ b/src/main/automations/headless-workspace-create.test.ts @@ -38,6 +38,7 @@ const automation: Automation = { workspaceMode: 'new_per_run', workspaceId: null, baseBranch: 'origin/main', + setupDecision: 'skip', reuseSession: false, timezone: 'UTC', rrule: 'FREQ=DAILY', @@ -67,7 +68,7 @@ describe('headless automation workspace create args', () => { repoSelector: 'repo-1', name: 'auto-nightly-review-run-20260102T0304', baseBranch: 'origin/main', - setupDecision: 'inherit', + setupDecision: 'skip', activate: false, createdWithAgent: 'codex', startupAgent: 'codex', @@ -88,4 +89,18 @@ describe('headless automation workspace create args', () => { } }) }) + + it('falls back to skip for legacy automations without a saved setup decision', () => { + const args = buildHeadlessAutomationWorktreeCreateArgs({ + automation: { ...automation, setupDecision: undefined }, + run: { + id: 'run-1', + title: 'Nightly review run', + scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + repo + }) + + expect(args.setupDecision).toBe('skip') + }) }) diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts index 497ad85573b..7e6e2f43e18 100644 --- a/src/main/automations/headless-workspace-create.ts +++ b/src/main/automations/headless-workspace-create.ts @@ -36,7 +36,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({ repoSelector: repo.id, name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), baseBranch: automation.baseBranch ?? undefined, - setupDecision: 'inherit', + setupDecision: automation.setupDecision ?? 'skip', activate: false, createdWithAgent: automation.agentId, startupAgent: automation.agentId, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 44c9a9caa69..9904cc54adc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1366,6 +1366,51 @@ describe('Store', () => { expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) + it('persists setup decisions only for new-per-run automations', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const newPerRun = store.createAutomation({ + name: 'Fresh', + prompt: 'Run checks', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + setupDecision: 'run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const existing = store.createAutomation({ + name: 'Reuse', + prompt: 'Summarize changes', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + setupDecision: 'run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + + expect(newPerRun.setupDecision).toBe('run') + expect(existing.setupDecision).toBeUndefined() + + const skipped = store.updateAutomation(newPerRun.id, { setupDecision: 'skip' }) + const switchedToExisting = store.updateAutomation(newPerRun.id, { + workspaceMode: 'existing', + workspaceId: 'wt1' + }) + + expect(skipped.setupDecision).toBe('skip') + expect(switchedToExisting.setupDecision).toBeUndefined() + expect( + store.updateAutomation(existing.id, { workspaceMode: 'new_per_run', setupDecision: 'run' }) + .setupDecision + ).toBe('run') + }) + it('derives automation source and run contexts from the project host setup', async () => { const store = await createStore() store.addRepo( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ce475f0487f..e6bca9b3119 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -731,13 +731,27 @@ function normalizeAutomationPrecheckResult( } function normalizeAutomationSessionReuse(automation: Automation): Automation { + const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode( + automation.workspaceMode, + automation.setupDecision + ) return { ...automation, precheck: normalizeAutomationPrecheck(automation.precheck), + setupDecision, reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true } } +function normalizeAutomationSetupDecisionForWorkspaceMode( + workspaceMode: Automation['workspaceMode'], + setupDecision: unknown +): Automation['setupDecision'] { + return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip') + ? setupDecision + : undefined +} + function getAutomationContextsForRepo( repo: Repo | undefined, projectHostSetups: readonly ProjectHostSetup[] @@ -4061,6 +4075,10 @@ export class Store { workspaceMode: input.workspaceMode, workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null, baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null, + setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode( + input.workspaceMode, + input.setupDecision + ), reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false, timezone: input.timezone, rrule: input.rrule, @@ -4128,6 +4146,12 @@ export class Store { ? (updates.baseBranch ?? null) : (current.baseBranch ?? null) : null, + setupDecision: + workspaceMode === 'new_per_run' + ? Object.hasOwn(updates, 'setupDecision') + ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision) + : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision) + : undefined, reuseSession: workspaceMode === 'existing' ? (updates.reuseSession ?? current.reuseSession ?? false) diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 9920e5c12ff..0c6d32d215d 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -82,6 +82,7 @@ describe('OrcaRuntimeService automation methods', () => { agentId: 'codex', repo: 'repo-1', workspaceMode: 'new_per_run', + setupDecision: 'skip', rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: 1 }) @@ -94,7 +95,8 @@ describe('OrcaRuntimeService automation methods', () => { agentId: 'codex', projectId: 'repo-1', workspaceMode: 'new_per_run', - workspaceId: null + workspaceId: null, + setupDecision: 'skip' }) ) expect(automation.id).toBe('auto-1') @@ -127,6 +129,15 @@ describe('OrcaRuntimeService automation methods', () => { expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) }) + it('passes setup decision updates through the shared store', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + await runtime.updateAutomation('auto-1', { setupDecision: 'run' }) + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { setupDecision: 'run' }) + }) + it('passes session reuse updates for existing-workspace automations', async () => { const existing = { ...existingAutomation, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5f8cd7fc52b..edc10ea87f2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ import { createSetupRunnerScript, getEffectiveHooks, getEffectiveHooksFromConfig, + getDefaultTabsLaunch, hasHooksFile, loadHooks, parseOrcaYaml, @@ -526,6 +527,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(getEffectiveHooksFromConfig).mockReset() + vi.mocked(getDefaultTabsLaunch).mockReset() vi.mocked(loadHooks).mockReset() vi.mocked(hasHooksFile).mockReset() vi.mocked(parseOrcaYaml).mockReset() @@ -534,6 +536,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null) + vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined) vi.mocked(loadHooks).mockReturnValue(null) vi.mocked(hasHooksFile).mockReturnValue(false) vi.mocked(parseOrcaYaml).mockReturnValue(null) @@ -3391,7 +3394,11 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) + // Why: runtime now provisions setup itself (fire-and-forget) and omits it + // from the RPC result so the caller does not double-spawn. The async spawn + // means we wait for both the agent and setup PTYs before asserting. expect(result.setup).toBeUndefined() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -3539,7 +3546,7 @@ describe('OrcaRuntimeService', () => { setupDecision: 'run' }) - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const initialEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expect(setupEnv.ORCA_TAB_ID).toBe(initialEnv.ORCA_TAB_ID) @@ -18776,6 +18783,7 @@ describe('OrcaRuntimeService', () => { }) expect(result.setup).toBeUndefined() expect(activateWorktree).not.toHaveBeenCalled() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -18892,7 +18900,10 @@ describe('OrcaRuntimeService', () => { expect(createSetupRunnerScript).toHaveBeenCalled() expect(runHook).not.toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(2) + // Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns + // on a later tick. The wait-for-setup guarantee is enforced by the shell + // nonce/marker in the wrapped commands below, not by JS spawn ordering. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) @@ -18963,7 +18974,8 @@ describe('OrcaRuntimeService', () => { startup: { command: 'claude' } }) - expect(spawn).toHaveBeenCalledTimes(2) + // Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) expect(spawn).toHaveBeenNthCalledWith( 2, @@ -19108,7 +19120,7 @@ describe('OrcaRuntimeService', () => { }) expect(activateWorktree).not.toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expectStablePaneKeyEnv(mainEnv) @@ -19188,6 +19200,94 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledTimes(1) }) + it('materializes default tabs for inactive local managed worktree creates', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-default-dev' }) + .mockResolvedValueOnce({ id: 'pty-default-test' }) + const revealTerminalSession = vi + .fn() + .mockResolvedValueOnce({ tabId: 'tab-default-dev' }) + .mockResolvedValueOnce({ tabId: 'tab-default-test' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs') + vi.mocked(getDefaultTabsLaunch).mockReturnValue({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-default-tabs', + head: 'def', + branch: 'runtime-default-tabs', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-default-tabs', + setupDecision: 'run' + }) + + expect(result.defaultTabs).toEqual({ + runCommands: true, + tabs: [ + { title: 'Dev', command: 'pnpm dev' }, + { title: 'Test', command: 'pnpm test' } + ] + }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn.mock.calls[0]![0]).toMatchObject({ command: 'pnpm dev' }) + expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'pnpm test' }) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 1, + result.worktree.id, + expect.objectContaining({ title: 'Dev', activate: false }) + ) + expect(revealTerminalSession).toHaveBeenNthCalledWith( + 2, + result.worktree.id, + expect.objectContaining({ title: 'Test', activate: false }) + ) + }) + it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} @@ -19656,7 +19756,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(spawn).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, expect.objectContaining({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2f816d61505..affb23d6a77 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2305,6 +2305,7 @@ export class OrcaRuntimeService { workspaceMode: target.workspaceMode, workspaceId: target.workspaceId, baseBranch: input.baseBranch, + setupDecision: input.setupDecision, reuseSession: input.reuseSession, timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, rrule: input.rrule, @@ -2341,6 +2342,9 @@ export class OrcaRuntimeService { if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { patch.baseBranch = updates.baseBranch } + if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) { + patch.setupDecision = updates.setupDecision + } if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) { patch.reuseSession = updates.reuseSession } @@ -11937,6 +11941,102 @@ export class OrcaRuntimeService { }) } + private async createDefaultTabTerminals( + worktreeSelector: string, + worktreeId: string, + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined + ): Promise { + if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { + return [] + } + const handles: string[] = [] + for (const template of defaultTabs.tabs) { + try { + const command = template.command?.trim() + const terminal = await this.createTerminal(worktreeSelector, { + ...(template.title ? { title: template.title } : {}), + ...(command && defaultTabs.runCommands ? { command } : {}) + }) + handles.push(terminal.handle) + if (template.color && terminal.tabId) { + await this.setMobileSessionTabProps(`id:${worktreeId}`, { + tabId: terminal.tabId, + color: template.color + }) + } + } catch (error) { + console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) + } + } + return handles + } + + private async provisionManagedWorktreeTerminals(args: { + worktreeSelector: string + worktreeId: string + worktreePath: string + setup?: CreateWorktreeResult['setup'] + defaultTabs?: CreateWorktreeResult['defaultTabs'] + primaryTerminalHandle?: string | null + hasStartupTerminal: boolean + setupCommandPlatform: 'windows' | 'posix' + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + }): Promise<{ setupSpawned: boolean }> { + if (!this.ptyController?.spawn) { + return { setupSpawned: false } + } + let setupSpawned = false + try { + const defaultTabHandles = await this.createDefaultTabTerminals( + args.worktreeSelector, + args.worktreeId, + args.defaultTabs + ) + let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null + const setupLaunchMode = + ( + this.requireStore().getSettings() as Partial< + Pick + > + ).setupScriptLaunchMode ?? 'new-tab' + if (!args.hasStartupTerminal && !primaryTerminalHandle) { + const terminal = await this.createTerminal(args.worktreeSelector) + primaryTerminalHandle = terminal.handle + } + if (args.setup) { + const setupCommand = + args.wrappedSetupCommand ?? + buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) + const shouldSplitSetup = + primaryTerminalHandle && + (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') + await (shouldSplitSetup + ? this.splitTerminal(primaryTerminalHandle!, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: args.setup.envVars, + activate: false + }) + : this.createTerminal(args.worktreeSelector, { + title: 'Setup', + command: setupCommand, + env: args.setup.envVars + })) + setupSpawned = true + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn( + `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` + ) + } + return { setupSpawned } + } + private async waitForStartupFollowupReady( handle: string, expectedProcess: string @@ -12772,6 +12872,10 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false + // Why: tracks whether runtime itself launched the setup script (via + // provisionManagedWorktreeTerminals). When true, renderer activation and the + // RPC return value must omit setup so the client does not spawn it a second + // time. Mirrors the wait-for-agent setup contract from #6298. let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null @@ -12839,53 +12943,39 @@ export class OrcaRuntimeService { console.warn(`[worktree-create] ${warning}`) } } - if (didSpawnStartup && setup && this.ptyController?.spawn) { - try { - // Why: reveal-on-adopt can create the startup tab before renderer - // activation handles setup. Honor the same split-vs-tab setting here - // because renderer activation will skip setup once the startup tab exists. - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { - if (!startupTerminalHandle) { - throw new Error('startup_terminal_missing') - } - await this.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false - }) - } else { - await this.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars - }) - } - didSpawnSetup = true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}` - : `Failed to create the setup terminal for ${worktreePath}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } if (shouldActivate) { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. + const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: once runtime spawned the startup PTY, renderer activation may see + // an existing terminal and skip setup/default tabs. Await provisioning so + // a failed setup spawn falls back to renderer activation (which still + // carries the wrapped command for retry); #6298's wait-for-setup + // guarantee is enforced by the shell marker, not by spawn timing. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: setup + ? isWindowsAbsolutePathLike(setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + } + // Why: when runtime spawned setup, omit it from activation. When setup + // spawn failed, fall through with the wrapped command so renderer + // activation retries it. const activationSetup = didSpawnSetup ? undefined : setup @@ -12896,54 +12986,50 @@ export class OrcaRuntimeService { : {}) } : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, worktree.id, activationSetup, effectiveStartup, - defaultTabs + activationDefaultTabs ) } else { - this.notifyActivateWorktree(repo.id, worktree.id, activationSetup, undefined, defaultTabs) + this.notifyActivateWorktree( + repo.id, + worktree.id, + activationSetup, + undefined, + activationDefaultTabs + ) + } + } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + void this.provisionManagedWorktreeTerminals({ + worktreeSelector: `id:${worktree.id}`, + worktreeId: worktree.id, + worktreePath, + ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: setup + ? isWindowsAbsolutePathLike(setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + // Why: runtime owns setup spawning here, so the RPC result must omit setup + // to keep the headless/mobile caller from launching it a second time. + if (setup) { + didSpawnSetup = true } } else if (this.ptyController?.spawn) { try { - let initialTerminalHandle: string | null = null - if (!didSpawnStartup) { - const terminal = await this.createTerminal(`id:${worktree.id}`) - initialTerminalHandle = terminal.handle - } - if (setup && !didSpawnSetup) { - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ) - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - const shouldSplitSetup = - initialTerminalHandle && - (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') - await (shouldSplitSetup - ? this.splitTerminal(initialTerminalHandle!, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false - }) - : this.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars - })) - didSpawnSetup = true - } + await this.createTerminal(`id:${worktree.id}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -13093,6 +13179,8 @@ export class OrcaRuntimeService { let warning = result.warning let didSpawnStartup = false + // Why: same no-double-spawn contract as the local path — once runtime + // provisions setup, omit it from activation and the RPC result. let didSpawnSetup = false let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null @@ -13156,48 +13244,35 @@ export class OrcaRuntimeService { } } - if (didSpawnStartup && result.setup && this.ptyController?.spawn) { - try { - // Why: remote/mobile task creates spawn the agent terminal in runtime, - // so renderer activation never receives the setup payload. Runtime - // must apply the same user-selected split-vs-tab setup placement. - const setupCommand = - wrappedSetupCommandStr ?? - buildSetupRunnerCommand( - result.setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { - if (!startupTerminalHandle) { - throw new Error('startup_terminal_missing') - } - await this.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: result.setup.envVars, - activate: false - }) - } else { - await this.createTerminal(`path:${result.worktree.path}`, { - title: 'Setup', - command: setupCommand, - env: result.setup.envVars - }) - } - didSpawnSetup = true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the setup terminal for ${result.worktree.path}: ${message}` - : `Failed to create the setup terminal for ${result.worktree.path}: ${message}` - } - } - const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { + const runtimeWillProvisionTerminals = + didSpawnStartup && Boolean(result.setup || result.defaultTabs) + if (runtimeWillProvisionTerminals) { + // Why: remote/mobile task creates spawn the agent terminal in runtime, + // so renderer activation may not materialize setup/default tabs. Await so + // a failed setup spawn falls back to renderer activation for retry. + const provisioned = await this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: result.setup + ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + // Why: carry the wait-for-agent wrapped setup command (#6298) so the + // remote Setup tab runs the same script the sequenced agent waits on. + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + didSpawnSetup = provisioned.setupSpawned + } + // Why: omit setup from activation when runtime spawned it; on spawn + // failure fall through with the wrapped command so renderer retries. const activationSetup = didSpawnSetup ? undefined : result.setup @@ -13208,13 +13283,14 @@ export class OrcaRuntimeService { : {}) } : undefined + const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, result.worktree.id, activationSetup, args.startup, - result.defaultTabs + activationDefaultTabs ) } else { this.notifyActivateWorktree( @@ -13222,36 +13298,41 @@ export class OrcaRuntimeService { result.worktree.id, activationSetup, undefined, - result.defaultTabs + activationDefaultTabs ) } } - if (!args.startup && !shouldActivate && this.ptyController?.spawn) { + if ( + !shouldActivate && + this.ptyController?.spawn && + (result.setup || result.defaultTabs || didSpawnStartup) + ) { + // Why: inactive terminal materialization matches normal worktree creation, + // but setup/default tab failures must not gate automation dispatch. + void this.provisionManagedWorktreeTerminals({ + worktreeSelector: `path:${result.worktree.path}`, + worktreeId: result.worktree.id, + worktreePath: result.worktree.path, + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + primaryTerminalHandle: startupTerminalHandle, + hasStartupTerminal: didSpawnStartup, + setupCommandPlatform: result.setup + ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) + ? 'windows' + : 'posix' + : 'posix', + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + }) + // Why: runtime owns setup spawning here, so omit setup from the RPC result + // to keep the headless/mobile caller from launching it a second time. + if (result.setup) { + didSpawnSetup = true + } + } else if (!shouldActivate && this.ptyController?.spawn) { try { - const terminal = await this.createTerminal(`path:${result.worktree.path}`) - if (result.setup && !didSpawnSetup) { - const setupCommand = buildSetupRunnerCommand( - result.setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') - ) - const setupLaunchMode = - (this.store.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - await (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal' - ? this.splitTerminal(terminal.handle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: result.setup.envVars, - activate: false - }) - : this.createTerminal(`path:${result.worktree.path}`, { - title: 'Setup', - command: setupCommand, - env: result.setup.envVars - })) - didSpawnSetup = true - } + await this.createTerminal(`path:${result.worktree.path}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index fe918875d22..c5a8cf37440 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -48,6 +48,7 @@ describe('automation RPC methods', () => { providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' } }, repo: 'repo-1', + setupDecision: 'skip', reuseSession: true, rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', dtstart: 1 @@ -58,6 +59,7 @@ describe('automation RPC methods', () => { id: 'auto-1', updates: { enabled: false, + setupDecision: 'run', reuseSession: false, rrule: '0 9 * * 1-5', dtstart: 2 @@ -79,12 +81,18 @@ describe('automation RPC methods', () => { runContext: expect.objectContaining({ hostId: 'runtime:gpu' }), sourceContext: expect.objectContaining({ hostId: 'local' }), repo: 'repo-1', + setupDecision: 'skip', reuseSession: true }) ) expect(runtime.updateAutomation).toHaveBeenCalledWith( 'auto-1', - expect.objectContaining({ enabled: false, reuseSession: false, rrule: '0 9 * * 1-5' }) + expect.objectContaining({ + enabled: false, + setupDecision: 'run', + reuseSession: false, + rrule: '0 9 * * 1-5' + }) ) expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 858e80596d0..d4e74b719fe 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -22,6 +22,7 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { }) const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() +const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { const hostId = normalizeExecutionHostId(value) if (!hostId) { @@ -109,6 +110,7 @@ const AutomationCreate = z.object({ workspace: OptionalString, workspaceMode: AutomationWorkspaceMode, baseBranch: OptionalPlainString, + setupDecision: SetupDecision, reuseSession: OptionalBoolean, timezone: OptionalString, rrule: AutomationSchedule, @@ -129,6 +131,7 @@ const AutomationUpdateFields = z.object({ workspaceMode: AutomationWorkspaceMode, // Why: update patches distinguish omitted from null so callers can clear a saved base branch. baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, reuseSession: OptionalBoolean, timezone: OptionalString, rrule: AutomationSchedule.optional(), diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 6d113de78a4..f595290d2a2 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -6,7 +6,15 @@ import type { AutomationSchedulePreset, AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types' +import type { + GlobalSettings, + OrcaHooks, + ProjectHostSetup, + Repo, + SetupDecision, + TuiAgent, + Worktree +} from '../../../../shared/types' import { isValidAutomationCronSchedule, isValidAutomationSchedule @@ -32,6 +40,7 @@ export type AutomationDraft = { workspaceMode: AutomationWorkspaceMode workspaceId: string baseBranch: string + setupDecision?: Extract reuseSession: boolean precheckCommand: string precheckTimeoutSeconds: string @@ -53,6 +62,9 @@ type AutomationEditorDialogProps = { canSave: boolean createTarget: AutomationCreateTarget repos: Repo[] + projectHostSetups: ProjectHostSetup[] + automationYamlHooksByRepoKey: Record + getAutomationHooksCacheKey: (repoId: string) => string repoMap: Map worktrees: Worktree[] settings: GlobalSettings | null @@ -62,6 +74,7 @@ type AutomationEditorDialogProps = { onCreateTargetChange: (target: AutomationCreateTarget) => void onOpenChange: (open: boolean) => void onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void onApplyTemplate: (template: AutomationTemplate) => void onSave: () => void } @@ -74,6 +87,9 @@ export function AutomationEditorDialog({ canSave, createTarget, repos, + projectHostSetups, + automationYamlHooksByRepoKey, + getAutomationHooksCacheKey, repoMap, worktrees, settings, @@ -83,6 +99,7 @@ export function AutomationEditorDialog({ onCreateTargetChange, onOpenChange, onDraftChange, + onSetupDecisionTouched, onApplyTemplate, onSave }: AutomationEditorDialogProps): React.JSX.Element { @@ -159,6 +176,9 @@ export function AutomationEditorDialog({ isSaving={isSaving} canSave={canSave} repos={repos} + projectHostSetups={projectHostSetups} + automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} + getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} worktrees={worktrees} settings={settings} @@ -170,6 +190,7 @@ export function AutomationEditorDialog({ onProjectChange={onProjectChange} getRepoHostLabel={getRepoHostLabel} onDraftChange={onDraftChange} + onSetupDecisionTouched={onSetupDecisionTouched} onOpenChange={onOpenChange} onSave={onSave} /> diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 22edb5f40a2..62a7be7aab7 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -7,11 +7,18 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' -import type { GlobalSettings, Repo, Worktree } from '../../../../shared/types' +import type { + GlobalSettings, + OrcaHooks, + ProjectHostSetup, + Repo, + Worktree +} from '../../../../shared/types' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { Field } from './automation-page-parts' import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField' import { AutomationSessionField } from './AutomationSessionField' +import { AutomationSetupDecisionField } from './AutomationSetupDecisionField' import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' import AutomationProjectCombobox from './AutomationProjectCombobox' @@ -25,6 +32,9 @@ type AutomationEditorDialogFooterProps = { isSaving: boolean canSave: boolean repos: Repo[] + projectHostSetups: ProjectHostSetup[] + automationYamlHooksByRepoKey: Record + getAutomationHooksCacheKey: (repoId: string) => string repoMap: Map worktrees: Worktree[] settings: GlobalSettings | null @@ -36,6 +46,7 @@ type AutomationEditorDialogFooterProps = { onProjectChange: (projectId: string) => void getRepoHostLabel?: (repo: Repo) => string | null | undefined onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void onOpenChange: (open: boolean) => void onSave: () => void } @@ -48,6 +59,9 @@ export function AutomationEditorDialogFooter({ isSaving, canSave, repos, + projectHostSetups, + automationYamlHooksByRepoKey, + getAutomationHooksCacheKey, repoMap, worktrees, settings, @@ -59,6 +73,7 @@ export function AutomationEditorDialogFooter({ onProjectChange, getRepoHostLabel, onDraftChange, + onSetupDecisionTouched, onOpenChange, onSave }: AutomationEditorDialogFooterProps): React.JSX.Element { @@ -233,6 +248,18 @@ export function AutomationEditorDialogFooter({ onDraftChange={onDraftChange} /> + {/* Why: a full-width row below the columned controls — the setup + switch is a per-automation preference, not a peer of the compact + column pickers, so it reads cleaner spanning the dialog. */} +
diff --git a/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx new file mode 100644 index 00000000000..2731dba8164 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getVisibleAutomationSetupDecision } from './automation-setup-decision' +import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog' +import type { OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' + +type AutomationSetupDecisionFieldProps = { + createTarget: AutomationCreateTarget + draft: AutomationDraft + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null + onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void + onSetupDecisionTouched: () => void +} + +export function AutomationSetupDecisionField({ + createTarget, + draft, + repos, + projectHostSetups, + yamlHooks, + onDraftChange, + onSetupDecisionTouched +}: AutomationSetupDecisionFieldProps): React.JSX.Element | null { + const [advancedOpen, setAdvancedOpen] = React.useState(false) + const defaultDecision = getVisibleAutomationSetupDecision({ + createTarget, + workspaceMode: draft.workspaceMode, + repoId: draft.projectId, + repos, + projectHostSetups, + yamlHooks + }) + if (!defaultDecision) { + return null + } + const checked = (draft.setupDecision ?? defaultDecision) === 'run' + const label = translate( + 'auto.components.automations.AutomationSetupDecisionField.5a7863909c', + 'Run setup for each new workspace' + ) + return ( + // Why: the setup choice is a power-user knob, so tuck it behind the same + // Advanced disclosure grammar the New Workspace composer uses. +
+ +
+
+
+ +

+ {translate( + 'auto.components.automations.AutomationSetupDecisionField.874b72195b', + "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + )} +

+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 935b112d44f..322f2170f4d 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -64,7 +64,7 @@ import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/proto import type { PreflightStatus } from '../../../../preload/api-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { buildAutomationCronSchedule, @@ -107,9 +107,17 @@ import { } from './AutomationEditorDialog' import { AutomationRunPageFrame } from './AutomationRunPageFrame' import { AutomationRunHistory } from './AutomationRunHistory' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getRepoBackedProviderAvailability, type RuntimeProviderPreflightStatus @@ -456,6 +464,15 @@ export default function AutomationsPage(): React.JSX.Element { const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) + const setupDecisionPolicyDefaultRef = useRef(undefined) + const setupDecisionDefaultSignatureRef = useRef(null) + const setupDecisionTouchedRef = useRef(false) + const automationHookCheckPromisesRef = useRef< + Map> + >(new Map()) + const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState< + Record + >({}) const [draft, setDraft] = useState({ name: '', prompt: '', @@ -464,6 +481,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: '', baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -541,6 +559,76 @@ export default function AutomationsPage(): React.JSX.Element { }), [selectedAutomationRuns.runs, worktreeMap] ) + const getDraftSetupDecisionDefault = useCallback( + ( + candidate: Pick + ): AutomationDraft['setupDecision'] => { + const settingsForRepo = getSettingsForRepoRuntimeOwner( + { repos, settings }, + candidate.projectId + ) + const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + return getVisibleAutomationSetupDecision({ + createTarget, + workspaceMode: candidate.workspaceMode, + repoId: candidate.projectId, + repos, + projectHostSetups, + yamlHooks: automationYamlHooksByRepoKey[hookKey] + }) + }, + [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings] + ) + const getAutomationHooksCacheKey = useCallback( + (repoId: string): string => { + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` + }, + [repos, settings] + ) + const loadAutomationYamlHooksForRepo = useCallback( + async (repoId: string): Promise => { + const key = getAutomationHooksCacheKey(repoId) + if (Object.prototype.hasOwnProperty.call(automationYamlHooksByRepoKey, key)) { + return automationYamlHooksByRepoKey[key] ?? null + } + const existingPromise = automationHookCheckPromisesRef.current.get(key) + if (existingPromise) { + return (await existingPromise).hooks + } + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + const promise = checkRuntimeHooks(settingsForRepo, repoId) + .then((result) => ({ + hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), + ok: result.status !== 'error' + })) + .catch(() => ({ hooks: null, ok: false })) + automationHookCheckPromisesRef.current.set(key, promise) + const { hooks, ok } = await promise + automationHookCheckPromisesRef.current.delete(key) + if (!ok) { + return hooks + } + setAutomationYamlHooksByRepoKey((current) => + Object.prototype.hasOwnProperty.call(current, key) ? current : { ...current, [key]: hooks } + ) + return hooks + }, + [automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, settings] + ) + const getDraftSetupDecisionDefaultSignature = useCallback( + (candidate: Pick): string => + [ + createTarget, + candidate.workspaceMode, + candidate.projectId, + getDraftSetupDecisionDefault(candidate) ?? 'none' + ].join(':'), + [createTarget, getDraftSetupDecisionDefault] + ) + const markSetupDecisionTouched = useCallback((): void => { + setupDecisionTouchedRef.current = true + }, []) // Why: keep the detail tab scoped even while the selected-run fetch catches up. const selectedRunsSource = selected && selectedAutomationRuns.automationId === selected.id @@ -1102,6 +1190,50 @@ export default function AutomationsPage(): React.JSX.Element { } }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + useEffect(() => { + if ( + !createOpen || + createTarget !== 'orca' || + draft.workspaceMode !== 'new_per_run' || + !draft.projectId + ) { + return + } + void loadAutomationYamlHooksForRepo(draft.projectId) + }, [ + createOpen, + createTarget, + draft.projectId, + draft.workspaceMode, + loadAutomationYamlHooksForRepo + ]) + + useEffect(() => { + if (!createOpen) { + setupDecisionPolicyDefaultRef.current = undefined + setupDecisionDefaultSignatureRef.current = null + setupDecisionTouchedRef.current = false + return + } + const nextDefault = getDraftSetupDecisionDefault(draft) + const nextSignature = getDraftSetupDecisionDefaultSignature(draft) + if (setupDecisionDefaultSignatureRef.current !== nextSignature) { + setupDecisionDefaultSignatureRef.current = nextSignature + setupDecisionTouchedRef.current = false + } + const previousDefault = setupDecisionPolicyDefaultRef.current + setupDecisionPolicyDefaultRef.current = nextDefault + const shouldApplyPolicyDefault = + !setupDecisionTouchedRef.current && + (nextDefault === undefined || + draft.setupDecision === undefined || + draft.setupDecision === previousDefault) + if (!shouldApplyPolicyDefault || draft.setupDecision === nextDefault) { + return + } + setDraft((current) => ({ ...current, setupDecision: nextDefault })) + }, [createOpen, draft, getDraftSetupDecisionDefault, getDraftSetupDecisionDefaultSignature]) + const applyTemplateToDraft = useCallback((template: AutomationTemplate): void => { setDraft((current) => ({ ...current, @@ -1124,6 +1256,7 @@ export default function AutomationsPage(): React.JSX.Element { ...current, agentId: 'hermes', workspaceMode: 'existing', + setupDecision: undefined, reuseSession: false })) } @@ -1143,6 +1276,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: target.workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1197,6 +1331,10 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: latest.workspaceMode, workspaceId: latest.workspaceId ?? '', baseBranch: latest.baseBranch ?? '', + setupDecision: getAutomationSetupDecisionDraftValue({ + workspaceMode: latest.workspaceMode, + persistedSetupDecision: latest.setupDecision + }), reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession, precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), @@ -1244,6 +1382,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1450,6 +1589,28 @@ export default function AutomationsPage(): React.JSX.Element { repos, projectHostSetups }) + let setupDecision = resolveAutomationSetupDecisionForSave({ + createTarget, + workspaceMode: draft.workspaceMode, + repoId: draft.projectId, + repos, + projectHostSetups, + yamlHooks: + createTarget === 'orca' && draft.workspaceMode === 'new_per_run' + ? await loadAutomationYamlHooksForRepo(draft.projectId) + : null, + draftSetupDecision: draft.setupDecision + }) + if (setupDecision === 'run') { + const trustDecision = await ensureHooksConfirmed( + useAppStore.getState(), + draft.projectId, + 'setup' + ) + if (trustDecision === 'skip') { + setupDecision = 'skip' + } + } if (!runContext) { toast.error( translate( @@ -1482,6 +1643,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, missedRunGraceMinutes @@ -1508,6 +1670,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, rrule, @@ -2035,6 +2198,9 @@ export default function AutomationsPage(): React.JSX.Element { isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} repos={repos} + projectHostSetups={projectHostSetups} + automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} + getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} worktrees={worktrees} settings={settings} @@ -2044,6 +2210,7 @@ export default function AutomationsPage(): React.JSX.Element { onCreateTargetChange={handleCreateTargetChange} onOpenChange={setCreateOpen} onDraftChange={setDraft} + onSetupDecisionTouched={markSetupDecisionTouched} onApplyTemplate={applyTemplateToDraft} onSave={() => void saveAutomation()} /> diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index e77ee46ff62..e9a5bc03bc2 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -95,6 +95,7 @@ describe('automation host client', () => { projectId: automation.projectId, workspaceMode: automation.workspaceMode, workspaceId: null, + setupDecision: 'run', timezone: automation.timezone, rrule: automation.rrule, dtstart: automation.dtstart @@ -115,6 +116,7 @@ describe('automation host client', () => { expect.objectContaining({ repo: 'repo-1', workspace: undefined, + setupDecision: 'run', runContext: automation.runContext }), { timeoutMs: 15_000 } diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts new file mode 100644 index 00000000000..b46d76da980 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: 'pnpm install', archive: '' } + } +} + +function setup(overrides: Partial = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('automation setup decision defaults', () => { + it.each([ + ['run-by-default', 'run'], + ['skip-by-default', 'skip'], + ['ask', 'skip'] + ] as const)('maps %s setup policy to %s', (setupRunPolicy, expectedDecision) => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy, + scripts: { setup: 'pnpm install', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null + }) + ).toBe(expectedDecision) + }) + + it('hides setup choice outside Orca new-run automations with setup', () => { + const baseArgs = { + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [], + yamlHooks: null + } + + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'existing' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'hermes', + workspaceMode: 'new_per_run' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'new_per_run', + repos: [ + { + ...repo, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + } + ] + }) + ).toBeUndefined() + }) + + it('uses ready project-host setup hook settings before repo fallback', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [ + setup({ + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: 'bun install', archive: '' } + } + }) + ], + yamlHooks: null + }) + ).toBe('skip') + }) + + it('shows setup choice for shared orca.yaml setup and default tabs', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: { setup: 'pnpm install' }, + defaultTabs: [] + } + }) + ).toBe('run') + + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: {}, + defaultTabs: [{ title: 'Dev', command: 'pnpm dev' }] + } + }) + ).toBe('skip') + }) + + it('omits setup decision when no setup source is visible', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'ask', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + }) + + it('fails closed when saving before shared hook inspection is available', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + draftSetupDecision: undefined + }) + ).toBe('skip') + }) + + it('loads legacy new-run automations as skip when editing', () => { + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: undefined + }) + ).toBe('skip') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: 'run' + }) + ).toBe('run') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'existing', + persistedSetupDecision: undefined + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/automations/automation-setup-decision.ts b/src/renderer/src/components/automations/automation-setup-decision.ts new file mode 100644 index 00000000000..f565e439f34 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.ts @@ -0,0 +1,102 @@ +import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' +import type { + OrcaHooks, + ProjectHostSetup, + Repo, + SetupDecision, + SetupRunPolicy +} from '../../../../shared/types' +import { getSetupConfig } from '@/lib/new-workspace' + +type AutomationSetupSource = { + setupScript: string + setupRunPolicy: SetupRunPolicy +} + +function getAutomationSetupSource( + repoId: string, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[], + yamlHooks: OrcaHooks | null | undefined +): AutomationSetupSource | null { + const setup = projectHostSetups.find( + (candidate) => candidate.repoId === repoId && candidate.setupState === 'ready' + ) + const repo = repos.find((candidate) => candidate.id === repoId) + const hookSettings = setup?.hookSettings ?? repo?.hookSettings + const setupConfig = getSetupConfig( + hookSettings ? { hookSettings } : repo, + yamlHooks === undefined ? null : yamlHooks + ) + if (!setupConfig) { + return null + } + return { + setupScript: setupConfig.command, + setupRunPolicy: hookSettings?.setupRunPolicy ?? 'run-by-default' + } +} + +export function getAutomationSetupDefaultDecision( + source: Pick | null +): Extract | undefined { + if (!source) { + return undefined + } + return source.setupRunPolicy === 'run-by-default' ? 'run' : 'skip' +} + +export function getVisibleAutomationSetupDecision(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + return getAutomationSetupDefaultDecision( + getAutomationSetupSource(args.repoId, args.repos, args.projectHostSetups, args.yamlHooks) + ) +} + +export function resolveAutomationSetupDecisionForSave(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null + draftSetupDecision: Extract | undefined +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + + const visibleDefault = getVisibleAutomationSetupDecision(args) + if (visibleDefault) { + return args.draftSetupDecision ?? visibleDefault + } + + if (args.yamlHooks === undefined) { + // Why: automations cannot pause later for an orca.yaml trust prompt; when + // hook inspection is unavailable, fail closed instead of inheriting setup. + return 'skip' + } + + return undefined +} + +export function getAutomationSetupDecisionDraftValue(args: { + workspaceMode: AutomationWorkspaceMode + persistedSetupDecision: SetupDecision | undefined +}): Extract | undefined { + if (args.persistedSetupDecision === 'run' || args.persistedSetupDecision === 'skip') { + return args.persistedSetupDecision + } + // Why: legacy new-run automations had no saved choice and now dispatch as + // skip for compatibility; editing them must not silently opt into setup. + return args.workspaceMode === 'new_per_run' ? 'skip' : undefined +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index c1fb2e63059..ec33fcd32dc 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -5,19 +5,15 @@ * co-locating the global handler maps that both the transport factory * and the eager-buffer reconnection logic share. */ -import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { EventProps } from '../../../../shared/telemetry-events' -import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' -import type { TuiAgent } from '../../../../shared/types' import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, - clearPreHandlerPtyState + clearPreHandlerPtyState, + drainPreHandlerPtyData, + drainPreHandlerPtyExit } from './pty-pre-handler-buffer' // ── Singleton PTY event dispatcher ─────────────────────────────────── @@ -30,21 +26,6 @@ export type PtyDataMeta = { rawLength?: number } -export type PtyBufferSnapshot = { - data: string - cols: number - rows: number - seq?: number - source?: 'headless' | 'renderer' - /** True when the snapshot captures an alternate-screen TUI (Claude Code, - * vim). Restore must NOT clear xterm's buffer in that case — the TUI's - * scrollback lives in xterm and a clear destroys scroll-up after a tab - * return. Mirrors the attach-time guard in pty-transport.ts. */ - alternateScreen?: boolean -} - -export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string } - export const ptyDataHandlers = new Map void>() /** Sidecar subscriptions that observe PTY data without owning the primary * handler. Used by features that need to react to the live byte stream @@ -289,126 +270,16 @@ export function registerEagerPtyBuffer( } eagerPtyHandles.set(ptyId, handle) + drainPreHandlerPtyData(ptyId, dataHandler) + // Why: launcher callbacks often capture the returned handle so they can + // flush output on exit. Defer a pre-handler exit by one microtask so the + // caller receives that handle before onExit fires. + queueMicrotask(() => { + if (ptyExitHandlers.get(ptyId) === exitHandler) { + drainPreHandlerPtyExit(ptyId, exitHandler) + } else { + clearPreHandlerPtyState(ptyId) + } + }) return handle } - -// ── PtyTransport interface ─────────────────────────────────────────── -// Why: lives here so pty-transport.ts stays under the 300-line limit. - -export type PtyConnectResult = { - id: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - isAlternateScreen?: boolean - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string } - replay?: string -} - -export type PtyTransport = { - connect: (options: { - url: string - cols?: number - rows?: number - /** Daemon session ID for reattach. When provided, the daemon reconnects - * to an existing session instead of creating a new one. */ - sessionId?: string - command?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - callbacks: { - onConnect?: () => void - onDisconnect?: () => void - onData?: (data: string, meta?: PtyDataMeta) => void - /** Replay bytes from a prior session (eager buffers, attach-time screen - * clears). Routed separately from onData so the renderer can engage - * the replay guard — otherwise xterm auto-replies to embedded query - * sequences leak into the shell. See replay-guard.ts. */ - onReplayData?: (data: string) => void - onStatus?: (shell: string) => void - onError?: (message: string, errors?: string[]) => void - onExit?: (code: number) => void - } - }) => void | Promise - /** Attach to an existing PTY that was eagerly spawned during startup. - * Skips pty:spawn — registers handlers and replays buffered data instead. */ - attach: (options: { - existingPtyId: string - cols?: number - rows?: number - /** When true, the session uses the alternate screen buffer (e.g., Codex). - * Skips the delayed double-resize since a single resize already triggers - * a full TUI repaint without content loss. */ - isAlternateScreen?: boolean - callbacks: { - onConnect?: () => void - onDisconnect?: () => void - onData?: (data: string, meta?: PtyDataMeta) => void - /** See note on connect.callbacks.onReplayData. */ - onReplayData?: (data: string) => void - onStatus?: (shell: string) => void - onError?: (message: string, errors?: string[]) => void - onExit?: (code: number) => void - } - }) => void - disconnect: () => void - sendInput: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise - resize: ( - cols: number, - rows: number, - meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } - ) => boolean - isConnected: () => boolean - getPtyId: () => string | null - getConnectionId?: () => string | null | undefined - getLocalSessionMetadata?: () => LocalPtySessionMetadata | null - serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise - preserve?: () => void - /** Unregister PTY handlers without killing the process for pane remounts. */ - detach?: () => void - destroy?: () => void | Promise -} - -export type IpcPtyTransportOptions = { - cwd?: string - env?: Record - command?: string - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - /** Orca worktree identity for scoped shell history. */ - worktreeId?: string - /** Why: closes the SIGKILL race documented in INVESTIGATION.md by letting - * main patch + sync-flush the (worktreeId, tabId, leafId → ptyId) binding - * before pty:spawn returns. Only the renderer's daemon-host path threads - * these from the calling pane's (tabId, leafId). */ - tabId?: string - leafId?: string - /** Whether renderer-backed runtime reveal should focus the created tab. */ - activate?: boolean - /** Why: mirrors PtySpawnOptions.shellOverride — see types.ts for rationale. */ - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - /** Telemetry metadata for the `agent_started` event. Forwarded verbatim - * to `pty:spawn` so main can fire the event after confirmed launch. The - * IPC handler re-validates the schema; this type is the renderer-side - * contract. */ - telemetry?: EventProps<'agent_started'> - onPtyExit?: (ptyId: string) => void - onTitleChange?: (title: string, rawTitle: string) => void - onPtySpawn?: (ptyId: string) => void - onBell?: () => void - onAgentBecameIdle?: (title: string) => void - onAgentBecameWorking?: () => void - onAgentExited?: () => void - /** Callback for OSC 9999 agent status payloads parsed from PTY output. */ - onAgentStatus?: (payload: ParsedAgentStatusPayload) => void -} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts new file mode 100644 index 00000000000..2f9a84bbd9b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -0,0 +1,109 @@ +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { EventProps } from '../../../../shared/telemetry-events' +import type { TuiAgent } from '../../../../shared/types' +import type { PtyDataMeta } from './pty-dispatcher' + +export type PtyBufferSnapshot = { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + /** True when the snapshot captures an alternate-screen TUI (Claude Code, + * vim). Restore must NOT clear xterm's buffer in that case — the TUI's + * scrollback lives in xterm and a clear destroys scroll-up after a tab + * return. Mirrors the attach-time guard in pty-transport.ts. */ + alternateScreen?: boolean +} + +export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string } + +export type PtyConnectResult = { + id: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + isAlternateScreen?: boolean + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string } + replay?: string +} + +type PtyCallbacks = { + onConnect?: () => void + onDisconnect?: () => void + onData?: (data: string, meta?: PtyDataMeta) => void + onReplayData?: (data: string) => void + onStatus?: (shell: string) => void + onError?: (message: string, errors?: string[]) => void + onExit?: (code: number) => void +} + +export type PtyTransport = { + connect: (options: { + url: string + cols?: number + rows?: number + sessionId?: string + command?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + callbacks: PtyCallbacks + }) => void | Promise + attach: (options: { + existingPtyId: string + cols?: number + rows?: number + isAlternateScreen?: boolean + callbacks: PtyCallbacks + }) => void + disconnect: () => void + sendInput: (data: string) => boolean + sendInputAccepted?: (data: string) => Promise + resize: ( + cols: number, + rows: number, + meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number } + ) => boolean + isConnected: () => boolean + getPtyId: () => string | null + getConnectionId?: () => string | null | undefined + getLocalSessionMetadata?: () => LocalPtySessionMetadata | null + serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise + preserve?: () => void + detach?: () => void + destroy?: () => void | Promise +} + +export type IpcPtyTransportOptions = { + cwd?: string + env?: Record + command?: string + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + activate?: boolean + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + telemetry?: EventProps<'agent_started'> + onPtyExit?: (ptyId: string) => void + onTitleChange?: (title: string, rawTitle: string) => void + onPtySpawn?: (ptyId: string) => void + onBell?: () => void + onAgentBecameIdle?: (title: string) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + onAgentStatus?: (payload: ParsedAgentStatusPayload) => void +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 67a2cdda139..e03ceee6c88 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -611,6 +611,21 @@ describe('createIpcPtyTransport', () => { expect(flushed.endsWith('TAIL$')).toBe(true) }) + it('drains pre-handler data and exit into eager buffers for fast background PTYs', async () => { + const { ensurePtyDispatcher, registerEagerPtyBuffer } = await import('./pty-transport') + const onEagerExit = vi.fn() + + ensurePtyDispatcher() + onData?.({ id: 'pty-fast-setup', data: 'setup failed fast\n' }) + onExit?.({ id: 'pty-fast-setup', code: 1 }) + + const handle = registerEagerPtyBuffer('pty-fast-setup', onEagerExit) + + expect(handle.flush()).toBe('setup failed fast\n') + await Promise.resolve() + expect(onEagerExit).toHaveBeenCalledWith('pty-fast-setup', 1) + }) + it('enforces the eager buffer cap in UTF-8 bytes for multi-byte output', async () => { const { registerEagerPtyBuffer } = await import('./pty-transport') const cap = 512 * 1024 diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index b2afd9f7dd1..92bf9ee5f6e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -22,12 +22,8 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' -import type { - PtyTransport, - IpcPtyTransportOptions, - PtyConnectResult, - PtyDataMeta -} from './pty-dispatcher' +import type { PtyDataMeta } from './pty-dispatcher' +import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' import { createAgentStatusOscProcessor, @@ -44,14 +40,14 @@ export { subscribeToPtyExit, unregisterPtyDataHandlers } from './pty-dispatcher' +export type { EagerPtyHandle } from './pty-dispatcher' export type { - EagerPtyHandle, + IpcPtyTransportOptions, LocalPtySessionMetadata, - PtyTransport, PtyBufferSnapshot, PtyConnectResult, - IpcPtyTransportOptions -} from './pty-dispatcher' + PtyTransport +} from './pty-transport-types' export { extractLastOscTitle } from '../../../../shared/agent-detection' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 534e4b64eb9..a6a946f2ec8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -9,7 +9,7 @@ import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' -import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createPtyOutputProcessor } from './pty-transport' import { unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client' import { diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts new file mode 100644 index 00000000000..b4c848f7e9f --- /dev/null +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -0,0 +1,314 @@ +import type * as ReactModule from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockLaunchAgentBackgroundSession = vi.fn() +const mockLaunchWorktreeBackgroundTerminals = vi.fn() +const mockSubmitPromptToAgentTab = vi.fn() +const mockFindReusableAutomationSession = vi.fn() +const mockObserveExistingAutomationSession = vi.fn() +const mockCreateWorktree = vi.fn() +const mockMarkDispatchResult = vi.fn() +const mockOnDispatchRequested = vi.fn() +const mockRendererReady = vi.fn() + +const setupLaunch = { + runnerScriptPath: '/tmp/setup.sh', + envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' } +} + +const createdWorktree = { + id: 'wt-created', + repoId: 'repo-1', + displayName: 'Automation worktree', + path: '/repo/worktree' +} +type TestWorktree = typeof createdWorktree + +const state = { + activeView: 'terminal' as const, + activeWorktreeId: 'wt-active', + activeTabId: 'tab-active', + activeTabType: 'terminal' as const, + repos: [{ id: 'repo-1', connectionId: null }], + agentStatusByPaneKey: {}, + allWorktrees: vi.fn<() => TestWorktree[]>(() => []), + createWorktree: mockCreateWorktree, + subscribe: vi.fn(() => () => {}), + setActiveView: vi.fn(), + setActiveWorktree: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn() +} + +function makeAutomation(overrides: Record = {}) { + return { + id: 'automation-1', + projectId: 'repo-1', + prompt: 'run this', + precheck: null, + agentId: 'claude', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + setupDecision: 'run', + reuseSession: false, + ...overrides + } +} + +function makeRun() { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Nightly setup run', + scheduledFor: Date.parse('2026-06-24T03:00:00Z'), + trigger: 'scheduled', + workspaceId: null, + workspaceDisplayName: null + } +} + +async function registerAndDispatch(automation = makeAutomation()): Promise { + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + const { useAutomationDispatchEvents: registerAutomationDispatchEvents } = + await import('./useAutomationDispatchEvents') + registerAutomationDispatchEvents() + const handler = mockOnDispatchRequested.mock.calls[0]?.[0] + if (!handler) { + throw new Error('dispatch handler was not registered') + } + await handler({ + automation, + run: makeRun(), + dispatchToken: 'dispatch-token' + }) +} + +vi.mock('@/lib/launch-agent-background-session', () => ({ + launchAgentBackgroundSession: mockLaunchAgentBackgroundSession +})) + +vi.mock('@/lib/launch-worktree-background-terminals', () => ({ + launchWorktreeBackgroundTerminals: mockLaunchWorktreeBackgroundTerminals +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + submitPromptToAgentTab: mockSubmitPromptToAgentTab +})) + +vi.mock('@/lib/automation-session-reuse', () => ({ + findReusableAutomationSession: mockFindReusableAutomationSession +})) + +vi.mock('@/lib/automation-session-observer', () => ({ + observeExistingAutomationSession: mockObserveExistingAutomationSession +})) + +vi.mock('@/components/automations/automation-run-output-snapshot', () => ({ + createAutomationRunOutputSnapshotBuffer: () => ({ + append: vi.fn(), + snapshot: () => '' + }), + selectAutomationRunOutputSnapshot: () => null +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'create-request-id' +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state, + subscribe: vi.fn(() => () => {}) + } +})) + +describe('useAutomationDispatchEvents setup launch', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.clearAllMocks() + state.activeView = 'terminal' + state.activeWorktreeId = 'wt-active' + state.activeTabId = 'tab-active' + state.activeTabType = 'terminal' + state.repos = [{ id: 'repo-1', connectionId: null }] + state.agentStatusByPaneKey = {} + state.allWorktrees.mockReturnValue([]) + mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch }) + mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined) + mockLaunchAgentBackgroundSession.mockResolvedValue({ + tabId: 'agent-tab', + ptyId: 'agent-pty', + startupPlan: {} + }) + mockOnDispatchRequested.mockReturnValue(() => {}) + vi.stubGlobal('window', { + api: { + automations: { + onDispatchRequested: mockOnDispatchRequested, + rendererReady: mockRendererReady, + markDispatchResult: mockMarkDispatchResult, + runPrecheck: vi.fn(), + listRuns: vi.fn().mockResolvedValue([]) + }, + ssh: { + needsPassphrasePrompt: vi.fn().mockResolvedValue(false), + getState: vi.fn().mockResolvedValue({ status: 'connected' }), + connect: vi.fn() + } + }, + dispatchEvent: vi.fn() + }) + }) + + it('starts setup terminal launch without waiting before launching the automation agent', async () => { + const order: string[] = [] + let finishSetupLaunch: (() => void) | null = null + mockLaunchWorktreeBackgroundTerminals.mockImplementation( + () => + new Promise((resolve) => { + finishSetupLaunch = () => { + order.push('setup') + resolve() + } + }) + ) + mockLaunchAgentBackgroundSession.mockImplementation(async () => { + order.push('agent') + return { tabId: 'agent-tab', ptyId: 'agent-pty', startupPlan: {} } + }) + + await registerAndDispatch() + + expect(mockCreateWorktree).toHaveBeenCalled() + expect(mockCreateWorktree.mock.calls[0][3]).toBe('run') + expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({ + worktreeId: 'wt-created', + setup: setupLaunch, + defaultTabs: undefined + }) + expect(state.setActiveView).not.toHaveBeenCalled() + expect(state.setActiveWorktree).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-created', + prompt: 'run this' + }) + ) + expect(order).toEqual(['agent']) + expect(finishSetupLaunch).not.toBeNull() + const completeSetupLaunch = finishSetupLaunch as unknown as () => void + completeSetupLaunch() + await Promise.resolve() + expect(order).toEqual(['agent', 'setup']) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-1', + status: 'dispatched', + workspaceId: 'wt-created', + terminalSessionId: 'agent-tab' + }) + ) + }) + + it('launches setup and default tabs without activating the created worktree', async () => { + const defaultTabs = { + tabs: [{ title: 'Dev', command: 'pnpm dev' }], + runCommands: true + } + mockCreateWorktree.mockResolvedValue({ + worktree: createdWorktree, + setup: setupLaunch, + defaultTabs + }) + + await registerAndDispatch() + + expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({ + worktreeId: 'wt-created', + setup: setupLaunch, + defaultTabs + }) + expect(state.setActiveView).not.toHaveBeenCalled() + expect(state.setActiveWorktree).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-created', + prompt: 'run this' + }) + ) + }) + + it('defaults legacy automations without a setup choice to skipping setup', async () => { + await registerAndDispatch(makeAutomation({ setupDecision: undefined })) + + expect(mockCreateWorktree.mock.calls[0][3]).toBe('skip') + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalled() + }) + + it('keeps launching the agent when background setup terminal launch fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + mockLaunchWorktreeBackgroundTerminals.mockRejectedValue(new Error('tab launch failed')) + + try { + await registerAndDispatch() + } finally { + warnSpy.mockRestore() + } + + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-created', + prompt: 'run this' + }) + ) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-1', + status: 'dispatched', + workspaceId: 'wt-created', + terminalSessionId: 'agent-tab' + }) + ) + }) + + it('does not rerun setup for existing-worktree automations', async () => { + const existingWorktree = { + id: 'wt-existing', + repoId: 'repo-1', + displayName: 'Existing workspace', + path: '/repo/existing' + } + state.allWorktrees.mockReturnValue([existingWorktree]) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: 'wt-existing', + setupDecision: 'run' + }) + ) + + expect(mockCreateWorktree).not.toHaveBeenCalled() + expect(mockLaunchWorktreeBackgroundTerminals).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-existing', + prompt: 'run this' + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 23331bdda48..2de085ada5e 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -6,6 +6,7 @@ import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-sess import { submitPromptToAgentPty } from '@/lib/agent-paste-draft' import { findReusableAutomationSession } from '@/lib/automation-session-reuse' import { observeExistingAutomationSession } from '@/lib/automation-session-observer' +import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals' import { useAppStore } from '@/store' import type { AutomationDispatchResult, @@ -173,50 +174,51 @@ export function useAutomationDispatchEvents(): void { } const automationWorkspaceCreateRequestId = createBrowserUuid() - const worktree = + const createResult = automation.workspaceMode === 'new_per_run' - ? ( - await useAppStore - .getState() - .createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - 'inherit', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - automation.agentId, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - automationProvenanceRequest: { - automationId: automation.id, - automationRunId: run.id, - dispatchToken, - createRequestId: automationWorkspaceCreateRequestId - } + ? await useAppStore + .getState() + .createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + automation.setupDecision ?? 'skip', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + automation.agentId, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId } - ) - ).worktree - : automation.workspaceId - ? automationWorktree - : null + } + ) + : null + const worktree = createResult + ? createResult.worktree + : automation.workspaceId + ? automationWorktree + : null if (!worktree) { await markDispatchResult({ @@ -233,6 +235,17 @@ export function useAutomationDispatchEvents(): void { } dispatchWorkspaceId = worktree.id dispatchWorkspaceDisplayName = worktree.displayName + if (createResult?.setup || createResult?.defaultTabs) { + void launchWorktreeBackgroundTerminals({ + worktreeId: worktree.id, + setup: createResult.setup, + defaultTabs: createResult.defaultTabs + }).catch((error) => { + // Why: setup/defaultTabs match normal worktree creation: they are + // best-effort terminal work and must not block the automation agent. + console.warn('[automations] Failed to launch workspace setup/default tabs:', error) + }) + } const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() let latestAssistantMessage: string | null = null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dc987fa1ea7..88ba83789db 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -356,6 +356,13 @@ } } }, + "worktree": { + "background": { + "terminals": { + "setupTitle": "Setup" + } + } + }, "work": { "item": { "direct": { @@ -11757,6 +11764,11 @@ "chooseHost": "Choose automation host", "adding": "Adding project…", "addProject": "Add project" + }, + "AutomationSetupDecisionField": { + "5a7863909c": "Run setup for each new workspace", + "18f000ad4e": "Advanced", + "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 12eb041c162..b62559b2463 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -356,6 +356,13 @@ } } }, + "worktree": { + "background": { + "terminals": { + "setupTitle": "Setup" + } + } + }, "work": { "item": { "direct": { @@ -11757,6 +11764,11 @@ "chooseHost": "Choose automation host", "adding": "Adding project…", "addProject": "Add project" + }, + "AutomationSetupDecisionField": { + "5a7863909c": "Ejecutar la configuración para cada espacio de trabajo nuevo", + "18f000ad4e": "Advanced", + "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 515a4873bc2..b644dc30453 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -356,6 +356,13 @@ } } }, + "worktree": { + "background": { + "terminals": { + "setupTitle": "Setup" + } + } + }, "work": { "item": { "direct": { @@ -11757,6 +11764,11 @@ "chooseHost": "Choose automation host", "adding": "Adding project…", "addProject": "Add project" + }, + "AutomationSetupDecisionField": { + "5a7863909c": "新しいワークスペースごとにセットアップを実行", + "18f000ad4e": "Advanced", + "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d0ca9068824..e9d2e812b92 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -356,6 +356,13 @@ } } }, + "worktree": { + "background": { + "terminals": { + "setupTitle": "Setup" + } + } + }, "work": { "item": { "direct": { @@ -11757,6 +11764,11 @@ "chooseHost": "자동화 호스트 선택", "adding": "프로젝트 추가 중...", "addProject": "프로젝트 추가" + }, + "AutomationSetupDecisionField": { + "5a7863909c": "새 워크스페이스마다 설정 실행", + "18f000ad4e": "Advanced", + "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cf8639fed03..90e546a59f2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -356,6 +356,13 @@ } } }, + "worktree": { + "background": { + "terminals": { + "setupTitle": "Setup" + } + } + }, "work": { "item": { "direct": { @@ -11757,6 +11764,11 @@ "chooseHost": "选择自动化主机", "adding": "正在添加项目…", "addProject": "添加项目" + }, + "AutomationSetupDecisionField": { + "5a7863909c": "为每个新工作区运行设置", + "18f000ad4e": "Advanced", + "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, "agent": { diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts new file mode 100644 index 00000000000..a33671bf9df --- /dev/null +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSpawn = vi.fn() +const mockCreateTab = vi.fn() +const mockSetTabCustomTitle = vi.fn() +const mockSetTabColor = vi.fn() +const mockSetTabLayout = vi.fn() +const mockUpdateTabPtyId = vi.fn() +const mockClearTabPtyId = vi.fn() +const mockCloseTab = vi.fn() +const mockRegisterEagerPtyBuffer = vi.fn() +const mockGetActiveRuntimeTarget = vi.fn() + +let uuidIndex = 0 + +const setupLaunch = { + runnerScriptPath: '/tmp/setup.sh', + envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' } +} + +const state = { + settings: { activeRuntimeEnvironmentId: null as string | null, setupScriptLaunchMode: 'new-tab' }, + repos: [{ id: 'repo-1', connectionId: null as string | null }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktree', + displayName: 'Worktree' + } + ] + }, + allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1'] ?? []), + createTab: mockCreateTab, + setTabCustomTitle: mockSetTabCustomTitle, + setTabColor: mockSetTabColor, + setTabLayout: mockSetTabLayout, + updateTabPtyId: mockUpdateTabPtyId, + clearTabPtyId: mockClearTabPtyId, + closeTab: mockCloseTab +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state + } +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => `00000000-0000-4000-8000-${String(++uuidIndex).padStart(12, '0')}` +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + registerEagerPtyBuffer: mockRegisterEagerPtyBuffer +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getSettingsForWorktreeRuntimeOwner: () => state.settings +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: mockGetActiveRuntimeTarget +})) + +describe('launchWorktreeBackgroundTerminals', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + uuidIndex = 0 + state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'new-tab' } + state.repos = [{ id: 'repo-1', connectionId: null }] + state.worktreesByRepo['repo-1'] = [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/worktree', + displayName: 'Worktree' + } + ] + let tabIndex = 0 + mockCreateTab.mockImplementation(() => ({ id: `tab-${++tabIndex}` })) + let ptyIndex = 0 + mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` })) + mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) + vi.stubGlobal('window', { + api: { + pty: { + spawn: mockSpawn + } + } + }) + }) + + it('spawns default tabs and setup as inactive background terminals', async () => { + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch, + defaultTabs: { + runCommands: true, + tabs: [{ title: 'Dev', color: '#f97316', command: 'pnpm dev' }] + } + }) + + expect(mockCreateTab).toHaveBeenCalledTimes(2) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + activate: false, + recordInteraction: false + }) + expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Dev', { + recordInteraction: false + }) + expect(mockSetTabColor).toHaveBeenCalledWith('tab-1', '#f97316') + expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cwd: '/repo/worktree', + command: 'pnpm dev', + connectionId: null, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: '00000000-0000-4000-8000-000000000001' + }) + ) + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /tmp/setup.sh', + env: expect.objectContaining({ ORCA_WORKTREE_PATH: '/repo/worktree' }), + tabId: 'tab-2', + leafId: '00000000-0000-4000-8000-000000000002' + }) + ) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-2', 'pty-2') + expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledTimes(2) + }) + + it('spawns setup in a split when setup launch mode requests a split', async () => { + state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' } + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch + }) + + expect(mockCreateTab).toHaveBeenCalledTimes(1) + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + tabId: 'tab-1', + leafId: '00000000-0000-4000-8000-000000000001' + }) + ) + expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command') + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /tmp/setup.sh', + tabId: 'tab-1', + leafId: '00000000-0000-4000-8000-000000000002' + }) + ) + expect(mockSetTabLayout).toHaveBeenLastCalledWith( + 'tab-1', + expect.objectContaining({ + root: expect.objectContaining({ + type: 'split', + direction: 'horizontal' + }), + ptyIdsByLeafId: { + '00000000-0000-4000-8000-000000000001': 'pty-1', + '00000000-0000-4000-8000-000000000002': 'pty-2' + }, + titlesByLeafId: { '00000000-0000-4000-8000-000000000002': 'Setup' } + }) + ) + }) + + it('spawns an initial terminal before a setup-only new-tab launch', async () => { + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch + }) + + expect(mockCreateTab).toHaveBeenCalledTimes(2) + expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command') + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /tmp/setup.sh', + tabId: 'tab-2', + leafId: '00000000-0000-4000-8000-000000000002' + }) + ) + expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + }) + + it('uses Windows setup commands for Windows SSH runner paths', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-windows' }] + state.worktreesByRepo['repo-1'] = [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo\\worktree', + displayName: 'Worktree' + } + ] + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: { + runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd', + envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' } + } + }) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'cmd.exe /c "C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd"', + connectionId: 'ssh-windows' + }) + ) + }) + + it('still attempts setup when a default tab fails to spawn', async () => { + const spawnError = new Error('pty unavailable') + mockSpawn + .mockRejectedValueOnce(spawnError) + .mockResolvedValueOnce({ id: 'pty-initial' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch, + defaultTabs: { + runCommands: true, + tabs: [{ title: 'Dev', command: 'pnpm dev' }] + } + }) + + expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { recordInteraction: false }) + expect(mockSpawn).toHaveBeenCalledTimes(3) + expect(mockSpawn).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + command: 'bash /tmp/setup.sh', + tabId: 'tab-3' + }) + ) + expect(warn).toHaveBeenCalledWith( + '[automations] Failed to launch workspace default tab:', + spawnError + ) + warn.mockRestore() + }) + + it('does not duplicate runtime-owned setup/defaultTabs handled by runtime create', async () => { + mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'environment', environmentId: 'env-1' }) + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch, + defaultTabs: { runCommands: true, tabs: [{ title: 'Dev', command: 'pnpm dev' }] } + }) + + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockSpawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts new file mode 100644 index 00000000000..896da11f8e5 --- /dev/null +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -0,0 +1,294 @@ +import { + registerEagerPtyBuffer, + type EagerPtyHandle +} from '@/components/terminal-pane/pty-dispatcher' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { buildSetupRunnerCommand } from '../../../shared/setup-runner-command' +import type { + TerminalLayoutSnapshot, + Worktree, + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../../shared/types' + +type BackgroundPane = { + leafId: string + ptyId: string +} + +type BackgroundTab = { + tabId: string + primary: BackgroundPane +} + +type BackgroundTerminalLaunch = { + command?: string + env?: Record + title?: string + color?: string +} + +function getSetupTabTitle(): string { + return translate('auto.lib.launch.worktree.background.terminals.setupTitle', 'Setup') +} + +export type LaunchWorktreeBackgroundTerminalsArgs = { + worktreeId: string + setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch +} + +function buildPaneEnv( + worktreeId: string, + tabId: string, + leafId: string, + env: Record | undefined +): Record { + return { + ...env, + ORCA_PANE_KEY: makePaneKey(tabId, leafId), + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } +} + +function buildSplitLayout( + first: BackgroundPane, + second: BackgroundPane, + direction: 'horizontal' | 'vertical', + secondTitle: string +): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction, + first: { type: 'leaf', leafId: first.leafId }, + second: { type: 'leaf', leafId: second.leafId } + }, + activeLeafId: first.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [first.leafId]: first.ptyId, + [second.leafId]: second.ptyId + }, + titlesByLeafId: { + [second.leafId]: secondTitle + } + } +} + +function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void { + const store = useAppStore.getState() + const layout = store.terminalLayoutsByTabId[tabId] + if (!layout) { + return + } + const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout + const nextPtyIds = { ...existingPtyIds } + delete nextPtyIds[leafId] + const trimmedOutput = output.trim() ? output : '' + store.setTabLayout(tabId, { + ...rest, + ...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}), + ...(trimmedOutput + ? { + buffersByLeafId: { + ...existingBuffers, + [leafId]: output + } + } + : existingBuffers + ? { buffersByLeafId: existingBuffers } + : {}) + }) +} + +function registerBackgroundPaneBuffer(tabId: string, leafId: string, ptyId: string): void { + let eagerBuffer: EagerPtyHandle | null = null + eagerBuffer = registerEagerPtyBuffer(ptyId, (exitPtyId) => { + persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '') + useAppStore.getState().clearTabPtyId(tabId, exitPtyId) + }) +} + +function buildSetupCommand(setup: WorktreeSetupLaunch): string { + return buildSetupRunnerCommand( + setup.runnerScriptPath, + isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' + ) +} + +async function spawnPane(args: { + worktree: Worktree + connectionId: string | null + tabId: string + leafId: string + command?: string + env?: Record +}): Promise { + const result = await window.api.pty.spawn({ + cols: 120, + rows: 40, + cwd: args.worktree.path, + ...(args.command ? { command: args.command } : {}), + env: buildPaneEnv(args.worktree.id, args.tabId, args.leafId, args.env), + connectionId: args.connectionId, + worktreeId: args.worktree.id, + tabId: args.tabId, + leafId: args.leafId + }) + return result.id +} + +async function createBackgroundTab(args: { + worktree: Worktree + connectionId: string | null + launch: BackgroundTerminalLaunch +}): Promise { + const store = useAppStore.getState() + const tab = store.createTab(args.worktree.id, undefined, undefined, { + activate: false, + recordInteraction: false + }) + if (args.launch.title) { + store.setTabCustomTitle(tab.id, args.launch.title, { recordInteraction: false }) + } + if (args.launch.color) { + store.setTabColor(tab.id, args.launch.color) + } + + const leafId = createBrowserUuid() + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) + let ptyId: string + try { + ptyId = await spawnPane({ + worktree: args.worktree, + connectionId: args.connectionId, + tabId: tab.id, + leafId, + command: args.launch.command, + env: args.launch.env + }) + } catch (error) { + store.closeTab(tab.id, { recordInteraction: false }) + throw error + } + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) + registerBackgroundPaneBuffer(tab.id, leafId, ptyId) + return { tabId: tab.id, primary: { leafId, ptyId } } +} + +async function addSetupSplit(args: { + worktree: Worktree + connectionId: string | null + tab: BackgroundTab + setup: WorktreeSetupLaunch + direction: 'horizontal' | 'vertical' +}): Promise { + const store = useAppStore.getState() + const setupLeafId = createBrowserUuid() + const setupPtyId = await spawnPane({ + worktree: args.worktree, + connectionId: args.connectionId, + tabId: args.tab.tabId, + leafId: setupLeafId, + command: buildSetupCommand(args.setup), + env: args.setup.envVars + }) + store.updateTabPtyId(args.tab.tabId, setupPtyId) + store.setTabLayout( + args.tab.tabId, + buildSplitLayout( + args.tab.primary, + { leafId: setupLeafId, ptyId: setupPtyId }, + args.direction, + getSetupTabTitle() + ) + ) + registerBackgroundPaneBuffer(args.tab.tabId, setupLeafId, setupPtyId) +} + +function getDefaultTabLaunches( + defaultTabs: WorktreeDefaultTabsLaunch | undefined +): BackgroundTerminalLaunch[] { + return (defaultTabs?.tabs ?? []).map((tab) => { + const command = tab.command?.trim() + return { + ...(tab.title ? { title: tab.title } : {}), + ...(tab.color ? { color: tab.color } : {}), + ...(command && defaultTabs?.runCommands ? { command } : {}) + } + }) +} + +export async function launchWorktreeBackgroundTerminals( + args: LaunchWorktreeBackgroundTerminalsArgs +): Promise { + if (!args.setup && !args.defaultTabs) { + return + } + const store = useAppStore.getState() + const runtimeTarget = getActiveRuntimeTarget( + getSettingsForWorktreeRuntimeOwner(store, args.worktreeId) + ) + if (runtimeTarget.kind === 'environment') { + // Runtime-owned worktrees materialize setup/defaultTabs inside createManagedWorktree. + return + } + + const worktree = store.allWorktrees().find((entry) => entry.id === args.worktreeId) + if (!worktree) { + throw new Error('The target workspace is no longer available.') + } + const repo = store.repos.find((entry) => entry.id === worktree.repoId) + const connectionId = repo?.connectionId ?? null + const defaultLaunches = getDefaultTabLaunches(args.defaultTabs) + const launchedTabs: BackgroundTab[] = [] + + for (const launch of defaultLaunches) { + try { + launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch })) + } catch (error) { + console.warn('[automations] Failed to launch workspace default tab:', error) + } + } + + const setupMode = store.settings?.setupScriptLaunchMode ?? 'new-tab' + const shouldSplitSetup = + args.setup && (setupMode === 'split-horizontal' || setupMode === 'split-vertical') + if (shouldSplitSetup) { + const primaryTab = + launchedTabs[0] ?? (await createBackgroundTab({ worktree, connectionId, launch: {} })) + await addSetupSplit({ + worktree, + connectionId, + tab: primaryTab, + setup: args.setup!, + direction: setupMode === 'split-horizontal' ? 'horizontal' : 'vertical' + }) + return + } + + if (args.setup) { + if (launchedTabs.length === 0) { + launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch: {} })) + } + await createBackgroundTab({ + worktree, + connectionId, + launch: { + title: getSetupTabTitle(), + command: buildSetupCommand(args.setup), + env: args.setup.envVars + } + }) + } +} diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index a2afa59fc06..de64f0bd0f4 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -124,6 +124,48 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') }) + it('can queue setup and default tabs without activating created tabs', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' } + }, + undefined, + { + runCommands: true, + tabs: [{ title: 'Dev', command: 'pnpm dev' }] + }, + { activateCreatedTabs: false } + ) + + expect(result).toBe('tab-1') + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false, + activate: false + }) + expect(createTab).toHaveBeenNthCalledWith(2, 'wt-1', undefined, undefined, { + recordInteraction: false, + activate: false + }) + expect(store.setActiveTab).not.toHaveBeenCalled() + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: 'pnpm dev' }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' } + }) + }) + it('does not run default tab commands when command execution is not approved', () => { const store = createMockStore() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 451575469de..668f3695ccb 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -93,6 +93,7 @@ type WorktreeActivationStore = Partial & { pendingActivationSpawn?: boolean launchAgent?: TuiAgent recordInteraction?: boolean + activate?: boolean } ) => { id: string } setActiveTab: (tabId: string) => void @@ -436,7 +437,8 @@ export function ensureWorktreeHasInitialTerminal( startup?: WorktreeStartupPayload, setup?: WorktreeSetupLaunch, issueCommand?: IssueCommandLaunch, - defaultTabs?: WorktreeDefaultTabsLaunch + defaultTabs?: WorktreeDefaultTabsLaunch, + opts?: { activateCreatedTabs?: boolean } ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) // Why: activation can now restore editor- or browser-only worktrees from the @@ -479,7 +481,8 @@ export function ensureWorktreeHasInitialTerminal( existingTerminalTabId, setup, issueCommand, - wrappedSetupCommandStr + wrappedSetupCommandStr, + opts ) return existingTerminalTabId } @@ -497,7 +500,8 @@ export function ensureWorktreeHasInitialTerminal( existingTerminalTabId, setup, issueCommand, - wrappedSetupCommandStr + wrappedSetupCommandStr, + opts ) return existingTerminalTabId } @@ -511,7 +515,8 @@ export function ensureWorktreeHasInitialTerminal( setup, issueCommand, defaultTabs, - wrappedSetupCommandStr + wrappedSetupCommandStr, + opts ) if (templatedTabId) { return templatedTabId @@ -533,9 +538,12 @@ export function ensureWorktreeHasInitialTerminal( : undefined) const terminalTab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, - ...(launchAgent ? { launchAgent } : {}) + ...(launchAgent ? { launchAgent } : {}), + ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) - store.setActiveTab(terminalTab.id) + if (opts?.activateCreatedTabs !== false) { + store.setActiveTab(terminalTab.id) + } // Why: the new-workspace flow can seed the first terminal with a selected // coding agent and user prompt. Queue that startup command on the initial @@ -550,7 +558,8 @@ export function ensureWorktreeHasInitialTerminal( terminalTab.id, setup, issueCommand, - wrappedSetupCommandStr + wrappedSetupCommandStr, + opts ) return terminalTab.id @@ -563,7 +572,8 @@ function applyDefaultTerminalTabs( setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined, - wrappedSetupCommandStr?: string + wrappedSetupCommandStr: string | undefined, + opts: { activateCreatedTabs?: boolean } | undefined ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -577,7 +587,8 @@ function applyDefaultTerminalTabs( for (const [index, template] of defaultTabs.tabs.entries()) { const tab = store.createTab(worktreeId, undefined, undefined, { pendingActivationSpawn: true, - recordInteraction: false + recordInteraction: false, + ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) if (index === 0) { firstTabId = tab.id @@ -597,7 +608,9 @@ function applyDefaultTerminalTabs( if (!firstTabId) { return null } - store.setActiveTab(firstTabId) + if (opts?.activateCreatedTabs !== false) { + store.setActiveTab(firstTabId) + } if (startup) { store.queueTabStartupCommand(firstTabId, startup) } @@ -607,7 +620,8 @@ function applyDefaultTerminalTabs( firstTabId, setup, issueCommand, - wrappedSetupCommandStr + wrappedSetupCommandStr, + opts ) return firstTabId } @@ -618,7 +632,8 @@ function queueSetupAndIssueCommands( terminalTabId: string, setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, - wrappedSetupCommandStr?: string + wrappedSetupCommandStr: string | undefined, + opts: { activateCreatedTabs?: boolean } | undefined ): void { // Why: the setup script launch location is user-configurable. The default // 'new-tab' creates a separate background tab titled "Setup" without @@ -634,12 +649,15 @@ function queueSetupAndIssueCommands( } if (mode === 'new-tab') { const setupTab = store.createTab(worktreeId, undefined, undefined, { - recordInteraction: false + recordInteraction: false, + ...(opts?.activateCreatedTabs === false ? { activate: false } : {}) }) // Why: createTab auto-activates the new tab. Revert activation so the // user's focus stays on the primary terminal — per the design, the // Setup tab runs unattended in the background. - store.setActiveTab(terminalTabId) + if (opts?.activateCreatedTabs !== false) { + store.setActiveTab(terminalTabId) + } // Why: customTitle wins over the auto-generated "Terminal N" label // everywhere the tab is rendered (tab bar, switcher, session snapshots), // so labeling via customTitle is the single authoritative source. diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index ba86f99fd91..ae52215b644 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -235,7 +235,8 @@ describe('staged background worktree creation', () => { undefined, undefined, undefined, - undefined + undefined, + { activateCreatedTabs: false } ) expect(queueNewWorkspaceTerminalFocus).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1') diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 262895459d2..36a54f17672 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -216,7 +216,8 @@ async function executeWorktreeCreation( startupOpt, result.setup, undefined, - result.defaultTabs + result.defaultTabs, + { activateCreatedTabs: false } ) } diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index bbe3ccf67dc..56788595e95 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from './types' +import type { SetupDecision, TuiAgent } from './types' import type { TaskSourceContext, WorkspaceRunContext } from './task-source-context' export type AutomationWorkspaceMode = 'existing' | 'new_per_run' @@ -99,6 +99,7 @@ export type Automation = { workspaceMode: AutomationWorkspaceMode workspaceId: string | null baseBranch: string | null + setupDecision?: SetupDecision reuseSession: boolean timezone: string rrule: string @@ -154,6 +155,7 @@ export type AutomationCreateInput = { workspaceMode: AutomationWorkspaceMode workspaceId?: string | null baseBranch?: string | null + setupDecision?: SetupDecision reuseSession?: boolean timezone: string rrule: string @@ -175,6 +177,7 @@ export type AutomationUpdateInput = Partial< | 'workspaceMode' | 'workspaceId' | 'baseBranch' + | 'setupDecision' | 'reuseSession' | 'timezone' | 'rrule' From bcbcf4126b6cf308ba2a7756c62d9ba2ad18f3b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:26:44 +0000 Subject: [PATCH 12/23] release: v1.4.105-rc.0 [rc-slot:2026-06-28-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 22e17d96b67..c8f437619a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.104-rc.6", + "version": "1.4.105-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b916248294742e0455e84a0d849d6158db232e63 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:43:07 -0700 Subject: [PATCH 13/23] Polish desktop native chat view (#6641) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Orca Co-authored-by: gsxdsm --- docs/native-chat-codex-tui-parity.md | 217 +++++++++ src/main/agent-hooks/server.ts | 1 + src/main/ipc/native-chat.test.ts | 259 +++++++++++ src/main/ipc/native-chat.ts | 168 +++++++ src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + src/main/ipc/skills.test.ts | 8 + src/main/ipc/skills.ts | 3 +- src/main/menu/register-app-menu.test.ts | 12 +- src/main/menu/register-app-menu.ts | 10 +- .../native-chat/session-file-resolver.test.ts | 163 +++++++ src/main/native-chat/session-file-resolver.ts | 117 +++++ .../native-chat/transcript-line-decoders.ts | 185 ++++++++ .../native-chat/transcript-read-cache.test.ts | 92 ++++ src/main/native-chat/transcript-read-cache.ts | 90 ++++ .../native-chat/transcript-reader.test.ts | 192 ++++++++ src/main/native-chat/transcript-reader.ts | 66 +++ .../native-chat/transcript-record-blocks.ts | 117 +++++ src/main/native-chat/transcript-watch.test.ts | 239 ++++++++++ src/main/native-chat/transcript-watch.ts | 254 +++++++++++ src/main/persistence.test.ts | 82 ++++ src/main/runtime/orca-runtime.test.ts | 61 ++- src/main/runtime/orca-runtime.ts | 56 ++- src/main/runtime/rpc/core.ts | 5 + src/main/runtime/rpc/dispatcher.ts | 3 + src/main/runtime/rpc/methods/index.ts | 2 + .../runtime/rpc/methods/native-chat.test.ts | 89 ++++ src/main/runtime/rpc/methods/native-chat.ts | 180 ++++++++ .../rpc/methods/session-tabs-schemas.ts | 4 +- src/main/runtime/rpc/methods/session-tabs.ts | 3 +- src/main/runtime/rpc/methods/skills.ts | 14 +- src/main/runtime/runtime-rpc.ts | 6 + src/main/skills/discovery.test.ts | 27 ++ src/main/window/createMainWindow.ts | 20 + src/preload/api-types.ts | 42 ++ src/preload/index.ts | 40 ++ .../dictation/DictationController.tsx | 30 ++ .../dictation/dictation-control-events.ts | 9 + .../native-chat/NativeChatApprovalCard.tsx | 55 +++ .../NativeChatAutocompleteMenus.tsx | 103 +++++ .../native-chat/NativeChatComposer.tsx | 424 +++++++++++++++++ .../native-chat/NativeChatComposerActions.tsx | 125 +++++ .../native-chat/NativeChatComposerField.tsx | 181 ++++++++ .../native-chat/NativeChatCopyButton.tsx | 66 +++ .../native-chat/NativeChatDiffView.tsx | 27 ++ .../native-chat/NativeChatEmptyState.tsx | 87 ++++ .../native-chat/NativeChatInteractiveCard.tsx | 85 ++++ .../native-chat/NativeChatMessageList.tsx | 361 +++++++++++++++ .../native-chat/NativeChatQuestionCard.tsx | 229 ++++++++++ .../native-chat/NativeChatToolRun.tsx | 144 ++++++ .../components/native-chat/NativeChatView.tsx | 430 ++++++++++++++++++ .../native-chat/native-chat-agent-commands.ts | 8 + ...native-chat-assembler-merge-parity.test.ts | 46 ++ .../native-chat-autoscroll.test.ts | 44 ++ .../native-chat/native-chat-autoscroll.ts | 44 ++ .../native-chat-availability.test.ts | 95 ++++ .../native-chat/native-chat-availability.ts | 43 ++ .../native-chat-composer-state.test.ts | 213 +++++++++ .../native-chat/native-chat-composer-state.ts | 187 ++++++++ .../native-chat-composer-target.ts | 30 ++ .../native-chat/native-chat-diff.test.ts | 63 +++ .../native-chat/native-chat-diff.ts | 70 +++ .../native-chat-dismiss-key.test.ts | 54 +++ .../native-chat/native-chat-dismiss-key.ts | 21 + .../native-chat-font-scale.test.ts | 118 +++++ .../native-chat/native-chat-font-scale.ts | 69 +++ .../native-chat-image-paste.test.ts | 25 + .../native-chat/native-chat-image-paste.ts | 47 ++ .../native-chat-image-transcript-markers.ts | 84 ++++ .../native-chat-incremental-assembler.test.ts | 109 +++++ .../native-chat-incremental-assembler.ts | 100 ++++ .../native-chat-interactive-prompt.test.ts | 165 +++++++ .../native-chat-interactive-prompt.ts | 200 ++++++++ .../native-chat/native-chat-live-status.ts | 77 ++++ .../native-chat-message-grouping.test.ts | 106 +++++ .../native-chat-message-grouping.ts | 118 +++++ .../native-chat/native-chat-noise.test.ts | 54 +++ .../native-chat/native-chat-noise.ts | 53 +++ .../native-chat-pagination.test.ts | 30 ++ .../native-chat/native-chat-pagination.ts | 21 + .../native-chat-pane-resolution.test.ts | 144 ++++++ .../native-chat-pane-resolution.ts | 56 +++ .../native-chat/native-chat-pending.test.ts | 259 +++++++++++ .../native-chat/native-chat-pending.ts | 253 +++++++++++ .../native-chat-runtime-send.test.ts | 241 ++++++++++ .../native-chat/native-chat-runtime-send.ts | 173 +++++++ .../native-chat-scrape-fallback.test.ts | 94 ++++ .../native-chat-scrape-fallback.ts | 125 +++++ .../native-chat-send-eligibility.test.ts | 34 ++ .../native-chat-send-eligibility.ts | 27 ++ .../native-chat/native-chat-send.test.ts | 108 +++++ .../native-chat/native-chat-send.ts | 66 +++ .../native-chat-session-assembler.test.ts | 272 +++++++++++ .../native-chat-session-assembler.ts | 191 ++++++++ .../native-chat/native-chat-shortcut.test.ts | 57 +++ .../native-chat/native-chat-shortcut.ts | 33 ++ .../native-chat-tab-agent-entry.test.ts | 102 +++++ .../native-chat-tab-agent-entry.ts | 21 + .../native-chat/native-chat-tool-fold.test.ts | 76 ++++ .../native-chat/native-chat-tool-fold.ts | 58 +++ .../native-chat-tool-summary.test.ts | 71 +++ .../native-chat/native-chat-tool-summary.ts | 80 ++++ .../native-chat-typing-redirect.test.ts | 57 +++ .../native-chat-typing-redirect.ts | 67 +++ .../native-chat-view-state.test.ts | 65 +++ .../native-chat/native-chat-view-state.ts | 36 ++ .../native-chat-working-suppression.test.ts | 38 ++ .../native-chat-working-suppression.ts | 16 + .../native-chat/use-native-chat-can-send.ts | 29 ++ ...-native-chat-composer-attachments.test.tsx | 98 ++++ .../use-native-chat-composer-attachments.ts | 152 +++++++ .../use-native-chat-composer-keydown.ts | 172 +++++++ .../use-native-chat-context-menu.tsx | 259 +++++++++++ .../native-chat/use-native-chat-font-scale.ts | 59 +++ .../use-native-chat-interactive-send.ts | 90 ++++ .../use-native-chat-live-session.test.ts | 91 ++++ .../use-native-chat-live-session.ts | 287 ++++++++++++ .../use-native-chat-skills.test.ts | 72 +++ .../native-chat/use-native-chat-skills.ts | 71 +++ .../use-native-chat-toggle-shortcut.ts | 62 +++ .../settings/ExperimentalPane.test.tsx | 122 +++++ .../components/settings/ExperimentalPane.tsx | 8 + .../NativeChatExperimentalSetting.tsx | 115 +++++ .../settings/experimental-search.ts | 5 + .../native-chat-experimental-search-entry.ts | 46 ++ .../src/components/tab-bar/SortableTab.tsx | 15 +- .../tab-bar/SortableTabContextMenu.tsx | 43 +- .../src/components/tab-bar/TabBar.tsx | 38 ++ .../TerminalContextMenu.test.tsx | 3 + .../terminal-pane/TerminalContextMenu.tsx | 27 +- .../components/terminal-pane/TerminalPane.tsx | 194 +++++++- .../TerminalPaneHeaderOverlay.tsx | 54 ++- .../TerminalPaneOverlayLayer.tsx | 8 + .../terminal-pane/terminal-bracketed-paste.ts | 23 +- .../use-terminal-pane-context-menu.ts | 14 +- .../use-terminal-pane-lifecycle.ts | 3 + .../terminal/terminal-tab-actions.test.ts | 30 +- .../terminal/terminal-tab-actions.ts | 35 +- .../WorkspaceCleanupDialog.tsx | 14 +- ...workspace-cleanup-presentation-fixtures.ts | 4 +- .../workspace-cleanup-presentation.ts | 18 +- src/renderer/src/hooks/useIpcEvents.test.ts | 25 +- src/renderer/src/hooks/useIpcEvents.ts | 26 +- src/renderer/src/i18n/locales/en.json | 136 +++++- src/renderer/src/i18n/locales/es.json | 132 +++++- src/renderer/src/i18n/locales/ja.json | 132 +++++- src/renderer/src/i18n/locales/ko.json | 132 +++++- src/renderer/src/i18n/locales/zh.json | 132 +++++- .../src/lib/launch-agent-in-new-tab.ts | 12 +- .../lib/native-chat-initial-view-mode.test.ts | 38 ++ .../src/lib/native-chat-initial-view-mode.ts | 29 ++ src/renderer/src/lib/native-chat-telemetry.ts | 60 +++ src/renderer/src/lib/use-tab-agent.ts | 8 +- .../src/lib/worktree-activation.test.ts | 30 ++ src/renderer/src/lib/worktree-activation.ts | 26 +- .../src/runtime/sync-runtime-graph.ts | 3 + .../src/runtime/web-runtime-session.ts | 23 +- .../src/runtime/web-session-reorder-intent.ts | 99 ++++ .../src/runtime/web-session-tabs-sync.test.ts | 144 ++++++ .../src/runtime/web-session-tabs-sync.ts | 59 ++- src/renderer/src/store/slices/agent-status.ts | 4 + .../src/store/slices/tab-view-mode.test.ts | 84 ++++ src/renderer/src/store/slices/tabs.ts | 70 +++ src/renderer/src/store/slices/terminals.ts | 10 +- src/renderer/src/web/web-preload-api.test.ts | 6 +- src/renderer/src/web/web-preload-api.ts | 81 +++- .../src/web/web-runtime-client.test.ts | 45 ++ src/renderer/src/web/web-runtime-client.ts | 28 +- src/shared/agent-hook-listener.test.ts | 152 +++++++ src/shared/agent-hook-listener.ts | 130 +++++- src/shared/agent-session-resume.test.ts | 28 ++ src/shared/agent-session-resume.ts | 46 +- src/shared/agent-status-types.test.ts | 36 ++ src/shared/agent-status-types.ts | 33 ++ src/shared/constants.ts | 2 + src/shared/native-chat-ask-types.ts | 18 + src/shared/native-chat-merge.ts | 107 +++++ src/shared/native-chat-slash-commands.test.ts | 66 +++ src/shared/native-chat-slash-commands.ts | 123 +++++ .../native-chat-stream-unsubscribe.test.ts | 18 + src/shared/native-chat-stream-unsubscribe.ts | 27 ++ src/shared/native-chat-streaming.test.ts | 90 ++++ src/shared/native-chat-streaming.ts | 63 +++ src/shared/native-chat-types.test.ts | 45 ++ src/shared/native-chat-types.ts | 120 +++++ src/shared/runtime-types.ts | 3 + src/shared/skills.ts | 2 + src/shared/telemetry-events.ts | 29 +- src/shared/types.ts | 15 + src/shared/workspace-session-schema.test.ts | 64 +++ src/shared/workspace-session-schema.ts | 8 +- 191 files changed, 15163 insertions(+), 204 deletions(-) create mode 100644 docs/native-chat-codex-tui-parity.md create mode 100644 src/main/ipc/native-chat.test.ts create mode 100644 src/main/ipc/native-chat.ts create mode 100644 src/main/native-chat/session-file-resolver.test.ts create mode 100644 src/main/native-chat/session-file-resolver.ts create mode 100644 src/main/native-chat/transcript-line-decoders.ts create mode 100644 src/main/native-chat/transcript-read-cache.test.ts create mode 100644 src/main/native-chat/transcript-read-cache.ts create mode 100644 src/main/native-chat/transcript-reader.test.ts create mode 100644 src/main/native-chat/transcript-reader.ts create mode 100644 src/main/native-chat/transcript-record-blocks.ts create mode 100644 src/main/native-chat/transcript-watch.test.ts create mode 100644 src/main/native-chat/transcript-watch.ts create mode 100644 src/main/runtime/rpc/methods/native-chat.test.ts create mode 100644 src/main/runtime/rpc/methods/native-chat.ts create mode 100644 src/renderer/src/components/dictation/dictation-control-events.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatComposer.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerActions.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerField.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatCopyButton.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatDiffView.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatEmptyState.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatView.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-agent-commands.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-autoscroll.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-availability.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-availability.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-state.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-state.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-target.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-diff.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-diff.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-dismiss-key.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-font-scale.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-font-scale.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-image-paste.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-image-paste.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-image-transcript-markers.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-incremental-assembler.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-live-status.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-message-grouping.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-noise.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-noise.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pagination.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pagination.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-resolution.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-runtime-send.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-scrape-fallback.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-send-eligibility.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-send.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-assembler.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-shortcut.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-shortcut.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tab-agent-entry.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tool-fold.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tool-summary.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-typing-redirect.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-view-state.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-view-state.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-working-suppression.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-can-send.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-font-scale.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-live-session.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-skills.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-skills.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts create mode 100644 src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx create mode 100644 src/renderer/src/components/settings/native-chat-experimental-search-entry.ts create mode 100644 src/renderer/src/lib/native-chat-initial-view-mode.test.ts create mode 100644 src/renderer/src/lib/native-chat-initial-view-mode.ts create mode 100644 src/renderer/src/lib/native-chat-telemetry.ts create mode 100644 src/renderer/src/runtime/web-session-reorder-intent.ts create mode 100644 src/renderer/src/store/slices/tab-view-mode.test.ts create mode 100644 src/shared/native-chat-ask-types.ts create mode 100644 src/shared/native-chat-merge.ts create mode 100644 src/shared/native-chat-slash-commands.test.ts create mode 100644 src/shared/native-chat-slash-commands.ts create mode 100644 src/shared/native-chat-stream-unsubscribe.test.ts create mode 100644 src/shared/native-chat-stream-unsubscribe.ts create mode 100644 src/shared/native-chat-streaming.test.ts create mode 100644 src/shared/native-chat-streaming.ts create mode 100644 src/shared/native-chat-types.test.ts create mode 100644 src/shared/native-chat-types.ts diff --git a/docs/native-chat-codex-tui-parity.md b/docs/native-chat-codex-tui-parity.md new file mode 100644 index 00000000000..89b0d2bd1b9 --- /dev/null +++ b/docs/native-chat-codex-tui-parity.md @@ -0,0 +1,217 @@ +# Native Chat Codex TUI Parity + +This note maps Codex TUI behavior to Orca native chat on branch +`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca +surface is a PTY harness around the running TUI, while real native parity should +move selected paths to Codex app-server protocol v2. + +## Source Map + +- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs` +- Slash command parsing and popup: + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs` +- Skills and mentions: + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs` +- Structured input and native protocol: + `/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`, + `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs` + +## Current Orca Architecture + +Orca native chat currently sends through the hosted terminal PTY. The composer +builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a +delayed Enter. This preserves local and SSH behavior because it uses the same +runtime path as terminal typing. + +That architecture is useful for incremental adoption, but it means native chat +does not own Codex state. It cannot directly set model, reasoning, permissions, +skills, or session lifecycle. It can only type commands into the TUI and observe +agent hooks/transcripts after the fact. + +## Slash Commands + +Codex behavior: + +- The parser accepts a first-line command of `/name `. +- The slash popup uses Codex's `SlashCommand` enum order as presentation order. +- Enter on a selected popup row dispatches the command. Tab completes it into + the draft. +- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`, + `keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and + `sandbox-add-read-dir`. +- Commands are control actions, not ordinary user chat turns. For example + `/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model` + opens the model picker; `/skills` opens skill management. + +Current Orca behavior: + +- Slash commands are still typed into the TUI over PTY. +- Native optimistic chat bubbles are suppressed for slash drafts so `/clear` + does not render as a fake queued user message. +- The Codex slash catalog now mirrors the visible TUI command list much more + closely, but it is still a copied catalog, not a live TUI query. + +Recommended route: + +- Short term: keep PTY dispatch for slash commands, but treat them as command + submissions. No optimistic chat bubbles. Enter dispatches; Tab completes. +- Medium term: route commands with app-server equivalents directly. Examples: + `thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`, + `model/list`, permissions/config reads and writes, `skills/list`. +- Long term: stop maintaining a renderer-side Codex command catalog. Either ask + Codex for the command inventory or host the Codex composer state machine. + +## Skills And `$` + +Codex behavior: + +- `$` opens the skill popup. Rows show display name, description, category tags, + selection state, filtering, sorting, and scrolling. +- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo + scope sorts before user/system/admin. Exact duplicate paths are deduped. +- Skill selection is structured. `UserInput` has `Skill { name, path }`, and + app-server protocol v2 mirrors it. Text `$skill` mentions are only the + fallback path and must be unambiguous. +- Skill injection reads the selected `SKILL.md` by path, records telemetry, and + avoids double-injecting already provided host skill prompts. + +Current Orca behavior: + +- Native chat discovers skills through Orca's skills IPC with the active + terminal tab's cwd. This is important for worktree symlinks like + `.agents/skills`. +- `$` autocomplete inserts plain `$skillName` text. That can work through the + TUI's text fallback, but it is not equivalent to structured + `UserInput::Skill { name, path }`. + +Recommended route: + +- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering, + scrolling, dedupe, and active-cwd discovery. +- Native mode: retain the selected skill's path and submit + `UserInput::Skill { name, path }` through app-server `turn` input. This avoids + ambiguity when multiple skills share a name and lets Codex inject the exact + file the user selected. + +## Files, Mentions, And Images + +Codex behavior: + +- User input supports `Text` with text elements, `Image`, `LocalImage`, + `Skill`, and `Mention`. +- The TUI has file search/mentions and image placeholders. Large pastes become + placeholders so text element ranges stay aligned. +- Remote image rows are first-class composer attachments and can be removed with + keyboard navigation. + +Current Orca behavior: + +- File attach inserts a path/reference into the draft and relies on the TUI to + interpret it. +- Image paste saves a temp file, then inserts the agent-specific reference. +- Local attachments are blocked for remote sessions because the local path may + not exist on the SSH target. + +Recommended route: + +- PTY mode: keep conservative path insertion and remote-session blocking. +- Native mode: send structured `LocalImage` or `Image` input through Codex + protocol and use remote runtime file transfer semantics for SSH. + +## Model, Reasoning, Permissions + +Codex behavior: + +- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related + commands are stateful TUI/app-server surfaces. +- App-server v2 already exposes model listing, config requirements, approval + policies, permission profiles, and reasoning effort fields. + +Current Orca behavior: + +- Native chat does not know or set Codex model/reasoning directly. Typing + `/model` opens Codex's TUI picker. +- Earlier UI controls for model/thinking were removed because they were not + wired to real Codex state. + +Recommended route: + +- Do not re-add model or reasoning dropdowns until they read from and write to + Codex app-server state. +- In PTY mode, expose `/model` as a command shortcut only. + +## Approvals, Elicitations, And Tool UI + +Codex behavior: + +- Approval overlays cover exec approval, permission approval, file change + approval, network approval, MCP elicitation, and request-user-input forms. +- App-server notifications include thread status, waiting-on-approval/user-input + flags, item start/completion, diff/plan updates, and skill changes. + +Current Orca behavior: + +- Native chat has interactive cards sourced from Orca's existing agent status + hooks. This is good for common question/approval flows, but it is not the full + Codex approval overlay model. + +Recommended route: + +- Keep PTY fallback for anything not represented in Orca hooks. +- For Codex-native mode, subscribe to app-server notifications and render + approvals/tool calls from protocol events rather than scraping terminal text. + +## Session And History + +Codex behavior: + +- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear` + are session lifecycle commands. +- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse + search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and + Ctrl+C quit/interrupt behavior. + +Current Orca behavior: + +- Native chat has small in-memory draft history and Enter/Shift+Enter. +- Session commands are typed into the hosted TUI. + +Recommended route: + +- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for + lifecycle commands. +- Native mode: use app-server thread APIs for lifecycle and expose real thread + transitions in the Orca UI. + +## Priority + +1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes, + slash commands never render as queued chat turns, interrupt clears working UI. +2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling, + filtering, source labels, and no product-specific hardcoding. +3. Keep fake model/thinking controls out until backed by Codex app-server state. +4. Add an app-server integration spike for Codex native mode: `skills/list`, + structured `UserInput::Skill`, model list/settings, and thread lifecycle. +5. Move approvals/tool rendering from hook approximations to protocol events. + +## Test Targets + +- `/clear` from native slash popup dispatches immediately and produces no + pending user bubble. +- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown + slash commands behave like the hosted TUI. +- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a + symlink. +- Down-arrow in `$` suggestions scrolls the popup window. +- Interrupt during work returns the composer from Stop to Send after the agent + status settles. +- SSH sessions never insert local-only attachment paths as if they were remote + files. diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 118d3524719..b5dc91792c9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -256,6 +256,7 @@ function equivalentParsedAgentStatusPayload( a.agentType === b.agentType && a.toolName === b.toolName && a.toolInput === b.toolInput && + a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted ) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts new file mode 100644 index 00000000000..066357f0b4c --- /dev/null +++ b/src/main/ipc/native-chat.test.ts @@ -0,0 +1,259 @@ +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +import { + clearNativeChatSubscriptions, + clearNativeChatTranscriptCache, + registerNativeChatHandlers +} from './native-chat' + +let tempRoots: string[] = [] + +beforeEach(() => { + handlers.clear() + listeners.clear() + clearNativeChatTranscriptCache() + clearNativeChatSubscriptions() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +async function invokeReadSession(args: { + agent: string + sessionId: string + limit?: number +}): Promise { + registerNativeChatHandlers() + const handler = handlers.get('nativeChat:readSession') + if (!handler) { + throw new Error('handler not registered') + } + return handler({}, args) +} + +describe('nativeChat:readSession handler', () => { + it('resolves a Claude transcript and returns the full conversation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) + tempRoots.push(root) + const projectsDir = join(root, '.claude', 'projects') + const projectDir = join(projectsDir, '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-ipc.jsonl'), + jsonLines([ + { + type: 'user', + uuid: 'u-1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Hi' } + }, + { + type: 'assistant', + uuid: 'a-1', + timestamp: '2026-06-01T10:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] } + } + ]) + ) + + // Point homedir-derived Claude root at our fixture via HOME so the resolver + // (which reads homedir() internally) finds the transcript. + const previousHome = process.env.HOME + process.env.HOME = root + try { + const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as { + messages?: unknown[] + error?: string + } + expect(result.error).toBeUndefined() + expect(result.messages).toHaveLength(2) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('windows to the most-recent `limit` turns and pages older history when raised', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + // Five user turns; reading with limit 2 returns only the last two, and a + // larger limit pages in older ones (chronological order preserved). + const records = [1, 2, 3, 4, 5].map((n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records)) + + const previousHome = process.env.HOME + process.env.HOME = root + try { + const windowed = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-limit', + limit: 2 + })) as { messages: { id: string }[] } + expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5']) + + const wider = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-limit', + limit: 4 + })) as { messages: { id: string }[] } + expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5']) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) + tempRoots.push(root) + const projectsDir = join(root, '.claude', 'projects') + const projectDir = join(projectsDir, '-repo') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'sess-sub.jsonl') + await writeFile( + filePath, + `${jsonLines([ + { + type: 'user', + uuid: 'u-1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Hi' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + const sent: { channel: string; payload: unknown }[] = [] + let destroyedCb: (() => void) | undefined + const sender = { + id: 1, + isDestroyed: () => false, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: (channel: string, payload: unknown) => sent.push({ channel, payload }) + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-1', + agent: 'claude', + sessionId: 'sess-sub' + } + ) + + // The listener dispatches handleSubscribe fire-and-forget; give it a beat + // to resolve the path and install the watcher before we append. + await new Promise((resolve) => setTimeout(resolve, 100)) + + await appendFile( + filePath, + `${JSON.stringify({ + type: 'assistant', + uuid: 'a-1', + timestamp: '2026-06-01T10:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] } + })}\n` + ) + + // Seed-at-0 means the first appended event carries the whole-file re-read; + // the new turn 'a-1' arrives across one of the appended events. Collect ids + // from every appended event and assert the new turn shows up. + const appendedIds = (): string[] => + sent + .filter((s) => s.channel === 'nativeChat:appended') + .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + await waitFor(() => appendedIds().includes('a-1')) + const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! + const payload = appendedEvent.payload as { subscriptionId: string } + expect(payload.subscriptionId).toBe('sub-1') + expect(appendedIds()).toContain('a-1') + + // Destroyed window tears down the watcher without error. + expect(destroyedCb).toBeDefined() + destroyedCb!() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + + it('returns an error for an unknown session without throwing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) + tempRoots.push(root) + const previousHome = process.env.HOME + process.env.HOME = root + try { + const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as { + error?: string + } + expect(result.error).toBeTruthy() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) +}) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts new file mode 100644 index 00000000000..ad6117db0c1 --- /dev/null +++ b/src/main/ipc/native-chat.ts @@ -0,0 +1,168 @@ +import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from '../native-chat/transcript-read-cache' +import type { ReadTranscriptResult } from '../native-chat/transcript-reader' +import { + subscribeNativeChatTranscript, + type NativeChatTranscriptSubscription +} from '../native-chat/transcript-watch' + +// Re-export so existing test imports of `clearNativeChatTranscriptCache` from +// this module keep working after the cache moved to transcript-read-cache.ts. +export { clearNativeChatTranscriptCache } + +export type NativeChatReadSessionArgs = { + agent: AgentType + sessionId: string + /** How many of the most-recent turns to return. The renderer starts at the + * default window and raises this to page in older history as it scrolls up. */ + limit?: number + /** Authoritative transcript path from the agent hook (providerSession), used to + * locate the file when the session id no longer names it (recent Claude Code). */ + transcriptPath?: string +} + +// Why: render only the most recent turns so switching to chat view on a long +// session (thousands of messages) doesn't stall on building that many message +// components. The full transcript is still cached; only the returned slice is +// capped. The renderer raises `limit` to page in older history; live appends +// extend it from there. +const DESKTOP_READ_WINDOW = 300 + +function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { + if (!('messages' in result) || result.messages.length <= limit) { + return result + } + return { ...result, messages: result.messages.slice(-limit) } +} + +async function readSession(args: NativeChatReadSessionArgs): Promise { + const { agent, sessionId } = args + // Clamp to a positive window; default to the desktop window for the first page. + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW + // Desktop is full-class: window by count only, no char truncation. + const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) + return windowTranscript(result, limit) +} + +export type NativeChatSubscribeArgs = { + /** Renderer-minted id, unique per webContents, echoed back on every emit so + * the renderer can route appends to the right hook instance. */ + subscriptionId: string + agent: AgentType + sessionId: string + /** Authoritative transcript path from the agent hook (providerSession). */ + transcriptPath?: string +} + +export type NativeChatAppendedPayload = { + subscriptionId: string + messages: NativeChatMessage[] +} + +type LiveSubscription = { + subscription: NativeChatTranscriptSubscription +} + +// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the +// same renderer can watch several panes, and a destroyed window tears down all +// of its watchers — strict teardown to avoid fd leaks (plan U4 risk). +const liveSubscriptions = new Map>() +const senderCleanupRegistered = new Set() + +function teardownSubscription(senderId: number, subscriptionId: string): void { + const bySubId = liveSubscriptions.get(senderId) + const live = bySubId?.get(subscriptionId) + if (!live || !bySubId) { + return + } + live.subscription.unsubscribe() + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + liveSubscriptions.delete(senderId) + } +} + +function teardownAllForSender(senderId: number): void { + const bySubId = liveSubscriptions.get(senderId) + if (!bySubId) { + return + } + for (const live of bySubId.values()) { + live.subscription.unsubscribe() + } + liveSubscriptions.delete(senderId) + senderCleanupRegistered.delete(senderId) +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + // Strict teardown: a closed/reloaded window releases every watcher it owns. + sender.once('destroyed', () => teardownAllForSender(sender.id)) +} + +async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { + const sender = event.sender + if (sender.isDestroyed()) { + return + } + const { subscriptionId, agent, sessionId, transcriptPath } = args + // Replace any prior subscription under the same id (session change/resubscribe). + teardownSubscription(sender.id, subscriptionId) + registerSenderCleanup(sender) + + const subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) + } + }) + + // The window may have gone away (or the subscription been replaced) while we + // resolved the file path — don't register a now-orphaned watcher. + const stillCurrent = !sender.isDestroyed() + if (!stillCurrent) { + subscription.unsubscribe() + return + } + const bySubId = liveSubscriptions.get(sender.id) ?? new Map() + // A concurrent subscribe with the same id beat us here; honor the latest. + const existing = bySubId.get(subscriptionId) + if (existing) { + existing.subscription.unsubscribe() + } + bySubId.set(subscriptionId, { subscription }) + liveSubscriptions.set(sender.id, bySubId) +} + +/** Test-only: drop all live transcript subscriptions between runs. */ +export function clearNativeChatSubscriptions(): void { + const senderIds = Array.from(liveSubscriptions.keys()) + for (const senderId of senderIds) { + teardownAllForSender(senderId) + } +} + +export function registerNativeChatHandlers(): void { + ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) => + readSession(args) + ) + ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => { + void handleSubscribe(event, args) + }) + ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { + teardownSubscription(event.sender.id, args.subscriptionId) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 72dd8bc8428..8bd2b511ac3 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -52,6 +52,7 @@ const { registerSkillsHandlersMock, registerWorkspaceSpaceHandlersMock, registerWorkspacePortHandlersMock, + registerNativeChatHandlersMock, registerEmulatorFrameStreamHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), @@ -103,6 +104,7 @@ const { registerSkillsHandlersMock: vi.fn(), registerWorkspaceSpaceHandlersMock: vi.fn(), registerWorkspacePortHandlersMock: vi.fn(), + registerNativeChatHandlersMock: vi.fn(), registerEmulatorFrameStreamHandlersMock: vi.fn() })) @@ -294,6 +296,10 @@ vi.mock('./hosted-review', () => ({ registerHostedReviewHandlers: registerHostedReviewHandlersMock })) +vi.mock('./native-chat', () => ({ + registerNativeChatHandlers: registerNativeChatHandlersMock +})) + import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { @@ -346,6 +352,7 @@ describe('registerCoreHandlers', () => { registerSkillsHandlersMock.mockReset() registerWorkspaceSpaceHandlersMock.mockReset() registerWorkspacePortHandlersMock.mockReset() + registerNativeChatHandlersMock.mockReset() registerEmulatorFrameStreamHandlersMock.mockReset() }) @@ -417,6 +424,7 @@ describe('registerCoreHandlers', () => { expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths }) + expect(registerNativeChatHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7bcf9756c51..0f4cb4ca789 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -24,6 +24,7 @@ import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { registerAiVaultHandlers } from './ai-vault' +import { registerNativeChatHandlers } from './native-chat' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' @@ -164,6 +165,7 @@ export function registerCoreHandlers( registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths }) + registerNativeChatHandlers() registerClipboardHandlers(store) registerUpdaterHandlers(store) registerSpeechHandlers(store) diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index b75b7048b08..680163f8b5d 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -83,6 +83,14 @@ describe('registerSkillsHandlers', () => { expect(getWslHomeMock).not.toHaveBeenCalled() }) + it('scopes host skill discovery to the active workspace cwd when provided', async () => { + const handler = getDiscoverHandler() + + await handler(null, { cwd: '/repo/worktree' }) + + expect(discoverSkillsMock).toHaveBeenCalledWith({ repos: [], cwd: '/repo/worktree' }) + }) + it('uses the selected project WSL distro for skill discovery', async () => { const handler = getDiscoverHandler() diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index e3f2e6a570b..b81fd695790 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -51,7 +51,8 @@ export function registerSkillsHandlers(store: Store): void { return discoverSkills({ repos: [], homeDir, cwd: homeDir }) } - return discoverSkills({ repos: store.getRepos() }) + const cwd = target?.cwd?.trim() || undefined + return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) } diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index db7660ce09e..39b266b6846 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -179,17 +179,21 @@ describe('registerAppMenu', () => { expect(paletteItem?.accelerator).toBeUndefined() }) - it('keeps Edit > Paste on the native Electron paste role in this split', () => { + it('routes Edit > Paste through Orca coordinated paste ownership', () => { const send = vi.fn() getFocusedWindowMock.mockReturnValue({ webContents: { send } }) registerAppMenu(buildMenuOptions()) const editSubmenu = getSubmenu(getTemplate(), 'Edit') - const pasteItem = editSubmenu.find((item) => item.role === 'paste') + const pasteItem = editSubmenu.find((item) => item.label === 'Paste') expect(pasteItem).toBeDefined() - expect(pasteItem?.click).toBeUndefined() - expect(send).not.toHaveBeenCalled() + expect(pasteItem?.role).toBeUndefined() + expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V') + + pasteItem?.click?.({} as never, {} as never, {} as never) + + expect(send).toHaveBeenCalledWith('ui:appMenuPaste') }) it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index eda524d44a6..a5390e36afc 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -162,7 +162,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, - { role: 'paste' }, + { + label: translateMain('menu.paste', 'Paste'), + accelerator: 'CmdOrCtrl+V', + click: () => { + // Why: a focused terminal/native-chat pane is not a native editable + // control, so raw Electron paste cannot know which Orca surface owns it. + BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste') + } + }, { role: 'selectAll' } ] } diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts new file mode 100644 index 00000000000..9e41174d169 --- /dev/null +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -0,0 +1,163 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveSessionFilePath } from './session-file-resolver' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + return root +} + +function restoreEnv(key: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } +} + +describe('resolveSessionFilePath', () => { + it('globs Claude project subdirs for .jsonl', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-123.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-123', { claudeProjectsDir }) + expect(resolved).toBe(target) + }) + + it('matches Codex rollout files by session id suffix', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-') + const codexSessionsDir = join(root, 'codex-sessions') + const dayDir = join(codexSessionsDir, '2026', '06', '04') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-2026-06-04T10-00-00-abc-session.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('codex', 'abc-session', { + codexSessionsDirs: [codexSessionsDir] + }) + expect(resolved).toBe(target) + }) + + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { + // Orca launches Codex with its own managed CODEX_HOME, so rollout files land + // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. + const root = await makeRoot('orca-native-chat-resolve-managed-') + const managedSessionsDir = join(root, 'codex-runtime-home', 'home', 'sessions') + const dayDir = join(managedSessionsDir, '2026', '06', '19') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-2026-06-19T04-20-39-019edf9c-managed.jsonl') + await writeFile(target, '{}\n') + + const previous = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = root + try { + const resolved = await resolveSessionFilePath('codex', '019edf9c-managed') + expect(resolved).toBe(target) + } finally { + if (previous === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previous + } + } + }) + + it('falls back to CODEX_HOME when the managed home has no match', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-home-') + const managedRoot = join(root, 'managed-userdata') + await mkdir(managedRoot, { recursive: true }) + const codexHome = join(root, 'custom-codex-home') + const dayDir = join(codexHome, 'sessions', '2026', '06', '05') + await mkdir(dayDir, { recursive: true }) + const target = join(dayDir, 'rollout-xyz-session.jsonl') + await writeFile(target, '{}\n') + + const previousCodex = process.env.CODEX_HOME + const previousUserData = process.env.ORCA_USER_DATA_PATH + process.env.CODEX_HOME = codexHome + // Point the managed home at an empty dir so the fallback is exercised. + process.env.ORCA_USER_DATA_PATH = managedRoot + try { + const resolved = await resolveSessionFilePath('codex', 'xyz-session') + expect(resolved).toBe(target) + } finally { + restoreEnv('CODEX_HOME', previousCodex) + restoreEnv('ORCA_USER_DATA_PATH', previousUserData) + } + }) + + it('returns null when no transcript matches', async () => { + const root = await makeRoot('orca-native-chat-resolve-missing-') + const claudeProjectsDir = join(root, 'claude-projects') + await mkdir(claudeProjectsDir, { recursive: true }) + expect(await resolveSessionFilePath('claude', 'nope', { claudeProjectsDir })).toBeNull() + }) + + it('returns null for unsupported agents', async () => { + expect(await resolveSessionFilePath('gemini', 'whatever')).toBeNull() + }) + + it('prefers the hook transcriptPath when it exists (Claude id != file name)', async () => { + // Recent Claude Code names the file with a UUID that differs from the hook + // session_id, so the id glob would miss it — but transcript_path is exact. + const root = await makeRoot('orca-native-chat-resolve-path-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + // The real transcript is named by a DIFFERENT id than the hook session id. + const realFile = join(projectDir, 'real-file-uuid.jsonl') + await writeFile(realFile, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'hook-session-id', { + claudeProjectsDir, + transcriptPath: realFile + }) + expect(resolved).toBe(realFile) + }) + + it('falls back to the id glob when the hook transcriptPath does not exist', async () => { + const root = await makeRoot('orca-native-chat-resolve-path-stale-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-xyz.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-xyz', { + claudeProjectsDir, + transcriptPath: join(projectDir, 'does-not-exist.jsonl') + }) + expect(resolved).toBe(target) + }) + + it('ignores a non-jsonl transcriptPath and falls back to the glob', async () => { + const root = await makeRoot('orca-native-chat-resolve-path-ext-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const bogus = join(projectDir, 'not-a-transcript.txt') + await writeFile(bogus, 'x') + const target = join(projectDir, 'sess-ok.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('claude', 'sess-ok', { + claudeProjectsDir, + transcriptPath: bogus + }) + expect(resolved).toBe(target) + }) +}) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts new file mode 100644 index 00000000000..7e94969408f --- /dev/null +++ b/src/main/native-chat/session-file-resolver.ts @@ -0,0 +1,117 @@ +import { existsSync } from 'fs' +import { homedir } from 'os' +import { basename, extname, join } from 'path' +import type { AgentType } from '../../shared/native-chat-types' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' + +// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads +// run in the main process against the runtime's own home directory; over SSH +// the remote main resolves its local home, so we never hardcode an absolute +// user path — homedir()/CODEX_HOME resolution stays runtime-relative and is +// computed per call (not at module load) so it tracks the live home. +function claudeProjectsDir(): string { + return join(homedir(), '.claude', 'projects') +} + +// Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed +// runtime home, so Orca-started Codex rollout files land under +// `/sessions`, NOT `~/.codex/sessions`. Search the managed home +// first (that's where this main process's Codex sessions actually live), then +// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves. +// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice. +function codexSessionsDirs(): string[] { + const candidates = [ + join(getOrcaManagedCodexHomePath(), 'sessions'), + join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions') + ] + return candidates.filter((dir, index) => candidates.indexOf(dir) === index) +} + +export type ResolveSessionFileOptions = { + /** Override the Claude projects root (used by tests / isolated scans). */ + claudeProjectsDir?: string + /** Override the Codex sessions roots, searched in order (tests / isolated + * scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */ + codexSessionsDirs?: string[] + /** Authoritative transcript path reported by the agent hook + * (`providerSession.transcriptPath`). When set and the file exists, it is used + * directly — recent Claude Code names the transcript with a UUID that differs + * from the hook session_id, so the id-based glob below would miss it. */ + transcriptPath?: string +} + +/** + * Resolve the on-disk JSONL transcript path for a given agent + session id. + * + * Prefers the hook-reported `transcriptPath` when it exists on disk (authoritative). + * Otherwise: Claude nests transcripts by project slug + * (`~/.claude/projects//.jsonl`), so we glob the projects subdirs for + * `.jsonl`. Codex stores rollout files under date-nested dirs whose file name + * embeds the session id, so we match by the session id appearing in the file name. + * Returns null when no matching transcript exists. + */ +export async function resolveSessionFilePath( + agent: AgentType, + sessionId: string, + options: ResolveSessionFileOptions = {} +): Promise { + // Why: the hook's transcript_path is the exact file the agent is writing, so it + // beats reconstructing a path from the session id. Guard with existsSync so a + // stale/remote path falls through to the id-based search rather than returning + // a non-existent file. + const hookPath = options.transcriptPath?.trim() + if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) { + return hookPath + } + + const trimmedId = sessionId.trim() + if (!trimmedId) { + return null + } + + if (agent === 'claude') { + return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) + } + if (agent === 'codex') { + return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) + } + return null +} + +async function resolveClaudeSessionFile( + sessionId: string, + projectsDir: string +): Promise { + const targetName = `${sessionId}.jsonl` + const files = await walkSessionFiles(projectsDir, 'claude', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => basename(path) === targetName + }) + return files[0] ?? null +} + +async function resolveCodexSessionFile( + sessionId: string, + sessionsDirs: string[] +): Promise { + // Codex rollout file names embed the session id (rollout--.jsonl), so + // match the id as a suffix of the file's base name rather than an exact name. + // Search each candidate root (managed home first) and stop at the first match. + for (const sessionsDir of sessionsDirs) { + if (!existsSync(sessionsDir)) { + continue + } + const files = await walkSessionFiles(sessionsDir, 'codex', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => { + const name = basename(path, extname(path)) + return name === sessionId || name.endsWith(`-${sessionId}`) + } + }) + if (files[0]) { + return files[0] + } + } + return null +} diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts new file mode 100644 index 00000000000..1a428cbb98a --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -0,0 +1,185 @@ +// Per-line record→NativeChatMessage decoders, shared by the full transcript +// reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so +// both paths apply identical record-shape mapping. Each decoder is stateless: +// it takes a single JSONL line plus a stable fallback id and returns one message +// or null (unknown/empty records are skipped, never thrown — plan KTD risk: +// schema drift). `fallbackId` is used only when the record carries no intrinsic +// id; the caller supplies a value unique per line. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +export function decodeClaudeTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const role = record.type + if (role !== 'user' && role !== 'assistant') { + return null + } + const message = asRecord(record.message) + const blocks = claudeContentBlocks(message?.content) + if (blocks.length === 0) { + return null + } + const messageId = extractString(record.uuid) ?? extractString(message?.id) + return { + id: messageId ?? fallbackId, + role: claudeMessageRole(role, blocks), + blocks, + timestamp: parseTimestamp(record.timestamp), + source: 'transcript' + } +} + +// Claude marks reasoning via `thinking` content blocks; when a message is made +// up solely of reasoning, surface it as a reasoning-role message. +function claudeMessageRole( + role: 'user' | 'assistant', + blocks: NativeChatBlock[] +): NativeChatMessage['role'] { + if (role === 'user') { + const onlyToolResults = blocks.every((block) => block.type === 'tool-result') + return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' + } + return role +} + +export function decodeCodexTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const payload = asRecord(record.payload) + if (!payload) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const baseId = extractString(payload.id) ?? fallbackId + + if (record.type === 'response_item') { + return codexResponseItem(payload, baseId, timestamp) + } + if (record.type === 'event_msg') { + return codexEventMessage(payload, baseId, timestamp) + } + return null +} + +function codexResponseItem( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'message') { + const blocks = claudeContentBlocks(payload.content) + if (blocks.length === 0) { + return null + } + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' + return { id, role, blocks, timestamp, source: 'transcript' } + } + if (payload.type === 'reasoning') { + const text = extractString(payload.text) ?? codexSummaryText(payload.summary) + if (!text) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call' || payload.type === 'local_shell_call') { + const name = extractString(payload.name) ?? 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call_output') { + return { + id, + role: 'tool', + blocks: [codexToolResult(payload.output)], + timestamp, + source: 'transcript' + } + } + return null +} + +function codexEventMessage( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'user_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + if (payload.type === 'agent_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + return null +} + +function codexCallInput(payload: Record): unknown { + if (payload.arguments !== undefined) { + return payload.arguments + } + return payload.input ?? payload.action ?? null +} + +function codexToolResult(output: unknown): NativeChatBlock { + const record = asRecord(output) + const isError = record?.success === false || record?.is_error === true + return { + type: 'tool-result', + output: toolResultOutput(record?.content ?? record?.output ?? output), + ...(isError ? { isError: true } : {}) + } +} + +function codexSummaryText(summary: unknown): string | null { + if (!Array.isArray(summary)) { + return null + } + const parts: string[] = [] + for (const item of summary) { + const text = extractString(asRecord(item)?.text) ?? extractString(item) + if (text) { + parts.push(text) + } + } + return parts.length ? parts.join('\n') : null +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts new file mode 100644 index 00000000000..04fd40355aa --- /dev/null +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TranscriptReader from './transcript-reader' + +// Spy on the underlying reader so we can assert cache hits issue zero reads. +const readSpy = vi.hoisted(() => vi.fn()) +vi.mock('./transcript-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNativeChatTranscript: (...args: Parameters) => { + readSpy(...args) + return actual.readNativeChatTranscript(...args) + } + } +}) + +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from './transcript-read-cache' + +let tempRoots: string[] = [] + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function seedSession(sessionId: string, turns: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + const records = Array.from({ length: turns }, (_unused, n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + const filePath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(filePath, jsonLines(records)) + process.env.HOME = root + return filePath +} + +beforeEach(() => { + clearNativeChatTranscriptCache() + readSpy.mockClear() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('readNativeChatTranscriptCached', () => { + it('returns the same cached object on an mtime hit without re-reading', async () => { + await seedSession('sess-hit', 3) + const first = await readNativeChatTranscriptCached('claude', 'sess-hit') + const second = await readNativeChatTranscriptCached('claude', 'sess-hit') + expect(readSpy).toHaveBeenCalledTimes(1) + // Same reference: the second call served the cached parse. + expect(second).toBe(first) + }) + + it('re-reads when the file mtime changes', async () => { + const filePath = await seedSession('sess-mtime', 2) + await readNativeChatTranscriptCached('claude', 'sess-mtime') + expect(readSpy).toHaveBeenCalledTimes(1) + // Bump mtime into the future to invalidate without changing content shape. + const future = new Date(Date.now() + 5_000) + await utimes(filePath, future, future) + await readNativeChatTranscriptCached('claude', 'sess-mtime') + expect(readSpy).toHaveBeenCalledTimes(2) + }) + + it('clear() empties the cache so the next read re-reads', async () => { + await seedSession('sess-clear', 1) + await readNativeChatTranscriptCached('claude', 'sess-clear') + clearNativeChatTranscriptCache() + await readNativeChatTranscriptCached('claude', 'sess-clear') + expect(readSpy).toHaveBeenCalledTimes(2) + }) + + it('returns an error result for an unknown session without throwing', async () => { + await seedSession('present', 1) + const result = await readNativeChatTranscriptCached('claude', 'absent') + expect('error' in result && result.error).toBeTruthy() + }) +}) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts new file mode 100644 index 00000000000..84151f1d590 --- /dev/null +++ b/src/main/native-chat/transcript-read-cache.ts @@ -0,0 +1,90 @@ +import { stat } from 'fs/promises' +import type { AgentType } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' +import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' + +// Why: both the desktop IPC handler and the runtime RPC handler read the same +// host-filesystem transcript, so a single process-global cache keyed by +// agent:sessionId maximizes the hit rate across desktop + every paired +// web/mobile client. Keying by connection instead would defeat the multi-client +// case this feature targets and multiply memory by the connection count. +// The cache stores ONE canonical, unwindowed parse; windowing and per-surface +// truncation stay in the callers so the same parse is reused across all `limit` +// values and every client kind. + +type CachedTranscript = { + result: ReadTranscriptResult + /** mtime of the resolved file when cached; a newer mtime invalidates it. */ + mtimeMs: number +} + +const cache = new Map() + +// Why: cap the cache so a long-lived process browsing many sessions can't grow +// it unbounded. Map preserves insertion order, so evicting the first key drops +// the oldest entry (a simple LRU once re-inserts bump recency; see setCached). +// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling +// shows RSS pressure now that one process serves many remote clients. +const MAX_CACHE_ENTRIES = 50 + +function setCached(key: string, value: CachedTranscript): void { + // Re-insert moves the key to the most-recent position for LRU eviction. + cache.delete(key) + cache.set(key, value) + while (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } +} + +function cacheKey(agent: AgentType, sessionId: string): string { + return `${agent}:${sessionId}` +} + +async function fileMtimeMs(filePath: string): Promise { + try { + return (await stat(filePath)).mtimeMs + } catch { + return Number.NaN + } +} + +/** + * Read the full transcript for an agent + session, returning the cached parse on + * an mtime hit and re-reading (and re-caching) when the file changed. Returns the + * canonical, unwindowed result; callers apply their own windowing/truncation. + */ +export async function readNativeChatTranscriptCached( + agent: AgentType, + sessionId: string, + /** Hook-reported authoritative transcript path, preferred over the id glob. */ + transcriptPath?: string +): Promise { + const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + if (!filePath) { + return { error: `No transcript found for ${agent} session ${sessionId}` } + } + + const key = cacheKey(agent, sessionId) + const mtimeMs = await fileMtimeMs(filePath) + const cached = cache.get(key) + if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { + // Bump recency so a frequently-read session survives eviction. + setCached(key, cached) + return cached.result + } + + const result = await readNativeChatTranscript(agent, sessionId, { filePath }) + if (Number.isFinite(mtimeMs)) { + setCached(key, { result, mtimeMs }) + } + return result +} + +/** Test-only: drop the per-session transcript cache between runs. */ +export function clearNativeChatTranscriptCache(): void { + cache.clear() +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts new file mode 100644 index 00000000000..864715011d1 --- /dev/null +++ b/src/main/native-chat/transcript-reader.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { readNativeChatTranscript } from './transcript-reader' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +async function writeFixture(prefix: string, records: unknown[]): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, jsonLines(records)) + return filePath +} + +describe('readNativeChatTranscript (claude)', () => { + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { + const records: unknown[] = [] + // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. + for (let turn = 0; turn < 4; turn++) { + records.push({ + type: 'user', + uuid: `u-${turn}`, + timestamp: `2026-06-01T10:0${turn}:00.000Z`, + message: { role: 'user', content: `Prompt **${turn}**` } + }) + records.push({ + type: 'assistant', + uuid: `a-${turn}`, + timestamp: `2026-06-01T10:0${turn}:30.000Z`, + message: { role: 'assistant', content: [{ type: 'text', text: `Reply _${turn}_` }] } + }) + } + // A tool_use then a tool_result (carried on a user record). + records.push({ + type: 'assistant', + uuid: 'a-tool', + timestamp: '2026-06-01T10:05:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] + } + }) + records.push({ + type: 'user', + uuid: 'u-toolresult', + timestamp: '2026-06-01T10:05:01.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'file-a\nfile-b', is_error: false }] + } + }) + + const filePath = await writeFixture('orca-native-chat-claude-', records) + const result = await readNativeChatTranscript('claude', 'sess', { filePath }) + expect('messages' in result).toBe(true) + if (!('messages' in result)) { + return + } + + expect(result.messages.length).toBe(10) + expect(result.messages.length).toBeGreaterThan(5) + expect(result.messages[0]).toMatchObject({ role: 'user', source: 'transcript' }) + // Markdown text preserved verbatim. + expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'Prompt **0**' }) + expect(result.messages[1].blocks[0]).toEqual({ type: 'text', text: 'Reply _0_' }) + + const toolCall = result.messages.find((m) => m.blocks[0]?.type === 'tool-call') + expect(toolCall?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'Bash', + input: { command: 'ls' } + }) + + const toolResult = result.messages.at(-1) + expect(toolResult?.role).toBe('tool') + expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' }) + }) + + it('marks thinking-only assistant content as a reasoning surface', async () => { + const filePath = await writeFixture('orca-native-chat-claude-think-', [ + { + type: 'assistant', + uuid: 'a-think', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'pondering' }] } + } + ]) + const result = await readNativeChatTranscript('claude', 'sess', { filePath }) + if (!('messages' in result)) { + throw new Error('expected messages') + } + expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'pondering' }) + }) +}) + +describe('readNativeChatTranscript (codex)', () => { + it('maps tool calls and results to tool-call/tool-result blocks', async () => { + const filePath = await writeFixture('orca-native-chat-codex-', [ + { + type: 'session_meta', + timestamp: '2026-06-01T10:00:00.000Z', + payload: { id: 'codex-sess', cwd: '/repo' } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Run the build' }] + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:02.000Z', + payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will run it' }] } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:03.000Z', + payload: { + type: 'function_call', + name: 'shell', + arguments: '{"command":["bash","-lc","make"]}' + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:04.000Z', + payload: { + type: 'function_call_output', + output: { content: 'build ok', success: true } + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:05.000Z', + payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Done.' }] } + } + ]) + + const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath }) + if (!('messages' in result)) { + throw new Error(`expected messages, got error`) + } + + const roles = result.messages.map((m) => m.role) + expect(roles).toEqual(['user', 'reasoning', 'assistant', 'tool', 'assistant']) + + const call = result.messages.find((m) => m.blocks[0]?.type === 'tool-call') + expect(call?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'shell', + input: '{"command":["bash","-lc","make"]}' + }) + + const toolResult = result.messages.find((m) => m.blocks[0]?.type === 'tool-result') + expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'build ok' }) + + const reasoning = result.messages.find((m) => m.role === 'reasoning') + expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' }) + }) +}) + +describe('readNativeChatTranscript (errors)', () => { + it('returns an error for an unreadable/missing file without throwing', async () => { + const result = await readNativeChatTranscript('claude', 'sess', { + filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl') + }) + expect('error' in result).toBe(true) + }) + + it('returns an error when no transcript can be resolved', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-')) + tempRoots.push(root) + const result = await readNativeChatTranscript('claude', 'missing', { + claudeProjectsDir: join(root, 'empty') + }) + expect('error' in result).toBe(true) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts new file mode 100644 index 00000000000..7fe13f0aadf --- /dev/null +++ b/src/main/native-chat/transcript-reader.ts @@ -0,0 +1,66 @@ +import { createReadStream } from 'fs' +import { createInterface } from 'readline' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { errorMessage } from '../ai-vault/session-scanner-values' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' + +export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } + +export type ReadTranscriptOptions = ResolveSessionFileOptions & { + /** Resolve directly to this file, skipping path discovery (used by tests). */ + filePath?: string +} + +/** + * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into + * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies + * NO message cap. Unknown record types are skipped rather than throwing, so a + * single malformed/unrecognized line cannot fail the whole read. The per-line + * record→message mapping is shared with the live tailer (transcript-watch.ts) + * via transcript-line-decoders.ts. + */ +export async function readNativeChatTranscript( + agent: AgentType, + sessionId: string, + options: ReadTranscriptOptions = {} +): Promise { + const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + if (!filePath) { + return { error: `No transcript found for ${agent} session ${sessionId}` } + } + try { + if (agent === 'claude') { + return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } + } + if (agent === 'codex') { + return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } + } + return { error: `Unsupported agent for native chat transcript: ${agent}` } + } catch (err) { + return { error: errorMessage(err) } + } +} + +async function readTranscript( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise { + const reader = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + const messages: NativeChatMessage[] = [] + let index = 0 + for await (const line of reader) { + // Why: fallback id embeds start offset 0 so it matches the live tailer's id + // for the same record (the tailer's first drain reads from offset 0 too). + // Records that re-emit then collapse by id in the assembler — no dup, no drop. + const message = decode(line, `${filePath}:0:${index}`) + if (message) { + messages.push(message) + } + index++ + } + return messages +} diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts new file mode 100644 index 00000000000..6355df277e5 --- /dev/null +++ b/src/main/native-chat/transcript-record-blocks.ts @@ -0,0 +1,117 @@ +// Centralized record→block mapping for native-chat transcripts. Kept separate +// from the reader so the Claude and Codex per-record decoders share one place +// to evolve as CLI transcript schemas drift (plan KTD risk: schema drift). + +import type { + NativeChatBlock, + NativeChatImageRefBlock, + NativeChatToolResultBlock +} from '../../shared/native-chat-types' +import { asRecord, extractString } from '../ai-vault/session-scanner-values' + +/** Coerce an arbitrary tool-result payload into a single output string. */ +export function toolResultOutput(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + const record = asRecord(value) + if (record) { + const text = extractString(record.text) ?? extractString(record.content) + if (text) { + return text + } + } + return value === undefined || value === null ? '' : JSON.stringify(value) + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + const record = asRecord(item) + const text = extractString(record?.text) ?? extractString(record?.content) + if (text) { + parts.push(text) + } + } + return parts.join('\n') +} + +/** Build the blocks for one Claude content array (string or block[]). */ +export function claudeContentBlocks(content: unknown): NativeChatBlock[] { + if (typeof content === 'string') { + const text = content.trim() + return text ? [{ type: 'text', text: content }] : [] + } + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of content) { + if (typeof item === 'string') { + if (item.trim()) { + blocks.push({ type: 'text', text: item }) + } + continue + } + const record = asRecord(item) + if (!record) { + continue + } + const block = claudeContentBlock(record) + if (block) { + blocks.push(block) + } + } + return blocks +} + +function claudeContentBlock(record: Record): NativeChatBlock | null { + switch (record.type) { + case 'text': { + const text = extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'thinking': { + // Reasoning surfaces as a text block; the message role marks it as reasoning. + const text = extractString(record.thinking) ?? extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'tool_use': { + const name = extractString(record.name) ?? 'tool' + return { type: 'tool-call', name, input: record.input } + } + case 'tool_result': + return toolResultBlock(record) + case 'image': + return imageRefBlock(record) + default: + return null + } +} + +function toolResultBlock(record: Record): NativeChatToolResultBlock { + return { + type: 'tool-result', + output: toolResultOutput(record.content), + ...(record.is_error === true ? { isError: true } : {}) + } +} + +function imageRefBlock(record: Record): NativeChatImageRefBlock | null { + const source = asRecord(record.source) + const url = extractString(source?.url) ?? extractString(record.url) + const path = extractString(record.path) + const alt = extractString(record.alt) ?? undefined + if (!url && !path) { + return null + } + return { + type: 'image-ref', + ...(path ? { path } : {}), + ...(url ? { url } : {}), + ...(alt ? { alt } : {}) + } +} diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts new file mode 100644 index 00000000000..9c1be087cbf --- /dev/null +++ b/src/main/native-chat/transcript-watch.test.ts @@ -0,0 +1,239 @@ +import { appendFile, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function tempFile(initial: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-')) + tempRoots.push(root) + const filePath = join(root, 'rollout.jsonl') + await writeFile(filePath, initial) + return filePath +} + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('timed out waiting for condition') + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +describe('subscribeNativeChatTranscript', () => { + it('re-emits from the top on first drain so appended turns are never dropped', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const batches: NativeChatMessage[][] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => batches.flat().some((m) => m.id === 'a-1')) + + sub.unsubscribe() + + // Seed-at-0 means the first drain re-reads the whole file; the assembler + // dedups by id. The appended turn must appear; the pre-existing line may + // appear too (collapsed downstream by id). + const ids = batches.flat().map((m) => m.id) + expect(ids).toContain('a-1') + }) + + it('appends a turn in the gap between initial read and first watcher drain exactly once', async () => { + // Simulate the read/subscribe race: a turn lands after the caller's + // readSession EOF but before the watcher's first drain. Seeding at 0 means + // the first drain reads it; the assembler later dedups by deterministic id. + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const seen: NativeChatMessage[] = [] + + // The gap turn is written BEFORE subscribe completes its first drain. + await appendFile(filePath, claudeLine('a-gap', 'assistant', 'raced reply')) + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + await waitFor(() => seen.some((m) => m.id === 'a-gap')) + sub.unsubscribe() + + // The raced turn is present, and not duplicated within a single drain pass. + expect(seen.filter((m) => m.id === 'a-gap')).toHaveLength(1) + }) + + it('recovers cleanly when a read throws (subscription not left deaf)', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + // Make the file unreadable mid-flight (EACCES on the read path). The drain's + // try/catch must break and reset `reading` in finally so a later append + // still tails once permissions are restored. + await waitFor(() => seen.some((m) => m.id === 'u-1')) + const { chmod } = await import('fs/promises') + await chmod(filePath, 0o000) + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')).catch(() => {}) + // Give the watcher a chance to attempt (and fail) a drain. + await new Promise((resolve) => setTimeout(resolve, 40)) + await chmod(filePath, 0o644) + await appendFile(filePath, claudeLine('a-2', 'assistant', 'recovered')) + + await waitFor(() => seen.some((m) => m.id === 'a-2')) + sub.unsubscribe() + expect(seen.some((m) => m.id === 'a-2')).toBe(true) + }) + + it('releases the watcher on unsubscribe (no leak)', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const before = getActiveNativeChatWatcherCount() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + debounceMs: 5 + }) + expect(getActiveNativeChatWatcherCount()).toBe(before + 1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + + // Idempotent: a second unsubscribe must not under-count. + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) + + it('coalesces rapid successive appends without dropping messages', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 10 + }) + + // Fire several appends back-to-back within the debounce window. + await appendFile(filePath, claudeLine('a-1', 'assistant', 'one')) + await appendFile(filePath, claudeLine('a-2', 'assistant', 'two')) + await appendFile(filePath, claudeLine('a-3', 'assistant', 'three')) + + await waitFor(() => ['a-1', 'a-2', 'a-3'].every((id) => seen.some((m) => m.id === id))) + sub.unsubscribe() + + // Order is preserved for the appended turns (the seed re-read may also carry + // the pre-existing u-1, which the assembler dedups downstream). + const appendedIds = seen.map((m) => m.id).filter((id) => id !== 'u-1') + expect(appendedIds).toEqual(['a-1', 'a-2', 'a-3']) + }) + + it('waits for an incomplete trailing JSONL line before advancing the offset', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + await waitFor(() => seen.some((m) => m.id === 'u-1')) + + const line = claudeLine('a-partial', 'assistant', 'split reply') + const splitAt = Math.floor(line.length / 2) + await appendFile(filePath, line.slice(0, splitAt)) + await new Promise((resolve) => setTimeout(resolve, 40)) + expect(seen.some((m) => m.id === 'a-partial')).toBe(false) + + await appendFile(filePath, line.slice(splitAt)) + await waitFor(() => seen.some((m) => m.id === 'a-partial')) + + sub.unsubscribe() + expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1) + }) + + it('survives file replacement / rotation (offset reset on shrink)', async () => { + const filePath = await tempFile( + claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply') + ) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + // Replace the file with shorter content (simulates rotation to a new, + // smaller session file at the same resolved path). + await writeFile(filePath, claudeLine('u-2', 'user', 'fresh')) + await waitFor(() => seen.some((m) => m.id === 'u-2')) + + // A subsequent append on the rotated file is still tailed. + await appendFile(filePath, claudeLine('a-2', 'assistant', 'fresh-reply')) + await waitFor(() => seen.some((m) => m.id === 'a-2')) + + sub.unsubscribe() + const ids = seen.map((m) => m.id) + expect(ids).toContain('u-2') + expect(ids).toContain('a-2') + }) + + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { + const before = getActiveNativeChatWatcherCount() + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: '', + onAppend: () => {} + }) + expect(getActiveNativeChatWatcherCount()).toBe(before) + // Must not throw. + sub.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts new file mode 100644 index 00000000000..ab202f59147 --- /dev/null +++ b/src/main/native-chat/transcript-watch.ts @@ -0,0 +1,254 @@ +// Live transcript tailing: watch a resolved session JSONL file and emit only +// the messages parsed from bytes appended since the last read. Modeled on the +// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's +// skipInitialBytes), but specialized to the NativeChatMessage record decoders. +// +// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription +// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes +// the watcher and clears the timer synchronously, and the module tracks the live +// watcher count so tests can assert no watcher survives teardown. + +import { watch, type FSWatcher } from 'fs' +import { open, stat } from 'fs/promises' +import type { Readable } from 'stream' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + /** Called with the newly-appended messages whenever the file grows. Never + * called with an empty array. */ + onAppend: (messages: NativeChatMessage[]) => void + /** Resolve directly to this file, skipping path discovery (used by tests). */ + filePath?: string + /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ + debounceMs?: number +} + +export type NativeChatTranscriptSubscription = { + /** Closes the watcher and releases the file handle. Idempotent. */ + unsubscribe: () => void +} + +// Why: a single watch event can fire several times for one append; we read from +// the last byte offset so re-entrant reads never re-emit prior messages. Each +// decoder is stateless per-line, so tailing reuses the same record→message +// mapping the full reader uses. +const DEFAULT_DEBOUNCE_MS = 40 + +// Why: process-wide count of live FSWatchers opened by this module. The U4 leak +// test asserts this returns to zero after unsubscribe so a forgotten handle is +// caught deterministically rather than relying on OS fd inspection. +let activeWatcherCount = 0 + +/** Test-only: number of fs watchers this module currently holds open. */ +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +function lineDecoderForAgent( + agent: AgentType +): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { + if (agent === 'claude') { + return decodeClaudeTranscriptLine + } + if (agent === 'codex') { + return decodeCodexTranscriptLine + } + return null +} + +async function fileSize(filePath: string): Promise { + try { + return (await stat(filePath)).size + } catch { + return 0 + } +} + +/** + * Read bytes [start, end) of the file and decode each complete line into a + * NativeChatMessage. Opens its own fd and always closes it (no leak on the read + * path, distinct from the long-lived watcher). Returns the messages plus the + * byte offset actually consumed so a partially-written trailing line is re-read + * on the next append rather than dropped. + */ +async function readAppendedMessages( + filePath: string, + start: number, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { + const end = await fileSize(filePath) + if (end <= start) { + // File shrank (rotation/replacement) or unchanged — caller resets offset. + return { messages: [], consumedTo: end } + } + + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ + encoding: 'utf-8', + start, + end: end - 1, + autoClose: false + }) + const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode) + return { messages, consumedTo: start + consumedBytes } + } finally { + await handle.close() + } +} + +async function decodeStreamLines( + stream: Readable, + filePath: string, + start: number, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { + const text = await readStreamText(stream) + const completeEnd = text.lastIndexOf('\n') + if (completeEnd === -1) { + return { messages: [], consumedBytes: 0 } + } + + // Why: transcript writers can flush mid-record. Only advance through + // newline-terminated JSONL so invalid partial JSON is retried on the next + // append instead of being lost forever. + const completeText = text.slice(0, completeEnd + 1) + const lines = completeText.split('\n') + const messages: NativeChatMessage[] = [] + for (const [index, line] of lines.entries()) { + if (!line) { + continue + } + // Fallback id embeds the byte offset so ids stay stable+unique across + // appends even when a record carries no intrinsic id. + const message = decode(line, `${filePath}:${start}:${index}`) + if (message) { + messages.push(message) + } + } + return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') } +} + +async function readStreamText(stream: Readable): Promise { + const chunks: string[] = [] + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + } + return chunks.join('') +} + +/** + * Subscribe to live appends on an agent's transcript file. Returns an + * unsubscribe fn that tears the watcher down completely. + * + * Handles file rotation/replacement: when the file shrinks (a new session id + * resolved to a smaller/newer file, or the file was truncated), the offset is + * reset to 0 so the replacement's content is read from the top. + */ +export async function subscribeNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs +): Promise { + const { agent, sessionId, onAppend, debounceMs } = args + const decode = lineDecoderForAgent(agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args)) + + if (!filePath || !decode) { + // Nothing watchable — return a no-op teardown so callers can unconditionally + // unsubscribe without null-checks. + return { unsubscribe: () => {} } + } + + // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This + // closes the read/subscribe race — a turn appended between the caller's + // readSession EOF and the watcher install is still emitted. Re-emitted lines + // collapse by deterministic id in the assembler (no dup, no drop). Subsequent + // drains use the incremental offset so the full re-read happens only once. + let offset = 0 + let closed = false + let reading = false + let pendingReadRequested = false + let debounceTimer: ReturnType | null = null + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + // A read is already in flight; mark that another pass is needed so rapid + // successive appends coalesce without dropping the trailing one. + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + const currentSize = await fileSize(filePath!) + if (currentSize < offset) { + // Rotation/replacement/truncation: re-read from the top. + offset = 0 + } + const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!) + offset = consumedTo + if (!closed && messages.length > 0) { + onAppend(messages) + } + } catch { + // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) + // must not leave the subscription permanently deaf. Stop this drain; + // the finally resets `reading` so a later fs event re-arms the read. + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + let watcher: FSWatcher + try { + watcher = watch(filePath, scheduleDrain) + } catch { + // File vanished between resolve and watch — return a no-op teardown. + return { unsubscribe: () => {} } + } + activeWatcherCount++ + + // Why: on some platforms fs.watch can miss the very first append that lands + // between offset-seed and watcher install. Kick one debounced drain so a + // turn written immediately after subscribe is still picked up. + scheduleDrain() + + return { + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher.close() + activeWatcherCount-- + } + } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9904cc54adc..c48cf0c47d7 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -8621,3 +8621,85 @@ describe('Store host-partitioned workspace sessions', () => { expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() }) }) + +describe('Store native-chat tab viewMode persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab + // persisted before the field existed must default to 'terminal' — i.e. the + // field is absent on restore — so older sessions stay backward-compatible. + it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => { + const WORKTREE = 'repo1::/worktree' + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: WORKTREE, + activeTabId: 'chat-tab', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + unifiedTabs: { + [WORKTREE]: [ + { + id: 'chat-tab', + entityId: 'chat-tab', + groupId: 'g1', + worktreeId: WORKTREE, + contentType: 'terminal', + label: 'Agent', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + viewMode: 'chat' + }, + { + // Legacy tab persisted before viewMode existed — no field at all. + id: 'legacy-tab', + entityId: 'legacy-tab', + groupId: 'g1', + worktreeId: WORKTREE, + contentType: 'terminal', + label: 'Legacy', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + }, + tabGroups: { + [WORKTREE]: [ + { + id: 'g1', + worktreeId: WORKTREE, + activeTabId: 'chat-tab', + tabOrder: ['chat-tab', 'legacy-tab'] + } + ] + } + } + }) + + const store = await createStore() + const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? [] + const chatTab = restored.find((tab) => tab.id === 'chat-tab') + const legacyTab = restored.find((tab) => tab.id === 'legacy-tab') + + expect(chatTab?.viewMode).toBe('chat') + // Missing on a legacy tab; renderer hydration treats absent as 'terminal'. + expect(legacyTab?.viewMode).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index edc10ea87f2..0c11b668578 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11111,7 +11111,12 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // The stale "working" status is suppressed (no spinner), but agent identity + // is retained so native chat can still address the idle agent's transcript. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('suppresses saved mobile agent status when the current terminal title is neutral', async () => { @@ -11178,7 +11183,11 @@ describe('OrcaRuntimeService', () => { title: 'bash' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => { @@ -11251,7 +11260,11 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => { @@ -12793,7 +12806,11 @@ describe('OrcaRuntimeService', () => { title: 'claude agents' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => { @@ -12864,7 +12881,11 @@ describe('OrcaRuntimeService', () => { title: 'zsh' }) ) - expect(result.tabs[0]).not.toHaveProperty('agentStatus') + // Stale "working" suppressed; agent identity retained for native chat. + const suppressed = result.tabs[0] + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude') + expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined() }) it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => { @@ -13391,6 +13412,36 @@ describe('OrcaRuntimeService', () => { expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false) }) + it('persists headless tab viewMode and surfaces it through a cold rehydrate', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + viewMode: 'chat' + }) + + const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find( + (tab) => tab.id === 'host-tab' + )! + expect(persisted.viewMode).toBe('chat') + + const live = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const liveSurface = live.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(liveSurface?.type === 'terminal' && liveSurface.viewMode).toBe('chat') + + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.viewMode).toBe('chat') + }) + it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => { // Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow, // and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index affb23d6a77..8595f783e4a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3293,6 +3293,7 @@ export class OrcaRuntimeService { ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout) } }) @@ -4208,7 +4209,12 @@ export class OrcaRuntimeService { // was never persisted. Persist to the workspace session + live snapshot. async setMobileSessionTabProps( worktreeSelector: string, - args: { tabId: string; color?: string | null; isPinned?: boolean } + args: { + tabId: string + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + } ): Promise<{ updated: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -4230,7 +4236,7 @@ export class OrcaRuntimeService { private persistHeadlessSessionTabProps( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean } + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { @@ -4248,7 +4254,8 @@ export class OrcaRuntimeService { ? { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } : tab ) @@ -4281,7 +4288,7 @@ export class OrcaRuntimeService { private applyHeadlessSessionTabPropsToSnapshot( worktreeId: string, tabId: string, - props: { color?: string | null; isPinned?: boolean } + props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { @@ -4296,7 +4303,8 @@ export class OrcaRuntimeService { return { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) + ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), + ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } }) if (!changed) { @@ -17818,10 +17826,41 @@ export class OrcaRuntimeService { const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title const liveTitleEvidence = leafTitle ?? ptyTitle const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) - const agentStatus = + // Why: keep the rich hook-driven status when the agent has a live + // interactive prompt or an active tool — those are authoritative agent + // activity even if the terminal's title isn't agent-classified (e.g. it + // shows a task/branch name). Otherwise the mobile/web client falls back to + // the OSC-title-only status and never sees interactivePrompt (the question + // card never renders). + const hasLiveAgentSignal = + tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null + const keepFullAgentStatus = tab.agentStatus && - (liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent') - ? { agentStatus: tab.agentStatus } + (liveTitleEvidence === null || + liveTitleEvidenceClassification === 'agent' || + hasLiveAgentSignal) + const agentStatus = keepFullAgentStatus + ? { agentStatus: tab.agentStatus } + : // Why: when live title evidence says the pane is idle (e.g. the Claude + // agents picker or a neutral shell title), suppress the stale "working" + // state so the client shows no spinner — but retain agent identity + // (agentType + providerSession) so native chat can still address an + // idle agent's transcript. Reset the transient state to 'done'. + tab.agentStatus?.agentType != null + ? { + agentStatus: { + state: 'done' as const, + prompt: '', + updatedAt: tab.agentStatus.updatedAt, + stateStartedAt: tab.agentStatus.stateStartedAt, + paneKey: tab.agentStatus.paneKey, + stateHistory: [], + agentType: tab.agentStatus.agentType, + ...(tab.agentStatus.providerSession + ? { providerSession: tab.agentStatus.providerSession } + : {}) + } + } : null // Why: web/mobile clients hold these handles across renderer graph syncs; // leaf handles are graph-epoch-bound, but PTY handles remain streamable. @@ -17849,6 +17888,7 @@ export class OrcaRuntimeService { ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index bac75923dc4..1ef817bf552 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -59,6 +59,11 @@ export type RpcContext = { // Why: WebSocket RPCs authenticate by mobile device token. State-owning // handlers use this to clean up when that paired device disconnects. clientId?: string + // Why: payload windowing/truncation tuned for the constrained mobile payload + // (e.g. native-chat block char cap) must not clip full-screen web/desktop + // clients. Carries the paired device's scope so handlers can gate the diet to + // phones only. Undefined for in-process callers → treat as full-class (no clip). + clientKind?: 'mobile' | 'runtime' // Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming // responses after the binary terminal cutover. Undefined on Unix/socket // transports and non-E2EE WebSocket paths. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 61b58e3aab0..e1c2fcd38f3 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -93,6 +93,7 @@ export class RpcDispatcher { connectionId?: string signal?: AbortSignal clientId?: string + clientKind?: 'mobile' | 'runtime' sendBinary?: (bytes: Uint8Array) => void registerBinaryStreamHandler?: ( streamId: number, @@ -125,6 +126,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + clientKind: options?.clientKind, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) @@ -158,6 +160,7 @@ export class RpcDispatcher { requestId: request.id, connectionId: options?.connectionId, clientId: options?.clientId, + clientKind: options?.clientKind, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler }, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bff744016c3..8018375a8a0 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -15,6 +15,7 @@ import { ACCOUNT_METHODS } from './accounts' import { PREFLIGHT_METHODS } from './preflight' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' +import { NATIVE_CHAT_METHODS } from './native-chat' import { FILE_METHODS } from './files' import { GIT_METHODS } from './git' import { GITHUB_METHODS } from './github' @@ -53,6 +54,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...PREFLIGHT_METHODS, ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, + ...NATIVE_CHAT_METHODS, ...FILE_METHODS, ...GIT_METHODS, ...GITHUB_METHODS, diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts new file mode 100644 index 00000000000..4d428c9e5f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' + +// Stub the shared cache so the handler returns a deterministic transcript with +// one oversized tool-result block; the test then asserts clip behavior per client. +const OVERSIZED = 'x'.repeat(5000) +const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) +vi.mock('../../../native-chat/transcript-read-cache', () => ({ + readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +})) + +import { NATIVE_CHAT_METHODS } from './native-chat' + +function makeMessage(text: string): NativeChatMessage { + return { + id: 'a-1', + role: 'assistant', + timestamp: 1_717_236_000_000, + source: 'transcript', + blocks: [{ type: 'tool-result', output: text, isError: false }] + } +} + +function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise { + const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession') + if (!method) { + throw new Error('readSession method not registered') + } + return method.handler as (params: unknown, ctx: RpcContext) => Promise +} + +function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { + return { runtime: {} as RpcContext['runtime'], clientKind } +} + +function firstOutput(result: unknown): string { + const messages = (result as { messages: NativeChatMessage[] }).messages + const block = messages[0].blocks[0] as { output: string } + return block.output +} + +describe('nativeChat.readSession clientKind truncation gating', () => { + it('clips oversized tool output for mobile clients', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const output = firstOutput(result) + expect(output.length).toBeLessThan(OVERSIZED.length) + expect(output).toContain('truncated') + }) + + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('runtime') + ) + expect(firstOutput(result)).toBe(OVERSIZED) + }) + + it('defaults to no clip when clientKind is undefined (in-process callers)', async () => { + cachedResult.value = { messages: [makeMessage(OVERSIZED)] } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith(undefined) + ) + expect(firstOutput(result)).toBe(OVERSIZED) + }) + + it('windows by count for all client kinds', async () => { + const many = Array.from({ length: 60 }, (_unused, n) => { + const message = makeMessage('small') + return { ...message, id: `m-${n}` } + }) + cachedResult.value = { messages: many } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + ctxWith('runtime') + ) + const messages = (result as { messages: NativeChatMessage[] }).messages + expect(messages).toHaveLength(40) + // Tail-only: the last id survives, the first is dropped. + expect(messages.at(-1)?.id).toBe('m-59') + expect(messages[0].id).toBe('m-20') + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts new file mode 100644 index 00000000000..f434df1d4ed --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentType } from '../../../../shared/native-chat-types' +import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' +import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' + +// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The +// desktop reaches the readers via Electron IPC; mobile/web clients reach the +// same pure readers through these runtime RPC methods so the native chat view +// works over the paired connection, not just in the desktop renderer. + +const NativeChatSession = z.object({ + agent: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing agent')) + .transform((v) => v as AgentType), + sessionId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing session id')), + // How many of the most-recent messages to return. Clients start small for a + // fast first paint and raise it to page older history in as the user scrolls. + limit: z.number().int().positive().max(2000).optional(), + // Optional client-supplied cleanup token. When present, the subscribe handler + // keys the fs-watcher cleanup under it so registration and unsubscribe derive + // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, + // which is exactly what existing mobile clients rely on). + subscriptionId: z.string().min(1).optional(), + // Authoritative transcript path from the agent hook (providerSession), used to + // locate the file directly when the session id no longer names it (recent + // Claude Code). Optional for back-compat with older clients. + transcriptPath: z.string().min(1).optional() +}) + +const NativeChatUnsubscribe = z.object({ + subscriptionId: z.string().min(1).optional() +}) + +// Why: a long agent session can hold thousands of turns (with full tool I/O). +// Shipping all of them over the paired connection and rendering them at once +// freezes the mobile app, so the runtime RPC windows to the most recent slice — +// the conversation tail is what the chat view shows first. The desktop IPC path +// is unaffected (it reads locally with a virtualized list). +// Small first page for a fast initial paint; the client raises `limit` to load +// older history as the user scrolls back. +const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. +// The mobile view only previews block bodies, so truncate them on the wire to +// keep the payload small; the marker tells the user content was clipped. +const MOBILE_BLOCK_CHAR_CAP = 4000 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string): string { + return text.length > MOBILE_BLOCK_CHAR_CAP + ? text.slice(0, MOBILE_BLOCK_CHAR_CAP) + TRUNCATION_MARKER + : text +} + +function clipBlock(block: NativeChatBlock): NativeChatBlock { + if (block.type === 'text') { + return block.text.length > MOBILE_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text) } : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output) } + : block + } + return block +} + +function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { + return { ...message, blocks: message.blocks.map(clipBlock) } +} + +/** Window a transcript to its most recent `limit` messages so a long session + * can't freeze the client. Windowing by count applies to ALL RPC clients — + * shipping thousands of turns over the paired link is bad for web and mobile + * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ +function windowTranscript( + messages: readonly NativeChatMessage[], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) + return messages.length > window ? messages.slice(-window) : messages.slice() +} + +/** Apply the windowed slice plus, for `mobile` clients only, oversized-block + * char truncation. Web/desktop (`runtime`, or undefined for in-process callers) + * are full-class surfaces and pass block bodies through untruncated — matching + * the desktop IPC path, which never clips. */ +function windowForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const windowed = windowTranscript(messages, limit) + return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed +} + +export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'nativeChat.readSession', + params: NativeChatSession, + handler: async (params, { clientKind }) => { + const result = await readNativeChatTranscriptCached( + params.agent, + params.sessionId, + params.transcriptPath + ) + // Window to the conversation tail (all clients); clip blocks for mobile only. + return 'messages' in result + ? { messages: windowForClient(result.messages, clientKind, params.limit) } + : result + } + }), + defineStreamingMethod({ + name: 'nativeChat.subscribe', + params: NativeChatSession, + handler: async (params, { runtime, connectionId, clientKind }, emit) => { + let closed = false + let unsubscribe = (): void => {} + // Why: the subscriber seeds its read offset at 0, so the first drain emits + // the whole transcript and later drains emit only appended turns. The first + // batch is windowed to the tail (a full transcript would freeze mobile); + // later incremental batches are smaller than the window so they pass through. + // Clients merge by message id, so the initial windowed batch doubles as the + // snapshot. Keyed by the client-supplied subscriptionId when present so + // registration and unsubscribe derive from the same token; otherwise by + // agent:sessionId, which is exactly the token existing mobile clients send to + // unsubscribe (no wire break). + const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` + const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + unsubscribe() + emit({ type: 'end' }) + }, + connectionId + ) + if (closed) { + return + } + const subscription = await subscribeNativeChatTranscript({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + onAppend: (messages) => { + if (closed) { + return + } + emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + } + }) + // The connection may have closed while the file was being resolved. + if (closed) { + subscription.unsubscribe() + return + } + unsubscribe = subscription.unsubscribe + } + }), + defineMethod({ + name: 'nativeChat.unsubscribe', + params: NativeChatUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + const connection = connectionId ?? 'local' + if (params.subscriptionId) { + runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`) + return { unsubscribed: true } + } + runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 275514a39f5..2c7c3183946 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -104,7 +104,9 @@ export const SetTabProps = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')), // undefined = leave unchanged; null = clear color / unset. color: z.string().max(64).nullable().optional(), - isPinned: z.boolean().optional() + isPinned: z.boolean().optional(), + // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). + viewMode: z.enum(['terminal', 'chat']).optional() }) export const CreateTerminalTab = WorktreeTabSelector.extend({ diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 59eb3d04ecc..ebdcc2a7f2c 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -103,7 +103,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ runtime.setMobileSessionTabProps(params.worktree, { tabId: params.tabId, ...(params.color !== undefined ? { color: params.color } : {}), - ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}) + ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}), + ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {}) }) }), defineStreamingMethod({ diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 0c157779068..8719cc528ab 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,10 +1,20 @@ +import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { discoverSkills } from '../../../skills/discovery' +const SkillDiscoveryParams = z.object({ + cwd: z.string().optional().nullable() +}) + export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.discover', - params: null, - handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() }) + params: SkillDiscoveryParams, + handler: async (params, { runtime }) => { + const cwd = params.cwd?.trim() || undefined + return cwd + ? discoverSkills({ repos: [], cwd }) + : discoverSkills({ repos: runtime.listRepos() }) + } }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index aebeaf9baad..154f0926145 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -297,6 +297,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', + 'nativeChat.readSession', + 'nativeChat.subscribe', + 'nativeChat.unsubscribe', 'settings.get', 'settings.update', 'ssh.connect', @@ -971,6 +974,9 @@ export class OrcaRuntimeRpcServer { await this.dispatcher.dispatchStreaming(request, reply, { connectionId, clientId: token, + // Why: gates the mobile-only payload diet (native-chat char clipping) so + // full-screen web/desktop runtime clients aren't truncated. + clientKind: device.scope, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 93a8e59aafc..dcee058f8e2 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -77,6 +77,33 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const worktree = join(root, 'worktree') + const realSkill = join(root, 'central-skills', 'ref-oss') + const linkedSkill = join(worktree, '.agents', 'skills', 'ref-oss') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(worktree, '.agents', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# ref-oss\n\nUse local OSS reference repos.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: worktree, + repos: [] + }) + + expect(result.skills.filter((entry) => entry.name === 'ref-oss')).toMatchObject([ + { + sourceKind: 'repo', + sourceLabel: 'Repo worktree .agents', + directoryPath: linkedSkill, + providers: ['agent-skills'] + } + ]) + }) + it('keeps home classification when cwd points at the same directory as home', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 8d6d2ba81dc..4ba541aad45 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -92,6 +92,18 @@ function nativeZoomCommandMatchesKeybindings( ) } +function isMacAppPasteInput(input: Electron.Input): boolean { + return ( + process.platform === 'darwin' && + input.type === 'keyDown' && + input.meta && + !input.control && + !input.alt && + !input.shift && + (input.code === 'KeyV' || input.key.toLowerCase() === 'v') + ) +} + // Why: the titlebar is 36px (border-box, 1px border-bottom). The visual // center of the CSS-centered content sits at ~18 CSS px from the top. // At zoom factor z that becomes 18·z window px. Traffic lights are @@ -844,6 +856,14 @@ export function createMainWindow( return } + if (isMacAppPasteInput(input)) { + // Why: native chat/terminal panes can own focus without being native + // editable controls, so route Cmd+V through Orca's paste ownership first. + event.preventDefault() + mainWindow.webContents.send('ui:appMenuPaste') + return + } + const keybindings = opts?.getKeybindings?.() const terminalShortcutContext: KeybindingMatchOptions = { context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 96429261e41..00514423fc5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -362,6 +362,7 @@ import type { OpenCodeUsageSummary } from '../shared/opencode-usage-types' import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types' +import type { AgentType, NativeChatMessage } from '../shared/native-chat-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' @@ -720,6 +721,46 @@ export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise } +export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } + +/** Messages appended to a live-tailed transcript since the previous emit. */ +export type NativeChatAppendedMessages = NativeChatMessage[] + +/** Wire payload for the `nativeChat:appended` push channel. */ +export type NativeChatAppendedPayload = { + subscriptionId: string + messages: NativeChatAppendedMessages +} + +export type NativeChatSubscribeArgs = { + /** Unique per-caller id, echoed on every append so multiple live panes in + * one renderer don't cross-talk. */ + subscriptionId: string + agent: AgentType + sessionId: string + /** Authoritative transcript path from the agent hook (providerSession). */ + transcriptPath?: string +} + +export type NativeChatApi = { + /** Read the on-disk transcript for an agent + session id, windowed to the most + * recent `limit` turns (defaults to the desktop window). The renderer raises + * `limit` to page in older history as it scrolls to the top. `transcriptPath` + * is the hook-reported authoritative file path, preferred over the id glob. */ + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ) => Promise + /** Live-tail a transcript: `onAppended` fires with only newly-appended + * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + subscribe: ( + args: NativeChatSubscribeArgs, + onAppended: (messages: NativeChatAppendedMessages) => void + ) => () => void +} + export type AppApi = { /** Returns the app identity currently exposed to native chrome and the titlebar. */ getIdentity: () => Promise @@ -2014,6 +2055,7 @@ export type PreloadApi = { codexUsage: CodexUsageApi openCodeUsage: OpenCodeUsageApi aiVault: AiVaultApi + nativeChat: NativeChatApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index be4ba360c2a..bac40a93a90 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -153,6 +153,12 @@ import type { } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' import type { AiVaultListArgs } from '../shared/ai-vault-types' +import type { AgentType } from '../shared/native-chat-types' +import type { + NativeChatAppendedMessages, + NativeChatAppendedPayload, + NativeChatReadSessionResult +} from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, type EditorPrepareHotExitDetail @@ -3489,6 +3495,40 @@ const api = { ipcRenderer.invoke('aiVault:listSessions', args) }, + nativeChat: { + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ): Promise => + ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + /** Start live tailing for a transcript. `onAppended` fires with only the + * newly-appended messages. Returns an unsubscribe fn that closes the + * main-process watcher (subscriptionId routes appends to this caller). */ + subscribe: ( + args: { + subscriptionId: string + agent: AgentType + sessionId: string + transcriptPath?: string + }, + onAppended: (messages: NativeChatAppendedMessages) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { + if (payload.subscriptionId === args.subscriptionId) { + onAppended(payload.messages) + } + } + ipcRenderer.on('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:subscribe', args) + return () => { + ipcRenderer.removeListener('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) + } + } + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 4781ec4f4f8..1d8486becf5 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -13,6 +13,7 @@ import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped import { translate } from '@/i18n/i18n' import { showDictationStartErrorToast } from './dictation-start-error-toast' import { useHoldDictationGesture } from './use-hold-dictation-gesture' +import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -265,6 +266,35 @@ export function DictationController() { stopDictation ]) + useEffect(() => { + const canDictate = (): boolean => Boolean(settings?.voice?.enabled && settings.voice.sttModel) + const handleControl = (event: Event): void => { + if (!canDictate() || dictationStateRef.current === 'stopping') { + return + } + const action = (event as CustomEvent).detail + if (action === 'start') { + if (dictationStateRef.current === 'idle') { + void startDictation() + } + return + } + if (action === 'stop') { + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } + return + } + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } else { + void startDictation() + } + } + document.addEventListener(DICTATION_CONTROL_EVENT, handleControl) + return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) + }, [settings?.voice?.enabled, settings?.voice?.sttModel, startDictation, stopDictation]) + useHoldDictationGesture({ dictationStateRef, holdGestureActiveRef, diff --git a/src/renderer/src/components/dictation/dictation-control-events.ts b/src/renderer/src/components/dictation/dictation-control-events.ts new file mode 100644 index 00000000000..14c40e8483b --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-control-events.ts @@ -0,0 +1,9 @@ +export const DICTATION_CONTROL_EVENT = 'dictation:control' + +export type DictationControlAction = 'toggle' | 'start' | 'stop' + +export function dispatchDictationControl(action: DictationControlAction): void { + document.dispatchEvent( + new CustomEvent(DICTATION_CONTROL_EVENT, { detail: action }) + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx new file mode 100644 index 00000000000..cbf041f62e3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -0,0 +1,55 @@ +import { ShieldQuestion } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { ChatApproval } from './native-chat-interactive-prompt' + +export type NativeChatApprovalCardProps = { + approval: ChatApproval + /** Send the chosen option's literal string to the agent's PTY. */ + onChoose: (send: string) => void +} + +/** + * Native renderer for an agent tool-approval (PermissionRequest) as an + * Allow/Deny card. Each button writes its option's literal `send` string back + * to the agent (a number to allow; ESC to deny). The first option reads as the + * affirmative action and gets the primary styling. + */ +export function NativeChatApprovalCard({ + approval, + onChoose +}: NativeChatApprovalCardProps): React.JSX.Element { + return ( +
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx new file mode 100644 index 00000000000..f0662c52acd --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { SlashCommandSuggestion } from './native-chat-composer-state' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export function NativeChatSlashMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: SlashCommandSuggestion[] + activeIndex: number + onChoose: (command: SlashCommandSuggestion) => void +}): React.JSX.Element { + return ( +
+ {suggestions.map((command, index) => ( + + ))} +
+ ) +} + +export function NativeChatMentionHint({ + query, + onAccept +}: { + query: string + onAccept: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function NativeChatSkillMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: DiscoveredSkill[] + activeIndex: number + onChoose: (skill: DiscoveredSkill) => void +}): React.JSX.Element { + const activeItemRef = useRef(null) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, suggestions]) + + return ( +
+ {suggestions.length === 0 ? ( +
+ {translate('components.native-chat.composer.noSkills', 'No matching skills')} +
+ ) : null} + {suggestions.map((skill, index) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx new file mode 100644 index 00000000000..f5a2f2c7100 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -0,0 +1,424 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { AgentType } from '../../../../shared/agent-status-types' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { sendNativeChatMessage, submitNativeChatPrompt } from './native-chat-runtime-send' +import { getAgentSlashCommands } from './native-chat-agent-commands' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + applyMentionSuggestion, + applySkillSuggestion, + applySlashSuggestion, + deriveComposerAutocomplete, + EMPTY_HISTORY, + isSlashCommandDraft, + pushHistory, + slashCommandDispatchText, + type HistoryState, + type SlashCommandSuggestion +} from './native-chat-composer-state' +import { resolveImagePaste } from './native-chat-image-paste' +import { NativeChatComposerField } from './NativeChatComposerField' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { useNativeChatSkills } from './use-native-chat-skills' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { dispatchDictationControl } from '../dictation/dictation-control-events' +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a +// PTY (matching how xterm forwards Escape). The richer interrupt-intent +// inference (agent-interrupt-intent.ts) is driven by the existing PTY input +// observers, so writing ESC through the same send path feeds that machinery. +const ESC = '\x1b' + +export type NativeChatComposerProps = { + /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ + terminalTabId: string + /** Specific split-pane PTY this chat view owns. */ + targetPtyId: string | null + agent: AgentType + /** + * Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop + * sends must be guarded rather than silently dropped. U9 wires the real lock + * state in; until then this defaults to `true` (sendable) and the composer + * already renders the guarded/disabled affordance when it is `false`. + */ + canSend?: boolean + /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ + isWorking?: boolean + /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ + onStop?: () => void + /** Optional optimistic-send hook: called with the sent text so the view can + * render a "queued" echo until the real transcript turn lands (mobile parity). */ + onOptimisticSend?: (text: string, imagePaths?: string[]) => void + /** Called with a dispatched slash command (e.g. `/clear`) so the view can show + * a small "Ran /clear" system line — slash commands aren't chat turns and + * otherwise leave no visible trace that anything happened. */ + onSlashCommand?: (command: string) => void +} + +export type NativeChatComposerHandle = { + focus: () => boolean + insertTypedText: (text: string) => boolean +} + +/** + * Rich native input for the chat view. Sends prompts into the running agent + * through the same verified runtime path as typed input (KTD4), so the agent + * cannot distinguish native input from keystrokes. Enter sends; Shift+Enter + * inserts a newline; multi-line is bracketed-paste wrapped; Esc interrupts. + * Slash-command and `@file` autocomplete are agent-aware; image paste persists a + * temp file and injects the agent-appropriate path (or reports unsupported). + */ +export const NativeChatComposer = forwardRef( + function NativeChatComposer( + { + terminalTabId, + targetPtyId, + agent, + canSend = true, + isWorking = false, + onStop, + onOptimisticSend, + onSlashCommand + }, + ref + ): React.JSX.Element { + const [draft, setDraft] = useState('') + const [caret, setCaret] = useState(0) + const [history, setHistory] = useState(EMPTY_HISTORY) + const [activeSuggestion, setActiveSuggestion] = useState(0) + const [notice, setNotice] = useState(null) + const [dictationPressed, setDictationPressed] = useState(false) + const skills = useNativeChatSkills(agent, terminalTabId) + const textareaRef = useRef(null) + const dictationState = useAppStore((store) => store.dictationState) + const voiceSettings = useAppStore((store) => store.settings?.voice) + const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' + const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel + const isDictating = + dictationPressed || + dictationState === 'starting' || + dictationState === 'listening' || + dictationState === 'stopping' + + const agentCommands = useMemo(() => getAgentSlashCommands(agent), [agent]) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []), + [draft, caret, agentCommands, agent, skills] + ) + + // Resolve the live ptyId for this chat leaf; runtime owner settings route + // local vs remote (SSH) sends. + const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } + }, [targetPtyId, terminalTabId]) + + const hasPty = targetPtyId !== null + const disabled = !hasPty || !canSend + + const syncCaret = useCallback((el: HTMLTextAreaElement) => { + setCaret(el.selectionStart ?? el.value.length) + }, []) + + const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } = + useNativeChatComposerAttachments({ + attachmentScopeKey: targetPtyId ?? terminalTabId, + caret, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const sendButtonDisabled = isWorking + ? !hasPty || !onStop + : disabled || (draft.trim() === '' && imageAttachments.length === 0) + + const insertTypedText = useCallback( + (text: string): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + const selectionStart = textarea.selectionStart ?? caret + const selectionEnd = textarea.selectionEnd ?? selectionStart + const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` + const nextCaret = selectionStart + text.length + textarea.focus() + setDraft(next) + setCaret(nextCaret) + setHistory((prev) => ({ entries: prev.entries, index: null })) + setActiveSuggestion(0) + requestAnimationFrame(() => { + textarea.setSelectionRange(nextCaret, nextCaret) + }) + return true + }, + [caret, draft] + ) + + const focus = useCallback((): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + textarea.focus() + return true + }, []) + + useImperativeHandle(ref, () => ({ focus, insertTypedText }), [focus, insertTypedText]) + + useEffect(() => { + return window.api.ui.onFileDrop((payload) => { + if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { + return + } + attachLocalPaths(payload.paths) + }) + }, [attachLocalPaths]) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (!filePath) { + return + } + attachLocalPaths([filePath]) + })() + }, [attachLocalPaths]) + + const focusForDictation = useCallback(() => { + textareaRef.current?.focus() + }, []) + + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation]) + + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, []) + + const send = useCallback(() => { + const text = draft + const imagePaths = imageAttachments.map((attachment) => attachment.path) + if ((text.trim() === '' && imagePaths.length === 0) || disabled) { + return + } + const target = resolveTarget() + if (!target) { + return + } + // Images are pasted into the hosted TUI as soon as they are attached, so the + // Send action only submits the text body (if any) plus Enter. + if (text.trim().length > 0) { + sendNativeChatMessage(target.settings, target.ptyId, text) + } else { + submitNativeChatPrompt(target.settings, target.ptyId) + } + // Slash commands are TUI controls, not chat turns: don't echo a user bubble, + // but DO surface a small "Ran /clear" system line so the command leaves a + // visible trace instead of seeming to do nothing. + if (isSlashCommandDraft(text)) { + onSlashCommand?.(text.trim()) + } else { + onOptimisticSend?.(text, imagePaths) + } + // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The + // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, text)) + setDraft('') + setCaret(0) + clearImageAttachments() + setNotice(null) + }, [ + agent, + clearImageAttachments, + draft, + imageAttachments, + disabled, + resolveTarget, + onOptimisticSend, + onSlashCommand + ]) + + const interrupt = useCallback(() => { + if (isWorking && onStop) { + onStop() + return + } + const target = resolveTarget() + if (!target) { + return + } + sendRuntimePtyInput(target.settings, target.ptyId, ESC) + }, [isWorking, onStop, resolveTarget]) + + const chooseSlash = useCallback((command: SlashCommandSuggestion) => { + const next = applySlashSuggestion(command) + setDraft(next) + setCaret(next.length) + setActiveSuggestion(0) + textareaRef.current?.focus() + }, []) + + const dispatchSlash = useCallback( + (command: SlashCommandSuggestion) => { + const next = slashCommandDispatchText(command) + const target = resolveTarget() + if (!target || disabled) { + return + } + sendNativeChatMessage(target.settings, target.ptyId, next) + // Surface the command as a system line (this is the autocomplete-menu + // dispatch path; the typed-Enter path in `send` does the same). + onSlashCommand?.(next.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, next)) + setDraft('') + setCaret(0) + setActiveSuggestion(0) + setNotice(null) + }, + [agent, disabled, resolveTarget, onSlashCommand] + ) + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const hasImage = Array.from(event.clipboardData.items).some((item) => + item.type.startsWith('image/') + ) + if (!hasImage) { + return + } + event.preventDefault() + // Why: snapshot the caret before the async temp-file round-trip — `caret` + // state can move (further typing/selection) while the await is in flight. + const caretAtPaste = caret + void (async () => { + const tempPath = await window.api.ui.saveClipboardImageAsTempFile() + if (!tempPath) { + return + } + const result = resolveImagePaste(agent, tempPath) + if (result.kind === 'unsupported') { + setNotice( + translate( + 'components.native-chat.composer.imageUnsupported', + 'Image paste is not supported for this agent.' + ) + ) + return + } + attachLocalPaths([result.path]) + setCaret(caretAtPaste) + setNotice(null) + })() + }, + [agent, attachLocalPaths, caret] + ) + + const handleKeyDown = useNativeChatComposerKeyDown({ + autocomplete, + activeSuggestion, + draft, + caret, + history, + chooseSlash, + dispatchSlash, + interrupt, + send, + setActiveSuggestion, + setDraft, + setCaret, + setHistory + }) + + return ( + { + setDraft(value) + setHistory((prev) => ({ entries: prev.entries, index: null })) + syncCaret(element) + setActiveSuggestion(0) + }} + onTextareaSelect={syncCaret} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onChooseSlash={chooseSlash} + onAcceptMention={() => { + if (autocomplete.mode !== 'mention') { + return + } + const result = applyMentionSuggestion(draft, caret, autocomplete.query) + setDraft(result.draft) + setCaret(result.caret) + textareaRef.current?.focus() + }} + onChooseSkill={(skill) => { + const result = applySkillSuggestion(draft, caret, skill.name) + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + textareaRef.current?.focus() + }} + onRemoveImageAttachment={(id) => removeImageAttachment(id)} + onAttach={pickAttachment} + onDictationToggle={toggleDictation} + onDictationHoldStart={startHoldDictation} + onDictationHoldEnd={stopHoldDictation} + onSend={send} + onStop={onStop} + /> + ) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx new file mode 100644 index 00000000000..fb67917b332 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -0,0 +1,125 @@ +import { ArrowUp, Mic, Plus, Square } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +export type NativeChatComposerActionsProps = { + attachDisabled: boolean + dictationDisabled: boolean + sendDisabled: boolean + isWorking: boolean + isDictating: boolean + isDictationHoldMode: boolean + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export function NativeChatComposerActions({ + attachDisabled, + dictationDisabled, + sendDisabled, + isWorking, + isDictating, + isDictationHoldMode, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerActionsProps): React.JSX.Element { + const dictationLabel = isDictating + ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') + : translate('components.native-chat.composer.startDictation', 'Start dictation') + return ( +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ + + + + + {dictationLabel} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx new file mode 100644 index 00000000000..40cb7b6adf7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -0,0 +1,181 @@ +import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import { Image as ImageIcon, ImageOff, X } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { basename } from '@/lib/path' +import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state' +import { + NativeChatMentionHint, + NativeChatSkillMenu, + NativeChatSlashMenu +} from './NativeChatAutocompleteMenus' +import { NativeChatComposerActions } from './NativeChatComposerActions' +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export type NativeChatComposerFieldProps = { + textareaRef: RefObject + draft: string + disabled: boolean + hasPty: boolean + canSend: boolean + autocomplete: ComposerAutocomplete + activeSuggestion: number + notice: string | null + imageAttachments: readonly NativeChatComposerImageAttachment[] + sendButtonDisabled: boolean + isWorking: boolean + attachDisabled: boolean + dictationDisabled: boolean + isDictating: boolean + isDictationHoldMode: boolean + onDraftChange: (value: string, element: HTMLTextAreaElement) => void + onTextareaSelect: (element: HTMLTextAreaElement) => void + onKeyDown: KeyboardEventHandler + onPaste: ClipboardEventHandler + onChooseSlash: (command: SlashCommandSuggestion) => void + onAcceptMention: () => void + onChooseSkill: (skill: DiscoveredSkill) => void + onRemoveImageAttachment: (id: string) => void + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export type NativeChatComposerImageAttachment = { + id: string + path: string +} + +export function NativeChatComposerField({ + textareaRef, + draft, + disabled, + hasPty, + canSend, + autocomplete, + activeSuggestion, + notice, + imageAttachments, + sendButtonDisabled, + isWorking, + attachDisabled, + dictationDisabled, + isDictating, + isDictationHoldMode, + onDraftChange, + onTextareaSelect, + onKeyDown, + onPaste, + onChooseSlash, + onAcceptMention, + onChooseSkill, + onRemoveImageAttachment, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerFieldProps): React.JSX.Element { + return ( +
+
+
+ {autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( + + ) : null} + {autocomplete.mode === 'mention' ? ( + + ) : null} + {autocomplete.mode === 'skill' ? ( + + ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} +
+ {imageAttachments.length > 0 ? ( +
+ {imageAttachments.map((attachment) => ( +
+ + {basename(attachment.path)} + +
+ ))} +
+ ) : null} +