mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
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:
+35
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user