import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared"; import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { ChoosePathButton } from "../components/PathInstructionsModal"; import { projectsApi } from "../api/projects"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; type WorkspaceFormState = { name: string; sourceType: ProjectWorkspaceSourceType; cwd: string; repoUrl: string; repoRef: string; defaultRef: string; visibility: ProjectWorkspaceVisibility; setupCommand: string; cleanupCommand: string; remoteProvider: string; remoteWorkspaceRef: string; sharedWorkspaceKey: string; runtimeConfig: string; }; type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, { value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." }, { value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." }, { value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." }, ]; const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [ { value: "default", label: "Default" }, { value: "advanced", label: "Advanced" }, ]; function 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; } } function isAbsolutePath(value: string) { return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); } function readText(value: string | null | undefined) { return value ?? ""; } function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) { return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); } function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); } function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState { return { name: workspace.name, sourceType: workspace.sourceType, cwd: readText(workspace.cwd), repoUrl: readText(workspace.repoUrl), repoRef: readText(workspace.repoRef), defaultRef: readText(workspace.defaultRef), visibility: workspace.visibility, setupCommand: readText(workspace.setupCommand), cleanupCommand: readText(workspace.cleanupCommand), remoteProvider: readText(workspace.remoteProvider), remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef), sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey), runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime), }; } function normalizeText(value: string) { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function parseRuntimeConfigJson(value: string) { const trimmed = value.trim(); if (!trimmed) return { ok: true as const, value: null as Record | null }; try { const parsed = JSON.parse(trimmed); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false as const, error: "Runtime services JSON must be a JSON object.", }; } return { ok: true as const, value: parsed as Record }; } catch (error) { return { ok: false as const, error: error instanceof Error ? error.message : "Invalid JSON.", }; } } function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { const patch: Record = {}; const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => { const initialValue = initialState[key]; const nextValue = nextState[key]; if (initialValue === nextValue) return; patch[key] = transform ? transform(nextValue) : nextValue; }; maybeAssign("name", normalizeText); maybeAssign("sourceType"); maybeAssign("cwd", normalizeText); maybeAssign("repoUrl", normalizeText); maybeAssign("repoRef", normalizeText); maybeAssign("defaultRef", normalizeText); maybeAssign("visibility"); maybeAssign("setupCommand", normalizeText); maybeAssign("cleanupCommand", normalizeText); maybeAssign("remoteProvider", normalizeText); maybeAssign("remoteWorkspaceRef", normalizeText); maybeAssign("sharedWorkspaceKey", normalizeText); if (initialState.runtimeConfig !== nextState.runtimeConfig) { const parsed = parseRuntimeConfigJson(nextState.runtimeConfig); if (!parsed.ok) throw new Error(parsed.error); patch.runtimeConfig = { workspaceRuntime: parsed.value, }; } return patch; } function validateWorkspaceForm(form: WorkspaceFormState) { const cwd = normalizeText(form.cwd); const repoUrl = normalizeText(form.repoUrl); const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef); if (form.sourceType === "remote_managed") { if (!remoteWorkspaceRef && !repoUrl) { return "Remote-managed workspaces require a remote workspace ref or repo URL."; } } else if (!cwd && !repoUrl) { return "Workspace requires at least one local path or repo URL."; } if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) { return "Local workspace path must be absolute."; } if (repoUrl) { try { new URL(repoUrl); } catch { return "Repo URL must be a valid URL."; } } const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig); if (!runtimeConfig.ok) { return runtimeConfig.error; } return null; } function Field({ label, hint, children, }: { label: string; hint?: string; children: React.ReactNode; }) { return ( ); } function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } export function ProjectWorkspaceDetail() { const { companyPrefix, projectId, workspaceId } = useParams<{ companyPrefix?: string; projectId: string; workspaceId: string; }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [form, setForm] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const routeProjectRef = projectId ?? ""; const routeWorkspaceId = workspaceId ?? ""; const routeCompanyId = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; }, [companies, companyPrefix]); const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId)); const projectQuery = useQuery({ queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null], queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId), enabled: canFetchProject, }); const project = projectQuery.data ?? null; const workspace = useMemo( () => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null, [project, routeWorkspaceId], ); const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; setSelectedCompanyId(project.companyId, { source: "route_sync" }); }, [project?.companyId, selectedCompanyId, setSelectedCompanyId]); useEffect(() => { if (!workspace) return; setForm(formStateFromWorkspace(workspace)); setErrorMessage(null); }, [workspace]); useEffect(() => { if (!project) return; setBreadcrumbs([ { label: "Projects", href: "/projects" }, { label: project.name, href: `/projects/${canonicalProjectRef}` }, { label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` }, { label: workspace?.name ?? routeWorkspaceId }, ]); }, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]); useEffect(() => { if (!project) return; if (routeProjectRef === canonicalProjectRef) return; navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true }); }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]); const invalidateProject = () => { if (!project) return; queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); if (lookupCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) }); } }; const updateWorkspace = useMutation({ mutationFn: (patch: Record) => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId), onSuccess: () => { invalidateProject(); setErrorMessage(null); }, onError: (error) => { setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace."); }, }); const setPrimaryWorkspace = useMutation({ mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId), onSuccess: () => { invalidateProject(); setErrorMessage(null); }, onError: (error) => { setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace."); }, }); const controlRuntimeServices = useMutation({ mutationFn: (action: "start" | "stop" | "restart") => projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, action, lookupCompanyId), onSuccess: (result, action) => { invalidateProject(); setErrorMessage(null); setRuntimeActionMessage( action === "stop" ? "Runtime services stopped." : action === "restart" ? "Runtime services restarted." : "Runtime services started.", ); }, onError: (error) => { setRuntimeActionMessage(null); setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services."); }, }); if (projectQuery.isLoading) return

Loading workspace…

; if (projectQuery.error) { return (

{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}

); } if (!project || !workspace || !form || !initialState) { return

Workspace not found for this project.

; } const saveChanges = () => { const validationError = validateWorkspaceForm(form); if (validationError) { setErrorMessage(validationError); return; } const patch = buildWorkspacePatch(initialState, form); if (Object.keys(patch).length === 0) return; updateWorkspace.mutate(patch); }; const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; return (
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
Project workspace

{workspace.name}

Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace checkout behavior, default runtime services for child execution workspaces, and let you override setup or cleanup commands when one workspace needs special handling.

{!workspace.isPrimary ? ( ) : (
This is the project’s primary codebase workspace.
)}
setForm((current) => current ? { ...current, name: event.target.value } : current)} placeholder="Workspace name" />
setForm((current) => current ? { ...current, cwd: event.target.value } : current)} placeholder="/absolute/path/to/workspace" />
setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} placeholder="https://github.com/org/repo" /> setForm((current) => current ? { ...current, repoRef: event.target.value } : current)} placeholder="origin/main" />
setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)} placeholder="origin/main" /> setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)} placeholder="frontend" />
setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)} placeholder="codespaces" /> setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)} placeholder="workspace-123" />