import { authApi } from "@/api/auth"; import { accessApi } from "@/api/access"; import { canManageProjectPrivacy } from "@/lib/issuePrivacy"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; 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"; import { ProjectAccessMembers } from "./ProjectAccessMembers"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog"; interface ProjectPropertiesProps { project: Project; repositories?: ReactNode; onUpdate?: (data: Record) => void; onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record) => void; getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState; onArchive?: (archived: boolean) => void; archivePending?: boolean; } export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error"; export type ProjectConfigFieldKey = | "name" | "description" | "status" | "visibility" | "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: "Sandbox runs take turns in the shared project workspace. Local/SSH folders allow concurrent runs.", }, { value: "allow", label: "Allow", help: "Runs never wait for the workspace; concurrent edits are possible.", }, ]; function SaveIndicator({ state }: { state: ProjectFieldSaveState }) { if (state === "saving") { return ( Saving ); } if (state === "saved") { return ( Saved ); } if (state === "error") { return ( Failed ); } return null; } function FieldLabel({ label, state, }: { label: string; state: ProjectFieldSaveState; }) { // The label column is a fixed 80px wide, so the indicator stacks below the // label instead of sitting beside it; otherwise "Description" + "Saving" // overflows into the value column and overlaps the field content. return (
{label}
); } function PropertyRow({ label, children, alignStart = false, valueClassName = "", }: { label: React.ReactNode; children: React.ReactNode; alignStart?: boolean; valueClassName?: string; }) { return (
{label}
{children}
); } 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 (

{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."}

{archivePending ? ( ) : confirming ? (
{action} “{project.name}”?
) : ( )}
); } export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); const { data: privacySession } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); const { data: privacyAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() }); const canManagePrivacy = canManageProjectPrivacy(project, privacySession?.user?.id ?? privacySession?.session?.userId ?? null, privacyAccess); const queryClient = useQueryClient(); const [openProjectConfirmation, setOpenProjectConfirmation] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); const commitField = (field: ProjectConfigFieldKey, data: Record) => { 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) => 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 }) => projectsApi.updateWorkspace(project.id, workspaceId, data), onSuccess: () => { setWorkspaceCwd(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); }, }); const updateExecutionWorkspacePolicy = (patch: Record) => { 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 = { ...(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 (
Open this project to the company? Everyone in the company will be able to discover this project. Its private tasks stay private. Members who only have access through this project will lose access to those tasks. Share tasks directly first if you want them to keep access. Keep private commitField("visibility", { visibility: "open" })}> Open to company
}> {onUpdate || onFieldUpdate ? ( commitField("name", { name })} immediate className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none" placeholder="Project name" /> ) : ( {project.name} )} } alignStart valueClassName="space-y-0.5" > {onUpdate || onFieldUpdate ? ( commitField("description", { description })} nullable as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." multiline /> ) : (

{project.description?.trim() || "No description"}

)}
} alignStart>

{project.visibility === "private" ? "Only access members can discover this project. Tasks shared directly remain readable on their own." : "Everyone in the company can discover this project. Individually private tasks keep their own access rules."}

{project.visibility === "private" ? : null}
{repositories ?? } } alignStart valueClassName="space-y-2" >
{ const created = await createSecret.mutateAsync({ name, value }); return created; }} onChange={(env) => commitField("env", { env: env ?? null })} />
}> {formatDate(project.updatedAt)} {project.targetDate && ( }> {formatDate(project.targetDate)} )}
{(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) &&
Codebase {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."}
{/* 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" ? (
Paperclip-managed folder.
) : null ) : (
Local folder
{codebase.effectiveLocalFolder}
{codebase.origin === "managed_checkout" && (
Paperclip-managed folder.
)}
{codebase.localFolder ? ( ) : null}
)} {hasAdditionalLegacyWorkspaces && (
Additional legacy workspace records exist on this project. Paperclip is using the primary workspace as the codebase view.
)} {primaryCodebaseWorkspace?.runtimeServices && primaryCodebaseWorkspace.runtimeServices.length > 0 ? (
{primaryCodebaseWorkspace.runtimeServices.map((service) => (
{service.serviceName} {service.status}
{service.url ? ( {service.url} ) : ( service.command ?? "No URL" )}
{service.exposure && service.exposure.state !== "removed" ? (
HTTPS {service.exposure.state.replace("_", " ")}
) : null}
{service.lifecycle}
))}
) : null}
{!hideHostPaths && workspaceMode === "local" && (
setWorkspaceCwd(e.target.value)} placeholder="/absolute/path/to/workspace" />
)} {workspaceError && (

{workspaceError}

)} {createWorkspace.isError && (

Failed to save workspace.

)} {removeWorkspace.isError && (

Failed to delete workspace.

)} {updateWorkspace.isError && (

Failed to update workspace.

)}
} {isolatedWorkspacesEnabled && workspaceIsolationControlsVisible ? ( <>
Execution Workspaces Project-owned defaults for isolated task checkouts and execution workspace behavior.
Enable isolated task checkouts
Let tasks choose between the project's primary checkout and an isolated execution workspace.
{onUpdate || onFieldUpdate ? ( commitField( "execution_workspace_enabled", updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!, )} /> ) : ( {executionWorkspacesEnabled ? "Enabled" : "Disabled"} )}
{executionWorkspacesEnabled ? (
New tasks default to isolated checkout
If disabled, new tasks stay on the project's primary checkout unless someone opts in.
commitField( "execution_workspace_default_mode", updateExecutionWorkspacePolicy({ defaultMode: executionWorkspaceDefaultMode === "isolated_workspace" ? "shared_workspace" : "isolated_workspace", })!, )} />
{onUpdate || onFieldUpdate ? ( ) : (
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find( (option) => option.value === executionWorkspaceSharedConcurrency, )?.label}
)}

{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find( (option) => option.value === executionWorkspaceSharedConcurrency, )?.help}

{executionWorkspaceAdvancedOpen ? (
Host-managed implementation: Git worktree
{showExecutionWorkspaceEnvironmentControl ? (
) : null}
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" />
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}}" />
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" />
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" />
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" />

Runs once before the first runtime-service start (heavy setup, e.g. DB seed). Leave empty to keep eager provisioning.

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" />

Provision runs inside the derived worktree before agent execution. Teardown is stored here for future cleanup flows.

) : null}
) : null}
) : null}
{onArchive && ( <>
Danger Zone
)} }> {formatDate(project.createdAt)}
); }