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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user