Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter>
<LocationProbe />
<PluginNewThreadComposer
draftKey="checkout-disappears"
defaultProjectId={project.id}
initialPrompt="run in the sandbox"
onSubmit={onSubmit}
/>
</MemoryRouter>,
);
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,
Expand Down Expand Up @@ -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(
Expand Down
81 changes: 67 additions & 14 deletions apps/app/src/components/promptbox/NewThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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
Expand All @@ -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 };
Expand All @@ -585,13 +625,12 @@ export function NewThreadComposer({
seedOverridden,
environmentSeed,
environmentProviders,
isProjectless,
environmentProvidersByHostId,
knownHostIds,
pickedProviderMachine,
selectionScope,
storedMachineId,
primaryHostId,
projectSources,
],
);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1554,6 +1605,7 @@ export function NewThreadComposer({
disabled: locks.environment,
isLoading: environmentProviders === undefined,
providers: environmentProviders ?? [],
providersByHostId: environmentProvidersByHostId,
selectedProviderHostId: providerHostId,
inputsControlProviderIds,
onSelectProvider: handleSelectProvider,
Expand Down Expand Up @@ -1673,6 +1725,7 @@ export function NewThreadComposer({
defaultMentionLinkResolver,
effectiveEnvironmentValue,
environmentProviders,
environmentProvidersByHostId,
executionOptionsRouting,
handleAttachFiles,
handleEditorFocus,
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/components/promptbox/NewThreadPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -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}
Expand Down
Loading