Files
PaperClipAI/ui/src/pages/InstanceExperimentalSettings.tsx
T
Devin Foley 0044fa8904 Let tenants edit env vars on managed sandbox environments; add managed-sandbox-only mode (#11200)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Environments give each agent run an execution target: the local
host, SSH, or a sandbox provider
> - A managed deployment can provision one platform-managed sandbox
environment through the `PAPERCLIP_MANAGED_CONFIG` `environments`
section
> - That row is fully locked today. A tenant cannot add environment
variables for their agents. There is also no way to hide local execution
— run selection falls back to the local row
> - A platform that manages the sandbox for its tenants needs both: the
tenant adds env vars (and nothing else), and local execution is neither
visible nor reachable
> - This pull request opens exactly one tenant edit (env vars) on the
managed sandbox row, and adds an `enableManagedSandboxOnly` mode that
hides local and makes run selection fail closed
> - The benefit is a complete managed-sandbox experience with no change
for self-hosted instances

## Linked Issues or Issue Description

**Subsystem affected**

Environments (managed sandbox provisioning, environment routes, run
environment selection) and the environments UI.

**Problem or motivation**

Platform-provisioned sandbox environments
(`metadata.managedByPaperclip`) reject every write on cloud-managed
instances. Agents often need environment variables inside their sandbox.
The tenant has no way to set them on the managed row. Separately, an
operator cannot remove local execution: the environment list always
shows the local row, and run selection falls back to it when no default
is set.

**Proposed solution**

Allow an envVars-only PATCH on the managed sandbox row, and echo those
env vars back for editing. Add a managed-tier feature
(`enableManagedSandboxOnly`) that hides the local environment from all
read surfaces and redirects local-landing run selection to the managed
sandbox environment, failing closed when it is unavailable.

**Alternatives considered**

UI-only hiding of the local row. This was rejected: it does not stop a
run from resolving to local, so it is presentation without enforcement.
Full unlock of the managed row was also rejected: name, driver, and
config stay platform-owned so boot reconciliation cannot fight tenant
edits.

## What Changed

- `server/src/routes/environments.ts`: the platform-provisioned write
floor admits an envVars-only PATCH on the generalized managed sandbox
row (sandbox driver, `managedByPaperclip`, not legacy kubernetes-marker
rows). Name, driver, config, status, metadata, and DELETE stay rejected.
The read floor stops blanking env vars on that row; credential-shaped
config keys stay redacted for every actor. Legacy kubernetes-marker rows
keep the full floor.
- Same file: under `enableManagedSandboxOnly`, the environments list and
the by-id read omit the local row for every actor, including instance
admins.
- `server/src/services/execution-workspace-policy.ts`:
`resolveExecutionWorkspaceEnvironmentId` gains the managed-sandbox-only
inputs. A selection that lands on the local environment is redirected to
the managed sandbox environment. With no active managed row it throws
`ManagedSandboxUnavailableError` — never local. Non-local selections
(ssh, user-created sandboxes) are untouched.
- `server/src/services/heartbeat.ts`: the run path reads the flag, looks
up the managed row (`findManagedSandboxEnvironment`, new read-only
finder in `environments.ts`), and passes both to the resolver. Mirrors
the forced-kubernetes precedent, which keeps precedence when both
regimes are on.
- `server/src/services/managed-environments.ts`: after a successful
reconcile, the instance default environment moves to the managed sandbox
row when the current default is unset, local, or dangling. A
tenant-chosen custom environment is never overridden.
- `packages/shared`: new `enableManagedSandboxOnly` key (schema default
false, catalog tier `managed`, cloudDefault false, selfHostedDefault
false) and the matching interface field.
- UI: managed rows show a "Managed by Paperclip" lock badge; editing one
opens a dedicated env-vars-only editor that sends the one PATCH shape
the server admits (the old full form failed with a 403 on save). New
`ui/src/lib/managed-sandbox-environment.ts` mirrors the local filter for
cached lists (applied in the project picker; the agent picker already
excluded local). The experimental settings page gains the toggle at its
alphabetical card position. `environmentsApi.update` now declares the
`envVars` field it already sent.
- Tests: environment route floor coverage (envVars-only accepted, mixed
bodies rejected, legacy rows still blanked and locked, local hidden and
404 under the flag, self-hosted unchanged), an embedded-postgres service
test pinning that boot reconciliation never touches tenant env vars,
resolver redirect/fail-closed cases, managed-environments
default-stamping cases, and UI lib/settings tests.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/environment-routes.test.ts
src/__tests__/environment-service.test.ts
src/__tests__/execution-workspace-policy.test.ts
src/services/managed-environments.test.ts` — all pass.
- `pnpm --filter @paperclipai/shared exec vitest run` — 425 pass
(catalog/schema default parity is pinned by an existing test).
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` and the affected UI
suites (CompanyEnvironments, InstanceExperimentalSettings incl.
card-order test, new lib test) — all pass.
- Full workspace `pnpm test`: 3,414 passed. 17 files report failures on
this machine; the identical 17 fail on a clean `origin/master` worktree
in the same environment (git-worktree/skills/embedded-postgres
environment dependencies and plugin-SDK zero-test collections). One
additional file (`issue-monitor-scheduler.test.ts`) failed one
timing-sensitive test in one of two full-suite runs and passes 7/7 in
isolation on this branch — a flake in a domain this diff does not touch.
The branch introduces no new failures.
- Self-hosted zero-delta: every new behavior is gated on the
cloud-managed instance check or the new flag, which defaults to false in
schema and catalog; pinned by the "does not floor platform-marked rows
on self-hosted instances" and flag-off tests.

## Risks

- Behavior is opt-in twice over: the write-floor exception applies only
to rows the managed-config provisioner stamps, and the hiding/forcing
applies only when `enableManagedSandboxOnly` is on (default false
everywhere). Self-hosted instances see no change.
- The env-vars echo is scoped to the generalized managed sandbox row;
legacy kubernetes-marker rows keep the blanket floor because
pre-generalization builds may have written platform values there.
- Fail-closed run selection means a managed instance with the flag on
and an archived managed row (provider plugin down) refuses runs with a
precise error instead of running locally. That is the intended posture.

## Model Used

Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use
via Claude Code CLI.

## 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] I have updated relevant documentation to reflect my changes
- [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
2026-08-11 11:40:57 -07:00

857 lines
37 KiB
TypeScript

import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Clock, FlaskConical, Lock, Play, Search } from "lucide-react";
import type {
InstanceExperimentalSettings,
InstanceExperimentalSettingsWithManaged,
IssueGraphLivenessAutoRecoveryPreview,
ManagedSettingMetadata,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function issueHref(identifier: string | null, issueId: string) {
if (!identifier) return `/issues/${issueId}`;
const prefix = identifier.split("-")[0] || "PAP";
return `/${prefix}/issues/${identifier}`;
}
function formatRecoveryState(state: string) {
return state.replace(/_/g, " ");
}
type WorktreeRunExecutionDisplayState =
| { kind: "off" }
| { kind: "armed"; activatedAt: string }
| { kind: "fail_closed"; reason: "missing_cutoff" | "missing_instance_id" | "instance_mismatch" };
/**
* Mirror of the server's `resolveWorktreeRunExecutionActivation` fail-closed
* ladder (server/src/services/instance-settings.ts) so the card never claims a
* copied/legacy row is arming execution. The derived fields are display-only —
* the PATCH the toggle sends still writes just the boolean.
*/
function resolveWorktreeRunExecutionDisplayState(
settings:
| Pick<
InstanceExperimentalSettings,
| "enableWorktreeRunExecution"
| "worktreeRunExecutionActivatedAt"
| "worktreeRunExecutionActivationInstanceId"
>
| undefined,
currentInstanceId: string | null,
): WorktreeRunExecutionDisplayState {
if (settings?.enableWorktreeRunExecution !== true) return { kind: "off" };
if (!settings.worktreeRunExecutionActivatedAt) return { kind: "fail_closed", reason: "missing_cutoff" };
if (!currentInstanceId) return { kind: "fail_closed", reason: "missing_instance_id" };
if (settings.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
return { kind: "fail_closed", reason: "instance_mismatch" };
}
return { kind: "armed", activatedAt: settings.worktreeRunExecutionActivatedAt };
}
function formatActivationTimestamp(iso: string): string {
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return iso;
return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
// PAP-11233: keep Conference Room code intact, but hide the user-facing opt-in for now.
const SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING = false;
function ManagedByCloudBadge() {
return (
<Badge variant="outline" className="text-muted-foreground">
<Lock aria-hidden="true" />
Managed by Paperclip Cloud
</Badge>
);
}
function ExperimentalToggleCard({
title,
description,
footnote,
checked,
onCheckedChange,
disabled,
managed,
ariaLabel,
}: {
title: string;
description: string;
footnote?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
managed?: ManagedSettingMetadata;
ariaLabel: string;
}) {
const isManaged = managed?.managed === true;
return (
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{isManaged ? <ManagedByCloudBadge /> : null}
</div>
<p className="max-w-2xl text-sm text-muted-foreground">{description}</p>
{footnote ? <p className="max-w-2xl text-xs text-muted-foreground">{footnote}</p> : null}
</div>
<ToggleSwitch
checked={checked}
onCheckedChange={(next) => {
if (isManaged) return;
onCheckedChange(next);
}}
disabled={disabled || isManaged}
aria-label={ariaLabel}
/>
</div>
</Card>
);
}
function RecoveryPreviewDialog({
preview,
open,
onOpenChange,
onEnableOnly,
onEnableAndRun,
isPending,
}: {
preview: IssueGraphLivenessAutoRecoveryPreview | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEnableOnly: () => void;
onEnableAndRun: () => void;
isPending: boolean;
}) {
const count = preview?.recoverableFindings ?? 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Confirm auto-recovery</DialogTitle>
<DialogDescription>
{preview
? `${count} recovery ${count === 1 ? "task" : "tasks"} match the last ${preview.lookbackHours} hours.`
: "Checking recovery candidates before enabling."}
</DialogDescription>
</DialogHeader>
<div className="max-h-(--sz-calc-36) space-y-3 overflow-y-auto pr-1">
{preview && preview.items.length === 0 ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
No recovery tasks would be created right now. Auto-recovery can still run for future liveness incidents in
this window.
</div>
) : null}
{preview?.items.map((item) => (
<Card key={item.incidentKey} className="block px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<a
href={issueHref(item.identifier, item.issueId)}
className="text-sm font-medium text-primary underline-offset-2 hover:underline"
>
{item.identifier ?? item.issueId}
</a>
<span className="rounded-sm bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{formatRecoveryState(item.state)}
</span>
</div>
<p className="mt-1 text-sm text-foreground">{item.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.reason}</p>
<div className="mt-2 text-xs text-muted-foreground">
Recovery target:{" "}
<a
href={issueHref(item.recoveryIdentifier, item.recoveryIssueId)}
className="text-primary underline-offset-2 hover:underline"
>
{item.recoveryIdentifier ?? item.recoveryIssueId}
</a>
</div>
</Card>
))}
</div>
{preview && preview.skippedOutsideLookback > 0 ? (
<p className="text-xs text-muted-foreground">
{preview.skippedOutsideLookback} current{" "}
{preview.skippedOutsideLookback === 1 ? "finding is" : "findings are"} outside the configured lookback and
will not be touched.
</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button variant="outline" onClick={onEnableOnly} disabled={isPending || !preview}>
Enable only
</Button>
<Button onClick={onEnableAndRun} disabled={isPending || !preview}>
{count > 0 ? `Enable and create ${count}` : "Enable"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const [lookbackHoursDraft, setLookbackHoursDraft] = useState("24");
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [pendingPreview, setPendingPreview] = useState<IssueGraphLivenessAutoRecoveryPreview | null>(null);
function closeRecoveryPreview() {
setPreviewDialogOpen(false);
setPendingPreview(null);
}
useEffect(() => {
setBreadcrumbs([
{ label: "Settings", href: "/company/settings" },
{ label: "Instance settings", href: "/company/settings/instance/general" },
{ label: "Experimental" },
]);
}, [setBreadcrumbs]);
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const toggleMutation = useMutation<
InstanceExperimentalSettingsWithManaged,
Error,
PatchInstanceExperimentalSettings,
{ previousSettings?: InstanceExperimentalSettingsWithManaged }
>({
mutationFn: async (patch: PatchInstanceExperimentalSettings) =>
instanceSettingsApi.updateExperimental(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: queryKeys.instance.experimentalSettings });
const previousSettings = queryClient.getQueryData<InstanceExperimentalSettingsWithManaged>(
queryKeys.instance.experimentalSettings,
);
if (previousSettings) {
queryClient.setQueryData<InstanceExperimentalSettingsWithManaged>(
queryKeys.instance.experimentalSettings,
{ ...previousSettings, ...patch },
);
}
return { previousSettings };
},
onSuccess: async (updatedSettings) => {
setActionError(null);
queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
},
onError: (error, _patch, context) => {
if (context?.previousSettings) {
queryClient.setQueryData(queryKeys.instance.experimentalSettings, context.previousSettings);
}
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
},
});
const previewMutation = useMutation({
mutationFn: async (lookbackHours: number) =>
instanceSettingsApi.previewIssueGraphLivenessAutoRecovery({ lookbackHours }),
onSuccess: (preview) => {
setActionError(null);
setPendingPreview(preview);
setPreviewDialogOpen(true);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to preview recovery tasks.");
},
});
const runRecoveryMutation = useMutation({
mutationFn: async (lookbackHours: number) =>
instanceSettingsApi.runIssueGraphLivenessAutoRecovery({ lookbackHours }),
onSuccess: async () => {
setActionError(null);
closeRecoveryPreview();
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to create recovery tasks.");
},
});
useEffect(() => {
const next = experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours;
if (typeof next === "number") {
setLookbackHoursDraft(String(next));
}
}, [experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours]);
const autoRecoveryManaged =
experimentalQuery.data?.managedKeys?.enableIssueGraphLivenessAutoRecovery?.managed === true;
// If refreshed settings mark auto-recovery as managed while the preview
// dialog is open, close it so its confirmation actions cannot emit a PATCH.
useEffect(() => {
if (autoRecoveryManaged) {
closeRecoveryPreview();
}
}, [autoRecoveryManaged]);
if (experimentalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
}
if (experimentalQuery.error) {
return (
<div className="text-sm text-destructive">
{experimentalQuery.error instanceof Error
? experimentalQuery.error.message
: "Failed to load experimental settings."}
</div>
);
}
const inWorktree = isWorktreeRuntime();
// Present only on cloud-managed instances: keys the managed overlay controls
// render locked with the "Managed by Paperclip Cloud" badge. Self-hosted
// responses carry no `managedKeys`, so every card stays editable.
const managedKeys = experimentalQuery.data?.managedKeys ?? {};
const enableWorktreeRunExecution = experimentalQuery.data?.enableWorktreeRunExecution === true;
const worktreeRunExecutionManaged = managedKeys.enableWorktreeRunExecution?.managed === true;
const worktreeRunExecutionState = resolveWorktreeRunExecutionDisplayState(
experimentalQuery.data,
getWorktreeInstanceId(),
);
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableApps = experimentalQuery.data?.enableApps === true;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableClassicTaskInterface = experimentalQuery.data?.enableClassicTaskInterface === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
const enableExperimentalFileViewer =
experimentalQuery.data?.enableExperimentalFileViewer === true;
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true;
const enableBetaSkills = experimentalQuery.data?.enableBetaSkills === true;
const enableSummaries = experimentalQuery.data?.enableSummaries === true;
const enableStatusCards = experimentalQuery.data?.enableStatusCards === true;
const summariesManaged = managedKeys.enableSummaries?.managed === true;
const statusCardsManaged = managedKeys.enableStatusCards?.managed === true;
const statusCardsBlockedByManagedSummaries = summariesManaged && !enableSummaries;
const summariesRequiredByManagedStatusCards = statusCardsManaged && enableStatusCards;
const enableDecisions = experimentalQuery.data?.enableDecisions === true;
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
const enableCases = experimentalQuery.data?.enableCases === true;
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
const enableSimplifiedEnglishInteractions =
experimentalQuery.data?.enableSimplifiedEnglishInteractions === true;
const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
const lookbackHours =
experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours ?? 24;
const parsedLookbackHours = Number.parseInt(lookbackHoursDraft, 10);
const lookbackHoursIsValid =
Number.isInteger(parsedLookbackHours) && parsedLookbackHours >= 1 && parsedLookbackHours <= 720;
const recoveryActionPending =
toggleMutation.isPending || previewMutation.isPending || runRecoveryMutation.isPending;
function previewForEnable() {
if (autoRecoveryManaged) return;
if (!lookbackHoursIsValid) {
setActionError("Lookback hours must be a whole number from 1 to 720.");
return;
}
closeRecoveryPreview();
previewMutation.mutate(parsedLookbackHours);
}
function enableOnly() {
if (autoRecoveryManaged) return;
if (!lookbackHoursIsValid) return;
closeRecoveryPreview();
toggleMutation.mutate({
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
});
}
function enableAndRun() {
if (autoRecoveryManaged) return;
if (!lookbackHoursIsValid) return;
closeRecoveryPreview();
toggleMutation.mutate({
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
}, {
onSuccess: () => runRecoveryMutation.mutate(parsedLookbackHours),
});
}
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Experimental</h1>
</div>
<p className="text-sm text-muted-foreground">
Opt into features that are still being evaluated before they become default behavior.
</p>
</div>
<div
role="alert"
className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3"
>
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">Experimental features may break at any time.</p>
<p className="text-muted-foreground">
These features are opt-in and come with no compatibility guarantees. They may change, break, or be
removed without notice. Avoid relying on them for critical or production workflows.
</p>
</div>
</div>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<ExperimentalToggleCard
title="Apps"
description="Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling."
checked={enableApps}
onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableApps}
ariaLabel="Toggle apps experimental setting"
/>
<Card className="block p-5">
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">Auto-Create Recovery Tasks</h2>
{autoRecoveryManaged ? <ManagedByCloudBadge /> : null}
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the
configured lookback window.
</p>
</div>
<ToggleSwitch
checked={enableIssueGraphLivenessAutoRecovery}
onCheckedChange={() => {
if (autoRecoveryManaged) return;
if (enableIssueGraphLivenessAutoRecovery) {
toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: false });
return;
}
previewForEnable();
}}
disabled={recoveryActionPending || autoRecoveryManaged}
aria-label="Toggle task graph liveness auto-recovery"
/>
</div>
<div className="grid gap-3 sm:grid-cols-(--gtc-35) sm:items-end">
<label className="space-y-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
Lookback hours
</span>
<Input
type="number"
min={1}
max={720}
step={1}
value={lookbackHoursDraft}
onChange={(event) => setLookbackHoursDraft(event.target.value)}
aria-invalid={!lookbackHoursIsValid}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => {
if (!lookbackHoursIsValid) {
setActionError("Lookback hours must be a whole number from 1 to 720.");
return;
}
toggleMutation.mutate({
issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours,
});
}}
disabled={recoveryActionPending || parsedLookbackHours === lookbackHours}
>
Save hours
</Button>
<Button
variant="outline"
onClick={previewForEnable}
disabled={recoveryActionPending}
>
<Search className="h-4 w-4" />
Preview
</Button>
<Button
onClick={() => {
if (!lookbackHoursIsValid) {
setActionError("Lookback hours must be a whole number from 1 to 720.");
return;
}
runRecoveryMutation.mutate(parsedLookbackHours);
}}
disabled={recoveryActionPending || !enableIssueGraphLivenessAutoRecovery}
>
<Play className="h-4 w-4" />
Run now
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Current window: last {lookbackHours} {lookbackHours === 1 ? "hour" : "hours"}.
</p>
</div>
</Card>
<ExperimentalToggleCard
title="Auto-Restart Dev Server When Idle"
description="In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server automatically when backend changes or migrations make the current boot stale."
checked={autoRestartDevServerWhenIdle}
onCheckedChange={(checked) => toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.autoRestartDevServerWhenIdle}
ariaLabel="Toggle guarded dev-server auto-restart"
/>
<ExperimentalToggleCard
title="Beta skills"
description="Allow agents to pin beta releases of the Paperclip core skill. Disabling this returns every agent to the default live skill without removing saved pins."
checked={enableBetaSkills}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBetaSkills: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableBetaSkills}
ariaLabel="Toggle beta skills experimental setting"
/>
<ExperimentalToggleCard
title="Built-in Agents"
description="Show Paperclip-managed built-in agent surfaces, including built-in roster badges, the Built-in agents tab, and built-in agent setup controls."
checked={enableBuiltInAgents}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBuiltInAgents: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableBuiltInAgents}
ariaLabel="Toggle built-in agents experimental setting"
/>
<ExperimentalToggleCard
title="Cases"
description="Durable work products (blog posts, tweet storms…) that tasks create and iterate on. Adds the Cases tab and the agent case API."
footnote="Turning Cases off hides the tab and blocks the case API; existing case data is kept."
checked={enableCases}
onCheckedChange={(checked) => toggleMutation.mutate({ enableCases: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableCases}
ariaLabel="Toggle cases experimental setting"
/>
<ExperimentalToggleCard
title="Classic Task Interface"
description="Restores the previous task detail page: the page-level header with inline description editing, the plain comment thread, and the fixed Properties sidebar. Chat-only features — streaming activity folding, inline plan and question cards, the three-mode composer — are unavailable in the classic view."
footnote="Switching takes effect immediately. No task data is affected."
checked={enableClassicTaskInterface}
onCheckedChange={(checked) => toggleMutation.mutate({ enableClassicTaskInterface: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableClassicTaskInterface}
ariaLabel="Toggle classic task interface experimental setting"
/>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
<ExperimentalToggleCard
title="Conference Room Chat"
description="Adds a Conference Room — one chat where you and your whole team work together — plus the live activity feed and the redesigned onboarding. Also restyles task threads as chat bubbles. Turn off anytime to restore the classic UI."
checked={enableConferenceRoomChat}
onCheckedChange={(checked) => toggleMutation.mutate({ enableConferenceRoomChat: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableConferenceRoomChat}
ariaLabel="Toggle conference room chat experimental setting"
/>
) : null}
<ExperimentalToggleCard
title="Decisions"
description="Show the Decisions item in the main sidebar — the attention home that surfaces the tasks awaiting your input — while the surface is still being evaluated."
checked={enableDecisions}
onCheckedChange={(checked) => toggleMutation.mutate({ enableDecisions: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableDecisions}
ariaLabel="Toggle decisions experimental setting"
/>
<ExperimentalToggleCard
title="Enable Environments"
description="Show environment management in company settings and allow project and agent environment assignment controls."
checked={enableEnvironments}
onCheckedChange={(checked) => toggleMutation.mutate({ enableEnvironments: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableEnvironments}
ariaLabel="Toggle environments experimental setting"
/>
<ExperimentalToggleCard
title="Enable External Objects"
description="Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced work objects."
checked={enableExternalObjects}
onCheckedChange={(checked) => toggleMutation.mutate({ enableExternalObjects: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableExternalObjects}
ariaLabel="Toggle external objects experimental setting"
/>
<ExperimentalToggleCard
title="Enable Isolated Workspaces"
description="Show execution workspace controls in project configuration and allow isolated workspace behavior for new and existing task runs."
checked={enableIsolatedWorkspaces}
onCheckedChange={(checked) => toggleMutation.mutate({ enableIsolatedWorkspaces: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableIsolatedWorkspaces}
ariaLabel="Toggle isolated workspaces experimental setting"
/>
<ExperimentalToggleCard
title="Experimental File Viewer"
description="Show task detail controls for browsing and previewing workspace files relative to a task."
checked={enableExperimentalFileViewer}
onCheckedChange={(checked) => toggleMutation.mutate({ enableExperimentalFileViewer: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableExperimentalFileViewer}
ariaLabel="Toggle experimental file viewer setting"
/>
<ExperimentalToggleCard
title="Goals Sidebar Link"
description="Restore the Goals item in the main sidebar while the goals surface is being evaluated."
checked={enableGoalsSidebarLink}
onCheckedChange={(checked) => toggleMutation.mutate({ enableGoalsSidebarLink: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableGoalsSidebarLink}
ariaLabel="Toggle goals sidebar link experimental setting"
/>
<ExperimentalToggleCard
title="Managed Sandbox Only"
description="Hide the local environment and run all agents in the platform-managed sandbox environment."
checked={enableManagedSandboxOnly}
onCheckedChange={(checked) => toggleMutation.mutate({ enableManagedSandboxOnly: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableManagedSandboxOnly}
ariaLabel="Toggle managed sandbox only experimental setting"
/>
{inWorktree ? (
<Card className="block p-5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">Run tasks in this worktree</h2>
{worktreeRunExecutionManaged ? <ManagedByCloudBadge /> : null}
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs
here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay
parked. Toggling off and on resets the cutoff.
</p>
</div>
<ToggleSwitch
checked={enableWorktreeRunExecution}
onCheckedChange={(checked) => {
if (worktreeRunExecutionManaged) return;
toggleMutation.mutate({ enableWorktreeRunExecution: checked });
}}
disabled={toggleMutation.isPending || worktreeRunExecutionManaged}
aria-label="Toggle worktree run execution setting"
/>
</div>
{worktreeRunExecutionState.kind === "armed" ? (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-foreground">
<Play className="h-4 w-4 shrink-0 text-emerald-600" />
<span>
Running tasks created after{" "}
<span className="font-medium">
{formatActivationTimestamp(worktreeRunExecutionState.activatedAt)}
</span>
.
</span>
</div>
) : null}
{worktreeRunExecutionState.kind === "fail_closed" ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-0.5">
<p className="font-medium text-foreground">Execution is suppressed — effectively off.</p>
<p className="text-muted-foreground">
{worktreeRunExecutionState.reason === "instance_mismatch"
? "This setting was armed in a different instance and copied here, so no tasks run automatically."
: "This setting is missing its activation cutoff, so no tasks run automatically."}{" "}
Toggle it off and back on to arm execution for tasks created here.
</p>
</div>
</div>
) : null}
</div>
</Card>
) : null}
<ExperimentalToggleCard
title="Server Info Debug View"
description='Show a "Server" section in the account drawer with the current server restart time and running commit.'
checked={enableServerInfoDebugView}
onCheckedChange={(checked) => toggleMutation.mutate({ enableServerInfoDebugView: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableServerInfoDebugView}
ariaLabel="Toggle server info debug view experimental setting"
/>
<ExperimentalToggleCard
title="Simplified English Interactions"
description="Instruct agents to write user interactions (plan confirmations, questions, suggested tasks, checkbox prompts) in ASD-STE100 Simplified Technical English, with brief context on what information the decision needs and what happens for each choice."
checked={enableSimplifiedEnglishInteractions}
onCheckedChange={(checked) =>
toggleMutation.mutate({ enableSimplifiedEnglishInteractions: checked })
}
disabled={toggleMutation.isPending}
managed={managedKeys.enableSimplifiedEnglishInteractions}
ariaLabel="Toggle simplified english interactions experimental setting"
/>
<ExperimentalToggleCard
title="Smoke Lab"
description='Add a "Smoke Lab" tab under Apps → Developer and an "Integration smoke" card on the dashboard for exercising every integration path against deterministic local fixtures (fake OAuth provider + loopback MCP servers). Private (non-public) deployments only.'
checked={enableSmokeLab}
onCheckedChange={(checked) => toggleMutation.mutate({ enableSmokeLab: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableSmokeLab}
ariaLabel="Toggle smoke lab experimental setting"
/>
<ExperimentalToggleCard
title="Status Cards"
description="Enable the experimental shared status-card board and its gated API. Existing card data is kept when this is disabled."
footnote="Enabling Status Cards also enables Summaries."
checked={enableStatusCards}
onCheckedChange={(checked) =>
toggleMutation.mutate(
checked
? { enableSummaries: true, enableStatusCards: true }
: { enableStatusCards: false },
)
}
disabled={toggleMutation.isPending || statusCardsBlockedByManagedSummaries}
managed={managedKeys.enableStatusCards}
ariaLabel="Toggle status cards experimental setting"
/>
<ExperimentalToggleCard
title="Summaries"
description="Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history. Existing summary data is kept when this is disabled."
footnote="Status Cards requires Summaries. Disabling Summaries also disables Status Cards."
checked={enableSummaries}
onCheckedChange={(checked) =>
toggleMutation.mutate(
checked || !enableStatusCards
? { enableSummaries: checked }
: { enableSummaries: false, enableStatusCards: false },
)
}
disabled={toggleMutation.isPending || summariesRequiredByManagedStatusCards}
managed={managedKeys.enableSummaries}
ariaLabel="Toggle summaries experimental setting"
/>
<ExperimentalToggleCard
title="Task Plan Decomposition Panel"
description="Show accepted-plan decomposition history on task detail pages. Intended for debugging and validating subtask creation behavior while the presentation is still being refined."
checked={enableIssuePlanDecompositions}
onCheckedChange={(checked) => toggleMutation.mutate({ enableIssuePlanDecompositions: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableIssuePlanDecompositions}
ariaLabel="Toggle task plan decomposition panel experimental setting"
/>
<ExperimentalToggleCard
title="Task Watchdogs"
description="Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue."
checked={enableTaskWatchdogs}
onCheckedChange={(checked) => toggleMutation.mutate({ enableTaskWatchdogs: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableTaskWatchdogs}
ariaLabel="Toggle task watchdogs experimental setting"
/>
{previewDialogOpen && !autoRecoveryManaged ? (
<RecoveryPreviewDialog
open
onOpenChange={(open) => {
if (!open) {
closeRecoveryPreview();
}
}}
preview={pendingPreview}
onEnableOnly={enableOnly}
onEnableAndRun={enableAndRun}
isPending={recoveryActionPending}
/>
) : null}
</div>
);
}