fix(native-chat): close the folder-create cancel window the probe opened

The probe added the first `await` inside `submitFolderWorkspaceCreate`. On
`main` that function ran straight through to `createFolderWorkspace` with no
suspension of its own, so its caller's `isSubmissionCancelled()` gate and the
create call sat in the same turn. With the probe inline, a composer dismissed
while the probe is in flight still creates the folder workspace and launches
an agent — the same defect the full-creation hoist fixed on the git path.

Resolve capabilities in `folder-submit-orchestration` above its existing gate
and hand them down, so the create path's prefix is synchronous again. The
parameter stays optional: a caller without a cancel gate keeps the probe.

Both new tests fail against `origin/main` and against this branch's previous
head; the cancel-window one still fails with its probe-pending assertion
removed, so it pins the create, not just the probe.
This commit is contained in:
Merge Sim
2026-09-09 23:58:18 -07:00
parent ad75db75d1
commit f492064432
4 changed files with 191 additions and 2 deletions
@@ -106,4 +106,39 @@ describe('submitFolderWorkspaceCreate launch route before hydration', () => {
)
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalled()
})
// A caller that owns a cancel gate resolves capabilities above it; re-probing here would
// reopen the window between that gate and createFolderWorkspace.
it('uses pre-resolved capabilities without probing again', async () => {
setLocalRuntimeCapabilitiesForTests(null)
const getStatus = vi.fn().mockResolvedValue({ capabilities: [] })
Object.assign(window, { api: { runtime: { getStatus } } })
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
mocks.startStructuredAgentLaunch.mockReturnValue({
sessionId: 'session-1',
launchResult: Promise.resolve({ sessionId: 'session-1' }),
isVisibilityUnknown: () => false,
releaseCallerAfterUnknownOutcome: () => {},
claimDefinitiveRefusalFallback: () => Promise.resolve()
})
const created = await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: 'hi',
lastAutoName: '',
linkedWorkItem: null,
note: '',
quickAgent: 'claude',
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
settings: structuredSettings,
hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
createFolderWorkspace: vi.fn(async () => makeFolderWorkspace()),
onOpenChange: vi.fn()
})
expect(created).toBe(true)
expect(getStatus).not.toHaveBeenCalled()
expect(mocks.startStructuredAgentLaunch).toHaveBeenCalled()
})
})
@@ -13,6 +13,7 @@ import type { LaunchSource } from '../../../../shared/telemetry-events'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
getLinkedItemDisplayName,
@@ -69,6 +70,9 @@ type SubmitFolderWorkspaceCreateParams = {
launchSource?: LaunchSource
runtimeEnvironmentId?: string | null
settings?: GlobalSettings | null
// Pre-resolved local capabilities. A caller that gates on cancellation must resolve them above
// its gate: probing in here suspends between that gate and `createFolderWorkspace` below.
hostCapabilities?: readonly RuntimeCapability[] | null
createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise<FolderWorkspace | null>
onOpenChange: (open: boolean) => void
}
@@ -90,6 +94,7 @@ export async function submitFolderWorkspaceCreate({
launchSource = 'sidebar',
runtimeEnvironmentId = null,
settings,
hostCapabilities: preResolvedHostCapabilities,
createFolderWorkspace,
onOpenChange
}: SubmitFolderWorkspaceCreateParams): Promise<boolean> {
@@ -147,7 +152,10 @@ export async function submitFolderWorkspaceCreate({
executionHostId: runtimeEnvironmentId
? `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
: (projectGroup.connectionId ?? 'local'),
hostCapabilities: await ensureLocalRuntimeCapabilities(),
hostCapabilities:
preResolvedHostCapabilities === undefined
? await ensureLocalRuntimeCapabilities()
: preResolvedHostCapabilities,
workspaceKind: 'folder',
promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit',
launchText: launchDraftPrompt ?? note,
@@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
const mocks = vi.hoisted(() => ({ submitFolderWorkspaceCreate: vi.fn(async () => true) }))
vi.mock('@/components/sidebar/folder-workspace-composer-submit', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, submitFolderWorkspaceCreate: mocks.submitFolderWorkspaceCreate }
})
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import {
useFolderSubmitOrchestration,
type FolderSubmitOrchestrationInput
} from './folder-submit-orchestration'
const structuredSettings = {
experimentalNativeChat: true,
openAgentTabsInChatByDefault: true,
experimentalStructuredNativeChat: true
} as GlobalSettings
function makeProjectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Platform',
parentPath: '/repo/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
function makeInput(): FolderSubmitOrchestrationInput {
return {
clearNewWorkspaceDraft: vi.fn(),
createFolderWorkspace: vi.fn(async () => null),
// Only the folder smart-GitHub gate is reached from this hook; the rest stay unexercised.
decisions: {
canResolveFolderSmartGitHubSubmit: () => false,
getInitialAutoManagedWorkspaceName: vi.fn(),
getInitialGitHubPrStartPointSelection: vi.fn(),
getMatchingLinkedTaskSourceContext: vi.fn(),
isExplicitWorkspaceNameInput: vi.fn(),
resolveInitialWorkspaceRunSeed: vi.fn(),
resolveSmartGitHubCreateNames: vi.fn(),
retargetGitHubPrStartPointSelection: vi.fn()
},
disabledTuiAgents: [],
folderCreateDisabled: false,
folderSourceRepos: [],
folderTargetConnectionId: null,
folderTargetIsRemote: false,
folderTargetRuntimeEnvironmentId: null,
isSubmissionCancelled: () => false,
lastAutoNameRef: { current: '' },
linkedWorkItem: null,
name: 'hi',
note: '',
onCreated: vi.fn(),
persistDraft: false,
resolvePendingSmartGitHubSubmit: vi.fn(async () => ({ kind: 'none' }) as const),
selectedProjectGroup: makeProjectGroup(),
setCreateError: vi.fn(),
setCreating: vi.fn(),
settings: structuredSettings,
taskSourceContext: null,
telemetrySource: undefined
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
describe('useFolderSubmitOrchestration capability probe', () => {
afterEach(() => {
setLocalRuntimeCapabilitiesForTests([])
Reflect.deleteProperty(window, 'api')
vi.clearAllMocks()
})
// submitFolderWorkspaceCreate runs straight through to createFolderWorkspace with no suspension
// of its own, so the probe has to settle on this side of the cancel gate.
it('does not create when the composer is dismissed while the probe is still pending', async () => {
setLocalRuntimeCapabilitiesForTests(null)
const status = deferred<{ capabilities: readonly string[] }>()
const getStatus = vi.fn(() => status.promise)
Object.assign(window, { api: { runtime: { getStatus } } })
let cancelled = false
const hook = renderHook(() =>
useFolderSubmitOrchestration({ ...makeInput(), isSubmissionCancelled: () => cancelled })
)
let submission!: Promise<void>
act(() => {
submission = hook.result.current.submitFolderTarget('claude')
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(getStatus).toHaveBeenCalledTimes(1)
cancelled = true
status.resolve({ capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] })
await act(async () => submission)
expect(mocks.submitFolderWorkspaceCreate).not.toHaveBeenCalled()
})
it('hands the resolved capabilities to the create path instead of letting it re-probe', async () => {
setLocalRuntimeCapabilitiesForTests(null)
const getStatus = vi
.fn()
.mockResolvedValue({ capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] })
Object.assign(window, { api: { runtime: { getStatus } } })
const hook = renderHook(() => useFolderSubmitOrchestration(makeInput()))
await act(async () => hook.result.current.submitFolderTarget('claude'))
expect(mocks.submitFolderWorkspaceCreate).toHaveBeenCalledWith(
expect.objectContaining({
hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
})
)
})
})
@@ -1,6 +1,6 @@
import type { ComposerModel } from './composer-model'
type FolderSubmitOrchestrationInput = Pick<
export type FolderSubmitOrchestrationInput = Pick<
ComposerModel,
| 'clearNewWorkspaceDraft'
| 'createFolderWorkspace'
@@ -41,6 +41,7 @@ import {
} from '../../../../shared/tui-agent-launch-defaults'
import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { ensureLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { translate } from '@/i18n/i18n'
import {
formatWorkspaceCreateError,
@@ -104,6 +105,10 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents)
? requestedAgent
: null
// Resolved before the cancel gate because submitFolderWorkspaceCreate reaches
// createFolderWorkspace with no suspension of its own: probing in there would let a
// dismissal during the probe still create the workspace.
const hostCapabilities = await ensureLocalRuntimeCapabilities()
if (isSubmissionCancelled()) {
return
}
@@ -147,6 +152,7 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId,
settings,
hostCapabilities,
createFolderWorkspace: (input) =>
createFolderWorkspace(input, {
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId