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}