import { useEffect, useMemo, useState } from "react"; import { Link, useLocation, 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 { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Tabs } from "@/components/ui/tabs"; import { ChoosePathButton } from "../components/PathInstructionsModal"; import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { projectsApi } from "../api/projects"; import { PageTabBar } from "../components/PageTabBar"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly"; 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"]; type ProjectWorkspaceBaseTab = "configuration"; type ProjectWorkspacePluginTab = `plugin:${string}`; type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab; type OrderedProjectWorkspaceTabItem = { value: ProjectWorkspaceTab; label: string; order: number; }; const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; const PROJECT_WORKSPACE_BASE_TAB_ITEMS: OrderedProjectWorkspaceTabItem[] = [ { value: "configuration", label: "Configuration", order: 30 }, ]; function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab { return typeof value === "string" && value.startsWith("plugin:"); } function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab { const tab = new URLSearchParams(search).get("tab"); if (isProjectWorkspacePluginTab(tab)) return tab; return "configuration"; } function orderProjectWorkspaceTabItems(items: OrderedProjectWorkspaceTabItem[]) { return items .map((item, index) => ({ item, index })) .sort((left, right) => left.item.order - right.item.order || left.index - right.index) .map(({ item }) => item); } 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 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: "Workspace commands 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 location = useLocation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { hideHostPaths } = useManagedSandboxOnly(); const [form, setForm] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const routeProjectRef = projectId ?? ""; const routeWorkspaceId = workspaceId ?? ""; const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]); 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)); const { slots: pluginDetailSlots, isLoading: pluginDetailSlotsLoading, errorMessage: pluginDetailSlotsError, } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "project_workspace", companyId: project?.companyId ?? null, enabled: Boolean(project?.companyId), }); const pluginTabItems = useMemo( () => pluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab, label: slot.displayName, order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER, slot, })), [pluginDetailSlots], ); const tabItems = useMemo( () => orderProjectWorkspaceTabItems([...PROJECT_WORKSPACE_BASE_TAB_ITEMS, ...pluginTabItems]), [pluginTabItems], ); 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)}${location.search}`, { replace: true }); }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, 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.all(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: (request: WorkspaceRuntimeControlRequest) => projectsApi.controlWorkspaceCommands(project!.id, routeWorkspaceId, request.action, lookupCompanyId, request), onSuccess: (result, request) => { invalidateProject(); setErrorMessage(null); setRuntimeActionMessage( request.action === "run" ? "Workspace job completed." : request.action === "stop" ? "Workspace service stopped. Task execution is not paused." : request.action === "restart" ? "Workspace service restarted. Task execution is not paused." : "Workspace service started.", ); }, onError: (error) => { setRuntimeActionMessage(null); setErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); }, }); 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 canRunWorkspaceCommands = Boolean(workspace.cwd); const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && canRunWorkspaceCommands; const runtimeControlSections = buildWorkspaceRuntimeControlSections({ runtimeConfig: workspace.runtimeConfig?.workspaceRuntime ?? null, runtimeServices: workspace.runtimeServices ?? [], canStartServices: canStartRuntimeServices, canRunJobs: canRunWorkspaceCommands, }); const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; 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; const handleTabChange = (tab: ProjectWorkspaceTab) => { const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId); if (isProjectWorkspacePluginTab(tab)) { navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`); return; } navigate(workspacePath); }; const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; return (
Project workspace

{workspace.name}

{!workspace.isPrimary ? ( ) : (
This is the project’s primary codebase workspace.
)}
handleTabChange(value as ProjectWorkspaceTab)}> ({ value: item.value, label: item.label }))} align="start" value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)} /> {activeTab === "configuration" ? (

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.

setForm((current) => current ? { ...current, name: event.target.value } : current)} placeholder="Workspace name" />
{/* The local path is an absolute path on the execution host. Under the managed-sandbox-only policy every agent runs in the platform-managed environment, so neither the field nor the folder picker renders; the server refuses a cwd write anyway. */} {!hideHostPaths && (
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" />