diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index b5bc5a73d2..321f059a50 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -1818,6 +1818,155 @@ describe("NewThreadComposer environment providers", () => { ); } + it.each([ + CHECKOUT_PROVIDER, + MANAGED_WORKTREE_SUGAR_PROVIDER, + { + ...BRANCH_PROVIDER, + requires: { ...BRANCH_PROVIDER.requires, projectCheckout: false }, + }, + ])( + "omits $displayName on machines without this project's checkout", + async (provider) => { + mocks.environmentProviders = [provider, HOST_PROVIDER]; + const submitted: NewThreadRequest[] = []; + renderUnseeded( + (request) => submitted.push(request), + "checkout-eligibility", + OTHER_PROJECT.id, + ); + await waitFor(() => { + const byHost = + latestPromptBoxProps().modeConfig.environment.providersByHostId; + expect( + byHost + ?.get("host_1") + .map((item: SystemEnvironmentProvider) => item.id), + ).toEqual([provider.id, HOST_PROVIDER.id]); + expect( + byHost + ?.get("host_2") + .map((item: SystemEnvironmentProvider) => item.id), + ).toEqual([HOST_PROVIDER.id]); + }); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + provider, + "host_2", + ); + }); + expect(latestPromptBoxProps().disabled).toBe(true); + expect( + latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, + ).toBe("host_2"); + await submit(); + expect(submitted).toHaveLength(0); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + HOST_PROVIDER, + "host_2", + ); + }); + await submit(); + expect(submitted[0].environment).toMatchObject({ + machine: { type: "existing", hostId: "host_2" }, + }); + }, + ); + + it("blocks an explicit machine when its checkout disappears and recovers on a valid choice", async () => { + const project = { + ...PROJECT_WITHOUT_CHECKOUT, + sources: [...PROJECT.sources], + }; + mocks.extraProjects = [project]; + mocks.environmentProviders = [BRANCH_PROVIDER]; + const onSubmit = vi.fn(); + const rendered = renderUnseeded( + onSubmit, + "checkout-disappears", + project.id, + ); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + BRANCH_PROVIDER, + "host_2", + ); + }); + expect(latestPromptBoxProps().disabled).toBe(false); + mocks.extraProjects = [{ ...project, sources: [PROJECT.sources[0]] }]; + rendered.rerender( + + + + , + ); + expect(latestPromptBoxProps().disabled).toBe(true); + expect( + latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, + ).toBe("host_2"); + await submit(); + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + BRANCH_PROVIDER, + "host_1", + ); + }); + await submit(); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + machine: { type: "existing", hostId: "host_1" }, + }), + }), + ); + }); + + it("uses per-machine availability without blocking a pending probe", async () => { + const provider = { + ...BRANCH_PROVIDER, + machineAvailability: { + host_1: null, + host_2: { + status: "unavailable" as const, + message: "Checkout is unavailable", + }, + }, + }; + mocks.environmentProviders = [provider]; + const onSubmit = vi.fn(); + renderUnseeded(onSubmit, "machine-availability"); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + provider, + "host_2", + ); + }); + expect( + latestPromptBoxProps().modeConfig.environment.providersByHostId.get( + "host_2", + )[0].availability, + ).toEqual(provider.machineAvailability.host_2); + expect(latestPromptBoxProps().disabled).toBe(true); + await submit(); + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + provider, + "host_1", + ); + }); + expect(latestPromptBoxProps().disabled).toBe(false); + await submit(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + it("updates the access banner for a composed machine without relying on plugin status", async () => { const composition: SystemEnvironmentProvider = { ...OPTIONAL_INPUTS_PROVIDER, @@ -2145,7 +2294,8 @@ describe("NewThreadComposer environment providers", () => { }); expect( latestPromptBoxProps().modeConfig.environment.selectedProviderHostId, - ).toBeNull(); + ).toBe("host_1"); + expect(latestPromptBoxProps().disabled).toBe(true); await act(async () => { latestPromptBoxProps().modeConfig.environment.onSelectProvider( diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 40de367652..00f7514ec7 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -444,8 +444,7 @@ export function NewThreadComposer({ const hostsQuery = useHosts(); const availableHosts = useMemo( - () => - selectHosts(hostsQuery.data, "persistent"), + () => selectHosts(hostsQuery.data, "persistent"), [hostsQuery.data], ); const systemConfigQuery = useSystemConfig(); @@ -498,6 +497,43 @@ export function NewThreadComposer({ ), [isProjectless, registeredEnvironmentProviders], ); + const { providers: projectEnvironmentProviders } = + useSystemEnvironmentProviders({ projectId }); + const projectGitRemoteUrl = currentProject?.gitRemoteUrl; + const environmentProvidersByHostId = useMemo( + () => + new Map( + availableHosts.map((host) => [ + host.id, + (environmentProviders ?? []) + .filter( + (provider) => + !provider.machineProviderId && + (!( + provider.requires.projectCheckout || + provider.requires.gitCheckout + ) || + findLocalPathProjectSourceForHost(projectSources, host.id) !== + undefined) && + (!provider.requires.gitRemote || projectGitRemoteUrl != null), + ) + .map((provider) => ({ + ...provider, + availability: + projectEnvironmentProviders?.find( + (candidate) => candidate.id === provider.id, + )?.machineAvailability[host.id] ?? null, + })), + ]), + ), + [ + availableHosts, + environmentProviders, + projectEnvironmentProviders, + projectGitRemoteUrl, + projectSources, + ], + ); const { providers: machineProviders } = useSystemMachineProviders(); const pluginList = usePluginList({ enabled: true }); @@ -549,16 +585,20 @@ export function NewThreadComposer({ const usable = (hostId: string | null): boolean => hostId !== null && knownHostIds.has(hostId) && - (isProjectless || - !provider.requires.projectCheckout || - findLocalPathProjectSourceForHost(projectSources, hostId) !== - undefined); + (environmentProvidersByHostId + .get(hostId) + ?.some( + (candidate) => + candidate.id === provider.id && + candidate.availability?.status !== "unavailable", + ) ?? + false); const picked = pickedProviderMachine?.selectionValue === effectiveValue ? pickedProviderMachine.machine : null; + if (picked !== null) return { provider, machine: picked }; const seeded = - picked === null && !seedOverridden && environmentSeed !== null && environmentSeed.selectionValue === effectiveValue @@ -568,7 +608,7 @@ export function NewThreadComposer({ selectionScope === "new-thread" && storedMachineId !== "" ? { type: "existing", hostId: storedMachineId } : null; - const candidate = picked ?? seeded ?? remembered; + const candidate = seeded ?? remembered; if (candidate?.type === "new") return { provider, machine: candidate }; if (usable(candidate?.hostId ?? null)) { return { provider, machine: candidate }; @@ -585,13 +625,12 @@ export function NewThreadComposer({ seedOverridden, environmentSeed, environmentProviders, - isProjectless, + environmentProvidersByHostId, knownHostIds, pickedProviderMachine, selectionScope, storedMachineId, primaryHostId, - projectSources, ], ); @@ -824,6 +863,17 @@ export function NewThreadComposer({ const providerMachine = providerSelection?.machine ?? null; const providerHostId = providerMachine?.type === "existing" ? providerMachine.hostId : null; + const selectedProviderMachineUnavailable = + providerHostId !== null && + !( + environmentProvidersByHostId + .get(providerHostId) + ?.some( + (provider) => + provider.id === selectedEnvironmentProvider?.id && + provider.availability?.status !== "unavailable", + ) ?? false + ); const selectedMachineProvider = providerMachine?.type === "new" ? machineProviders?.find( @@ -1324,10 +1374,11 @@ export function NewThreadComposer({ supportsServiceTier, ], ); - const submissionEnvironment = - selectedEnvironment ?? - (selectionScope === "new-thread" ? seed?.environment : undefined) ?? - null; + const submissionEnvironment = selectedProviderMachineUnavailable + ? null + : (selectedEnvironment ?? + (selectionScope === "new-thread" ? seed?.environment : undefined) ?? + null); const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ environmentProviderInputsBlocker: machineProviderInputs.blockedReason ?? environmentProviderInputsBlocker, @@ -1554,6 +1605,7 @@ export function NewThreadComposer({ disabled: locks.environment, isLoading: environmentProviders === undefined, providers: environmentProviders ?? [], + providersByHostId: environmentProvidersByHostId, selectedProviderHostId: providerHostId, inputsControlProviderIds, onSelectProvider: handleSelectProvider, @@ -1673,6 +1725,7 @@ export function NewThreadComposer({ defaultMentionLinkResolver, effectiveEnvironmentValue, environmentProviders, + environmentProvidersByHostId, executionOptionsRouting, handleAttachFiles, handleEditorFocus, diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index 07d0d7b157..102a1cd01d 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -89,6 +89,7 @@ export interface NewThreadEnvironmentConfig { disabled?: boolean; isLoading?: boolean; providers?: readonly SystemEnvironmentProvider[]; + providersByHostId?: EnvironmentPickerUIProps["providersByHostId"]; machineProviders?: readonly SystemMachineProvider[]; selectedProviderHostId?: string | null; inputsControlProviderIds?: ReadonlySet; @@ -465,6 +466,7 @@ export function EnvironmentSlot({ disabled={environment.disabled} isLoading={environment.isLoading} providers={providers} + providersByHostId={environment.providersByHostId} selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider}