From 5e3d8a12bff755d75cd54abb8154b5bd24fa3626 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 18:08:13 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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} +