From 53f2e50122fc75f0ab4809a98029ef65efe1d8df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:24:22 -0700 Subject: [PATCH] Keep linked workspace prompts as editable drafts (#5734) --- src/main/remote-agent-trust-presets.test.ts | 46 ++++ src/main/remote-agent-trust-presets.ts | 15 +- .../folder-workspace-composer-submit.test.ts | 228 +++++++++++++++++- .../folder-workspace-composer-submit.ts | 164 ++++++++++--- ...poserState-host-context-boundaries.test.ts | 16 ++ src/renderer/src/hooks/useComposerState.ts | 21 +- .../src/lib/linked-work-item-context.test.ts | 4 +- .../src/lib/linked-work-item-context.ts | 2 + .../src/lib/worktree-creation-flow.test.ts | 35 +++ .../src/lib/worktree-creation-flow.ts | 16 +- 10 files changed, 499 insertions(+), 48 deletions(-) diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 6591f741f..226d689a5 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -55,6 +55,28 @@ describe('markRemoteAgentWorkspaceTrusted', () => { ) }) + it('writes Codex trust when the remote home is a Windows absolute path', async () => { + const fsProvider = makeFsProvider({ + realpath: vi.fn(async () => 'C:/Users/alice/platform') + }) + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: 'C:\\Users\\alice\\' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'codex', + connectionId: 'ssh-windows', + workspacePath: 'C:\\Users\\alice\\platform' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith('C:/Users/alice/.codex') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:/Users/alice/.codex/config.toml', + expect.stringContaining('[projects."C:/Users/alice/platform"]') + ) + }) + it('writes Cursor trust marker on the remote host', async () => { const fsProvider = makeFsProvider() mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) @@ -72,6 +94,30 @@ describe('markRemoteAgentWorkspaceTrusted', () => { ) }) + it('sanitizes Windows path characters in remote Cursor trust marker paths', async () => { + const fsProvider = makeFsProvider({ + realpath: vi.fn(async () => 'C:/Users/alice/platform') + }) + mocks.getActiveMultiplexer.mockReturnValue({ + request: vi.fn(async () => ({ resolvedPath: 'C:/Users/alice/' })) + }) + mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) + + await markRemoteAgentWorkspaceTrusted({ + preset: 'cursor', + connectionId: 'ssh-windows', + workspacePath: 'C:\\Users\\alice\\platform' + }) + + expect(fsProvider.createDir).toHaveBeenCalledWith( + 'C:/Users/alice/.cursor/projects/C-Users-alice-platform' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:/Users/alice/.cursor/projects/C-Users-alice-platform/.workspace-trusted', + expect.stringContaining('"workspacePath": "C:/Users/alice/platform"') + ) + }) + it('appends Copilot trusted folder remotely without clobbering config keys', async () => { const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined) const fsProvider = makeFsProvider({ diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index 4d6e5f94d..c7ded0446 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -3,6 +3,10 @@ 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' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathSeparators +} from '../shared/cross-platform-path' export async function markRemoteAgentWorkspaceTrusted(args: { preset: AgentTrustPreset @@ -33,8 +37,13 @@ async function resolveRemoteHome(connectionId: string): Promise { 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) + const home = + typeof result.resolvedPath === 'string' + ? normalizeRuntimePathSeparators(result.resolvedPath.trim()) + : '' + return home && + (home.startsWith('/') || isWindowsAbsolutePathLike(home)) && + !hasRemotePathControlCharacter(home) ? home.replace(/\/$/, '') : null } @@ -87,7 +96,7 @@ async function markRemoteCursorWorkspaceTrusted( remoteHome: string, workspacePath: string ): Promise { - const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') + const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-') if (!slug) { return } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 0b733f057..1e388c82e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,16 +1,27 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type * as NewWorkspaceModule from '@/lib/new-workspace' const mocks = vi.hoisted(() => ({ - activateAndRevealFolderWorkspace: vi.fn() + activateAndRevealFolderWorkspace: vi.fn(), + ensureAgentStartupInTerminal: vi.fn() })) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace })) +vi.mock('@/lib/new-workspace', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ensureAgentStartupInTerminal: mocks.ensureAgentStartupInTerminal + } +}) + import { getFolderWorkspaceAgentLaunchPlatform, submitFolderWorkspaceCreate @@ -51,8 +62,21 @@ function makeFolderWorkspace(overrides: Partial = {}): FolderWo } describe('submitFolderWorkspaceCreate', () => { + beforeEach(() => { + mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' }) + Object.assign(window, { + api: { + agentTrust: { + markTrusted: vi.fn().mockResolvedValue(undefined) + } + } + }) + }) + afterEach(() => { mocks.activateAndRevealFolderWorkspace.mockReset() + mocks.ensureAgentStartupInTerminal.mockReset() + Reflect.deleteProperty(window, 'api') vi.restoreAllMocks() }) @@ -138,6 +162,7 @@ describe('submitFolderWorkspaceCreate', () => { const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup expect(startup?.command).toContain('--model') expect(startup?.command).toContain('gpt-5.4') + expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() }) it('does not mark first-input rename when the folder workspace has an explicit name', async () => { @@ -198,6 +223,205 @@ describe('submitFolderWorkspaceCreate', () => { }) }) + it('keeps linked Codex context out of submitted startup and pastes it as a draft', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const linkedWorkItem = { + provider: 'github' as const, + type: 'pr' as const, + number: 91, + title: 'Restore linked quick-create', + url: 'https://github.com/stablyai/orca/pull/91', + repoId: 'repo-1' + } + + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: '', + lastAutoName: '', + linkedWorkItem, + note: 'Review this before starting', + quickAgent: 'codex', + autoRenameBranchFromWork: true, + agentCmdOverrides: {}, + launchSource: 'new_workspace_composer', + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(createFolderWorkspace).toHaveBeenCalledWith({ + projectGroupId: 'group-1', + name: 'Restore linked quick-create', + connectionId: null, + linkedTask: linkedWorkItem, + createdWithAgent: 'codex' + }) + const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup + expect(startup?.command).toBe('codex') + expect(startup?.command).not.toContain(linkedWorkItem.url) + expect(startup?.command).not.toContain('Review this before starting') + expect(window.api.agentTrust?.markTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/repo/platform/hi' + }) + expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({ + worktreeId: folderWorkspaceKey('folder-workspace-1'), + primaryTabId: 'tab-1', + startup: expect.objectContaining({ + agent: 'codex', + launchCommand: 'codex', + followupPrompt: null, + draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}` + }) + }) + }) + + it('pre-marks remote linked Codex folder workspaces trusted before draft paste', async () => { + const createFolderWorkspace = vi.fn(async () => + makeFolderWorkspace({ + connectionId: 'ssh-1', + folderPath: '/home/alice/platform/Trust remote folder draft' + }) + ) + const linkedWorkItem = { + provider: 'github' as const, + type: 'pr' as const, + number: 92, + title: 'Trust remote folder draft', + url: 'https://github.com/stablyai/orca/pull/92', + repoId: 'repo-1' + } + const projectGroup = { + ...makeProjectGroup(), + connectionId: 'ssh-1', + parentPath: '/home/alice/platform' + } + + await submitFolderWorkspaceCreate({ + projectGroup, + name: '', + lastAutoName: '', + linkedWorkItem, + note: '', + quickAgent: 'codex', + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + isRemote: true, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(window.api.agentTrust?.markTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/home/alice/platform/Trust remote folder draft', + connectionId: 'ssh-1' + }) + expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: folderWorkspaceKey('folder-workspace-1'), + startup: expect.objectContaining({ + agent: 'codex', + draftPrompt: linkedWorkItem.url + }) + }) + ) + }) + + it('delivers non-linked follow-up prompts for agents that need stdin after launch', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: 'Aider followup', + lastAutoName: '', + linkedWorkItem: null, + note: 'Fix the failing folder prompt flow', + quickAgent: 'aider', + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup + expect(startup?.command).toBe('aider') + expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({ + worktreeId: folderWorkspaceKey('folder-workspace-1'), + primaryTabId: 'tab-1', + startup: expect.objectContaining({ + agent: 'aider', + launchCommand: 'aider', + followupPrompt: 'Fix the failing folder prompt flow' + }) + }) + }) + + it('uses native draft launch for linked agents with prefill support', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const linkedWorkItem = { + provider: 'gitlab' as const, + type: 'mr' as const, + number: 17, + title: 'Review folder workspace draft', + url: 'https://gitlab.example.com/group/project/-/merge_requests/17', + repoId: 'repo-1' + } + + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: '', + lastAutoName: '', + linkedWorkItem, + note: 'Check the migration path', + quickAgent: 'claude', + autoRenameBranchFromWork: true, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup + expect(startup?.command).toContain('claude --prefill') + expect(startup?.command).toContain('Check the migration path') + expect(startup?.command).toContain(linkedWorkItem.url) + expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() + }) + + it('keeps explicit blank linked folder creates free of agent startup and draft paste', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const linkedWorkItem = { + provider: 'github' as const, + type: 'issue' as const, + number: 42, + title: 'Restore checkout polish', + url: 'https://github.com/stablyai/orca/issues/42', + repoId: 'repo-1' + } + + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: '', + lastAutoName: '', + linkedWorkItem, + note: 'Keep this as metadata only', + quickAgent: null, + autoRenameBranchFromWork: true, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(createFolderWorkspace).toHaveBeenCalledWith({ + projectGroupId: 'group-1', + name: 'Restore checkout polish', + connectionId: null, + linkedTask: linkedWorkItem + }) + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + runtimeEnvironmentId: null + }) + expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() + }) + it('does not mark first-input rename without submitted first input', async () => { const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index deb619212..37de0bc49 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,18 +1,24 @@ import { CLIENT_PLATFORM, - buildAgentPromptWithContext, + ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' -import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context' +import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + type AgentStartupPlan +} from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { LaunchSource } from '../../../../shared/telemetry-events' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, toFolderWorkspaceLinkedTask @@ -55,6 +61,87 @@ export function getFolderWorkspaceAgentLaunchPlatform( return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM } +function buildFolderWorkspaceLinkedStartupPlan(args: { + agent: TuiAgent + linkedWorkItem: LinkedWorkItemSummary + note: string + cliAvailable: boolean + agentCmdOverrides: Record | undefined + agentArgs?: string | null + agentEnv?: Record + platform: NodeJS.Platform +}): AgentStartupPlan | null { + const { prompt, draftPrompt } = resolveQuickCreateLinkedWorkItemPrompt( + args.linkedWorkItem, + args.note, + { + cliAvailable: args.cliAvailable + } + ) + const linkedDraftPrompt = (draftPrompt ?? prompt.trim()) || null + const draftLaunchPlan = linkedDraftPrompt + ? buildAgentDraftLaunchPlan({ + agent: args.agent, + draft: linkedDraftPrompt, + cmdOverrides: args.agentCmdOverrides ?? {}, + agentArgs: args.agentArgs, + agentEnv: args.agentEnv, + platform: args.platform + }) + : null + if (draftLaunchPlan) { + return { + agent: draftLaunchPlan.agent, + launchCommand: draftLaunchPlan.launchCommand, + expectedProcess: draftLaunchPlan.expectedProcess, + followupPrompt: null, + ...(draftLaunchPlan.startupCommandDelivery + ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } + : {}), + ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) + } + } + + const startupPlan = buildAgentStartupPlan({ + agent: args.agent, + // Why: linked context must stay reviewable; launch empty, then paste the + // draft after the agent is ready instead of submitting it on argv/stdin. + prompt: '', + cmdOverrides: args.agentCmdOverrides ?? {}, + agentArgs: args.agentArgs, + agentEnv: args.agentEnv, + platform: args.platform, + allowEmptyPromptLaunch: true + }) + if (startupPlan && linkedDraftPrompt) { + startupPlan.draftPrompt = linkedDraftPrompt + } + return startupPlan +} + +async function preflightFolderWorkspaceAgentTrust(args: { + agent: TuiAgent | null + workspacePath: string | null + connectionId?: string | null +}): Promise { + if (!args.agent || !window.api.agentTrust?.markTrusted) { + return + } + const preflight = TUI_AGENT_CONFIG[args.agent].preflightTrust + if (!preflight || !args.workspacePath) { + return + } + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: args.workspacePath, + ...(args.connectionId ? { connectionId: args.connectionId } : {}) + }) + } catch { + // Best-effort: the user can still accept the agent trust prompt manually. + } +} + export async function submitFolderWorkspaceCreate({ projectGroup, name, @@ -78,20 +165,36 @@ export async function submitFolderWorkspaceCreate({ nameIsAutoManaged && linkedName ? linkedName : name.trim() || linkedName || `${projectGroup.name} workspace` + const launchPlatform = getFolderWorkspaceAgentLaunchPlatform(projectGroup) // Why: only suggest `orca linear` when the launched terminal can actually // resolve the CLI; SSH launches get the relay shim, local launches may not. - const linearCliAvailable = linkedWorkItem?.linearIdentifier - ? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null }) - : false - const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, { - cliAvailable: linearCliAvailable - }) - const startupPrompt = buildAgentPromptWithContext( - note, - [], - linkedPromptContext.linkedUrls, - linkedPromptContext.linkedContextBlocks - ) + const linearCliAvailable = + quickAgent && linkedWorkItem?.linearIdentifier + ? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null }) + : false + const startupPlan = + quickAgent && linkedWorkItem + ? buildFolderWorkspaceLinkedStartupPlan({ + agent: quickAgent, + linkedWorkItem, + note, + cliAvailable: linearCliAvailable, + agentCmdOverrides, + agentArgs, + agentEnv, + platform: launchPlatform + }) + : quickAgent + ? buildAgentStartupPlan({ + agent: quickAgent, + prompt: note, + cmdOverrides: agentCmdOverrides ?? {}, + agentArgs, + agentEnv, + platform: launchPlatform, + allowEmptyPromptLaunch: true + }) + : null // Why: the pending badge should only appear when the submitted prompt can // actually produce the first agent message that names the workspace. const pendingFirstAgentMessageRename = @@ -99,7 +202,7 @@ export async function submitFolderWorkspaceCreate({ !name.trim() && !linkedWorkItem && Boolean(quickAgent) && - startupPrompt.trim().length > 0 + note.trim().length > 0 const workspace = await createFolderWorkspace({ projectGroupId: projectGroup.id, @@ -114,18 +217,12 @@ export async function submitFolderWorkspaceCreate({ if (!workspace) { return false } + await preflightFolderWorkspaceAgentTrust({ + agent: quickAgent, + workspacePath: workspace.folderPath, + connectionId: workspace.connectionId ?? projectGroup.connectionId + }) - const startupPlan = quickAgent - ? buildAgentStartupPlan({ - agent: quickAgent, - prompt: startupPrompt, - cmdOverrides: agentCmdOverrides ?? {}, - agentArgs, - agentEnv, - platform: getFolderWorkspaceAgentLaunchPlatform(projectGroup), - allowEmptyPromptLaunch: true - }) - : null const startup = quickAgent && startupPlan ? { @@ -143,10 +240,21 @@ export async function submitFolderWorkspaceCreate({ : undefined onOpenChange(false) try { - activateAndRevealFolderWorkspace(workspace.id, { + const activation = activateAndRevealFolderWorkspace(workspace.id, { ...(startup ? { startup } : {}), runtimeEnvironmentId }) + if ( + startupPlan && + (startupPlan.followupPrompt || startupPlan.draftPrompt) && + activation !== false + ) { + void ensureAgentStartupInTerminal({ + worktreeId: folderWorkspaceKey(workspace.id), + primaryTabId: activation.primaryTabId, + startup: startupPlan + }) + } } catch (error) { // Why: creation already succeeded. Do not leave the completed create modal // open if the follow-up reveal/startup path hits a transient issue. diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 2330ade13..d0f734fd9 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -383,4 +383,20 @@ describe('useComposerState host-context boundaries', () => { expect(quickSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') expect(quickSubmit).not.toContain('platform: CLIENT_PLATFORM') }) + + it('prepares linked quick-create drafts for the selected default agent', () => { + const quickSubmit = sourceBetween( + HOOK_SOURCE, + 'const submitQuick = useCallback', + 'const createGateInput' + ) + + expect(quickSubmit).toContain( + 'const promptLinkedWorkItem = agent === null ? null : submitLinkedWorkItem' + ) + expect(quickSubmit).toContain('resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem') + expect(quickSubmit).not.toContain('explicitAgentChoice') + expect(quickSubmit).not.toContain('shouldPrepareQuickLinkedWorkItemAgentPrompt') + expect(HOOK_SOURCE).not.toContain('resolveQuickWorkspaceSubmitAgent') + }) }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 0dc93e8e5..ccb3201c7 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -2681,10 +2681,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (!selectedProjectGroup?.parentPath || folderCreateDisabled) { return } - const agent = - requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents) - ? requestedAgent - : null setCreateError(null) setCreating(true) try { @@ -2696,6 +2692,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : ({ kind: 'none' } as const) const smartGitHubMetadata = smartGitHubResolution.kind === 'none' ? null : smartGitHubResolution + const agent = + requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents) + ? requestedAgent + : null const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, name: smartGitHubMetadata?.workspaceName ?? name, @@ -3129,10 +3129,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS await submitFolderTarget(requestedAgent) return } - const agent = - requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents) - ? requestedAgent - : null const workspaceNameSeed = getWorkspaceSeedName({ explicitName: name, prompt: '', @@ -3161,6 +3157,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartGitHubResolution.kind === 'none' ? linkedWorkItem : smartGitHubResolution.linkedWorkItem + const agent = + requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents) + ? requestedAgent + : null const submitLinkedIssueNumber = smartGitHubResolution.kind === 'none' ? parsedLinkedIssueNumber @@ -3288,11 +3288,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: backend startup is safe only when the launch command is // self-contained. Agents that need post-ready paste/follow-up stay on // the renderer path so prompt delivery is not skipped. - const quickLinearCliAvailable = submitLinkedWorkItem?.linearIdentifier + const promptLinkedWorkItem = agent === null ? null : submitLinkedWorkItem + const quickLinearCliAvailable = promptLinkedWorkItem?.linearIdentifier ? await isOrcaCliAvailableForLaunch({ remote: isRemote }) : false const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } = - resolveQuickCreateLinkedWorkItemPrompt(submitLinkedWorkItem, trimmedNote, { + resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem, trimmedNote, { cliAvailable: quickLinearCliAvailable }) const draftLaunchPlan = diff --git a/src/renderer/src/lib/linked-work-item-context.test.ts b/src/renderer/src/lib/linked-work-item-context.test.ts index 4de4b9da8..3f3f7c4b6 100644 --- a/src/renderer/src/lib/linked-work-item-context.test.ts +++ b/src/renderer/src/lib/linked-work-item-context.test.ts @@ -190,7 +190,7 @@ describe('resolveQuickCreateLinkedWorkItemPrompt', () => { ).toEqual({ prompt: 'use this note', draftPrompt: null }) }) - it('falls back to the URL for non-Linear quick creates', () => { + it('drafts the note above the URL for non-Linear quick creates', () => { expect( resolveQuickCreateLinkedWorkItemPrompt( { number: 42, url: 'https://github.com/acme/repo/issues/42' }, @@ -199,7 +199,7 @@ describe('resolveQuickCreateLinkedWorkItemPrompt', () => { ) ).toEqual({ prompt: '', - draftPrompt: 'https://github.com/acme/repo/issues/42' + draftPrompt: 'note\n\nhttps://github.com/acme/repo/issues/42' }) }) }) diff --git a/src/renderer/src/lib/linked-work-item-context.ts b/src/renderer/src/lib/linked-work-item-context.ts index 31cd5dd69..cc984340b 100644 --- a/src/renderer/src/lib/linked-work-item-context.ts +++ b/src/renderer/src/lib/linked-work-item-context.ts @@ -207,6 +207,8 @@ export function resolveQuickCreateLinkedWorkItemPrompt( const draftPrompt = linearDraft ? [trimmedNote, linearDraft].filter(Boolean).join('\n\n') : linkedUrl + ? [trimmedNote, linkedUrl].filter(Boolean).join('\n\n') + : null const isLinearTypedOnly = linkedWorkItem?.number === 0 && Boolean(trimmedNote) && !draftPrompt return { prompt: isLinearTypedOnly ? trimmedNote : '', diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 447c0052d..3751d5301 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -34,6 +36,8 @@ vi.mock('@/lib/new-workspace', () => ({ import { runBackgroundWorktreeCreation } from './worktree-creation-flow' +const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), 'utf8') + function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { return { repoId: 'repo-1', @@ -49,6 +53,14 @@ function makeRequest(overrides: Partial = {}): Worktree } } +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + describe('runBackgroundWorktreeCreation', () => { it('uses the captured repo-owner progress mode instead of focused runtime state', () => { store.settings.activeRuntimeEnvironmentId = null @@ -83,3 +95,26 @@ describe('runBackgroundWorktreeCreation', () => { ) }) }) + +describe('worktree creation flow agent trust preflight', () => { + it('forwards the repo SSH connection id when pre-marking agent trust', () => { + const preflight = sourceBetween( + FLOW_SOURCE, + 'async function preflightAgentTrust', + 'async function executeWorktreeCreation' + ) + const createFlow = sourceBetween( + FLOW_SOURCE, + 'const backendSpawned = result.startupTerminal?.spawned === true', + '// `createWorktree` already inserted the real worktree row' + ) + + expect(preflight).toContain('connectionId?: string | null') + expect(preflight).toContain('...(connectionId ? { connectionId } : {})') + expect(createFlow).toContain('repoConnectionId') + expect(createFlow).toContain('repo.id === worktree.repoId') + expect(createFlow).toContain( + 'await preflightAgentTrust(request, worktree.path, repoConnectionId)' + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 1eb9f3276..87f091890 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -48,7 +48,11 @@ function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boo return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local' } -async function preflightAgentTrust(request: WorktreeCreationRequest, path: string): Promise { +async function preflightAgentTrust( + request: WorktreeCreationRequest, + path: string, + connectionId?: string | null +): Promise { // Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste // as menu input on first launch. Pre-write the trust artifact before any // terminal spawns. Best-effort — the worktree already exists, so a failure @@ -61,7 +65,11 @@ async function preflightAgentTrust(request: WorktreeCreationRequest, path: strin return } try { - await window.api.agentTrust.markTrusted({ preset: preflight, workspacePath: path }) + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: path, + ...(connectionId ? { connectionId } : {}) + }) } catch { // Best-effort: continue with launch. } @@ -137,7 +145,9 @@ async function executeWorktreeCreation( const startupOpt = buildStartupOpt(request, backendSpawned) if (worktree.path) { - await preflightAgentTrust(request, worktree.path) + const repoConnectionId = + useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null + await preflightAgentTrust(request, worktree.path, repoConnectionId) } // `createWorktree` already inserted the real worktree row. Whether we steal