diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-launch-route.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-launch-route.test.ts index f17c082f827..d8d91dd4202 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-launch-route.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-launch-route.test.ts @@ -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() + }) }) 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 6a25f7373c6..36a0a928ddb 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -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 onOpenChange: (open: boolean) => void } @@ -90,6 +94,7 @@ export async function submitFolderWorkspaceCreate({ launchSource = 'sidebar', runtimeEnvironmentId = null, settings, + hostCapabilities: preResolvedHostCapabilities, createFolderWorkspace, onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { @@ -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, diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.test.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.test.ts new file mode 100644 index 00000000000..65b68b0d712 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.test.ts @@ -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>() + 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() { + let resolve!: (value: T) => void + const promise = new Promise((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 + 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] + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index 6c5503bbdf0..28dc448bac1 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -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