paperclip/ui/src/components/ProjectProperties.tsx

1017 lines
44 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
import { ProjectRepositories } from "./ProjectRepositories";
import { cn, formatDate } from "../lib/utils";
import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
interface ProjectPropertiesProps {
project: Project;
repositories?: ReactNode;
onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
onArchive?: (archived: boolean) => void;
archivePending?: boolean;
}
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "goals"
| "env"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_shared_concurrency"
| "execution_workspace_environment"
| "execution_workspace_base_ref"
| "execution_workspace_branch_template"
| "execution_workspace_worktree_parent_dir"
| "execution_workspace_provision_command"
| "execution_workspace_runtime_provision_command"
| "execution_workspace_teardown_command";
const SHARED_WORKSPACE_CONCURRENCY_OPTIONS: {
value: SharedWorkspaceConcurrency;
label: string;
help: string;
}[] = [
{
value: "auto",
label: "Auto",
help: "Concurrent runs on local/SSH runners; runs take turns in cloud environments.",
},
{
value: "serialize",
label: "Serialize",
help: "Runs always take turns in the shared project workspace.",
},
{
value: "allow",
label: "Allow",
help: "Runs never wait for the workspace; concurrent edits are possible.",
},
];
function SaveIndicator({ state }: { state: ProjectFieldSaveState }) {
if (state === "saving") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Saving
</span>
);
}
if (state === "saved") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Saved
</span>
);
}
if (state === "error") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-destructive">
<AlertCircle className="h-3 w-3" />
Failed
</span>
);
}
return null;
}
function FieldLabel({
label,
state,
}: {
label: string;
state: ProjectFieldSaveState;
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<SaveIndicator state={state} />
</div>
);
}
function PropertyRow({
label,
children,
alignStart = false,
valueClassName = "",
}: {
label: React.ReactNode;
children: React.ReactNode;
alignStart?: boolean;
valueClassName?: string;
}) {
return (
<div className={cn("flex gap-3 py-1.5 items-start")}>
<div className="shrink-0 w-20 mt-0.5">{label}</div>
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5 flex-wrap", valueClassName)}>
{children}
</div>
</div>
);
}
function ArchiveDangerZone({
project,
onArchive,
archivePending,
}: {
project: Project;
onArchive: (archived: boolean) => void;
archivePending?: boolean;
}) {
const [confirming, setConfirming] = useState(false);
const isArchive = !project.archivedAt;
const action = isArchive ? "Archive" : "Unarchive";
return (
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{isArchive
? "Archive this project to hide it from the sidebar and project selectors."
: "Unarchive this project to restore it in the sidebar and project selectors."}
</p>
{archivePending ? (
<Button size="sm" variant="destructive" disabled>
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{isArchive ? "Archiving..." : "Unarchiving..."}
</Button>
) : confirming ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive font-medium">
{action} &ldquo;{project.name}&rdquo;?
</span>
<Button
size="sm"
variant="destructive"
onClick={() => {
setConfirming(false);
onArchive(isArchive);
}}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirming(false)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => setConfirming(true)}
>
{isArchive ? (
<><Archive className="h-3 w-3 mr-1" />{action} project</>
) : (
<><ArchiveRestore className="h-3 w-3 mr-1" />{action} project</>
)}
</Button>
)}
</div>
);
}
export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
if (onFieldUpdate) {
onFieldUpdate(field, data);
return;
}
onUpdate?.(data);
};
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const { data: userSecretDefinitions = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.userDefinitions(selectedCompanyId)
: ["user-secret-definitions", "none"],
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
retry: false,
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select an organization to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const { data: environments } = useQuery({
queryKey: queryKeys.environments.list(selectedCompanyId!),
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const workspaces = project.workspaces ?? [];
const codebase = project.codebase;
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
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";
// Absent/unset round-trips as "auto" — we only write a value once the user picks one.
const executionWorkspaceSharedConcurrency: SharedWorkspaceConcurrency =
executionWorkspacePolicy?.sharedWorkspaceConcurrency ?? "auto";
const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? "";
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
type: "git_worktree",
baseRef: "",
branchTemplate: "",
worktreeParentDir: "",
};
// Defense in depth alongside the server's managed-sandbox-only read
// filter: a cached environments list may still carry the local row.
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
// The gate for the host-path surfaces below. It fails closed whenever the
// policy is unknown — in flight and also on a failed read: an unresolved
// policy reads as "not managed", which would show the local folder the policy
// exists to hide.
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
const runSelectableEnvironments = filterManagedSandboxSelectableEnvironments(
environments ?? [],
managedSandboxOnly,
).filter((environment) => {
if (environment.driver === "local" || environment.driver === "ssh") return true;
if (environment.driver !== "sandbox") return false;
const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null;
return provider !== null && provider !== "fake";
});
const showExecutionWorkspaceEnvironmentControl = environmentsEnabled && runSelectableEnvironments.length > 1;
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
if (project.urlKey !== project.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
}
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
}
};
const createWorkspace = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.createWorkspace(project.id, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const removeWorkspace = useMutation({
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const updateWorkspace = useMutation({
mutationFn: ({ workspaceId, data }: { workspaceId: string; data: Record<string, unknown> }) =>
projectsApi.updateWorkspace(project.id, workspaceId, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate && !onFieldUpdate) return;
return {
executionWorkspacePolicy: {
enabled: executionWorkspacesEnabled,
defaultMode: executionWorkspaceDefaultMode,
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
...executionWorkspacePolicy,
...patch,
},
};
};
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isSafeExternalUrl = (value: string | null | undefined) => {
if (!value) return false;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
};
const deriveSourceType = (cwd: string | null, repoUrl: string | null) => {
if (repoUrl) return "git_repo";
if (cwd) return "local_path";
return undefined;
};
const persistCodebase = (patch: { cwd?: string | null; repoUrl?: string | null }) => {
const nextCwd = patch.cwd !== undefined ? patch.cwd : codebase.localFolder;
const nextRepoUrl = patch.repoUrl !== undefined ? patch.repoUrl : codebase.repoUrl;
if (!nextCwd && !nextRepoUrl) {
if (primaryCodebaseWorkspace) {
removeWorkspace.mutate(primaryCodebaseWorkspace.id);
}
return;
}
const data: Record<string, unknown> = {
...(patch.cwd !== undefined ? { cwd: patch.cwd } : {}),
...(patch.repoUrl !== undefined ? { repoUrl: patch.repoUrl } : {}),
...(deriveSourceType(nextCwd, nextRepoUrl) ? { sourceType: deriveSourceType(nextCwd, nextRepoUrl) } : {}),
isPrimary: true,
};
if (primaryCodebaseWorkspace) {
updateWorkspace.mutate({ workspaceId: primaryCodebaseWorkspace.id, data });
return;
}
createWorkspace.mutate(data);
};
const submitLocalWorkspace = () => {
const cwd = workspaceCwd.trim();
if (!cwd) {
setWorkspaceError(null);
persistCodebase({ cwd: null });
return;
}
if (!isAbsolutePath(cwd)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
setWorkspaceError(null);
persistCodebase({ cwd });
};
const clearLocalWorkspace = () => {
const confirmed = window.confirm(
codebase.repoUrl
? "Clear local folder from this workspace?"
: "Delete this workspace local folder?",
);
if (!confirmed) return;
persistCodebase({ cwd: null });
};
return (
<div>
<div className="space-y-1 pb-4">
<PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
{onUpdate || onFieldUpdate ? (
<DraftInput
value={project.name}
onCommit={(name) => commitField("name", { name })}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none"
placeholder="Project name"
/>
) : (
<span className="text-sm">{project.name}</span>
)}
</PropertyRow>
<PropertyRow
label={<FieldLabel label="Description" state={fieldState("description")} />}
alignStart
valueClassName="space-y-0.5"
>
{onUpdate || onFieldUpdate ? (
<InlineEditor
value={project.description ?? ""}
onSave={(description) => commitField("description", { description })}
nullable
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
) : (
<p className="text-sm text-muted-foreground">
{project.description?.trim() || "No description"}
</p>
)}
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
valueClassName="space-y-2"
>
<div className="space-y-2">
<EnvironmentVariablesEditor
footerHint={null}
value={project.env ?? {}}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
{project.targetDate && (
<PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
<span className="text-sm">{formatDate(project.targetDate)}</span>
</PropertyRow>
)}
</div>
<Separator className="my-4" />
<div className="space-y-1 py-4">
{(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) && <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Codebase</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-(length:--text-nano) text-muted-foreground hover:text-foreground"
aria-label="Codebase help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
{hideHostPaths
? "Repo identifies the source of truth. Agents check it out in the platform-managed environment."
: "Repo identifies the source of truth. Local folder is the default place agents write code."}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-2 rounded-md border border-border/70 p-3">
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
platform-managed environment, so the path, the folder controls,
and the edit panel below all disappear. A managed checkout keeps
its one-line label so the codebase still reads as accounted for,
but never renders the path itself.
*/}
{hideHostPaths ? (
codebase.origin === "managed_checkout" ? (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
) : null
) : (
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Local folder</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{codebase.effectiveLocalFolder}
</div>
{codebase.origin === "managed_checkout" && (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("local");
setWorkspaceCwd(codebase.localFolder ?? "");
setWorkspaceError(null);
}}
>
{codebase.localFolder ? "Change local folder" : "Set local folder"}
</Button>
{codebase.localFolder ? (
<Button
variant="ghost"
size="icon-xs"
onClick={clearLocalWorkspace}
aria-label="Clear local folder"
>
<Trash2 className="h-3 w-3" />
</Button>
) : null}
</div>
</div>
</div>
)}
{hasAdditionalLegacyWorkspaces && (
<div className="text-(length:--text-micro) text-muted-foreground">
Additional legacy workspace records exist on this project. Paperclip is using the primary workspace as the codebase view.
</div>
)}
{primaryCodebaseWorkspace?.runtimeServices && primaryCodebaseWorkspace.runtimeServices.length > 0 ? (
<div className="space-y-1">
{primaryCodebaseWorkspace.runtimeServices.map((service) => (
<div
key={service.id}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-1"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-(length:--text-micro) font-medium">{service.serviceName}</span>
<Badge variant="ghost"
className={cn(
"px-1.5 text-(length:--text-nano) uppercase tracking-wide",
service.status === "running"
? "bg-green-500/15 text-green-700 dark:text-green-300"
: service.status === "failed"
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-muted text-muted-foreground",
)}
>
{service.status}
</Badge>
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{service.url ? (
<a
href={service.url}
target="_blank"
rel="noreferrer"
className="hover:text-foreground hover:underline"
>
{service.url}
</a>
) : (
service.command ?? "No URL"
)}
</div>
{service.exposure && service.exposure.state !== "removed" ? (
<div
className={cn(
"text-(length:--text-nano)",
service.exposure.state === "failed" || service.exposure.state === "cleanup_pending"
? "text-destructive"
: "text-muted-foreground",
)}
>
HTTPS {service.exposure.state.replace("_", " ")}
</div>
) : null}
</div>
<div className="text-(length:--text-nano) text-muted-foreground whitespace-nowrap">
{service.lifecycle}
</div>
</div>
))}
</div>
) : null}
</div>
{!hideHostPaths && workspaceMode === "local" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceCwd}
onChange={(e) => setWorkspaceCwd(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={(!workspaceCwd.trim() && !primaryCodebaseWorkspace) || createWorkspace.isPending || updateWorkspace.isPending}
onClick={submitLocalWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceCwd("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
{createWorkspace.isError && (
<p className="text-xs text-destructive">Failed to save workspace.</p>
)}
{removeWorkspace.isError && (
<p className="text-xs text-destructive">Failed to delete workspace.</p>
)}
{updateWorkspace.isError && (
<p className="text-xs text-destructive">Failed to update workspace.</p>
)}
</div>}
{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-(length:--text-nano) text-muted-foreground hover:text-foreground"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated task 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 task checkouts</span>
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
</div>
<div className="text-xs text-muted-foreground">
Let tasks choose between the project's primary checkout and an isolated execution workspace.
</div>
</div>
{onUpdate || onFieldUpdate ? (
<ToggleSwitch
checked={executionWorkspacesEnabled}
onCheckedChange={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
/>
) : (
<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 tasks default to isolated checkout</span>
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
If disabled, new tasks stay on the project's primary checkout unless someone opts in.
</div>
</div>
<ToggleSwitch
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
onCheckedChange={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
defaultMode:
executionWorkspaceDefaultMode === "isolated_workspace"
? "shared_workspace"
: "isolated_workspace",
})!,
)}
/>
</div>
<div className="space-y-0.5">
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-sm">
<span>Shared workspace concurrency</span>
<SaveIndicator state={fieldState("execution_workspace_shared_concurrency")} />
</label>
</div>
{onUpdate || onFieldUpdate ? (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
aria-label="Shared workspace concurrency"
value={executionWorkspaceSharedConcurrency}
onChange={(e) =>
commitField(
"execution_workspace_shared_concurrency",
updateExecutionWorkspacePolicy({
sharedWorkspaceConcurrency: e.target.value as SharedWorkspaceConcurrency,
})!,
)}
>
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<div className="text-xs">
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
(option) => option.value === executionWorkspaceSharedConcurrency,
)?.label}
</div>
)}
<p className="text-(length:--text-micro) text-muted-foreground">
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
(option) => option.value === executionWorkspaceSharedConcurrency,
)?.help}
</p>
</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>
{showExecutionWorkspaceEnvironmentControl ? (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Environment</span>
<SaveIndicator state={fieldState("execution_workspace_environment")} />
</label>
</div>
<select
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={executionWorkspaceEnvironmentId}
onChange={(e) =>
commitField(
"execution_workspace_environment",
updateExecutionWorkspacePolicy({
environmentId: e.target.value || null,
})!,
)}
>
<option value="">No environment</option>
{runSelectableEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environmentDisplayLabel(environment)}
</option>
))}
</select>
</div>
) : null}
<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>Runtime provision command</span>
<SaveIndicator state={fieldState("execution_workspace_runtime_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.runtimeProvisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_runtime_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
runtimeProvisionCommand: 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-runtime.sh"
/>
<p className="mt-1 text-xs text-muted-foreground">
Runs once before the first runtime-service start (heavy setup, e.g. DB seed). Leave empty to keep eager provisioning.
</p>
</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-(length:--text-micro) 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>
{onArchive && (
<>
<Separator className="my-4" />
<div className="space-y-4 py-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone
</div>
<ArchiveDangerZone
project={project}
onArchive={onArchive}
archivePending={archivePending}
/>
</div>
</>
)}
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
</div>
);
}