import { useState } from "react"; import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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"; import { statusBadge, statusBadgeDefault } from "../lib/status-colors"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; const PROJECT_STATUSES = [ { value: "backlog", label: "Backlog" }, { value: "planned", label: "Planned" }, { value: "in_progress", label: "In Progress" }, { value: "completed", label: "Completed" }, { value: "cancelled", label: "Cancelled" }, ]; interface ProjectPropertiesProps { project: Project; 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" | "goals" | "execution_workspace_enabled" | "execution_workspace_default_mode" | "execution_workspace_base_ref" | "execution_workspace_branch_template" | "execution_workspace_worktree_parent_dir" | "execution_workspace_provision_command" | "execution_workspace_teardown_command"; 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; }) { return (
{label}
); } function PropertyRow({ label, children, alignStart = false, valueClassName = "", }: { label: React.ReactNode; children: React.ReactNode; alignStart?: boolean; valueClassName?: string; }) { return (
{label}
{children}
); } function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (status: string) => void }) { const [open, setOpen] = useState(false); const colorClass = statusBadge[status] ?? statusBadgeDefault; return ( {PROJECT_STATUSES.map((s) => ( ))} ); } 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, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const [goalOpen, setGoalOpen] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); const [workspaceRepoUrl, setWorkspaceRepoUrl] = 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: allGoals } = useQuery({ queryKey: queryKeys.goals.list(selectedCompanyId!), 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 : project.goalId ? [project.goalId] : []; const linkedGoals = project.goals.length > 0 ? project.goals : linkedGoalIds.map((id) => ({ id, title: allGoals?.find((g) => g.id === id)?.title ?? id.slice(0, 8), })); const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id)); const workspaces = project.workspaces ?? []; const codebase = project.codebase; const primaryCodebaseWorkspace = project.primaryWorkspace ?? null; 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 ?? { type: "git_worktree", baseRef: "", branchTemplate: "", worktreeParentDir: "", }; 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.list(selectedCompanyId) }); } }; const createWorkspace = useMutation({ mutationFn: (data: Record) => projectsApi.createWorkspace(project.id, data), onSuccess: () => { setWorkspaceCwd(""); setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); }, }); const removeWorkspace = useMutation({ mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId), onSuccess: () => { setWorkspaceCwd(""); setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); }, }); const updateWorkspace = useMutation({ mutationFn: ({ workspaceId, data }: { workspaceId: string; data: Record }) => projectsApi.updateWorkspace(project.id, workspaceId, data), onSuccess: () => { setWorkspaceCwd(""); setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); }, }); const removeGoal = (goalId: string) => { if (!onUpdate && !onFieldUpdate) return; commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) }); }; const addGoal = (goalId: string) => { if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return; commitField("goals", { goalIds: [...linkedGoalIds, goalId] }); setGoalOpen(false); }; 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 isGitHubRepoUrl = (value: string) => { try { const parsed = new URL(value); const host = parsed.hostname.toLowerCase(); if (host !== "github.com" && host !== "www.github.com") return false; const segments = parsed.pathname.split("/").filter(Boolean); return segments.length >= 2; } catch { return false; } }; 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 formatRepoUrl = (value: string) => { try { const parsed = new URL(value); const segments = parsed.pathname.split("/").filter(Boolean); if (segments.length < 2) return parsed.host; const owner = segments[0]; const repo = segments[1]?.replace(/\.git$/i, ""); if (!owner || !repo) return parsed.host; return `${parsed.host}/${owner}/${repo}`; } catch { return value; } }; 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 submitRepoWorkspace = () => { const repoUrl = workspaceRepoUrl.trim(); if (!repoUrl) { setWorkspaceError(null); persistCodebase({ repoUrl: null }); return; } if (!isGitHubRepoUrl(repoUrl)) { setWorkspaceError("Repo must use a valid GitHub repo URL."); return; } setWorkspaceError(null); persistCodebase({ repoUrl }); }; 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 }); }; const clearRepoWorkspace = () => { const hasLocalFolder = Boolean(codebase.localFolder); const confirmed = window.confirm( hasLocalFolder ? "Clear repo from this workspace?" : "Delete this workspace repo?", ); if (!confirmed) return; if (primaryCodebaseWorkspace && hasLocalFolder) { updateWorkspace.mutate({ workspaceId: primaryCodebaseWorkspace.id, data: { repoUrl: null, repoRef: null, defaultRef: null, sourceType: deriveSourceType(codebase.localFolder, null) }, }); return; } persistCodebase({ repoUrl: null }); }; return (
}> {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 })} as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." multiline /> ) : (

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

)}
}> {onUpdate || onFieldUpdate ? ( commitField("status", { status })} /> ) : ( )} {project.leadAgentId && ( {project.leadAgentId.slice(0, 8)} )} } alignStart valueClassName="space-y-2" > {linkedGoals.length > 0 && (
{linkedGoals.map((goal) => ( {goal.title} {(onUpdate || onFieldUpdate) && ( )} ))}
)} {(onUpdate || onFieldUpdate) && ( {availableGoals.length === 0 ? (
All goals linked.
) : ( availableGoals.map((goal) => ( )) )}
)}
}> {formatDate(project.createdAt)} }> {formatDate(project.updatedAt)} {project.targetDate && ( }> {formatDate(project.targetDate)} )}
Codebase Repo identifies the source of truth. Local folder is the default place agents write code.
Repo
{codebase.repoUrl ? (
{isSafeExternalUrl(codebase.repoUrl) ? ( {formatRepoUrl(codebase.repoUrl)} ) : (
{codebase.repoUrl}
)}
) : (
Not set.
)}
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.lifecycle}
))}
) : null}
{workspaceMode === "local" && (
setWorkspaceCwd(e.target.value)} placeholder="/absolute/path/to/workspace" />
)} {workspaceMode === "repo" && (
setWorkspaceRepoUrl(e.target.value)} placeholder="https://github.com/org/repo" />
)} {workspaceError && (

{workspaceError}

)} {createWorkspace.isError && (

Failed to save workspace.

)} {removeWorkspace.isError && (

Failed to delete workspace.

)} {updateWorkspace.isError && (

Failed to update workspace.

)}
{isolatedWorkspacesEnabled ? ( <>
Execution Workspaces Project-owned defaults for isolated issue checkouts and execution workspace behavior.
Enable isolated issue checkouts
Let issues choose between the project's primary checkout and an isolated execution workspace.
{onUpdate || onFieldUpdate ? ( ) : ( {executionWorkspacesEnabled ? "Enabled" : "Disabled"} )}
{executionWorkspacesEnabled ? (
New issues default to isolated checkout
If disabled, new issues stay on the project's primary checkout unless someone opts in.
{executionWorkspaceAdvancedOpen ? (
Host-managed implementation: Git worktree
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_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
)}
); }