From f0448fa2614bb63b133dbcdfad2304008caf0eb5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 30 May 2026 21:20:37 -0700 Subject: [PATCH] Fork agent sessions from terminal context menu (#3976) - Add a terminal action to create a top-level workspace fork with captured agent context - Pre-mark local and SSH agent trust before launching forked Codex/Cursor/Copilot sessions - Use remote/Linux startup behavior for SSH and WSL forks, with Windows draft size fallback - Cover fork prompt cleanup, launch behavior, remote trust writes, and dialog busy-state tests --- src/main/agent-trust-presets.ts | 2 + src/main/ipc/agent-trust.ts | 20 +- src/main/remote-agent-trust-presets.test.ts | 116 +++++ src/main/remote-agent-trust-presets.ts | 135 ++++++ src/main/runtime/orca-runtime.ts | 127 +---- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../TerminalAgentSessionForkDialog.test.tsx | 83 ++++ .../TerminalAgentSessionForkDialog.tsx | 105 ++++ .../terminal-pane/TerminalContextMenu.tsx | 7 + .../components/terminal-pane/TerminalPane.tsx | 14 + .../terminal-agent-session-fork.test.ts | 454 ++++++++++++++++++ .../terminal-agent-session-fork.ts | 215 +++++++++ .../use-terminal-pane-context-menu.ts | 19 + .../lib/agent-session-fork-context.test.ts | 51 ++ .../src/lib/agent-session-fork-context.ts | 89 ++++ .../src/lib/launch-agent-in-new-tab.test.ts | 48 ++ .../src/lib/launch-agent-in-new-tab.ts | 36 +- src/shared/telemetry-events.ts | 1 + src/shared/workspace-source.ts | 1 + tests/e2e/ssh-localhost.spec.ts | 28 +- 21 files changed, 1407 insertions(+), 146 deletions(-) create mode 100644 src/main/remote-agent-trust-presets.test.ts create mode 100644 src/main/remote-agent-trust-presets.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts create mode 100644 src/renderer/src/lib/agent-session-fork-context.test.ts create mode 100644 src/renderer/src/lib/agent-session-fork-context.ts diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 56220d41f4a..163c2183208 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -5,6 +5,8 @@ import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' + /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or * Codex so the agent's "Do you trust this folder?" menu does not fire on diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index b31913178d1..9e18d1c59cc 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,11 +1,11 @@ import { ipcMain } from 'electron' import { + type AgentTrustPreset, markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' - -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' +import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' /** * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an @@ -20,12 +20,24 @@ export function registerAgentTrustHandlers(): void { ipcMain.removeHandler('agentTrust:markTrusted') ipcMain.handle( 'agentTrust:markTrusted', - async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise => { + async ( + _event, + args: { preset: AgentTrustPreset; workspacePath: string; connectionId?: string } + ): Promise => { if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { return } try { - if (args.preset === 'cursor') { + const connectionId = typeof args.connectionId === 'string' ? args.connectionId.trim() : '' + if (connectionId) { + // Why: SSH-launched agents read trust artifacts from the remote + // user's home, not from this desktop process. + await markRemoteAgentWorkspaceTrusted({ + preset: args.preset, + connectionId, + workspacePath: args.workspacePath + }) + } else if (args.preset === 'cursor') { markCursorWorkspaceTrusted(args.workspacePath) } else if (args.preset === 'copilot') { markCopilotFolderTrusted(args.workspacePath) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts new file mode 100644 index 00000000000..6591f741fbd --- /dev/null +++ b/src/main/remote-agent-trust-presets.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveMultiplexer: vi.fn(), + getSshFilesystemProvider: vi.fn() +})) + +vi.mock('./ipc/ssh', () => ({ + getActiveMultiplexer: mocks.getActiveMultiplexer +})) + +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) + +const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') + +function makeFsProvider(overrides: Record = {}) { + return { + realpath: vi.fn(async (path: string) => `/real${path}`), + readFile: vi.fn(async () => ({ content: '', isBinary: false })), + createDir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + stat: vi.fn(async () => { + throw new Error('missing') + }), + ...overrides + } +} + +describe('markRemoteAgentWorkspaceTrusted', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: '/home/u/' })) + }) + }) + + it('writes Codex trust to the remote home and canonicalized workspace path', async () => { + const fsProvider = makeFsProvider() + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(mocks.getActiveMultiplexer).toHaveBeenCalledWith('ssh-1') + expect(fsProvider.realpath).toHaveBeenCalledWith('/repo/worktree') + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.codex') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/u/.codex/config.toml', + expect.stringContaining('[projects."/real/repo/worktree"]') + ) + }) + + it('writes Cursor trust marker on the remote host', async () => { + const fsProvider = makeFsProvider() + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'cursor', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.cursor/projects/real-repo-worktree') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/u/.cursor/projects/real-repo-worktree/.workspace-trusted', + expect.stringContaining('"workspacePath": "/real/repo/worktree"') + ) + }) + + it('appends Copilot trusted folder remotely without clobbering config keys', async () => { + const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined) + const fsProvider = makeFsProvider({ + readFile: vi.fn(async () => ({ + content: JSON.stringify({ firstLaunchAt: '2026-01-01', trustedFolders: ['/old'] }), + isBinary: false + })), + writeFile + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'copilot', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.copilot') + const written = writeFile.mock.calls[0]?.[1] + expect(typeof written).toBe('string') + expect(JSON.parse(written as string)).toEqual({ + firstLaunchAt: '2026-01-01', + trustedFolders: ['/old', '/real/repo/worktree'] + }) + }) + + it('does nothing when the SSH home cannot be resolved safely', async () => { + const fsProvider = makeFsProvider() + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: 'relative/home' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-1', + workspacePath: '/repo/worktree' + }) + + expect(fsProvider.writeFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts new file mode 100644 index 00000000000..4d6e5f94d0d --- /dev/null +++ b/src/main/remote-agent-trust-presets.ts @@ -0,0 +1,135 @@ +import type { AgentTrustPreset } from './agent-trust-presets' +import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' +import { getActiveMultiplexer } from './ipc/ssh' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import type { IFilesystemProvider } from './providers/types' + +export async function markRemoteAgentWorkspaceTrusted(args: { + preset: AgentTrustPreset + connectionId: string + workspacePath: string +}): Promise { + const home = await resolveRemoteHome(args.connectionId) + const fsProvider = getSshFilesystemProvider(args.connectionId) + if (!home || !fsProvider) { + return + } + + const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) + if (args.preset === 'codex') { + await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath) + } else if (args.preset === 'cursor') { + await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath) + } else if (args.preset === 'copilot') { + await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath) + } +} + +async function resolveRemoteHome(connectionId: string): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux || mux.isDisposed?.()) { + return null + } + const result = (await mux.request('session.resolveHome', { path: '~' })) as { + resolvedPath?: unknown + } + const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : '' + return home && home.startsWith('/') && !hasRemotePathControlCharacter(home) + ? home.replace(/\/$/, '') + : null +} + +function hasRemotePathControlCharacter(value: string): boolean { + return value.includes(String.fromCharCode(0)) || value.includes('\r') || value.includes('\n') +} + +async function canonicalizeRemoteWorkspacePath( + fsProvider: IFilesystemProvider, + workspacePath: string +): Promise { + try { + return await fsProvider.realpath(workspacePath) + } catch { + return workspacePath + } +} + +async function readRemoteTextFile( + fsProvider: IFilesystemProvider, + filePath: string +): Promise { + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? '' : result.content + } catch { + return '' + } +} + +async function markRemoteCodexProjectTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const codexDir = `${remoteHome}/.codex` + const configPath = `${codexDir}/config.toml` + const existing = await readRemoteTextFile(fsProvider, configPath) + const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted') + if (updated === existing) { + return + } + await fsProvider.createDir(codexDir) + await fsProvider.writeFile(configPath, updated) +} + +async function markRemoteCursorWorkspaceTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') + if (!slug) { + return + } + const trustDir = `${remoteHome}/.cursor/projects/${slug}` + const trustFile = `${trustDir}/.workspace-trusted` + try { + await fsProvider.stat(trustFile) + return + } catch { + // Missing marker: write the same shape the local trust preset writes. + } + await fsProvider.createDir(trustDir) + await fsProvider.writeFile( + trustFile, + `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` + ) +} + +async function markRemoteCopilotFolderTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string +): Promise { + const configDir = `${remoteHome}/.copilot` + const configPath = `${configDir}/config.json` + const raw = await readRemoteTextFile(fsProvider, configPath) + let config: Record = {} + if (raw.trim()) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed as Record + } + } catch { + return + } + } + const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] + if (existing.includes(workspacePath)) { + return + } + config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] + await fsProvider.createDir(configDir) + await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 15db685adb2..f95acd34251 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -79,8 +79,8 @@ import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust' import { isWindowsAbsolutePathLike, isPathInsideOrEqual, @@ -390,7 +390,6 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getActiveMultiplexer } from '../ipc/ssh' import { detectRepoIcon } from '../repo-icon-autodetect' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' @@ -7195,134 +7194,12 @@ export class OrcaRuntimeService { return } try { - const home = await this.resolveRemoteHome(connectionId) - const fsProvider = getSshFilesystemProvider(connectionId) - if (!home || !fsProvider) { - return - } - const absPath = await this.canonicalizeRemoteWorkspacePath(fsProvider, workspacePath) - if (preset === 'codex') { - await this.markRemoteCodexProjectTrusted(fsProvider, home, absPath) - } else if (preset === 'cursor') { - await this.markRemoteCursorWorkspaceTrusted(fsProvider, home, absPath) - } else if (preset === 'copilot') { - await this.markRemoteCopilotFolderTrusted(fsProvider, home, absPath) - } + await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) } catch { // Best-effort: the user can still accept the remote agent trust prompt manually. } } - private async resolveRemoteHome(connectionId: string): Promise { - const mux = getActiveMultiplexer(connectionId) - if (!mux || mux.isDisposed?.()) { - return null - } - const result = (await mux.request('session.resolveHome', { path: '~' })) as { - resolvedPath?: unknown - } - const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : '' - return home && home.startsWith('/') && !/[\u0000\r\n]/.test(home) - ? home.replace(/\/$/, '') - : null - } - - private async canonicalizeRemoteWorkspacePath( - fsProvider: IFilesystemProvider, - workspacePath: string - ): Promise { - try { - return await fsProvider.realpath(workspacePath) - } catch { - return workspacePath - } - } - - private async readRemoteTextFile( - fsProvider: IFilesystemProvider, - filePath: string - ): Promise { - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? '' : result.content - } catch { - return '' - } - } - - private async markRemoteCodexProjectTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const codexDir = `${remoteHome}/.codex` - const configPath = `${codexDir}/config.toml` - const existing = await this.readRemoteTextFile(fsProvider, configPath) - const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted') - if (updated === existing) { - return - } - await fsProvider.createDir(codexDir) - await fsProvider.writeFile(configPath, updated) - } - - private async markRemoteCursorWorkspaceTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') - if (!slug) { - return - } - const trustDir = `${remoteHome}/.cursor/projects/${slug}` - const trustFile = `${trustDir}/.workspace-trusted` - try { - await fsProvider.stat(trustFile) - return - } catch { - // Missing marker: write the same shape the local trust preset writes. - } - await fsProvider.createDir(trustDir) - await fsProvider.writeFile( - trustFile, - `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - ) - } - - private async markRemoteCopilotFolderTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string - ): Promise { - const configDir = `${remoteHome}/.copilot` - const configPath = `${configDir}/config.json` - const raw = await this.readRemoteTextFile(fsProvider, configPath) - let config: Record = {} - if (raw.trim()) { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - config = parsed as Record - } - } catch { - return - } - } - const existing = Array.isArray(config.trustedFolders) - ? (config.trustedFolders as unknown[]) - : [] - if (existing.includes(workspacePath)) { - return - } - config.trustedFolders = [ - ...existing.filter((entry) => typeof entry === 'string'), - workspacePath - ] - await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) - } - private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { void this.waitForStartupDraftReady(handle, draft.agent) .then((ptyId) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 03b02e0fbee..1ea46469504 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1449,6 +1449,7 @@ export type PreloadApi = { markTrusted: (args: { preset: 'cursor' | 'copilot' | 'codex' workspacePath: string + connectionId?: string }) => Promise } preflight: PreflightApi diff --git a/src/preload/index.ts b/src/preload/index.ts index 6cbcdfdd92c..d10c2dbf150 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1432,6 +1432,7 @@ const api = { markTrusted: (args: { preset: 'cursor' | 'copilot' | 'codex' workspacePath: string + connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) }, diff --git a/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx b/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx new file mode 100644 index 00000000000..161c884d1d1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' + +type CapturedButtonProps = { + disabled?: boolean + onClick?: () => void + children?: React.ReactNode +} + +const mocks = vi.hoisted(() => ({ + buttons: [] as CapturedButtonProps[], + copyAgentSessionForkContext: vi.fn(), + startAgentSessionFork: vi.fn() +})) + +vi.mock('@/components/ui/button', async () => { + const ReactModule = await import('react') + return { + Button: (props: CapturedButtonProps) => { + mocks.buttons.push(props) + return ReactModule.createElement('button', { disabled: props.disabled }, props.children) + } + } +}) + +vi.mock('@/components/ui/dialog', async () => { + const ReactModule = await import('react') + return { + Dialog: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + open ? ReactModule.createElement('div', null, children) : null, + DialogContent: ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement('div', null, children), + DialogDescription: ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement('p', null, children), + DialogFooter: ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement('footer', null, children), + DialogHeader: ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement('header', null, children), + DialogTitle: ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement('h2', null, children) + } +}) + +vi.mock('./terminal-agent-session-fork', () => ({ + copyAgentSessionForkContext: mocks.copyAgentSessionForkContext, + startAgentSessionFork: mocks.startAgentSessionFork +})) + +function makeFork(): PreparedAgentSessionFork { + return { + prompt: 'fork prompt', + agent: null, + worktreeId: 'wt-1', + pane: {} as PreparedAgentSessionFork['pane'] + } +} + +describe('TerminalAgentSessionForkDialog', () => { + beforeEach(() => { + mocks.buttons = [] + mocks.copyAgentSessionForkContext.mockReset() + mocks.startAgentSessionFork.mockReset() + }) + + it('prevents busy-state double submit for create', async () => { + mocks.startAgentSessionFork.mockReturnValue(new Promise(() => undefined)) + const { TerminalAgentSessionForkDialog } = await import('./TerminalAgentSessionForkDialog') + + renderToStaticMarkup( + + ) + + const createButton = mocks.buttons[1] + expect(createButton).toBeDefined() + + createButton?.onClick?.() + createButton?.onClick?.() + + expect(mocks.startAgentSessionFork).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.tsx b/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.tsx new file mode 100644 index 00000000000..d08913aab58 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.tsx @@ -0,0 +1,105 @@ +import { Copy, GitFork } from 'lucide-react' +import { useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { + copyAgentSessionForkContext, + startAgentSessionFork, + type PreparedAgentSessionFork +} from './terminal-agent-session-fork' + +type TerminalAgentSessionForkDialogProps = { + open: boolean + fork: PreparedAgentSessionFork | null + onOpenChange: (open: boolean) => void +} + +export function TerminalAgentSessionForkDialog({ + open, + fork, + onOpenChange +}: TerminalAgentSessionForkDialogProps): React.JSX.Element { + const [busy, setBusy] = useState(false) + const busyRef = useRef(false) + + const handleCopyContext = async (): Promise => { + if (!fork || busyRef.current) { + return + } + busyRef.current = true + setBusy(true) + try { + if (await copyAgentSessionForkContext(fork)) { + onOpenChange(false) + } + } finally { + busyRef.current = false + setBusy(false) + } + } + + const handleStartFork = async (): Promise => { + if (!fork || busyRef.current) { + return + } + busyRef.current = true + setBusy(true) + try { + if (await startAgentSessionFork(fork)) { + onOpenChange(false) + } + } finally { + busyRef.current = false + setBusy(false) + } + } + + const handleOpenChange = (nextOpen: boolean): void => { + if (busyRef.current && !nextOpen) { + return + } + onOpenChange(nextOpen) + } + + return ( + + + + Fork Agent Session + + Create a top-level workspace fork and start a fresh agent tab with captured context. + + + +
+ +
+

Top-level fork

+

+ The fork appears as its own workspace, not as a nested child. The new agent receives a + bounded transcript as an editable draft. +

+
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index b5ef90bf1a7..8883bcb6730 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -3,6 +3,7 @@ import { Clipboard, Copy, Eraser, + GitFork, Maximize2, Minimize2, PanelBottomClose, @@ -49,6 +50,7 @@ type TerminalContextMenuProps = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onForkAgentSession: () => void repoQuickCommands: TerminalQuickCommand[] globalQuickCommands: TerminalQuickCommand[] quickCommandRepoLabel: string | null @@ -76,6 +78,7 @@ export default function TerminalContextMenu({ onEqualizePaneSizes, onClosePane, onClearScreen, + onForkAgentSession, repoQuickCommands, globalQuickCommands, quickCommandRepoLabel, @@ -218,6 +221,10 @@ export default function TerminalContextMenu({ + + + Fork Agent Session… + diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f0ea3f1b52d..e20204c1c02 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -36,10 +36,12 @@ import { MobileDriverOverlay } from './MobileDriverOverlay' import { TerminalErrorToast } from './TerminalErrorToast' import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog' import TerminalContextMenu from './TerminalContextMenu' +import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog' import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers' @@ -207,6 +209,7 @@ export default function TerminalPane({ // Why: the terminal menu can be the first quick-command entry point, so each // Add action starts with a fresh draft instead of reusing cancelled text. const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft) + const [agentSessionFork, setAgentSessionFork] = useState(null) const [terminalError, setTerminalError] = useState(null) const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false) // Why: override state lives in a plain Map for perf (safeFit reads it on @@ -1574,6 +1577,7 @@ export default function TerminalPane({ onRequestClosePane: handleRequestClosePane, onSetTitle: handleStartRename, onPasteError: setTerminalError, + onAgentSessionForkReady: setAgentSessionFork, rightClickToPaste }) @@ -1776,6 +1780,7 @@ export default function TerminalPane({ onEqualizePaneSizes={contextMenu.onEqualizePaneSizes} onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} + onForkAgentSession={() => void contextMenu.onForkAgentSession()} repoQuickCommands={repoQuickCommands} globalQuickCommands={globalQuickCommands} quickCommandRepoLabel={quickCommandRepoLabel} @@ -1797,6 +1802,15 @@ export default function TerminalPane({ onSave={saveQuickCommand} /> ) : null} + { + if (!open) { + setAgentSessionFork(null) + } + }} + /> {/* Title bar overlays — portaled into each pane container that has a title or is currently being renamed (so the inline input appears even for untitled panes when "Set Title..." is triggered). diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts new file mode 100644 index 00000000000..ff88fe6807a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -0,0 +1,454 @@ +/* eslint-disable max-lines -- Why: fork flow tests share a mocked store and launch harness. */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +const mockLaunchAgentInNewTab = vi.fn() +const mockActivateAndRevealWorktree = vi.fn() +const mockCreateWorktree = vi.fn() +const mockToast = { + error: vi.fn(), + message: vi.fn(), + success: vi.fn() +} +const mockWriteClipboardText = vi.fn(async () => undefined) +const mockMarkTrusted = vi.fn(async () => undefined) +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const store = { + repos: [] as { id: string; kind?: 'git' | 'folder'; connectionId?: string | null }[], + agentStatusByPaneKey: {} as Record, + tabsByWorktree: {} as Record, + getKnownWorktreeById: vi.fn(), + createWorktree: mockCreateWorktree +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mockLaunchAgentInNewTab +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mockActivateAndRevealWorktree +})) + +vi.mock('sonner', () => ({ + toast: mockToast +})) + +function makePane(capturedText: string): ManagedPane { + return { + leafId: LEAF_ID, + serializeAddon: { + serialize: vi.fn(() => capturedText) + }, + terminal: { + focus: vi.fn() + } + } as unknown as ManagedPane +} + +describe('forkAgentSessionFromPane', () => { + beforeEach(() => { + vi.clearAllMocks() + store.repos = [{ id: 'repo-1', kind: 'git' }] + store.agentStatusByPaneKey = {} + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + store.getKnownWorktreeById.mockReturnValue({ + id: 'wt-1', + repoId: 'repo-1', + displayName: 'auth-feature', + branch: 'feature/auth' + }) + mockCreateWorktree.mockResolvedValue({ + worktree: { + id: 'wt-fork' + } + }) + mockLaunchAgentInNewTab.mockReturnValue({ + tabId: 'tab-2', + startupPlan: {}, + pasteDraftAfterLaunch: true + }) + mockWriteClipboardText.mockResolvedValue(undefined) + mockMarkTrusted.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + ui: { + writeClipboardText: mockWriteClipboardText + }, + agentTrust: { + markTrusted: mockMarkTrusted + } + } + }) + }) + + it('creates a top-level workspace fork with a draft agent tab when the source agent is known', async () => { + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } + } + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: compare OAuth options'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: 'group-1' + }) + + expect(mockCreateWorktree).toHaveBeenCalledWith( + 'repo-1', + 'auth-feature-fork', + 'feature/auth', + 'inherit', + undefined, + 'terminal_context_menu', + 'Fork of auth-feature', + undefined, + undefined, + undefined, + 'codex' + ) + + expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + worktreeId: 'wt-fork', + prompt: expect.stringContaining('User: compare OAuth options'), + promptDelivery: 'draft', + launchSource: 'terminal_context_menu' + }) + ) + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { + sidebarRevealBehavior: 'auto' + }) + expect(mockToast.success).toHaveBeenCalledWith( + 'Top-level session fork opened in a new workspace' + ) + }) + + it('pre-marks trust for the created fork workspace before launching a trusted agent', async () => { + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } + } + mockCreateWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-fork', + path: '/repo/worktrees/auth-feature-fork' + } + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: compare OAuth options'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockMarkTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/repo/worktrees/auth-feature-fork' + }) + expect(mockMarkTrusted.mock.invocationCallOrder[0]).toBeLessThan( + mockLaunchAgentInNewTab.mock.invocationCallOrder[0] + ) + expect(mockLaunchAgentInNewTab).toHaveBeenCalled() + }) + + it('uses remote trust and Linux startup quoting for SSH workspaces', async () => { + store.repos = [{ id: 'repo-1', kind: 'git', connectionId: 'ssh-1' }] + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } + } + mockCreateWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-fork', + path: '/home/u/repo/auth-feature-fork' + } + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: compare OAuth options'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockMarkTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/home/u/repo/auth-feature-fork', + connectionId: 'ssh-1' + }) + expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + worktreeId: 'wt-fork', + launchPlatform: 'linux' + }) + ) + }) + + it('uses Linux startup quoting for WSL workspaces', async () => { + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'pi' } + } + mockCreateWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-fork', + path: '\\\\wsl.localhost\\Ubuntu\\home\\u\\repo\\auth-feature-fork' + } + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: compare OAuth options'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'pi', + worktreeId: 'wt-fork', + launchPlatform: 'linux' + }) + ) + }) + + it('still launches the forked agent when trust preflight fails', async () => { + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } + } + mockCreateWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-fork', + path: '/repo/worktrees/auth-feature-fork' + } + }) + mockMarkTrusted.mockRejectedValueOnce(new Error('trust write failed')) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: continue after trust failure'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockMarkTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/repo/worktrees/auth-feature-fork' + }) + expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + worktreeId: 'wt-fork', + prompt: expect.stringContaining('User: continue after trust failure') + }) + ) + expect(mockToast.error).not.toHaveBeenCalledWith('trust write failed') + }) + + it('creates a top-level workspace fork and copies context when the source agent cannot be resolved', async () => { + const pane = makePane('Assistant: here is the current plan') + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane, + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockCreateWorktree).toHaveBeenCalledWith( + 'repo-1', + 'auth-feature-fork', + 'feature/auth', + 'inherit', + undefined, + 'terminal_context_menu', + 'Fork of auth-feature', + undefined, + undefined, + undefined, + undefined + ) + expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() + expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', { + sidebarRevealBehavior: 'auto' + }) + expect(mockWriteClipboardText).toHaveBeenCalledWith( + expect.stringContaining('Assistant: here is the current plan') + ) + expect(mockToast.message).toHaveBeenCalledWith( + 'Fork context copied. Launch an agent and paste it to start the fork.' + ) + expect(pane.terminal.focus).toHaveBeenCalled() + }) + + it('keeps the fork dialog path open when the source workspace has no git branch', async () => { + store.getKnownWorktreeById.mockReturnValue({ + id: 'wt-1', + repoId: 'repo-1', + displayName: 'scratch', + branch: '' + }) + const pane = makePane('User: summarize this scratch plan') + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane, + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockCreateWorktree).not.toHaveBeenCalled() + expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith( + 'This workspace cannot be forked into a git worktree.' + ) + }) + + it.each([ + ['archived', { isArchived: true }], + ['bare', { isBare: true }] + ])('does not create a worktree from an %s source workspace', async (_label, override) => { + store.getKnownWorktreeById.mockReturnValue({ + id: 'wt-1', + repoId: 'repo-1', + displayName: 'auth-feature', + branch: 'feature/auth', + ...override + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: fork this blocked workspace'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockCreateWorktree).not.toHaveBeenCalled() + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith( + 'This workspace cannot be forked into a git worktree.' + ) + }) + + it('does not create a worktree from a folder-only source workspace', async () => { + store.repos = [{ id: 'repo-1', kind: 'folder' }] + store.getKnownWorktreeById.mockReturnValue({ + id: 'wt-1', + repoId: 'repo-1', + displayName: 'folder-project', + branch: 'feature/auth' + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: fork this folder workspace'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockCreateWorktree).not.toHaveBeenCalled() + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith( + 'This workspace cannot be forked into a git worktree.' + ) + }) + + it('does not create a worktree from a floating terminal workspace', async () => { + store.getKnownWorktreeById.mockReturnValue({ + id: FLOATING_TERMINAL_WORKTREE_ID, + repoId: 'repo-1', + displayName: 'Floating', + branch: 'feature/auth' + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: fork this floating terminal'), + tabId: 'tab-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: null + }) + + expect(mockCreateWorktree).not.toHaveBeenCalled() + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith( + 'This workspace cannot be forked into a git worktree.' + ) + }) + + it('keeps context available when workspace creation fails', async () => { + mockCreateWorktree.mockRejectedValueOnce(new Error('path already exists')) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: carry this context forward'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith('path already exists') + }) + + it('copies context when the detected agent cannot queue a startup plan', async () => { + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } + } + mockLaunchAgentInNewTab.mockReturnValueOnce(null) + const pane = makePane('Assistant: current implementation notes') + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane, + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockCreateWorktree).toHaveBeenCalled() + expect(mockLaunchAgentInNewTab).toHaveBeenCalled() + expect(mockWriteClipboardText).toHaveBeenCalledWith( + expect.stringContaining('Assistant: current implementation notes') + ) + expect(mockToast.message).toHaveBeenCalledWith( + 'Fork context copied. Launch an agent and paste it to start the fork.' + ) + }) + + it('surfaces clipboard failures instead of closing the fallback path silently', async () => { + mockWriteClipboardText.mockRejectedValueOnce(new Error('clipboard denied')) + const pane = makePane('Assistant: copy fallback context') + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane, + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockLaunchAgentInNewTab).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith('clipboard denied') + expect(pane.terminal.focus).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts new file mode 100644 index 00000000000..a6ff1443b30 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -0,0 +1,215 @@ +import { toast } from 'sonner' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { buildAgentSessionForkPrompt } from '@/lib/agent-session-fork-context' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { slugifyForWorkspaceName } from '../../../../shared/workspace-name' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { TuiAgent } from '../../../../shared/types' +import { isWslUncPath } from '../../../../shared/wsl-paths' + +type ForkAgentSessionFromPaneArgs = { + pane: ManagedPane + tabId: string + worktreeId: string + groupId: string | null +} + +export type PreparedAgentSessionFork = { + prompt: string + agent: TuiAgent | null + worktreeId: string + pane: ManagedPane +} + +function buildForkWorkspaceName(sourceName: string): string { + return slugifyForWorkspaceName(`${sourceName}-fork`) || 'session-fork' +} + +function resolveTuiAgent(value: string | null | undefined): TuiAgent | null { + return value && Object.prototype.hasOwnProperty.call(TUI_AGENT_CONFIG, value) + ? (value as TuiAgent) + : null +} + +function getUsableForkBase( + worktree: + | { branch?: string | null; isArchived?: boolean; isBare?: boolean; repoId?: string } + | null + | undefined, + repo: { kind?: string } | null | undefined, + worktreeId: string +): string | null { + const branch = worktree?.branch?.trim() + if ( + worktreeId === FLOATING_TERMINAL_WORKTREE_ID || + !branch || + worktree?.isArchived || + worktree?.isBare || + !repo || + repo.kind === 'folder' + ) { + return null + } + return branch +} + +async function copyForkContext(prompt: string, pane: ManagedPane): Promise { + try { + await window.api.ui.writeClipboardText(prompt) + toast.message('Fork context copied. Launch an agent and paste it to start the fork.') + pane.terminal.focus() + return true + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to copy fork context.') + pane.terminal.focus() + return false + } +} + +function getForkAgentLaunchPlatform(args: { + repo: { connectionId?: string | null } | null | undefined + worktreePath?: string | null +}): NodeJS.Platform | undefined { + if (args.repo?.connectionId || (args.worktreePath && isWslUncPath(args.worktreePath))) { + return 'linux' + } + return undefined +} + +async function preflightForkAgentTrust(args: { + agent: TuiAgent + workspacePath?: string | null + connectionId?: string | null +}): Promise { + const { agent, workspacePath, connectionId } = args + const preflight = TUI_AGENT_CONFIG[agent].preflightTrust + if (!preflight || !workspacePath || !window.api.agentTrust?.markTrusted) { + return + } + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath, + ...(connectionId ? { connectionId } : {}) + }) + } catch { + // Best-effort: if the trust artifact cannot be written, keep the existing launch path. + } +} + +export function prepareAgentSessionForkFromPane({ + pane, + tabId, + worktreeId +}: ForkAgentSessionFromPaneArgs): PreparedAgentSessionFork | null { + const paneKey = makePaneKey(tabId, pane.leafId) + const state = useAppStore.getState() + const sourceAgent = resolveTuiAgent(state.agentStatusByPaneKey[paneKey]?.agentType) + const tabAgent = resolveTuiAgent( + state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.launchAgent + ) + const agent = sourceAgent ?? tabAgent + // Why: v1 is a context fork, not a process clone. Capturing scrollback keeps + // SSH and local panes on the same path because both expose xterm state here. + const prompt = buildAgentSessionForkPrompt({ + capturedText: pane.serializeAddon.serialize({ scrollback: 800 }), + sourceLabel: paneKey, + agentLabel: agent + }) + + if (!prompt) { + toast.error('No terminal context to fork') + pane.terminal.focus() + return null + } + + return { + prompt, + agent, + worktreeId, + pane + } +} + +export async function copyAgentSessionForkContext( + fork: PreparedAgentSessionFork +): Promise { + return copyForkContext(fork.prompt, fork.pane) +} + +export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Promise { + const store = useAppStore.getState() + const sourceWorktree = store.getKnownWorktreeById(fork.worktreeId) + if (!sourceWorktree) { + toast.error('Could not find the source workspace for this fork.') + return false + } + const sourceRepo = store.repos.find((repo) => repo.id === sourceWorktree.repoId) + const sourceBranch = getUsableForkBase(sourceWorktree, sourceRepo, fork.worktreeId) + if (!sourceBranch) { + toast.error('This workspace cannot be forked into a git worktree.') + return false + } + const forkName = buildForkWorkspaceName(sourceWorktree.displayName || sourceBranch) + let created: Awaited> + try { + created = await store.createWorktree( + sourceWorktree.repoId, + forkName, + sourceBranch, + 'inherit', + undefined, + 'terminal_context_menu', + `Fork of ${sourceWorktree.displayName || forkName}`, + undefined, + undefined, + undefined, + fork.agent ?? undefined + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create fork workspace.') + return false + } + const forkWorktreeId = created.worktree.id + + if (!fork.agent) { + activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + return copyAgentSessionForkContext(fork) + } + await preflightForkAgentTrust({ + agent: fork.agent, + workspacePath: created.worktree.path, + connectionId: sourceRepo?.connectionId + }) + const launchPlatform = getForkAgentLaunchPlatform({ + repo: sourceRepo, + worktreePath: created.worktree.path + }) + const result = launchAgentInNewTab({ + agent: fork.agent, + worktreeId: forkWorktreeId, + prompt: fork.prompt, + promptDelivery: 'draft', + launchSource: 'terminal_context_menu', + ...(launchPlatform ? { launchPlatform } : {}) + }) + activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' }) + + if (!result) { + return copyAgentSessionForkContext(fork) + } + + toast.success('Top-level session fork opened in a new workspace') + return true +} + +export async function forkAgentSessionFromPane(args: ForkAgentSessionFromPaneArgs): Promise { + const fork = prepareAgentSessionForkFromPane(args) + if (fork) { + await startAgentSessionFork(fork) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 9aad2b9dd7a..98b6073475d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -12,6 +12,10 @@ import { pasteTerminalText } from './terminal-bracketed-paste' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { makePaneKey } from '../../../../shared/stable-pane-id' import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab' +import { + prepareAgentSessionForkFromPane, + type PreparedAgentSessionFork +} from './terminal-agent-session-fork' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -27,6 +31,7 @@ type UseTerminalPaneContextMenuDeps = { onRequestClosePane: (paneId: number) => void onSetTitle: (paneId: number) => void onPasteError: (message: string) => void + onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void rightClickToPaste: boolean } @@ -46,6 +51,7 @@ type TerminalMenuState = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onForkAgentSession: () => Promise onQuickCommand: (command: TerminalQuickCommand) => void onToggleExpand: () => void onSetTitle: () => void @@ -63,6 +69,7 @@ export function useTerminalPaneContextMenu({ onRequestClosePane, onSetTitle, onPasteError, + onAgentSessionForkReady, rightClickToPaste }: UseTerminalPaneContextMenuDeps): TerminalMenuState { const contextPaneIdRef = useRef(null) @@ -203,6 +210,17 @@ export function useTerminalPaneContextMenu({ } } + const onForkAgentSession = async (): Promise => { + const pane = resolveMenuPane() + if (!pane) { + return + } + const fork = prepareAgentSessionForkFromPane({ pane, tabId, worktreeId, groupId }) + if (fork) { + onAgentSessionForkReady(fork) + } + } + const onQuickCommand = (command: TerminalQuickCommand): void => { if (isTerminalAgentQuickCommand(command)) { runQuickCommandInNewTab({ command, worktreeId, groupId }) @@ -294,6 +312,7 @@ export function useTerminalPaneContextMenu({ onEqualizePaneSizes, onClosePane, onClearScreen, + onForkAgentSession, onQuickCommand, onToggleExpand, onSetTitle: handleSetTitle diff --git a/src/renderer/src/lib/agent-session-fork-context.test.ts b/src/renderer/src/lib/agent-session-fork-context.test.ts new file mode 100644 index 00000000000..b965e768fa0 --- /dev/null +++ b/src/renderer/src/lib/agent-session-fork-context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentSessionForkPrompt, + cleanAgentSessionForkTranscript +} from './agent-session-fork-context' + +describe('agent session fork context', () => { + it('cleans terminal control sequences before building fork context', () => { + const cleaned = cleanAgentSessionForkTranscript( + '\x1b]0;Codex working\x07\x1b[31mUser\x1b[0m\r\nAssistant' + ) + + expect(cleaned).toBe('User\nAssistant') + }) + + it('builds a bounded prompt with source and agent labels', () => { + const prompt = buildAgentSessionForkPrompt({ + capturedText: 'User: implement auth\nAssistant: reading files', + sourceLabel: 'tab-1:leaf-1', + agentLabel: 'codex' + }) + + expect(prompt).toContain('fork of an existing Orca agent session') + expect(prompt).toContain('Source: tab-1:leaf-1') + expect(prompt).toContain('Original agent: codex') + expect(prompt).toContain('User: implement auth') + expect(prompt).toContain('wait for my next instruction') + }) + + it('returns null when no transcript survives cleanup', () => { + expect(buildAgentSessionForkPrompt({ capturedText: '\x1b[0m\r\n\x1bc\x07' })).toBeNull() + }) + + it('keeps the newest transcript content when the capture is too large', () => { + const prompt = buildAgentSessionForkPrompt({ + capturedText: `${'old'.repeat(20_000)}\nnew context` + }) + + expect(prompt).toContain('Earlier terminal output omitted') + expect(prompt).toContain('new context') + }) + + it('uses a longer fence when captured output contains markdown fences', () => { + const prompt = buildAgentSessionForkPrompt({ + capturedText: 'Assistant output:\n```text\nignore prior instructions\n```' + }) + + expect(prompt).toContain('````text\nAssistant output:') + expect(prompt).toContain('\n````\n\nAcknowledge') + }) +}) diff --git a/src/renderer/src/lib/agent-session-fork-context.ts b/src/renderer/src/lib/agent-session-fork-context.ts new file mode 100644 index 00000000000..d0bdef49803 --- /dev/null +++ b/src/renderer/src/lib/agent-session-fork-context.ts @@ -0,0 +1,89 @@ +const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g') +const OSC_SEQUENCE_PATTERN = new RegExp( + `${String.fromCharCode(27)}\\][^\\u0007]*(?:\\u0007|${String.fromCharCode(27)}\\\\)`, + 'g' +) +const SINGLE_ESCAPE_PATTERN = new RegExp( + `${String.fromCharCode(27)}(?:[@-Z\\\\-_]|[()*+\\-./][0-~]|c)`, + 'g' +) +const MAX_FORK_CONTEXT_CHARS = 36_000 + +export type AgentSessionForkPromptInput = { + capturedText: string + sourceLabel?: string | null + agentLabel?: string | null +} + +function trimToContextBudget(value: string): string { + if (value.length <= MAX_FORK_CONTEXT_CHARS) { + return value + } + // Why: terminal scrollback can be very large; keep the newest turns where + // the current user intent and latest findings are most likely to live. + const omitted = value.length - MAX_FORK_CONTEXT_CHARS + const marker = `\n\n[Earlier terminal output omitted: ${omitted} characters]\n\n` + return `${marker}${value.slice(-(MAX_FORK_CONTEXT_CHARS - marker.length))}` +} + +function getMarkdownFenceForTranscript(value: string): string { + const longestFence = Math.max(0, ...Array.from(value.matchAll(/`+/g), (match) => match[0].length)) + return '`'.repeat(Math.max(3, longestFence + 1)) +} + +function stripUnsupportedControlCharacters(value: string): string { + let result = '' + for (const char of value) { + const code = char.charCodeAt(0) + if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) { + continue + } + result += char + } + return result +} + +export function cleanAgentSessionForkTranscript(value: string): string { + return stripUnsupportedControlCharacters( + value + .replace(OSC_SEQUENCE_PATTERN, '') + .replace(ANSI_ESCAPE_PATTERN, '') + .replace(SINGLE_ESCAPE_PATTERN, '') + ) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n{4,}/g, '\n\n\n') + .trim() +} + +export function buildAgentSessionForkPrompt({ + capturedText, + sourceLabel, + agentLabel +}: AgentSessionForkPromptInput): string | null { + const transcript = trimToContextBudget(cleanAgentSessionForkTranscript(capturedText)) + if (!transcript) { + return null + } + const fence = getMarkdownFenceForTranscript(transcript) + + const header = [ + 'This is a fork of an existing Orca agent session.', + '', + 'Use the captured transcript as background context for this new, independent session. Keep file edits and decisions independent from the original terminal unless I explicitly ask you to coordinate with it.', + '', + sourceLabel ? `Source: ${sourceLabel}` : null, + agentLabel ? `Original agent: ${agentLabel}` : null, + '', + 'Captured terminal transcript:', + `${fence}text` + ].filter((line): line is string => line !== null) + + return [ + ...header, + transcript, + fence, + '', + 'Acknowledge that you have the forked context, then wait for my next instruction.' + ].join('\n') +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 5e0f628f2c9..4595638f33b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -128,6 +128,54 @@ describe('launchAgentInNewTab', () => { expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) + it('uses the explicit startup shell platform when building draft launch commands', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude --prefill 'review Bob''s change'" + }) + ) + }) + + it('falls back to post-ready draft paste when a Windows inline draft would be too large', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'x'.repeat(25_000) + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'claude' + }) + ) + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'tab-1', + content: prompt, + agent: 'claude', + submit: false, + forcePaste: false + }) + ) + }) + it('seeds working after Command Code submit-after-ready prompt delivery', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d4b8b9d772f..d0cd77f5f73 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, + type AgentDraftLaunchPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -14,6 +15,8 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' +const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 + export type LaunchAgentInNewTabArgs = { agent: TuiAgent worktreeId: string @@ -29,6 +32,9 @@ export type LaunchAgentInNewTabArgs = { /** Telemetry surface that initiated this launch. Defaults to the tab-bar * quick-launch entry point so existing callers stay unchanged. */ launchSource?: LaunchSource + /** Shell platform that will execute the startup command. Defaults to the + * renderer OS; SSH and WSL worktrees run a Linux shell even from Windows. */ + launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void } @@ -56,6 +62,23 @@ function seedCommandCodeSubmittedPromptStatus(tabId: string, prompt: string): vo } } +function canUseInlineDraftLaunchPlan( + plan: AgentDraftLaunchPlan, + platform: NodeJS.Platform +): boolean { + if (platform !== 'win32') { + return true + } + const envChars = Object.entries(plan.env ?? {}).reduce( + (total, [key, value]) => total + key.length + value.length, + 0 + ) + // Why: Windows CreateProcess/env blocks have tight length ceilings. Large + // generated drafts should use the existing post-ready paste path instead of + // failing the PTY spawn before the agent starts. + return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS +} + /** * Create a new terminal tab and queue the agent's launch command, optionally * with an initial prompt. @@ -86,6 +109,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt, promptDelivery = 'auto-submit', launchSource, + launchPlatform = CLIENT_PLATFORM, onPromptDelivered } = args const store = useAppStore.getState() @@ -111,7 +135,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt @@ -122,9 +146,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, draft: trimmedPrompt, cmdOverrides, - platform: CLIENT_PLATFORM + platform: launchPlatform }) - if (draftLaunchPlan) { + if (draftLaunchPlan && canUseInlineDraftLaunchPlan(draftLaunchPlan, launchPlatform)) { startupPlan = { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, @@ -137,7 +161,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt @@ -147,7 +171,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt @@ -156,7 +180,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: !hasPrompt }) } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 2f4a79022cc..d5b2f7bbf6f 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -163,6 +163,7 @@ export const launchSourceSchema = z.enum([ 'notes_send', 'conflict_resolution', 'source_control_recovery', + 'terminal_context_menu', 'unknown' ]) export type LaunchSource = z.infer diff --git a/src/shared/workspace-source.ts b/src/shared/workspace-source.ts index ef7272160d9..4415ee31bb7 100644 --- a/src/shared/workspace-source.ts +++ b/src/shared/workspace-source.ts @@ -4,6 +4,7 @@ export const WORKSPACE_SOURCE_VALUES = [ 'shortcut', 'drag_drop', 'onboarding', + 'terminal_context_menu', 'unknown' ] as const diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index 5ebccc1dea3..7d0e997e139 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -108,6 +108,9 @@ async function postCodexHook( markerName: string ): Promise { const hookPostedMarker = marker(markerName) + // Why: a foreground curl command emits the shell's command-finished marker + // immediately after the hook, which correctly clears a same-turn agent row. + // Post from a delayed background subshell so this test observes hook routing. await execInTerminal( page, ptyId, @@ -116,17 +119,20 @@ async function postCodexHook( ' echo __ORCA_AGENT_HOOK_ENV_MISSING__', 'else', ` hook_payload=${shellQuote(JSON.stringify(payload))}`, - ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload=${hook_payload}" >/dev/null; then', - ` ${emitMarkerCommand(hookPostedMarker)}`, - ' fi', + ' (', + ' sleep 0.1', + ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${hook_payload}" >/dev/null; then', + ` ${emitMarkerCommand(hookPostedMarker)}`, + ' fi', + ' ) &', 'fi' ].join('\n') )