fix(ui): reorder environment driver dropdown and drop Local option (#8329)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run on **environments**, configured in Company Settings →
Environments, where each environment has a **driver** (how/where it
runs)
> - The "new environment" form exposed a Driver `<select>` ordered `SSH
→ Sandbox → Local`, with **Local** as a selectable create option
> - You can only ever have one local environment (it's the host running
Paperclip), so offering **Local** in the create flow is misleading — and
Sandbox is the most common choice, yet it sat in the middle of the list
> - This pull request removes the **Local** option from the create form
and moves **Sandbox** to the top of the list (`Sandbox → SSH`)
> - The benefit is a create flow that only offers drivers you can
actually create, with the most-used driver first

## Linked Issues or Issue Description

No public GitHub issue exists. Describing the underlying problem inline,
following the **Feature request** issue template:

### Problem or motivation

When creating a new environment (Company Settings → Environments → New
environment), the Driver `<select>` lists three options in this order:
`SSH`, `Sandbox`, `Local`. **Local** is selectable even though a local
environment represents the Paperclip host itself and cannot be created
more than once, so choosing it in the create flow is not a valid action.
**Sandbox**, the most common choice, sits in the middle of the list
instead of first.

### Proposed solution

Drop **Local** from the create dropdown and order the remaining options
**Sandbox** first, then **SSH**. The create flow then only offers
drivers you can actually create, with the most-used driver surfaced
first. Existing local environments must still render and be editable, so
the `"local"` driver value is retained in the type union.

### Alternatives considered

Keeping **Local** but disabling it: rejected — a permanently-disabled
option is noise and still implies local environments are creatable here.
Hiding the whole driver field when only one option remains: rejected —
both Sandbox and SSH remain valid, so the selector is still needed.

### Roadmap alignment

Not roadmap-tracked. This is a small, self-contained UX correction to an
existing form, not new core feature work.

## What Changed

- Removed the **Local** `<option>` from the new-environment Driver
`<select>`.
- Reordered the remaining options to **Sandbox** (when sandbox creation
is enabled) then **SSH** (was `SSH → Sandbox → Local`).
- Simplified the now-dead `local` branch in the select's `onChange`
handler (`driver` resolves to `sandbox` or `ssh` only).
- Updated the Driver field hint text to describe only Sandbox and SSH.
- Kept the `"local"` value in the `driver` type union so existing local
environments still render/read correctly — only the create-form option
was dropped.
- Added a unit test asserting the driver options omit `local` and list
`sandbox` before `ssh`.

## Verification

- `pnpm --filter ./ui typecheck` (`tsc -b`) — passes.
- `pnpm --filter ./ui vitest run src/pages/CompanySettings.test.tsx` — 3
passed (includes the new assertion).

Driver option ordering (create form):

| | Before | After |
|---|---|---|
| 1 | SSH | Sandbox* |
| 2 | Sandbox* | SSH |
| 3 | Local | — (removed) |

*Sandbox appears when at least one run-capable sandbox provider plugin
is installed.

Note on screenshots: the Driver control is a native `<select>`; its
expanded option list is OS-rendered and cannot be captured in a page
screenshot. The before/after option order is shown above and locked in
by the new unit test.

## Risks

Low risk. Pure create-form UI change. The `"local"` driver type is
retained for reading/editing existing environments, so no existing
environment is affected. No API, schema, or migration changes.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), extended reasoning with tool use,
via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — native `<select>` reorder; expanded list isn't
screenshot-capturable. Before/after option order documented above and
covered by a unit test.
- [ ] I have updated relevant documentation to reflect my changes — no
user-facing docs cover this dropdown
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-06-19 09:53:09 -07:00
committed by GitHub
parent af22abc80e
commit a0c7e38ccd
2 changed files with 110 additions and 9 deletions
+6 -9
View File
@@ -589,7 +589,7 @@ export function CompanyEnvironments() {
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<Field label="Driver" hint="Local runs on this host. SSH stores a remote machine target. Sandbox stores plugin-backed provider config on the shared environment seam.">
<Field label="Driver" hint="Sandbox stores plugin-backed provider config on the shared environment seam. SSH stores a remote machine target.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.driver}
@@ -610,19 +610,16 @@ export function CompanyEnvironments() {
: {}
)
: current.sandboxConfig,
driver:
e.target.value === "local"
? "local"
: e.target.value === "sandbox"
? "sandbox"
: "ssh",
driver: e.target.value === "sandbox" ? "sandbox" : "ssh",
}))}
>
<option value="ssh">SSH</option>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="local">Local</option>
<option value="ssh">SSH</option>
{environmentForm.driver === "local" ? (
<option value="local">Local</option>
) : null}
</select>
</Field>
+104
View File
@@ -165,6 +165,110 @@ describe("CompanyEnvironments", () => {
});
});
it("omits the Local driver option and lists Sandbox before SSH", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities(AGENT_ADAPTER_TYPES, {
sandboxProviders: {
"secure-plugin": {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
displayName: "Secure Sandbox",
configSchema: { type: "object", properties: {} },
},
},
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const driverSelect = Array.from(container.querySelectorAll("select"))
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
| HTMLSelectElement
| undefined;
expect(driverSelect).toBeTruthy();
const driverOptionValues = Array.from(driverSelect!.options).map((option) => option.value);
expect(driverOptionValues).not.toContain("local");
expect(driverOptionValues).toEqual(["sandbox", "ssh"]);
await act(async () => {
root.unmount();
});
});
it("shows the Local driver option when editing an existing local environment", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockEnvironmentsApi.list.mockResolvedValue([
{
id: "env-local",
companyId: "company-1",
name: "Local host",
description: null,
driver: "local",
status: "active",
config: {},
metadata: null,
createdAt: new Date("2026-04-25T00:00:00.000Z"),
updatedAt: new Date("2026-04-25T00:00:00.000Z"),
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const editButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Edit");
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const driverSelect = Array.from(container.querySelectorAll("select"))
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
| HTMLSelectElement
| undefined;
expect(driverSelect).toBeTruthy();
const driverOptionValues = Array.from(driverSelect!.options).map((option) => option.value);
expect(driverOptionValues).toContain("local");
expect(driverSelect!.value).toBe("local");
await act(async () => {
root.unmount();
});
});
it("preserves sandbox config when re-selecting the same provider while editing", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({