Add instance experimental setting for isolated workspaces

Introduce a singleton instance_settings store and experimental settings API, add the Experimental instance settings page, and gate execution workspace behavior behind the new enableIsolatedWorkspaces flag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-17 09:24:28 -05:00
parent 6c779fbd48
commit e39ae5a400
32 changed files with 10849 additions and 262 deletions

View File

@@ -25,6 +25,7 @@ import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { PluginPage } from "./pages/PluginPage";
@@ -307,6 +308,7 @@ export function App() {
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>

View File

@@ -12,4 +12,5 @@ export { costsApi } from "./costs";
export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";

View File

@@ -0,0 +1,12 @@
import type {
InstanceExperimentalSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
api.patch<InstanceExperimentalSettings>("/instance/settings/experimental", patch),
};

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -23,6 +23,7 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">

View File

@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -189,6 +190,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId;
const { data: agents } = useQuery({
@@ -258,7 +263,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const currentExecutionWorkspaceSelection =
issue.executionWorkspacePreference

View File

@@ -24,32 +24,16 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;

View File

@@ -4,6 +4,7 @@ import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -341,6 +342,11 @@ export function NewIssueDialog() {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: newIssueOpen,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt),
@@ -635,7 +641,10 @@ export function NewIssueDialog() {
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy ?? null;
const executionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? selectedProject?.executionWorkspacePolicy ?? null
: null;
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
@@ -743,7 +752,10 @@ export function NewIssueDialog() {
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
: null;
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
@@ -1106,9 +1118,9 @@ export function NewIssueDialog() {
</div>
{currentProject && (
<div className="px-4 pb-2 shrink-0 space-y-2">
<div className="px-4 py-3 shrink-0 space-y-2">
{currentProjectSupportsExecutionWorkspace && (
<div className="rounded-md border border-border px-3 py-2 space-y-1.5">
<div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div>
<div className="text-[11px] text-muted-foreground">
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.

View File

@@ -5,6 +5,7 @@ import type { Project } from "@paperclipai/shared";
import { StatusBadge } from "./StatusBadge";
import { cn, formatDate } from "../lib/utils";
import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
@@ -173,6 +174,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const linkedGoalIds = project.goalIds.length > 0
? project.goalIds
@@ -194,6 +199,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id);
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const executionWorkspaceDefaultMode =
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
@@ -781,244 +787,255 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)}
</div>
<Separator className="my-4" />
{isolatedWorkspacesEnabled ? (
<>
<Separator className="my-4" />
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Execution Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm font-medium">
<span>Enable isolated issue checkouts</span>
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
</div>
<div className="text-xs text-muted-foreground">
Let issues choose between the projects primary checkout and an isolated execution workspace.
</div>
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Execution Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
</TooltipContent>
</Tooltip>
</div>
{onUpdate || onFieldUpdate ? (
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
</span>
)}
</div>
{executionWorkspacesEnabled && (
<>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm">
<span>New issues default to isolated checkout</span>
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
<div className="flex items-center gap-2 text-sm font-medium">
<span>Enable isolated issue checkouts</span>
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
</div>
<div className="text-[11px] text-muted-foreground">
If disabled, new issues stay on the projects primary checkout unless someone opts in.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
defaultMode: executionWorkspaceDefaultMode === "isolated_workspace" ? "shared_workspace" : "isolated_workspace",
})!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated_workspace" ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
<div className="border-t border-border/60 pt-2">
<button
type="button"
className="flex items-center gap-2 w-full py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
>
{executionWorkspaceAdvancedOpen ? "Hide advanced checkout settings" : "Show advanced checkout settings"}
</button>
</div>
{executionWorkspaceAdvancedOpen && (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Host-managed implementation: <span className="text-foreground">Git worktree</span>
Let issues choose between the project's primary checkout and an isolated execution workspace.
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Base ref</span>
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.baseRef ?? ""}
onCommit={(value) =>
commitField("execution_workspace_base_ref", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
baseRef: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="origin/main"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Branch template</span>
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.branchTemplate ?? ""}
onCommit={(value) =>
commitField("execution_workspace_branch_template", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
branchTemplate: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="{{issue.identifier}}-{{slug}}"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Worktree parent dir</span>
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
onCommit={(value) =>
commitField("execution_workspace_worktree_parent_dir", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
worktreeParentDir: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder=".paperclip/worktrees"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
provisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree.sh"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_teardown_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
teardownCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
future cleanup flows.
</p>
</div>
)}
</>
)}
</div>
</div>
{onUpdate || onFieldUpdate ? (
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
</span>
)}
</div>
{executionWorkspacesEnabled ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm">
<span>New issues default to isolated checkout</span>
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
</div>
<div className="text-[11px] text-muted-foreground">
If disabled, new issues stay on the project's primary checkout unless someone opts in.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
defaultMode:
executionWorkspaceDefaultMode === "isolated_workspace"
? "shared_workspace"
: "isolated_workspace",
})!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated_workspace"
? "translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
</div>
<div className="border-t border-border/60 pt-2">
<button
type="button"
className="flex w-full items-center gap-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
>
{executionWorkspaceAdvancedOpen
? "Hide advanced checkout settings"
: "Show advanced checkout settings"}
</button>
</div>
{executionWorkspaceAdvancedOpen ? (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Host-managed implementation: <span className="text-foreground">Git worktree</span>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Base ref</span>
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.baseRef ?? ""}
onCommit={(value) =>
commitField("execution_workspace_base_ref", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
baseRef: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="origin/main"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Branch template</span>
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.branchTemplate ?? ""}
onCommit={(value) =>
commitField("execution_workspace_branch_template", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
branchTemplate: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="{{issue.identifier}}-{{slug}}"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Worktree parent dir</span>
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
onCommit={(value) =>
commitField("execution_workspace_worktree_parent_dir", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
worktreeParentDir: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder=".paperclip/worktrees"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
provisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree.sh"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_teardown_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
teardownCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
future cleanup flows.
</p>
</div>
) : null}
</div>
) : null}
</div>
</div>
</>
) : null}
</div>

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "./instance-settings";
describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
"/instance/settings/plugins/example?tab=config#logs",
);
});
it("falls back to the default page for unknown paths", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/nope")).toBe(
DEFAULT_INSTANCE_SETTINGS_PATH,
);
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
});

View File

@@ -0,0 +1,24 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (
pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental"
) {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}

View File

@@ -69,6 +69,7 @@ export const queryKeys = {
},
instance: {
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},
health: ["health"] as const,
secrets: {

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "Experimental" },
]);
}, [setBreadcrumbs]);
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
},
});
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 enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
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>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs.
</p>
</div>
<button
type="button"
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
>
<span
className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}