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 { ExternalLink, Github, Plus, Trash2, X } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; 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; } const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { 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 }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const [goalOpen, setGoalOpen] = useState(false); const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); 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 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) return; onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) }); }; const addGoal = (goalId: string) => { if (!onUpdate || linkedGoalIds.includes(goalId)) return; onUpdate({ goalIds: [...linkedGoalIds, goalId] }); setGoalOpen(false); }; 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 ? ( onUpdate({ status })} /> ) : ( )} {project.leadAgentId && ( {project.leadAgentId.slice(0, 8)} )}
Goals
{linkedGoals.length === 0 ? ( None ) : (
{linkedGoals.map((goal) => ( {goal.title} {onUpdate && ( )} ))}
)} {onUpdate && ( {availableGoals.length === 0 ? (
All goals linked.
) : ( availableGoals.map((goal) => ( )) )}
)}
{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}
))}
)}
{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.

)}
{formatDate(project.createdAt)} {formatDate(project.updatedAt)}
); }