import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Clock, FlaskConical, Play, Search } from "lucide-react"; import type { IssueGraphLivenessAutoRecoveryPreview, PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Button } from "@/components/ui/button"; 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, " "); } 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 ( Confirm auto-recovery {preview ? `${count} recovery ${count === 1 ? "task" : "tasks"} match the last ${preview.lookbackHours} hours.` : "Checking recovery candidates before enabling."}
{preview && preview.items.length === 0 ? (
No recovery tasks would be created right now. Auto-recovery can still run for future liveness incidents in this window.
) : null} {preview?.items.map((item) => (
{item.identifier ?? item.issueId} {formatRecoveryState(item.state)}

{item.title}

{item.reason}

Recovery target:{" "} {item.recoveryIdentifier ?? item.recoveryIssueId}
))}
{preview && preview.skippedOutsideLookback > 0 ? (

{preview.skippedOutsideLookback} current{" "} {preview.skippedOutsideLookback === 1 ? "finding is" : "findings are"} outside the configured lookback and will not be touched.

) : null}
); } export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); const [lookbackHoursDraft, setLookbackHoursDraft] = useState("24"); const [previewDialogOpen, setPreviewDialogOpen] = useState(false); const [pendingPreview, setPendingPreview] = useState(null); useEffect(() => { setBreadcrumbs([ { label: "Instance Settings" }, { label: "Experimental" }, ]); }, [setBreadcrumbs]); const experimentalQuery = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); const toggleMutation = useMutation({ mutationFn: async (patch: PatchInstanceExperimentalSettings) => instanceSettingsApi.updateExperimental(patch), onSuccess: async () => { setActionError(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), queryClient.invalidateQueries({ queryKey: queryKeys.health }), ]); }, onError: (error) => { 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); setPreviewDialogOpen(false); 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]); if (experimentalQuery.isLoading) { return
Loading experimental settings...
; } if (experimentalQuery.error) { return (
{experimentalQuery.error instanceof Error ? experimentalQuery.error.message : "Failed to load experimental settings."}
); } const enableEnvironments = experimentalQuery.data?.enableEnvironments === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableIssuePlanDecompositions = experimentalQuery.data?.enableIssuePlanDecompositions === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === 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 (!lookbackHoursIsValid) { setActionError("Lookback hours must be a whole number from 1 to 720."); return; } previewMutation.mutate(parsedLookbackHours); } function enableOnly() { if (!lookbackHoursIsValid) return; toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours, }, { onSuccess: () => setPreviewDialogOpen(false), }); } function enableAndRun() { if (!lookbackHoursIsValid) return; toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours, }, { onSuccess: () => runRecoveryMutation.mutate(parsedLookbackHours), }); } return (

Experimental

Opt into features that are still being evaluated before they become default behavior.

{actionError && (
{actionError}
)}

Enable Environments

Show environment management in company settings and allow project and agent environment assignment controls.

toggleMutation.mutate({ enableEnvironments: !enableEnvironments })} disabled={toggleMutation.isPending} aria-label="Toggle environments experimental setting" />

Enable Isolated Workspaces

Show execution workspace controls in project configuration and allow isolated workspace behavior for new and existing issue runs.

toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })} disabled={toggleMutation.isPending} aria-label="Toggle isolated workspaces experimental setting" />

Issue Plan Decomposition Panel

Show accepted-plan decomposition history on issue detail pages. Intended for debugging and validating subtask creation behavior while the presentation is still being refined.

toggleMutation.mutate({ enableIssuePlanDecompositions: !enableIssuePlanDecompositions, }) } disabled={toggleMutation.isPending} aria-label="Toggle issue plan decomposition panel experimental setting" />

Cloud Sync

Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces. Saved connections and run history are preserved when this is disabled.

toggleMutation.mutate({ enableCloudSync: !enableCloudSync })} disabled={toggleMutation.isPending} aria-label="Toggle cloud sync experimental setting" />

Auto-Restart Dev Server When Idle

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.

toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })} disabled={toggleMutation.isPending} aria-label="Toggle guarded dev-server auto-restart" />

Auto-Create Issue Recovery Tasks

Let the heartbeat scheduler create recovery issues for issue dependency chains found inside the configured lookback window.

{ if (enableIssueGraphLivenessAutoRecovery) { toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: false }); return; } previewForEnable(); }} disabled={recoveryActionPending} aria-label="Toggle issue graph liveness auto-recovery" />

Current window: last {lookbackHours} {lookbackHours === 1 ? "hour" : "hours"}.

); }