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 { 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, 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" }, ]; // TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship. const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false; interface ProjectPropertiesProps { project: Project; onUpdate?: (data: Record) => void; onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record) => void; getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState; } 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"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; 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) => ( ))} ); } export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: 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 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 executionWorkspacePolicy = project.executionWorkspacePolicy ?? null; const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true; const executionWorkspaceDefaultMode = executionWorkspacePolicy?.defaultMode === "isolated" ? "isolated" : "project_primary"; const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? { type: "git_worktree", baseRef: "", branchTemplate: "", worktreeParentDir: "", }; const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); 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: invalidateProject, }); const updateWorkspace = useMutation({ mutationFn: ({ workspaceId, data }: { workspaceId: string; data: Record }) => projectsApi.updateWorkspace(project.id, workspaceId, data), onSuccess: 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 deriveWorkspaceNameFromPath = (value: string) => { const normalized = value.trim().replace(/[\\/]+$/, ""); const segments = normalized.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? "Local folder"; }; const deriveWorkspaceNameFromRepo = (value: string) => { try { const parsed = new URL(value); const segments = parsed.pathname.split("/").filter(Boolean); const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? ""; return repo || "GitHub repo"; } catch { return "GitHub repo"; } }; const formatGitHubRepo = (value: string) => { try { const parsed = new URL(value); const segments = parsed.pathname.split("/").filter(Boolean); if (segments.length < 2) return value; const owner = segments[0]; const repo = segments[1]?.replace(/\.git$/i, ""); if (!owner || !repo) return value; return `${owner}/${repo}`; } catch { return value; } }; const submitLocalWorkspace = () => { const cwd = workspaceCwd.trim(); if (!isAbsolutePath(cwd)) { setWorkspaceError("Local folder must be a full absolute path."); return; } setWorkspaceError(null); createWorkspace.mutate({ name: deriveWorkspaceNameFromPath(cwd), cwd, }); }; const submitRepoWorkspace = () => { const repoUrl = workspaceRepoUrl.trim(); if (!isGitHubRepoUrl(repoUrl)) { setWorkspaceError("Repo workspace must use a valid GitHub repo URL."); return; } setWorkspaceError(null); createWorkspace.mutate({ name: deriveWorkspaceNameFromRepo(repoUrl), cwd: REPO_ONLY_CWD_SENTINEL, repoUrl, }); }; const clearLocalWorkspace = (workspace: Project["workspaces"][number]) => { const confirmed = window.confirm( workspace.repoUrl ? "Clear local folder from this workspace?" : "Delete this workspace local folder?", ); if (!confirmed) return; if (workspace.repoUrl) { updateWorkspace.mutate({ workspaceId: workspace.id, data: { cwd: null }, }); return; } removeWorkspace.mutate(workspace.id); }; const clearRepoWorkspace = (workspace: Project["workspaces"][number]) => { const hasLocalFolder = Boolean(workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL); const confirmed = window.confirm( hasLocalFolder ? "Clear GitHub repo from this workspace?" : "Delete this workspace repo?", ); if (!confirmed) return; if (hasLocalFolder) { updateWorkspace.mutate({ workspaceId: workspace.id, data: { repoUrl: null, repoRef: null }, }); return; } removeWorkspace.mutate(workspace.id); }; 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 ? ( None ) : (
{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)} )}
Workspaces Workspaces give your agents hints about where the work is
{workspaces.length === 0 ? (

No workspace configured.

) : (
{workspaces.map((workspace) => (
{workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL ? (
{workspace.cwd}
) : null} {workspace.repoUrl ? (
{formatGitHubRepo(workspace.repoUrl)}
) : null} {workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
{workspace.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.

)}
{SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && ( <>
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.

)} )}
)}
); }