Keep linked workspace prompts as editable drafts (#5734)
This commit is contained in:
parent
fe8b60b11c
commit
53f2e50122
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
|||
const result = (await mux.request('session.resolveHome', { path: '~' })) as {
|
||||
resolvedPath?: unknown
|
||||
}
|
||||
const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : ''
|
||||
return home && home.startsWith('/') && !hasRemotePathControlCharacter(home)
|
||||
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<void> {
|
||||
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-')
|
||||
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-')
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof NewWorkspaceModule>()
|
||||
return {
|
||||
...actual,
|
||||
ensureAgentStartupInTerminal: mocks.ensureAgentStartupInTerminal
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
getFolderWorkspaceAgentLaunchPlatform,
|
||||
submitFolderWorkspaceCreate
|
||||
|
|
@ -51,8 +62,21 @@ function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): 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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> | undefined
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string>
|
||||
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<void> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 : '',
|
||||
|
|
|
|||
|
|
@ -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> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
|
|
@ -49,6 +53,14 @@ function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): 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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boo
|
|||
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
|
||||
}
|
||||
|
||||
async function preflightAgentTrust(request: WorktreeCreationRequest, path: string): Promise<void> {
|
||||
async function preflightAgentTrust(
|
||||
request: WorktreeCreationRequest,
|
||||
path: string,
|
||||
connectionId?: string | null
|
||||
): Promise<void> {
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue