fix(ui): move environment create/edit into a dialog (#8391)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents and the work they do.
> - This change lives in the board UI, specifically the instance
environments settings page where operators define reusable execution
targets.
> - That page currently rendered the create/edit environment form inline
near the bottom of the screen.
> - Because the form appeared away from the action that triggered it, it
was easy to miss and felt inconsistent with the rest of the UI.
> - This pull request moves environment creation and editing into a
proper centered dialog with the existing form behavior preserved.
> - The benefit is a more obvious, consistent, and easier-to-complete
settings flow for environment management.

## Linked Issues or Issue Description

No public GitHub issue matched this exact UI problem when I searched
related issues and PRs.

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip (or can reproduce
on `master`).
- [x] I have confirmed the issue originates in Paperclip itself, not
adapter or local configuration.

### What happened?

On `Settings -> Instance settings -> Environments`, clicking create or
edit revealed the environment form inline near the bottom of the page.
Because the form appeared away from the action that triggered it, it was
easy to miss.

### Expected behavior

Create and edit should open a centered modal dialog with a dimmed
backdrop and the same form controls.

### Steps to reproduce

1. Open `Settings -> Instance settings -> Environments`.
2. Click the create or edit action for an environment.
3. Observe that the form expands inline on the page instead of opening
in a modal.

### Paperclip version or commit

Observed on current `master` before this PR, for example
[`e93d78b46`](https://github.com/paperclipai/paperclip/commit/e93d78b46).

### Deployment mode

Local dev (`pnpm dev`).

### Installation method

Built from source (`pnpm dev` / `pnpm build`).

### Agent adapter(s) involved

- [x] Not adapter-specific (core bug)

### Database mode

Embedded PGlite (default, `DATABASE_URL` unset).

### Access context

Board (human operator).

### Node.js version

Node.js 25.6.1 in the local contributor environment.

### Operating system

macOS local development environment.

### Relevant logs or output

None. This was a visible UI behavior issue rather than a logged
server/runtime error.

### Relevant config (if applicable)

Not config-related.

### Additional context

Related PR/search context checked before opening this fix. UI preview
screenshots are attached below.

### Privacy checklist

- [x] I have reviewed all pasted output for PII and redacted where
necessary.

## What Changed

- Moved the environment create/edit form in
`ui/src/pages/CompanyEnvironments.tsx` into a shared dialog.
- Added an explicit `Add environment` action near the saved environments
list and wired row edit actions to open the same dialog in prefilled
mode.
- Preserved existing save/test behavior while resetting dialog-local
mutation state when the modal opens or closes.
- Extended `ui/src/pages/CompanyEnvironments.test.tsx` to cover
add-open/cancel and edit-open/save dialog flows.
- Updated `ui/src/pages/CompanySettings.test.tsx` so the existing
environments coverage opens and inspects the dialog through the Radix
portal.

## Verification

- `node node_modules/vitest/vitest.mjs run
ui/src/pages/CompanyEnvironments.test.tsx`
- `node node_modules/vitest/vitest.mjs run
ui/src/pages/CompanySettings.test.tsx`
- Manual review:
  - Open `Settings -> Instance settings -> Environments`
- Click `Add environment` and confirm the form opens in a centered
dialog
- Click `Edit` on an existing environment and confirm the dialog opens
with existing values
  - Confirm `Cancel`, `Test`, and save actions still behave as expected
- Before/after screenshots:
- [Cancel environment
creation/edit](https://artifacts.cutter.sh/8391/run-1175b5c-2026-06-20T18-47-11/preview/clip-01.mp4)
- [Add a new
environment](https://artifacts.cutter.sh/8391/run-1175b5c-2026-06-20T18-47-11/preview/change-02.png)
- [Save an
environment](https://artifacts.cutter.sh/8391/run-1175b5c-2026-06-20T18-47-11/preview/change-03.png)

## Risks

- Low risk overall because the change is contained to the environments
page and keeps the existing form fields and mutations.
- The main behavior change is modal layout, so the biggest risk is
regressions in tall-form scrolling or smaller-screen dialog ergonomics.

> I checked [`ROADMAP.md`](ROADMAP.md). This is a tightly scoped UI
polish fix, not duplicate roadmap-level core feature work.

## Model Used

- OpenAI GPT-5.4 via the `codex_local` Paperclip adapter
- High reasoning mode with tool use, shell execution, git operations,
and targeted test execution
- Model-assisted authoring and verification of the UI and test changes

## 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
- [x] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] 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-20 12:26:53 -07:00
committed by GitHub
parent e93d78b46c
commit e9931f116a
3 changed files with 413 additions and 235 deletions
+99
View File
@@ -17,6 +17,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
setDefault: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
@@ -52,6 +53,13 @@ vi.mock("@/api/secrets", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Minimal Radix dialog dependency for jsdom.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
async function act(callback: () => void | Promise<void>) {
await callback();
@@ -73,6 +81,14 @@ function testProviderButtons(container: HTMLElement): HTMLButtonElement[] {
});
}
function findButton(root: ParentNode, label: string): HTMLButtonElement | undefined {
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === label);
}
function getOpenDialog(): HTMLElement | null {
return document.body.querySelector("[role='dialog']");
}
describe("CompanyEnvironments — test provider button", () => {
let container: HTMLDivElement;
let probeResolvers: Map<string, () => void>;
@@ -81,6 +97,7 @@ describe("CompanyEnvironments — test provider button", () => {
container = document.createElement("div");
document.body.appendChild(container);
probeResolvers = new Map();
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
mockEnvironmentsApi.capabilities.mockResolvedValue({ adapters: [], sandboxProviders: {} });
mockSecretsApi.list.mockResolvedValue([]);
@@ -88,6 +105,20 @@ describe("CompanyEnvironments — test provider button", () => {
{ id: "env-1", name: "Alpha", driver: "sandbox", description: null, config: { provider: "e2b" } },
{ id: "env-2", name: "Beta", driver: "sandbox", description: null, config: { provider: "e2b" } },
]);
mockEnvironmentsApi.create.mockImplementation(async (_companyId: string, body: { name: string }) => ({
id: "env-new",
name: body.name,
driver: "ssh",
description: null,
config: {},
}));
mockEnvironmentsApi.update.mockImplementation(async (environmentId: string, body: { name: string }) => ({
id: environmentId,
name: body.name,
driver: "sandbox",
description: null,
config: { provider: "e2b" },
}));
// Each probe stays pending until its resolver is called, so the testing
// state remains observable and can be settled per environment.
mockEnvironmentsApi.probe.mockImplementation(
@@ -175,4 +206,72 @@ describe("CompanyEnvironments — test provider button", () => {
expect(buttons[1].textContent?.trim()).toBe("Testing...");
expect(buttons[1].disabled).toBe(true);
});
it("opens the add-environment form in a dialog and closes it on cancel", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await act(async () => {
findButton(container, "Add environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(getOpenDialog()?.textContent).toContain("Add environment");
await act(async () => {
findButton(document.body, "Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(getOpenDialog()).toBeNull();
});
it("opens the edit form in a dialog with existing values and closes after save", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
await act(async () => {
findButton(container, "Edit")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog?.textContent).toContain("Edit environment");
expect(
Array.from(dialog?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"),
).toBe(true);
await act(async () => {
findButton(document.body, "Save environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockEnvironmentsApi.update).toHaveBeenCalledExactlyOnceWith(
"env-1",
expect.objectContaining({ name: "Alpha", driver: "sandbox" }),
);
expect(getOpenDialog()).toBeNull();
});
});
+278 -230
View File
@@ -13,6 +13,14 @@ import { environmentsApi } from "@/api/environments";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EnvVarEditor } from "@/components/EnvVarEditor";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -181,6 +189,7 @@ export function CompanyEnvironments() {
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [environmentDialogOpen, setEnvironmentDialogOpen] = useState(false);
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
@@ -245,13 +254,17 @@ export function CompanyEnvironments() {
return await environmentsApi.create(selectedCompanyId!, body);
},
onSuccess: async (environment) => {
const wasEditing = editingEnvironmentId !== null;
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId!),
});
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
pushToast({
title: editingEnvironmentId ? "Environment updated" : "Environment created",
title: wasEditing ? "Environment updated" : "Environment created",
body: `${environment.name} is ready.`,
tone: "success",
});
@@ -345,14 +358,26 @@ export function CompanyEnvironments() {
});
useEffect(() => {
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
setProbeResults({});
setTestingEnvironmentId(null);
}, [selectedCompanyId]);
function handleStartCreateEnvironment() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
setEnvironmentDialogOpen(true);
}
function handleEditEnvironment(environment: Environment) {
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
setEditingEnvironmentId(environment.id);
setEnvironmentDialogOpen(true);
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
setEnvironmentForm({
@@ -396,9 +421,13 @@ export function CompanyEnvironments() {
});
}
function handleCancelEnvironmentEdit() {
function closeEnvironmentDialog() {
if (environmentMutation.isPending) return;
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
@@ -590,6 +619,12 @@ export function CompanyEnvironments() {
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-medium">Saved environments</div>
<Button size="sm" onClick={handleStartCreateEnvironment}>
Add environment
</Button>
</div>
{savedEnvironments.length === 0 ? (
<div className="text-sm text-muted-foreground">No saved environments yet. Local remains the default until you add another target.</div>
) : (
@@ -672,256 +707,269 @@ export function CompanyEnvironments() {
})
)}
</div>
</div>
<div className="border-t border-border/60 pt-4">
<div className="mb-3 text-sm font-medium">
{editingEnvironmentId ? "Edit environment" : "Add environment"}
</div>
<div className="space-y-3">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.name}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
/>
</Field>
<Field label="Description" hint="Optional note about what this machine is for.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.description}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<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}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver: e.target.value === "sandbox" ? "sandbox" : "ssh",
}))}
>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="ssh">SSH</option>
{environmentForm.driver === "local" ? (
<option value="local">Local</option>
) : null}
</select>
</Field>
<Dialog
open={environmentDialogOpen}
onOpenChange={(open) => {
if (open) {
setEnvironmentDialogOpen(true);
return;
}
closeEnvironmentDialog();
}}
>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
<DialogTitle>{editingEnvironmentId ? "Edit environment" : "Add environment"}</DialogTitle>
<DialogDescription>
Configure a reusable execution target for your agents.
</DialogDescription>
</DialogHeader>
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshHost}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
/>
</Field>
<Field label="Port" hint="Defaults to 22.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="number"
min={1}
max={65535}
value={environmentForm.sshPort}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH login user.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshUsername}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
placeholder="/Users/paperclip/workspace"
value={environmentForm.sshRemoteWorkspacePath}
onChange={(e) =>
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
/>
</Field>
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
<div className="space-y-2">
<select
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.name}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
/>
</Field>
<Field label="Description" hint="Optional note about what this machine is for.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.description}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<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}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver: e.target.value === "sandbox" ? "sandbox" : "ssh",
}))}
>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="ssh">SSH</option>
{environmentForm.driver === "local" ? (
<option value="local">Local</option>
) : null}
</select>
</Field>
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sshPrivateKeySecretId}
type="text"
value={environmentForm.sshHost}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
/>
</Field>
<Field label="Port" hint="Defaults to 22.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="number"
min={1}
max={65535}
value={environmentForm.sshPort}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH login user.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshUsername}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
placeholder="/Users/paperclip/workspace"
value={environmentForm.sshRemoteWorkspacePath}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sshPrivateKeySecretId: e.target.value,
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
}))}
>
<option value="">No saved secret</option>
{(secrets ?? []).map((secret) => (
<option key={secret.id} value={secret.id}>{secret.name}</option>
))}
</select>
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
/>
</Field>
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
<div className="space-y-2">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sshPrivateKeySecretId}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sshPrivateKeySecretId: e.target.value,
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
}))}
>
<option value="">No saved secret</option>
{(secrets ?? []).map((secret) => (
<option key={secret.id} value={secret.id}>{secret.name}</option>
))}
</select>
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshPrivateKey}
disabled={!!environmentForm.sshPrivateKeySecretId}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
/>
</div>
</Field>
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshPrivateKey}
disabled={!!environmentForm.sshPrivateKeySecretId}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
value={environmentForm.sshKnownHosts}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
/>
</Field>
<div className="md:col-span-2">
<ToggleField
label="Strict host key checking"
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
checked={environmentForm.sshStrictHostKeyChecking}
onChange={(checked) =>
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
/>
</div>
</Field>
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshKnownHosts}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
/>
</Field>
<div className="md:col-span-2">
<ToggleField
label="Strict host key checking"
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
checked={environmentForm.sshStrictHostKeyChecking}
onChange={(checked) =>
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
/>
</div>
</div>
) : null}
) : null}
{environmentForm.driver === "sandbox" ? (
<div className="space-y-3">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</div>
) : null}
{environmentForm.driver === "sandbox" ? (
<div className="space-y-3">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</div>
) : null}
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."
>
<EnvVarEditor
value={environmentForm.envVars}
secrets={secrets ?? []}
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
onChange={(env) =>
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => environmentMutation.mutate(environmentForm)}
disabled={environmentMutation.isPending || !environmentFormValid}
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."
>
{environmentMutation.isPending
? editingEnvironmentId
? "Saving..."
: "Creating..."
: editingEnvironmentId
? "Save environment"
: "Create environment"}
</Button>
{editingEnvironmentId ? (
<Button
size="sm"
variant="ghost"
onClick={handleCancelEnvironmentEdit}
disabled={environmentMutation.isPending}
>
Cancel
</Button>
) : null}
{environmentForm.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(environmentForm)}
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
>
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test"}
</Button>
) : null}
<EnvVarEditor
value={environmentForm.envVars}
secrets={secrets ?? []}
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
onChange={(env) =>
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
/>
</Field>
{environmentMutation.isError ? (
<span className="text-xs text-destructive">
<div className="text-xs text-destructive">
{environmentMutation.error instanceof Error
? environmentMutation.error.message
: "Failed to save environment"}
</span>
</div>
) : null}
{draftEnvironmentProbeMutation.data ? (
<span className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
<div className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
{draftEnvironmentProbeMutation.data.summary}
</span>
</div>
) : null}
</div>
</div>
</div>
</div>
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
<Button
variant="outline"
onClick={closeEnvironmentDialog}
disabled={environmentMutation.isPending}
>
Cancel
</Button>
{environmentForm.driver !== "local" ? (
<Button
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(environmentForm)}
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
>
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test"}
</Button>
) : null}
<Button
onClick={() => environmentMutation.mutate(environmentForm)}
disabled={environmentMutation.isPending || !environmentFormValid}
>
{environmentMutation.isPending
? editingEnvironmentId
? "Saving..."
: "Creating..."
: editingEnvironmentId
? "Save environment"
: "Create environment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+36 -5
View File
@@ -97,6 +97,13 @@ vi.mock("../context/CompanyContext", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub;
async function flushReact() {
await act(async () => {
@@ -105,6 +112,10 @@ async function flushReact() {
});
}
function getOpenDialog(): HTMLElement | null {
return document.body.querySelector("[role='dialog']");
}
describe("CompanyEnvironments", () => {
let container: HTMLDivElement;
@@ -198,7 +209,20 @@ describe("CompanyEnvironments", () => {
await flushReact();
await flushReact();
const driverSelect = Array.from(container.querySelectorAll("select"))
const addEnvironmentButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Add environment",
);
expect(addEnvironmentButton).toBeTruthy();
await act(async () => {
addEnvironmentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? [])
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
| HTMLSelectElement
| undefined;
@@ -254,7 +278,10 @@ describe("CompanyEnvironments", () => {
});
await flushReact();
const driverSelect = Array.from(container.querySelectorAll("select"))
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? [])
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
| HTMLSelectElement
| undefined;
@@ -337,8 +364,12 @@ describe("CompanyEnvironments", () => {
});
await flushReact();
const providerSelect = Array.from(container.querySelectorAll("select"))
.find((select) => Array.from(select.options).some((option) => option.value === "secure-plugin")) as HTMLSelectElement | undefined;
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
const providerSelect = Array.from(dialog?.querySelectorAll("select") ?? []).find((select) =>
Array.from(select.options).some((option) => option.value === "secure-plugin"),
) as HTMLSelectElement | undefined;
expect(providerSelect).toBeTruthy();
await act(async () => {
@@ -347,7 +378,7 @@ describe("CompanyEnvironments", () => {
});
await flushReact();
const templateInput = Array.from(container.querySelectorAll("input"))
const templateInput = Array.from(dialog?.querySelectorAll("input") ?? [])
.find((input) => (input as HTMLInputElement).value === "saved-template") as HTMLInputElement | undefined;
expect(templateInput?.value).toBe("saved-template");