diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts new file mode 100644 index 00000000000..df328ad647d --- /dev/null +++ b/src/main/agent-trust-presets.test.ts @@ -0,0 +1,113 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const testState = { + fakeHomeDir: '' +} + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } = + await import('./agent-trust-presets') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + testState.fakeHomeDir = '' +}) + +describe('markCursorWorkspaceTrusted', () => { + it('writes ~/.cursor/projects//.workspace-trusted with the cwd payload', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + expect(slugDirs.length).toBe(1) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + expect(existsSync(trustFile)).toBe(true) + const payload = JSON.parse(readFileSync(trustFile, 'utf-8')) + expect(payload.workspacePath).toBeTruthy() + expect(typeof payload.trustedAt).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('is idempotent — re-marking the same workspace does not overwrite trustedAt', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-')) + try { + markCursorWorkspaceTrusted(workspace) + const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects') + const slugDirs = readdirSync(projectsDir) + const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted') + const firstPayload = readFileSync(trustFile, 'utf-8') + markCursorWorkspaceTrusted(workspace) + const secondPayload = readFileSync(trustFile, 'utf-8') + expect(secondPayload).toBe(firstPayload) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) + +describe('markCopilotFolderTrusted', () => { + it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + try { + markCopilotFolderTrusted(workspace) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(existsSync(configPath)).toBe(true) + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(Array.isArray(parsed.trustedFolders)).toBe(true) + expect(parsed.trustedFolders.length).toBe(1) + expect(typeof parsed.trustedFolders[0]).toBe('string') + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('preserves existing config keys and dedups already-trusted folders', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const realpath = realpathSync(workspace) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync( + join(testState.fakeHomeDir, '.copilot', 'config.json'), + JSON.stringify({ + firstLaunchAt: '2026-01-01T00:00:00.000Z', + trustedFolders: [realpath] + }) + ) + markCopilotFolderTrusted(workspace) + const parsed = JSON.parse( + readFileSync(join(testState.fakeHomeDir, '.copilot', 'config.json'), 'utf-8') + ) + expect(parsed.firstLaunchAt).toBe('2026-01-01T00:00:00.000Z') + expect(parsed.trustedFolders).toHaveLength(1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts new file mode 100644 index 00000000000..e6c87c52b3f --- /dev/null +++ b/src/main/agent-trust-presets.ts @@ -0,0 +1,115 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so + * the agent's "Do you trust this folder?" menu does not fire on first launch. + * + * Why: Orca's "drop URL into agent input as a draft" flow injects the URL + * via bracketed-paste once the TUI is up. If the trust menu intercepts the + * keystrokes (each menu reads a single character or numbered option), the + * paste either selects an arbitrary option or quits the session. Pre-writing + * the same trust artifacts that the agent writes after the user accepts is + * the only documented bypass — both CLIs read these files at startup before + * showing the menu. + * + * Side note: a `--trust`-style CLI flag exists in cursor-agent but only + * applies in `--print/headless` mode (per its --help). Copilot has no + * documented flag at all (verified against @github/copilot 1.0.32 bundle). + */ + +/** + * Cursor's CLI keeps a per-workspace trust marker at: + * ~/.cursor/projects//.workspace-trusted + * where is the absolute path with the leading `/` stripped and + * remaining `/` replaced with `-`. The file payload is `{ trustedAt, + * workspacePath }`. Verified against the cursor-agent CLI bundle + * (versions/2026.04.17-787b533/index.ts: `_=".workspace-trusted"`, slug + * derived via the same util that resolves `~/.cursor/projects/`). + */ +export function markCursorWorkspaceTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const slug = cursorWorkspaceSlug(absPath) + if (!slug) { + return + } + const trustDir = join(homedir(), '.cursor', 'projects', slug) + const trustFile = join(trustDir, '.workspace-trusted') + if (existsSync(trustFile)) { + return + } + mkdirSync(trustDir, { recursive: true }) + const payload = JSON.stringify( + { trustedAt: new Date().toISOString(), workspacePath: absPath }, + null, + 2 + ) + writeFileAtomically(trustFile, `${payload}\n`) +} + +/** + * GitHub Copilot CLI keeps a global list of trusted folders in + * ~/.copilot/config.json under `trustedFolders` (verified against the + * @github/copilot 1.0.32 bundle: `addTrustedFolder` and `isFolderTrusted` + * both read/write this exact key, and folder comparison is done after a + * realpath() resolution). + * + * We append to the array in-place so unrelated config keys (loggedInUsers, + * copilotTokens, etc.) survive untouched. + */ +export function markCopilotFolderTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const configDir = join(homedir(), '.copilot') + const configPath = join(configDir, 'config.json') + let config: Record = {} + try { + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + config = parsed as Record + } + } + } catch { + // Why: a corrupted config.json is the user's to fix — refuse to overwrite + // it from this side-effect path. Copilot will rewrite the file itself + // after the user accepts the trust prompt manually. + return + } + const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] + const normalizedExisting = existing.map((entry) => + typeof entry === 'string' ? canonicalize(entry) : null + ) + if (normalizedExisting.includes(absPath)) { + return + } + const next = [...existing.filter((e) => typeof e === 'string'), absPath] + config.trustedFolders = next + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + +function canonicalize(p: string): string { + // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but + // both Cursor and Copilot's trust comparators run realpath() before the + // string compare. Mirror that so a worktree under a symlinked parent + // (orca caches realpath()'d worktree paths) matches the agent's lookup. + try { + if (existsSync(p)) { + return realpathSync(p) + } + } catch { + // Fall through to the raw input. + } + return p +} + +function cursorWorkspaceSlug(absPath: string): string { + const stripped = absPath.replace(/^[\\/]+/, '') + const slug = stripped.replace(/[\\/]+/g, '-') + return slug +} diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 313404888bd..40951d84dd7 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -318,7 +318,7 @@ export function setupGuestShortcutForwarding(args: { } else if (action?.type === 'openQuickOpen') { renderer.send('ui:openQuickOpen') } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace', action.tab) + renderer.send('ui:openNewWorkspace') } else if (action?.type === 'jumpToWorktreeIndex') { renderer.send('ui:jumpToWorktreeIndex', action.index) } else { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ebf866f3248..81d427efb70 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -829,6 +829,25 @@ export async function getWorkItem( } } +export async function getWorkItemByOwnerRepo( + repoPath: string, + ownerRepo: OwnerRepo, + number: number, + type: 'issue' | 'pr' +): Promise { + await acquire() + try { + if (type === 'issue') { + return await fetchIssueWorkItem(repoPath, ownerRepo, number) + } + return await fetchPullRequestWorkItem(repoPath, ownerRepo, number) + } catch { + return null + } finally { + release() + } +} + /** * Get PR info for a given branch using gh CLI. * Returns null if gh is not installed, or no PR exists for the branch. diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts new file mode 100644 index 00000000000..9ded836a371 --- /dev/null +++ b/src/main/ipc/agent-trust.ts @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron' +import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' + +export type AgentTrustPreset = 'cursor' | 'copilot' + +/** + * Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar + * directory behind a "Do you trust this folder?" menu that consumes + * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL + * paste flow needs the input box, not the menu, so before Orca spawns the + * agent it asks main to write the same trust artifacts the agents write + * after the user accepts. Best-effort: any IO error is swallowed so a failed + * trust write never blocks the workspace from opening. + */ +export function registerAgentTrustHandlers(): void { + ipcMain.removeHandler('agentTrust:markTrusted') + ipcMain.handle( + 'agentTrust:markTrusted', + async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise => { + if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { + return + } + try { + if (args.preset === 'cursor') { + markCursorWorkspaceTrusted(args.workspacePath) + } else if (args.preset === 'copilot') { + markCopilotFolderTrusted(args.workspacePath) + } + } catch { + // Best-effort: see Why above. The user can still accept the trust + // prompt manually if writing the artifact fails. + } + } + ) +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 419f0f47c28..d0da43341c3 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -15,6 +15,7 @@ import { listWorkItems, countWorkItems, getWorkItem, + getWorkItemByOwnerRepo, createIssue, updateIssue, addIssueComment, @@ -143,6 +144,25 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItem) ) + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + } + ) => + getWorkItemByOwnerRepo( + assertRegisteredRepo(args.repoPath, store).path, + { owner: args.owner, repo: args.repo }, + args.number, + args.type + ) + ) ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItemDetails) ) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 31c11d5aa69..e43f3de60c9 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -21,6 +21,7 @@ const { registerRuntimeHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, + registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, @@ -53,6 +54,7 @@ const { registerRuntimeHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), + registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -158,6 +160,10 @@ vi.mock('./agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) +vi.mock('./agent-trust', () => ({ + registerAgentTrustHandlers: registerAgentTrustHandlersMock +})) + vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -205,6 +211,7 @@ describe('registerCoreHandlers', () => { registerRuntimeHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() + registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f9ac8580a95..2213476ca7f 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -29,6 +29,7 @@ import { registerSidekickHandlers } from './sidekick' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' +import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' import { @@ -72,6 +73,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() + registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index a47d7d0886a..ec92d3a93ab 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -514,9 +514,7 @@ export function createMainWindow( // Why: routed through the main process so focus contexts that bypass // the renderer's window-level keydown (contentEditable markdown editor, // browser-guest webContents) still reach the new-workspace composer. - // Forward the target tab so Cmd/Ctrl+Shift+N lands on the - // "Create from…" tab instead of the default quick-create form. - mainWindow.webContents.send('ui:openNewWorkspace', action.tab) + mainWindow.webContents.send('ui:openNewWorkspace') return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 94fe938322d..e5aaf567b87 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -506,6 +506,13 @@ export type PreloadApi = { number: number type?: 'issue' | 'pr' }) => Promise | null> + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }) => Promise | null> workItemDetails: (args: { repoPath: string number: number @@ -717,6 +724,9 @@ export type PreloadApi = { geminiStatus: () => Promise cursorStatus: () => Promise } + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise + } preflight: PreflightApi notifications: { dispatch: (args: NotificationDispatchRequest) => Promise @@ -941,7 +951,7 @@ export type PreloadApi = { onToggleRightSidebar: (callback: () => void) => () => void onToggleWorktreePalette: (callback: () => void) => () => void onOpenQuickOpen: (callback: () => void) => () => void - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void) => () => void + onOpenNewWorkspace: (callback: () => void) => () => void onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onNewBrowserTab: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 3ae75bd4e22..2a62a9e4332 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -534,6 +534,14 @@ const api = { type?: 'issue' | 'pr' }): Promise => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { repoPath: string number: number @@ -848,6 +856,11 @@ const api = { ipcRenderer.invoke('agentHooks:cursorStatus') }, + agentTrust: { + markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise => + ipcRenderer.invoke('agentTrust:markTrusted', args) + }, + preflight: { check: (args?: { force?: boolean @@ -1563,13 +1576,8 @@ const api = { ipcRenderer.on('ui:openQuickOpen', listener) return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) }, - onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, tab: 'quick' | 'create-from') => { - // Why: older main-process builds may send this event without a payload - // — default to 'quick' so the preload contract stays forward-compatible - // during a partial rollout where only one side has shipped the tab arg. - callback(tab ?? 'quick') - } + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openNewWorkspace', listener) return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) }, diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 78230e9d879..98219c7a5f6 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -11,15 +11,17 @@ import { Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { SparsePreset, TuiAgent } from '../../../shared/types' +import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types' import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' +import SmartWorkspaceNameField, { + type SmartWorkspaceNameSelection +} from '@/components/new-workspace/SmartWorkspaceNameField' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -35,7 +37,12 @@ type NewWorkspaceComposerCardProps = { repoId: string onRepoChange: (value: string) => void name: string - onNameChange: (event: React.ChangeEvent) => void + onNameValueChange: (value: string) => void + onSmartGitHubItemSelect: (item: GitHubWorkItem) => void + onSmartBranchSelect: (refName: string) => void + onSmartLinearIssueSelect: (issue: LinearIssue) => void + smartNameSelection: SmartWorkspaceNameSelection | null + onClearSmartNameSelection: () => void detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -175,7 +182,12 @@ export default function NewWorkspaceComposerCard({ repoId, onRepoChange, name, - onNameChange, + onNameValueChange, + onSmartGitHubItemSelect, + onSmartBranchSelect, + onSmartLinearIssueSelect, + smartNameSelection, + onClearSmartNameSelection, detectedAgentIds, onOpenAgentSettings, advancedOpen, @@ -240,12 +252,12 @@ export default function NewWorkspaceComposerCard({ onDragEnter={dragHandlers.onDragEnter} onDragLeave={dragHandlers.onDragLeave} className={cn( - 'grid gap-1 rounded-md transition', + 'grid min-w-0 gap-1 rounded-md transition', isFileDragOver && 'ring-2 ring-ring/30', containerClassName )} > -
+
@@ -284,30 +296,33 @@ export default function NewWorkspaceComposerCard({ />
-
+
- { + onValueChange={onNameValueChange} + onGitHubItemSelect={onSmartGitHubItemSelect} + onBranchSelect={onSmartBranchSelect} + onLinearIssueSelect={onSmartLinearIssueSelect} + selectedSource={smartNameSelection} + onClearSelectedSource={onClearSmartNameSelection} + onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user // progress through the form with just the keyboard. - if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) { - return - } - event.preventDefault() const root = composerRef?.current const agentTrigger = root?.querySelector( '[data-agent-combobox-root="true"][role="combobox"]' ) agentTrigger?.focus() }} - placeholder="Workspace name" - className="h-9 text-sm" />
@@ -348,6 +363,21 @@ export default function NewWorkspaceComposerCard({ />
+
+ +
+
) : null} -
- -
-
+ + + Open in browser + + + ) : null} + + + + + + Clear + + +
+ ) : ( + <> + + { + onValueChange(event.target.value) + if (mode !== 'text') { + setOpen(true) + } + }} + onFocus={() => { + if (mode !== 'text') { + setOpen(true) + } + }} + onKeyDown={(event) => { + if (event.key === 'Tab' && event.shiftKey) { + const activeTrigger = tabsListRef.current?.querySelector( + `[data-smart-name-mode="${mode}"]` + ) + if (activeTrigger) { + event.preventDefault() + activeTrigger.focus() + return + } + } + if ( + event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey + ) { + if (open && rows.length > 0) { + const row = rows.find((entry) => entry.value === commandValue) + if (row) { + event.preventDefault() + handleSelect(row) + } + return + } + onPlainEnter?.() + } + if (event.key === 'Escape' && open) { + event.stopPropagation() + setOpen(false) + } + }} + placeholder={placeholder} + className="h-9 pl-8 text-sm" + /> + + )} +
+ + event.preventDefault()} + onPointerDownOutside={(event) => { + // Why: the input is a PopoverAnchor, not a PopoverTrigger, so + // Radix treats clicks on it as outside the popover. Keep focus + // clicks and mode-tab clicks from immediately closing results. + const target = event.target as Node + if ( + localInputRef.current?.contains(target) || + tabsListRef.current?.contains(target) + ) { + event.preventDefault() + } + }} + onFocusOutside={(event) => { + const target = event.target as Node + if ( + localInputRef.current?.contains(target) || + tabsListRef.current?.contains(target) + ) { + event.preventDefault() + } + }} + > + + {loading && rows.length === 0 ? ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) : rows.length === 0 ? ( +
+ {mode === 'linear' && !linearStatus.connected + ? 'Connect Linear in Settings to search issues.' + : 'Start typing to create a name or find a source.'} +
+ ) : ( + + {rows.map((row) => ( + handleSelect(row)} + className="gap-2 px-2 py-1.5 text-xs" + > + + + + ))} + + )} + + + + + !next && dismissCrossRepoPrompt()} + > + + + Switch project? + + The GitHub URL points to {crossRepoPrompt?.link.slug.owner}/ + {crossRepoPrompt?.link.slug.repo}, which is different from the selected project. + + + + + + {crossRepoPrompt?.matchingRepo ? ( + + ) : ( + + )} + + + +
+ ) +} + +function RowIcon({ row }: { row: RowEntry }): React.JSX.Element { + if (row.kind === 'use-name') { + return + } + if (row.kind === 'github') { + return row.item.type === 'pr' ? ( + + ) : ( + + ) + } + if (row.kind === 'branch') { + return + } + return ( + + L + + ) +} + +function SelectionIcon({ kind }: { kind: SmartWorkspaceNameSelection['kind'] }): React.JSX.Element { + if (kind === 'github-pr') { + return + } + if (kind === 'github-issue') { + return + } + if (kind === 'branch') { + return + } + return ( + + L + + ) +} + +function RowLabel({ row }: { row: RowEntry }): React.JSX.Element { + if (row.kind === 'use-name') { + return ( + + Use “{row.name}” as + workspace name + + ) + } + if (row.kind === 'github') { + return ( + + #{row.item.number} {row.item.title} + + ) + } + if (row.kind === 'branch') { + return {row.refName} + } + return ( + + {row.issue.identifier} {row.issue.title} + + ) +} + +function sameSlug(left: RepoSlug, right: RepoSlug): boolean { + return ( + left.owner.toLowerCase() === right.owner.toLowerCase() && + left.repo.toLowerCase() === right.repo.toLowerCase() + ) +} + +async function getRepoSlugCached( + repo: RepoOption, + cache: Map +): Promise { + if (cache.has(repo.id)) { + return cache.get(repo.id) ?? null + } + if (repo.connectionId) { + cache.set(repo.id, null) + return null + } + try { + const slug = await window.api.gh.repoSlug({ repoPath: repo.path }) + cache.set(repo.id, slug) + return slug + } catch { + cache.set(repo.id, null) + return null + } +} + +async function findMatchingRepoForSlug( + repos: RepoOption[], + slug: RepoSlug, + cache: Map +): Promise { + for (const repo of repos) { + const candidate = await getRepoSlugCached(repo, cache) + if (candidate && sameSlug(candidate, slug)) { + return repo + } + } + return null +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index ba2aaa0eff3..8abbe6223fe 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -13,6 +13,36 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-t // MaxListenersExceededWarning with many panes/tabs. 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 + * (e.g. agent-paste-draft watching for DECSET 2004 / bracketed-paste- + * enable). Sidecars are invoked AFTER the primary handler so xterm rendering + * is never delayed by a side-effect-only watcher. Each Set entry is one + * active subscription; removal is by Set.delete inside the unsubscribe fn. */ +export const ptyDataSidecars = new Map void>>() + +/** Register a side-channel data watcher for a PTY without taking ownership + * of the primary handler. Returns an unsubscribe fn. ensurePtyDispatcher() + * is called automatically so the underlying IPC stream is wired up. */ +export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { + ensurePtyDispatcher() + let set = ptyDataSidecars.get(ptyId) + if (!set) { + set = new Set() + ptyDataSidecars.set(ptyId, set) + } + set.add(watcher) + return () => { + const current = ptyDataSidecars.get(ptyId) + if (!current) { + return + } + current.delete(watcher) + if (current.size === 0) { + ptyDataSidecars.delete(ptyId) + } + } +} /** Per-PTY replay handlers for relay pty.attach replay data. Routed through * a dedicated pty:replay IPC channel so the renderer can engage the replay * guard and suppress xterm auto-replies during replay. */ @@ -50,6 +80,20 @@ export function ensurePtyDispatcher(): void { ptyDispatcherAttached = true window.api.pty.onData((payload) => { ptyDataHandlers.get(payload.id)?.(payload.data) + const sidecars = ptyDataSidecars.get(payload.id) + if (sidecars && sidecars.size > 0) { + // Why: snapshot the Set before iterating because watchers commonly + // unsubscribe themselves on the very chunk that satisfies them + // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately + // tears down). Iterating the live Set in that case can skip a + // watcher or — if a watcher synchronously subscribes a sibling — + // double-fire. The Set is never large (one watcher per active + // ready-wait), so the array allocation is cheap. + const snapshot = Array.from(sidecars) + for (const watcher of snapshot) { + watcher(payload.data) + } + } }) window.api.pty.onReplay((payload) => { ptyReplayHandlers.get(payload.id)?.(payload.data) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3f38a873122..21a3a6c0028 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -9,10 +9,12 @@ import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isGitRepoKind } from '../../../shared/repo-kind' import type { GitHubWorkItem, + LinearIssue, OrcaHooks, SetupDecision, SetupRunPolicy, @@ -35,6 +37,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' +import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -65,7 +68,12 @@ export type ComposerCardProps = { repoId: string onRepoChange: (value: string) => void name: string - onNameChange: (event: React.ChangeEvent) => void + onNameValueChange: (value: string) => void + onSmartGitHubItemSelect: (item: GitHubWorkItem) => void + onSmartBranchSelect: (refName: string) => void + onSmartLinearIssueSelect: (issue: LinearIssue) => void + smartNameSelection: SmartWorkspaceNameSelection | null + onClearSmartNameSelection: () => void agentPrompt: string onAgentPromptChange: (value: string) => void onPromptKeyDown: (event: React.KeyboardEvent) => void @@ -675,8 +683,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // discriminant, so the union-preserving shape must be asserted. // Why: the link popover intentionally does NOT surface // `envelope.errors?.issues`. Per-surface error copy lives in the - // Tasks view (TaskPage) and the new-workspace Create tab - // (CreateFromTab) — a partial-failure banner inside the small + // Tasks view (TaskPage) and the smart workspace-name field — a + // partial-failure banner inside the small // @-mention popover would crowd the input and the user would // already see the same error on the originating Tasks page. If a // future UX decision flips this, add an error row to the popover's @@ -807,9 +815,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }, [name]) - const handleNameChange = useCallback( - (event: React.ChangeEvent): void => { - const nextName = event.target.value + const handleNameValueChange = useCallback( + (nextName: string): void => { // Why: linked GitHub items should keep refreshing the suggested workspace // name only while the current value is still auto-managed. As soon as the // user edits the field by hand, later issue/PR selections must stop @@ -824,7 +831,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [name] ) - const handleAddAttachment = useCallback(async (): Promise => { try { const selectedPath = await window.api.shell.pickAttachment() @@ -1042,6 +1048,119 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [applyLinkedWorkItem] ) + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem): void => { + applyLinkedWorkItem(item) + setStartFromResetHint(null) + const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo + if (item.type !== 'pr' || !repoForItem) { + return + } + void window.api.worktrees + .resolvePrBase({ + repoId: repoForItem.id, + prNumber: item.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + .then((result) => { + if ('error' in result) { + return + } + handleBaseBranchPrSelect(result.baseBranch, item) + }) + }, + [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string): void => { + setBaseBranch(refName) + setStartFromResetHint(null) + if (!name.trim() || name === lastAutoNameRef.current) { + setName(refName) + lastAutoNameRef.current = refName + } + }, + [name] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue): void => { + setLinkedIssue('') + setLinkedPR(null) + setLinkedWorkItem({ + type: 'issue', + // Why: Linear identifiers are strings (e.g. ENG-123); keep GitHub + // numeric metadata empty and carry the real source through the URL. + number: 0, + title: issue.title, + url: issue.url + }) + const suggestedName = issue.title + if (!name.trim() || name === lastAutoNameRef.current) { + setName(suggestedName) + lastAutoNameRef.current = suggestedName + } + const details = [ + `[${issue.identifier}] ${issue.title}`, + `Status: ${issue.state.name} · Team: ${issue.team.name}`, + issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null, + issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null, + `URL: ${issue.url}`, + issue.description ? `\n${issue.description}` : null + ] + .filter(Boolean) + .join('\n') + if (!noteRef.current.trim() || noteRef.current === lastAutoNoteRef.current) { + setNote(details) + lastAutoNoteRef.current = details + } + }, + [name] + ) + + const handleClearSmartNameSelection = useCallback((): void => { + setLinkedIssue('') + setLinkedPR(null) + setLinkedWorkItem(null) + setBaseBranch(undefined) + setStartFromResetHint(null) + if (name === lastAutoNameRef.current) { + setName('') + lastAutoNameRef.current = '' + } + if (noteRef.current === lastAutoNoteRef.current) { + setNote('') + lastAutoNoteRef.current = '' + } + }, [name]) + + const smartNameSelection = useMemo(() => { + if (linkedWorkItem) { + const isLinear = linkedWorkItem.number === 0 && !linkedWorkItem.url.includes('github.com') + const kind: SmartWorkspaceNameSelection['kind'] = isLinear + ? 'linear' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + isLinear || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } + } + if (baseBranch) { + return { kind: 'branch', label: baseBranch } + } + return null + }, [baseBranch, linkedWorkItem]) + const handleOpenAgentSettings = useCallback((): void => { openSettingsTarget({ pane: 'agents', repoId: null }) openSettingsPage() @@ -1202,8 +1321,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const workspaceName = getWorkspaceSeedName({ explicitName: name, prompt: '', - linkedIssueNumber: null, - linkedPR: null, + linkedIssueNumber: parsedLinkedIssueNumber, + linkedPR, fallbackName: fallbackCreatureName }) if ( @@ -1241,18 +1360,76 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const worktree = result.worktree const trimmedNote = note.trim() - await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {}) + await applyWorktreeMeta(worktree.id, { + ...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}), + ...(linkedPR !== null ? { linkedPR } : {}), + ...(trimmedNote ? { comment: trimmedNote } : {}) + }) - const startupPlan = - agent === null - ? null - : buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + // Why: when a linked work item is selected in the quick flow, launch + // the agent with a blank prompt and type the URL into its input as a + // draft (no trailing Enter). This lets the user review/edit before + // sending instead of auto-executing a "Complete " template. + // Falls back to the trimmed note when the linked item carries no + // number/URL (Linear typed-only entries). + const isLinearTypedOnly = linkedWorkItem?.number === 0 && Boolean(trimmedNote) + const quickPrompt = isLinearTypedOnly && trimmedNote ? trimmedNote : '' + const quickDraftPrompt = linkedWorkItem && !isLinearTypedOnly ? linkedWorkItem.url : null + + // Why: agents that gate first-launch behind a "Do you trust this + // folder?" menu (cursor-agent, copilot) consume the bracketed paste + // as menu input. Pre-write the trust artifact so the menu is + // skipped — best-effort, errors swallowed by main. Guard the IPC + // presence so a stale preload bundle doesn't crash the launch with + // "Cannot read properties of undefined". + if (agent && worktree.path && window.api.agentTrust?.markTrusted) { + const preflight = TUI_AGENT_CONFIG[agent].preflightTrust + if (preflight) { + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: worktree.path }) + } catch { + // Best-effort: continue with launch. + } + } + } + + // Why: prefer the agent's native prefill flag (currently Claude's + // `--prefill`) when it has one — sidesteps the readiness/paste race + // entirely. Falls through to the type-after-ready path for every + // other agent. + const draftLaunchPlan = + agent === null || !quickDraftPrompt + ? null + : buildAgentDraftLaunchPlan({ + agent, + draft: quickDraftPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM + }) + + let startupPlan: ReturnType = null + if (draftLaunchPlan) { + startupPlan = { + agent: draftLaunchPlan.agent, + launchCommand: draftLaunchPlan.launchCommand, + expectedProcess: draftLaunchPlan.expectedProcess, + followupPrompt: null + } + } else if (agent !== null) { + startupPlan = buildAgentStartupPlan({ + agent, + prompt: quickPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + if (startupPlan && quickDraftPrompt) { + startupPlan.draftPrompt = quickDraftPrompt + } + } activateAndRevealWorktree(worktree.id, { setup: result.setup, @@ -1287,10 +1464,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS clearNewWorkspaceDraft, createWorktree, fallbackCreatureName, + linkedPR, + linkedWorkItem, name, normalizedSparseDirectories, note, onCreated, + parsedLinkedIssueNumber, persistDraft, repoId, requiresExplicitSetupChoice, @@ -1323,7 +1503,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId, onRepoChange: handleRepoChange, name, - onNameChange: handleNameChange, + onNameValueChange: handleNameValueChange, + onSmartGitHubItemSelect: handleSmartGitHubItemSelect, + onSmartBranchSelect: handleSmartBranchSelect, + onSmartLinearIssueSelect: handleSmartLinearIssueSelect, + smartNameSelection, + onClearSmartNameSelection: handleClearSmartNameSelection, agentPrompt, onAgentPromptChange: setAgentPrompt, onPromptKeyDown: handlePromptKeyDown, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 48b9832ba72..ad5ceb7a831 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -137,7 +137,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onOpenNewWorkspace((tab) => { + window.api.ui.onOpenNewWorkspace(() => { // Why: mirror the renderer's App.tsx Cmd+N guard — only open the // composer when there is at least one real git repo configured, so // users on a fresh install don't get a modal with nothing to target. @@ -146,15 +146,10 @@ export function useIpcEvents(): void { return } dispatchClearModifierHints() - // Why: if the composer is already open, switch tabs in place so - // repeated Cmd+N / Cmd+Shift+N presses toggle between Quick and - // Create-from without remounting and losing in-flight composer state - // (repo pick, note drafts). Opening when closed seeds the initial tab. if (store.activeModal === 'new-workspace-composer') { - store.setNewWorkspaceComposerTab(tab) return } - store.openModal('new-workspace-composer', { initialTab: tab }) + store.openModal('new-workspace-composer') }) ) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts new file mode 100644 index 00000000000..7054bed4419 --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -0,0 +1,172 @@ +import type { TuiAgent } from '../../../shared/types' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { useAppStore } from '@/store' +import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher' + +// Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi / +// OpenCode / Gemini / cursor-agent / copilot) treat the inserted text as a +// single atomic paste — the payload lands in the input buffer as a draft +// instead of echoing character-by-character or triggering line-edit +// shortcuts. Intentionally omit a trailing '\r' so the draft never auto- +// submits; the user reviews and sends the prompt themselves. +const BRACKETED_PASTE_BEGIN = '\x1b[200~' +const BRACKETED_PASTE_END = '\x1b[201~' + +// Why: every prefill-capable TUI we ship support for (claude / codex / pi / +// opencode / gemini / cursor-agent / copilot) emits `CSI ? 2004 h` (DECSET +// 2004 — bracketed-paste-enable) on its output stream when its input layer +// is wired up. That sequence is the protocol-level "I accept bracketed +// paste" handshake — but on its own it doesn't mean "the input box is +// rendered and visible". OpenCode in particular emits DECSET 2004 during +// its alt-screen setup at ~500ms, then runs a 1.3s splash render with NO +// data on the PTY, then paints the actual input box at ~1.85s. Pasting +// during the silent gap drops the bytes. +// +// Strategy: take DECSET 2004 as the necessary precondition, then wait for +// the TUI's render burst to finish — defined as `BRACKETED_PASTE_QUIET_MS` +// of stream silence after the most recent post-`?2004h` byte. This +// captures both the fast TUIs (claude/pi/codex emit their setup escapes +// in one burst, then go quiet) and the slow ones (opencode emits, sleeps, +// emits again, then goes quiet). Verified against opencode/claude/pi in +// a node-pty rig: paste lands on the first try with a 1500ms quiet window. +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const BRACKETED_PASTE_QUIET_MS = 1500 + +// Why: deterministic signal can fail in two ways: (1) the agent never +// emits DECSET 2004 (no shipped agent does this — guarded as a fallback), +// or (2) the launch fails outright. The hard timeout caps the wait so a +// stuck launch doesn't pin a Promise forever. +const READINESS_TIMEOUT_MS = 8000 + +/** + * Wait until the agent on `tabId` has rendered its input-accepting TUI, + * then bracketed-paste `content` into its input buffer. Never appends + * `\r`, so the draft stays editable for the user to review / append + * before sending. + * + * Returns true when the paste was issued, false on timeout or missing + * PTY. `onTimeout` lets the caller surface a UI hint (e.g. toast) when + * the agent doesn't reach a ready state inside `timeoutMs`. + * + * Readiness combines two stream signals: + * 1. `\x1b[?2004h` (DECSET 2004 — bracketed-paste-enable) on the PTY + * output. This is the protocol-level "I accept bracketed paste" + * handshake. + * 2. ≥`BRACKETED_PASTE_QUIET_MS` of silence after the last byte of the + * post-handshake render burst. Captures TUIs (OpenCode) that emit + * DECSET 2004 early and then run a multi-second splash before + * drawing the actual input box. + */ +export async function pasteDraftWhenAgentReady(args: { + tabId: string + content: string + agent?: TuiAgent + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, content, agent, timeoutMs, onTimeout } = args + + // Why: agents with a documented prefill flag (currently Claude — see + // TUI_AGENT_CONFIG.claude.draftPromptFlag) launch with the URL already + // in their input box. Pasting again would duplicate it. Callers should + // not invoke this helper for those agents; the early return guards + // against accidental double-injection if a stale call slips through. + if (agent && TUI_AGENT_CONFIG[agent].draftPromptFlag) { + return false + } + + const budget = timeoutMs ?? READINESS_TIMEOUT_MS + const ptyId = await waitForPtyId(tabId, budget) + if (!ptyId) { + onTimeout?.() + return false + } + + const ready = await waitForInputBoxReady(ptyId, budget) + if (!ready) { + onTimeout?.() + return false + } + + window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) + return true +} + +/** + * Tap the PTY data stream as a side-channel observer (does NOT take over + * the primary handler that feeds xterm) and resolve `true` once we see + * DECSET 2004 *and* the post-handshake render burst settles for + * `BRACKETED_PASTE_QUIET_MS`. Resolves `false` on hard timeout. + * + * Why a sidecar subscription: + * - the main pane may attach mid-flight; we must not race against its + * handler registration on the dispatcher's primary slot. + * - DECSET 2004 may straddle two data chunks at ANSI parser boundaries, + * so we keep a small ring of recent bytes and search the union. + */ +function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false + let recent = '' + let saw2004 = false + let quietTimer: number | null = null + let unsubscribe: (() => void) | null = null + + const finish = (value: boolean): void => { + if (settled) { + return + } + settled = true + window.clearTimeout(hardTimer) + if (quietTimer !== null) { + window.clearTimeout(quietTimer) + } + unsubscribe?.() + resolve(value) + } + + const armQuietTimer = (): void => { + if (quietTimer !== null) { + window.clearTimeout(quietTimer) + } + quietTimer = window.setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS) + } + + unsubscribe = subscribeToPtyData(ptyId, (data) => { + // Why: keep just enough recent bytes that an escape sequence split + // across two IPC frames is still detectable. 64 bytes >> 8-byte + // sequence; cheap and bounded. + recent = (recent + data).slice(-64) + if (!saw2004 && recent.includes(DECSET_BRACKETED_PASTE)) { + saw2004 = true + } + if (saw2004) { + // Reset the quiet window on every byte we see post-handshake. + // The TUI's render is "done" when the stream goes quiet for + // BRACKETED_PASTE_QUIET_MS — at that point the input box is + // mounted and bracketed paste lands in the input buffer. + armQuietTimer() + } + }) + + const hardTimer = window.setTimeout(() => finish(false), timeoutMs) + }) +} + +/** + * Why: activation creates the tab synchronously but the PTY spawn is + * async. Poll the store until the primary PTY id appears or the budget + * expires. Tight interval because the wait is normally <200ms — only the + * first launch on a cold app reaches the tail of this. + */ +async function waitForPtyId(tabId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0] + if (ptyId) { + return ptyId + } + await new Promise((resolve) => window.setTimeout(resolve, 50)) + } + return null +} diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index e6db2b488f8..d8701171827 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -52,6 +52,7 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null { export function parseGitHubIssueOrPRLink(input: string): { slug: RepoSlug number: number + type: 'issue' | 'pr' } | null { const trimmed = input.trim() if (!trimmed) { @@ -76,6 +77,7 @@ export function parseGitHubIssueOrPRLink(input: string): { return { slug: { owner: match[1], repo: match[2] }, + type: url.pathname.toLowerCase().includes('/pull/') ? 'pr' : 'issue', number: Number.parseInt(match[3], 10) } } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index decb9219d13..182fc38d73c 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,8 +1,9 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' -import { waitForAgentReady } from '@/lib/agent-ready-wait' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { CLIENT_PLATFORM, @@ -30,13 +31,9 @@ export type LaunchableWorkItem = { linearIdentifier?: string } -// Why: bracketed paste markers let modern TUIs treat the inserted text as a -// single atomic paste — Claude Code / Codex / Gemini put it in their input -// buffer as a draft instead of echoing character-by-character. Intentionally -// omit a trailing '\r' so the draft never auto-submits; the user gets to -// review and send the prompt themselves. -const BRACKETED_PASTE_BEGIN = '\x1b[200~' -const BRACKETED_PASTE_END = '\x1b[201~' +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". export type LaunchWorkItemDirectArgs = { item: LaunchableWorkItem @@ -47,7 +44,7 @@ export type LaunchWorkItemDirectArgs = { openModalFallback: () => void /** Optional base branch to start the worktree from. When omitted the * worktree inherits the repo's effective base ref. Used by the - * "Create from…" PR row to branch from the PR's head so the first + * smart workspace-name PR selection to branch from the PR's head so the first * commit lands on the correct base without the user touching the UI. */ baseBranch?: string } @@ -105,31 +102,15 @@ async function pasteWorkItemDraftWhenAgentReady(args: { content: string }): Promise { const { primaryTabId, startupPlan, content } = args - const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { - timeoutMs: 5000 + await pasteDraftWhenAgentReady({ + tabId: primaryTabId, + content, + agent: startupPlan.agent, + onTimeout: () => + toast.message( + 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' + ) }) - if (!readyResult.ready) { - toast.message( - 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' - ) - return - } - - const finalState = useAppStore.getState() - const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] - if (!ptyId) { - return - } - - // Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can - // interpret our paste markers. `title-idle` means the TUI has fully rendered - // its input box and enabled paste mode; weaker signals (`foreground-match`, - // `child-process`) only confirm the binary is running — the TUI's input - // setup may still be in-flight, especially on slow shell environments. - const graceMs = readyResult.reason === 'title-idle' ? 150 : 600 - await new Promise((resolve) => window.setTimeout(resolve, graceMs)) - - window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) } /** @@ -179,25 +160,70 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let worktreeId: string let primaryTabId: string | null let startupPlan: ReturnType = null + let draftLaunchedNatively = false try { const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision) worktreeId = result.worktree.id + const worktreePath = result.worktree.path const detectedIds = new Set(await detectedAgentsPromise) const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) - // Why: launch the agent with no prompt so the first frame it draws is the - // empty input box. The URL paste below populates that input buffer, which - // gives the user a reviewable draft instead of a submitted request. - startupPlan = + const draftContent = item.pasteContent ?? item.url + + // Why: agents that gate first-launch behind a "Do you trust this folder?" + // menu (cursor-agent, copilot) consume the bracketed paste as menu input. + // Pre-write the same trust artifact those CLIs write after the user + // accepts so the menu never fires. Best-effort — main swallows errors, + // and we guard the IPC presence so a stale preload bundle (which can + // ship a renderer that's ahead of the loaded preload) doesn't crash the + // launch with "Cannot read properties of undefined". + if (effectiveAgent && worktreePath && window.api.agentTrust?.markTrusted) { + const preflight = TUI_AGENT_CONFIG[effectiveAgent].preflightTrust + if (preflight) { + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: worktreePath + }) + } catch { + // Best-effort: continue with launch even if the trust write + // throws. The user can dismiss the trust menu manually. + } + } + } + + // Why: prefer a native prefill flag (e.g. `claude --prefill `) when + // the agent's CLI exposes one — the TUI mounts with the URL already in + // its input box, which sidesteps the readiness/paste race entirely. Fall + // back to launching with no prompt + bracketed-paste-after-ready for + // every other agent so the URL still lands as a draft (not auto- + // submitted as the first turn). + const draftLaunchPlan = effectiveAgent === null ? null - : buildAgentStartupPlan({ + : buildAgentDraftLaunchPlan({ agent: effectiveAgent, - prompt: '', + draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + platform: CLIENT_PLATFORM }) + if (draftLaunchPlan) { + startupPlan = { + agent: draftLaunchPlan.agent, + launchCommand: draftLaunchPlan.launchCommand, + expectedProcess: draftLaunchPlan.expectedProcess, + followupPrompt: null + } + draftLaunchedNatively = true + } else if (effectiveAgent !== null) { + startupPlan = buildAgentStartupPlan({ + agent: effectiveAgent, + prompt: '', + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + } const activation = activateAndRevealWorktree(worktreeId, { setup: result.setup, @@ -241,18 +267,20 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setRightSidebarOpen(true) } - // Why: at this point the workspace is live and the agent (if any) has been - // queued on `primaryTabId`. The paste step below is the only remaining - // draft-specific work; bail out cleanly when either prerequisite is missing. - if (!primaryTabId || !startupPlan) { + // Why: at this point the workspace is live and the agent (if any) has + // been queued on `primaryTabId`. The post-launch paste step below only + // applies to agents that lacked a native prefill flag; for agents that + // were launched with the URL already on argv (Claude --prefill today), + // the URL is in the input box already — pasting again would duplicate it. + if (!primaryTabId || !startupPlan || draftLaunchedNatively) { return } const content = item.pasteContent ?? item.url - // Why: the workspace is already created and visible; waiting up to 5s for - // agent readiness here kept the Create-from modal in "Creating workspace…". - // Continue the draft paste in the background so selection latency ends when - // the worktree is ready, not when the TUI input buffer is ready. + // Why: the workspace is already created and visible; do not block selection + // latency on agent readiness. Run the paste in the background so the + // "Use" CTA's spinner ends when the worktree is ready, not when the TUI + // input buffer is ready. void pasteWorkItemDraftWhenAgentReady({ primaryTabId, startupPlan, content }) } diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 619c30e03d3..0e9dfd32d92 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { isShellProcess } from '@/lib/tui-agent-startup' import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' @@ -195,65 +196,87 @@ export function getWorkspaceSeedName(args: { return 'workspace' } +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". + export async function ensureAgentStartupInTerminal(args: { worktreeId: string startup: AgentStartupPlan }): Promise { const { worktreeId, startup } = args - if (startup.followupPrompt === null) { + const draftPrompt = startup.draftPrompt ?? null + if (startup.followupPrompt === null && draftPrompt === null) { return } - let promptInjected = false - + // Why: poll until a terminal tab + PTY exists for the worktree before we + // can interact with it. Activation creates the tab synchronously but the + // PTY spawn is async, so a brief wait is normal. + let tabId: string | null = null + let ptyId: string | null = null for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => window.setTimeout(resolve, 150)) } - const state = useAppStore.getState() - const tabId = + tabId = state.activeTabIdByWorktree[worktreeId] ?? state.tabsByWorktree[worktreeId]?.[0]?.id ?? null if (!tabId) { continue } - - const ptyId = state.ptyIdsByTabId[tabId]?.[0] - if (!ptyId) { - continue + ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null + if (ptyId) { + break } + } + if (!tabId || !ptyId) { + return + } + // Why: followupPrompt is the legacy path for stdin-after-start agents + // (aider, goose, etc.) that need their initial prompt typed into the live + // session and submitted. Wait until the agent owns the PTY before writing. + if (startup.followupPrompt) { + await waitForAgentForeground(ptyId, startup.expectedProcess) + window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + } + + // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the + // agent's input buffer (no per-char echo, no auto-submit). Shared with the + // launch-work-item-direct flow so both behave identically. + if (draftPrompt) { + await pasteDraftWhenAgentReady({ + tabId, + content: draftPrompt, + agent: startup.agent + }) + } +} + +// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with +// a hasChildProcesses fallback after several polls). Preserve that behavior so +// stdin-after-start agents still receive their prompt under the same +// conditions. Returns when the agent appears ready or the budget expires. +async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => window.setTimeout(resolve, 150)) + } try { const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' - const agentOwnsForeground = - foreground === startup.expectedProcess || - foreground.startsWith(`${startup.expectedProcess}.`) - - if (agentOwnsForeground && !promptInjected && startup.followupPrompt) { - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true + const owns = + foreground === expectedProcess || + foreground.startsWith(`${expectedProcess}.`) || + foreground.endsWith(`/${expectedProcess}`) + if (owns) { return } - - if (agentOwnsForeground && promptInjected) { - return - } - - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if ( - !promptInjected && - startup.followupPrompt && - hasChildProcesses && - !isShellProcess(foreground) && - attempt >= 4 - ) { - // Why: the initial agent launch is already queued on the first terminal - // tab. Only agents without a verified startup-prompt flag need extra - // help here: once the TUI owns the PTY, type the draft prompt into the - // live session instead of launching the binary a second time. - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true - return + if (attempt >= 4 && !isShellProcess(foreground)) { + const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) + if (hasChildProcesses) { + return + } } } catch { // Ignore transient PTY inspection failures and keep polling. diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index fd612afa88f..046fe5ef29c 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan, isShellProcess } from './tui-agent-startup' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + isShellProcess +} from './tui-agent-startup' describe('buildAgentStartupPlan', () => { it('passes Claude prompts as a positional interactive argument', () => { @@ -11,6 +15,7 @@ describe('buildAgentStartupPlan', () => { platform: 'darwin' }) ).toEqual({ + agent: 'claude', launchCommand: "claude 'Fix the bug'", expectedProcess: 'claude', followupPrompt: null @@ -26,6 +31,7 @@ describe('buildAgentStartupPlan', () => { platform: 'linux' }) ).toEqual({ + agent: 'gemini', launchCommand: "gemini --prompt-interactive 'Investigate this regression'", expectedProcess: 'gemini', followupPrompt: null @@ -41,6 +47,7 @@ describe('buildAgentStartupPlan', () => { platform: 'linux' }) ).toEqual({ + agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', followupPrompt: 'Refactor the parser' @@ -56,6 +63,7 @@ describe('buildAgentStartupPlan', () => { platform: 'linux' }) ).toEqual({ + agent: 'autohand', launchCommand: 'autohand', expectedProcess: 'autohand', followupPrompt: 'Add tests for the parser' @@ -71,6 +79,7 @@ describe('buildAgentStartupPlan', () => { platform: 'darwin' }) ).toEqual({ + agent: 'cursor', launchCommand: "cursor-agent 'Review this file'", expectedProcess: 'cursor-agent', followupPrompt: null @@ -86,6 +95,7 @@ describe('buildAgentStartupPlan', () => { platform: 'linux' }) ).toEqual({ + agent: 'droid', launchCommand: "/opt/factory/bin/droid 'Ship the fix'", expectedProcess: 'droid', followupPrompt: null @@ -101,6 +111,7 @@ describe('buildAgentStartupPlan', () => { platform: 'darwin' }) ).toEqual({ + agent: 'copilot', launchCommand: "copilot -i 'Fix the bug'", expectedProcess: 'copilot', followupPrompt: null @@ -127,6 +138,7 @@ describe('buildAgentStartupPlan', () => { platform: 'darwin' }) ).toEqual({ + agent: 'copilot', launchCommand: "copilot -i 'Fix the bug'", expectedProcess: 'copilot', followupPrompt: null @@ -134,6 +146,60 @@ describe('buildAgentStartupPlan', () => { }) }) +describe('buildAgentDraftLaunchPlan', () => { + it('uses Claude --prefill to seed the input box without submitting', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'https://github.com/acme/repo/issues/42', + cmdOverrides: {}, + platform: 'darwin' + }) + ).toEqual({ + agent: 'claude', + launchCommand: "claude --prefill 'https://github.com/acme/repo/issues/42'", + expectedProcess: 'claude' + }) + }) + + it('returns null for agents without a documented prefill flag', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'codex', + draft: 'https://github.com/acme/repo/issues/42', + cmdOverrides: {}, + platform: 'darwin' + }) + ).toBeNull() + }) + + it('returns null for an empty draft so callers fall back cleanly', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: ' ', + cmdOverrides: {}, + platform: 'darwin' + }) + ).toBeNull() + }) + + it('honors cmdOverrides so custom Claude install paths still prefill', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'review this', + cmdOverrides: { claude: '/opt/anthropic/bin/claude' }, + platform: 'linux' + }) + ).toEqual({ + agent: 'claude', + launchCommand: "/opt/anthropic/bin/claude --prefill 'review this'", + expectedProcess: 'claude' + }) + }) +}) + describe('isShellProcess', () => { it('treats common shells as non-agent foreground processes', () => { expect(isShellProcess('bash')).toBe(true) diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index 59c64c0f04d..ac053735814 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -2,9 +2,19 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/types' export type AgentStartupPlan = { + /** Why: surfaces the agent id so downstream paste-draft logic can resolve + * the per-agent draft injection strategy without re-deriving from the + * launch command string. */ + agent: TuiAgent launchCommand: string expectedProcess: string followupPrompt: string | null + /** Why: text to type into the live agent input WITHOUT submitting it (no + * trailing \r). Used by the quick-create flow to pre-fill a linked work + * item URL so the user can edit/add to it before sending. Independent from + * `followupPrompt` so the call site can choose: type-and-submit (followup) + * or type-and-leave-pending (draft). */ + draftPrompt?: string | null } function quoteStartupArg(value: string, platform: NodeJS.Platform): string { @@ -32,6 +42,7 @@ export function buildAgentStartupPlan(args: { return null } return { + agent, launchCommand: baseCommand, expectedProcess: config.expectedProcess, followupPrompt: null @@ -42,6 +53,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'argv') { return { + agent, launchCommand: `${baseCommand} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null @@ -50,6 +62,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt') { return { + agent, launchCommand: `${baseCommand} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null @@ -58,6 +71,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt-interactive') { return { + agent, launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null @@ -66,6 +80,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-interactive') { return { + agent, launchCommand: `${baseCommand} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null @@ -73,6 +88,7 @@ export function buildAgentStartupPlan(args: { } return { + agent, launchCommand: baseCommand, expectedProcess: config.expectedProcess, // Why: several agent TUIs either lack a documented "start interactive @@ -83,4 +99,45 @@ export function buildAgentStartupPlan(args: { } } +export type AgentDraftLaunchPlan = { + agent: TuiAgent + launchCommand: string + expectedProcess: string +} + +/** + * Why: when the agent's CLI exposes a documented "prefill but don't submit" + * flag (currently only `claude --prefill `), launch with that flag so + * the TUI mounts with the draft already in its input box. This is strictly + * better than the post-launch bracketed-paste fallback in agent-paste-draft.ts + * because it eliminates the empirical readiness wait entirely — the agent + * controls when its input is rendered. + * + * Returns `null` when the agent has no native prefill flag; callers fall + * back to the paste-after-ready path. + */ +export function buildAgentDraftLaunchPlan(args: { + agent: TuiAgent + draft: string + cmdOverrides: Partial> + platform: NodeJS.Platform +}): AgentDraftLaunchPlan | null { + const { agent, draft, cmdOverrides, platform } = args + const config = TUI_AGENT_CONFIG[agent] + if (!config.draftPromptFlag) { + return null + } + const trimmed = draft.trim() + if (!trimmed) { + return null + } + const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const quoted = quoteStartupArg(trimmed, platform) + return { + agent, + launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`, + expectedProcess: config.expectedProcess + } +} + export { isShellProcess } from '../../../shared/agent-detection' diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 37728199c18..d286f2f8542 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -158,17 +158,6 @@ export type UISlice = { modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void - /** Active tab inside the new-workspace composer modal. Mutable while the - * modal is open so Cmd+N / Cmd+Shift+N can toggle tabs without tearing - * down the composer state. */ - newWorkspaceComposerTab: 'quick' | 'create-from' - setNewWorkspaceComposerTab: (tab: 'quick' | 'create-from') => void - /** Remembered sub-tab inside the Create-from tab (PRs / Issues / Branches / - * Linear). Persists across composer opens within a session so users who - * always start from Linear (for example) don't have to click back to that - * tab every time. */ - createFromSubTab: 'prs' | 'issues' | 'branches' | 'linear' - setCreateFromSubTab: (tab: 'prs' | 'issues' | 'branches' | 'linear') => void trustedOrcaHooks: PersistedTrustedOrcaHooks markOrcaHookScriptConfirmed: ( repoId: string, @@ -369,27 +358,8 @@ export const createUISlice: StateCreator = (set, get) activeModal: 'none', modalData: {}, - openModal: (modal, data = {}) => { - // Why: when the new-workspace composer opens, seed its active tab from - // modalData.initialTab so Cmd+Shift+N lands directly on the "Create from…" - // tab without the Quick tab flashing first. Default to 'quick' when no - // explicit target is provided so existing callers keep their behavior. - if (modal === 'new-workspace-composer') { - const requestedTab = (data as { initialTab?: 'quick' | 'create-from' }).initialTab - set({ - activeModal: modal, - modalData: data, - newWorkspaceComposerTab: requestedTab ?? 'quick' - }) - return - } - set({ activeModal: modal, modalData: data }) - }, + openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }), closeModal: () => set({ activeModal: 'none', modalData: {} }), - newWorkspaceComposerTab: 'quick', - setNewWorkspaceComposerTab: (tab) => set({ newWorkspaceComposerTab: tab }), - createFromSubTab: 'prs', - setCreateFromSubTab: (tab) => set({ createFromSubTab: tab }), trustedOrcaHooks: {}, markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index b68260a6a2c..f666d5114e3 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -12,6 +12,22 @@ export type TuiAgentConfig = { launchCmd: string expectedProcess: string promptInjectionMode: AgentPromptInjectionMode + /** Why: flag that launches the TUI with the given text already in the + * input box but NOT submitted, so the user still gets a reviewable draft. + * Only set when the CLI documents native support — e.g. Claude's + * `--prefill `. The draft-launch flow prefers this over the + * post-launch bracketed-paste path because it eliminates the empirical + * agent-readiness wait entirely: the TUI mounts with the input pre-filled. + * Agents without native support fall through to the paste-after-ready + * code path in agent-paste-draft.ts. */ + draftPromptFlag?: string + /** Why: agents that gate first-launch behind a "Do you trust this + * folder?" menu (Cursor-Agent, GitHub Copilot CLI) consume the bracketed + * paste as menu input. Pre-write the same trust artifact the agent writes + * after the user accepts so the menu never fires. The actual file/path + * written lives in src/main/agent-trust-presets.ts; this flag just routes + * the workspace path through the matching preset before the agent spawns. */ + preflightTrust?: 'cursor' | 'copilot' } // Why: the new-workspace handoff depends on three pieces of per-agent @@ -25,7 +41,12 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'claude', launchCmd: 'claude', expectedProcess: 'claude', - promptInjectionMode: 'argv' + promptInjectionMode: 'argv', + // Why: `claude --prefill ` lands the TUI with `` in the + // input box, nothing submitted. Strictly better than the paste-after- + // ready fallback because it eliminates the readiness race entirely. + // See PR https://github.com/stablyai/orca/pull/926 for context. + draftPromptFlag: '--prefill' }, codex: { detectCmd: 'codex', @@ -128,7 +149,13 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'cursor-agent', launchCmd: 'cursor-agent', expectedProcess: 'cursor-agent', - promptInjectionMode: 'argv' + promptInjectionMode: 'argv', + // Why: cursor-agent's first-launch trust menu ([a]/[w]/[q]) used to + // swallow our bracketed paste. Pre-writing the same `.workspace-trusted` + // marker the CLI itself writes after the user accepts (see + // agent-trust-presets.ts) makes the menu skip entirely, so the draft + // URL paste lands in the input as intended. + preflightTrust: 'cursor' }, droid: { detectCmd: 'droid', @@ -174,6 +201,12 @@ export const TUI_AGENT_CONFIG: Record = { // completion, which would kill the TUI session Orca is hosting. // `-i/--interactive ` starts an interactive session with the // initial prompt pre-executed — the behavior Orca needs. - promptInjectionMode: 'flag-interactive' + promptInjectionMode: 'flag-interactive', + // Why: Copilot's first-launch trust menu used to swallow our bracketed + // paste. Pre-appending the workspace path to `trustedFolders` in + // ~/.copilot/config.json (the same array Copilot's own + // `addTrustedFolder` writes after the user accepts) makes the menu skip + // entirely. See agent-trust-presets.ts for the file layout. + preflightTrust: 'copilot' } } diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 4d30d73c0a0..06cae2cc09f 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -270,23 +270,22 @@ describe('resolveWindowShortcutAction', () => { ).toBeNull() }) - it('routes Cmd/Ctrl+Shift+N to the Create-from tab of the new-workspace composer', () => { - // Why: the shift variant of the new-workspace shortcut jumps straight to - // the "Create from…" tab so users can start from an existing GH/Linear - // item without a detour through the quick-create form. + it('routes Cmd/Ctrl+Shift+N to the unified new-workspace composer', () => { + // Why: keep the former Create-from shortcut accepted so muscle memory + // still opens the composer; source switching now lives in the smart name field. expect( resolveWindowShortcutAction( { code: 'KeyN', key: 'n', meta: true, control: false, alt: false, shift: true }, 'darwin' ) - ).toEqual({ type: 'openNewWorkspace', tab: 'create-from' }) + ).toEqual({ type: 'openNewWorkspace' }) expect( resolveWindowShortcutAction( { code: 'KeyN', key: 'n', meta: false, control: true, alt: false, shift: true }, 'linux' ) - ).toEqual({ type: 'openNewWorkspace', tab: 'create-from' }) + ).toEqual({ type: 'openNewWorkspace' }) // Alt must still be rejected — the allowlist is alt-free for Cmd/Ctrl+N // so future chords like Cmd+Alt+Shift+N remain available. @@ -320,7 +319,7 @@ describe('resolveWindowShortcutAction', () => { [{ code: 'KeyR', key: 'p', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }], [ { code: 'KeyL', key: 'n', meta: true, alt: false, shift: false }, - { type: 'openNewWorkspace', tab: 'quick' } + { type: 'openNewWorkspace' } ], [ { code: 'KeyC', key: 'j', meta: true, alt: false, shift: false }, @@ -350,7 +349,7 @@ describe('resolveWindowShortcutAction', () => { ], [ { code: 'KeyN', key: 'Dead', meta: true, alt: false, shift: false }, - { type: 'openNewWorkspace', tab: 'quick' } + { type: 'openNewWorkspace' } ], [{ code: 'KeyP', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }] ] diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index 4b742caaa06..2d2c751e16d 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -13,7 +13,7 @@ export type WindowShortcutAction = | { type: 'toggleLeftSidebar' } | { type: 'toggleRightSidebar' } | { type: 'openQuickOpen' } - | { type: 'openNewWorkspace'; tab: 'quick' | 'create-from' } + | { type: 'openNewWorkspace' } | { type: 'jumpToWorktreeIndex'; index: number } | { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' } @@ -167,12 +167,11 @@ export function resolveWindowShortcutAction( // main process so it reaches the renderer even when focus lives inside // a contentEditable surface (markdown rich editor) or a browser guest // webContents, both of which bypass the renderer's window-level keydown. - // Cmd/Ctrl+Shift+N opens the composer on the "Create from…" tab so users - // can start a workspace directly from an existing PR, issue, branch, or - // Linear ticket without going through the quick-create flow first. + // Shift is accepted for compatibility with the former Create-from shortcut; + // the unified composer now exposes source switching inside the name field. if (matchesLetterShortcut(input, 'n', 'KeyN')) { if (!input.alt) { - return { type: 'openNewWorkspace', tab: input.shift ? 'create-from' : 'quick' } + return { type: 'openNewWorkspace' } } } diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 0bda64b3667..38eb07dd2e9 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -87,9 +87,12 @@ test.describe('Create Workspace', () => { }) await orcaPage.waitForTimeout(100) - // 3. Type the workspace name into the Name input. This is what lets - // the composer pass its `workspaceName` guard inside submitQuick. - const nameInput = dialog.getByPlaceholder(/Workspace name/i) + // 3. Type the workspace name into the unified smart-name input. + // The composer's default mode is 'smart'; its placeholder advertises + // multiple input shapes ("Type a name, #1234, branch, GitHub or + // Linear URL"). Plain free-form text is treated as a workspace name + // by submitQuick, which is what we want here. + const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() await nameInput.fill(workspaceName)