import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared"; import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; type WorkspaceFormState = { name: string; cwd: string; repoUrl: string; baseRef: string; branchName: string; providerRef: string; provisionCommand: string; teardownCommand: string; cleanupCommand: string; inheritRuntime: boolean; workspaceRuntime: string; }; 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 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 normalizeText(value: string) { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function parseWorkspaceRuntimeJson(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 runtime 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 formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormState { return { name: workspace.name, cwd: readText(workspace.cwd), repoUrl: readText(workspace.repoUrl), baseRef: readText(workspace.baseRef), branchName: readText(workspace.branchName), providerRef: readText(workspace.providerRef), provisionCommand: readText(workspace.config?.provisionCommand), teardownCommand: readText(workspace.config?.teardownCommand), cleanupCommand: readText(workspace.config?.cleanupCommand), inheritRuntime: !workspace.config?.workspaceRuntime, workspaceRuntime: formatJson(workspace.config?.workspaceRuntime), }; } function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { const patch: Record = {}; const configPatch: Record = {}; const maybeAssign = ( key: keyof Pick, ) => { if (initialState[key] === nextState[key]) return; patch[key] = key === "name" ? (normalizeText(nextState[key]) ?? initialState.name) : normalizeText(nextState[key]); }; maybeAssign("name"); maybeAssign("cwd"); maybeAssign("repoUrl"); maybeAssign("baseRef"); maybeAssign("branchName"); maybeAssign("providerRef"); const maybeAssignConfigText = (key: keyof Pick) => { if (initialState[key] === nextState[key]) return; configPatch[key] = normalizeText(nextState[key]); }; maybeAssignConfigText("provisionCommand"); maybeAssignConfigText("teardownCommand"); maybeAssignConfigText("cleanupCommand"); if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) { const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime); if (!parsed.ok) throw new Error(parsed.error); configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value; } if (Object.keys(configPatch).length > 0) { patch.config = configPatch; } return patch; } function validateForm(form: WorkspaceFormState) { const repoUrl = normalizeText(form.repoUrl); if (repoUrl) { try { new URL(repoUrl); } catch { return "Repo URL must be a valid URL."; } } if (!form.inheritRuntime) { const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime); if (!runtimeJson.ok) { return runtimeJson.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}
); } function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) { return (
{children}
); } function MonoValue({ value, copy }: { value: string; copy?: boolean }) { return (
{value} {copy ? ( ) : null}
); } function WorkspaceLink({ project, workspace, }: { project: Project; workspace: ProjectWorkspace; }) { return {workspace.name}; } export function ExecutionWorkspaceDetail() { const { workspaceId } = useParams<{ workspaceId: string }>(); const queryClient = useQueryClient(); const { setBreadcrumbs } = useBreadcrumbs(); const { selectedCompanyId, setSelectedCompanyId } = useCompany(); const [form, setForm] = useState(null); const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryFn: () => executionWorkspacesApi.get(workspaceId!), enabled: Boolean(workspaceId), }); const workspace = workspaceQuery.data ?? null; const projectQuery = useQuery({ queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"], queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId), enabled: Boolean(workspace?.projectId), }); const project = projectQuery.data ?? null; const sourceIssueQuery = useQuery({ queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"], queryFn: () => issuesApi.get(workspace!.sourceIssueId!), enabled: Boolean(workspace?.sourceIssueId), }); const sourceIssue = sourceIssueQuery.data ?? null; const derivedWorkspaceQuery = useQuery({ queryKey: workspace?.derivedFromExecutionWorkspaceId ? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId) : ["execution-workspaces", "detail", "__none__"], queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!), enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId), }); const derivedWorkspace = derivedWorkspaceQuery.data ?? null; const linkedIssuesQuery = useQuery({ queryKey: workspace ? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) : ["issues", "__execution-workspace__", "__none__"], queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }), enabled: Boolean(workspace?.companyId), }); const linkedIssues = linkedIssuesQuery.data ?? []; const linkedProjectWorkspace = useMemo( () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, [project, workspace?.projectWorkspaceId], ); const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; const runtimeConfigSource = workspace?.config?.workspaceRuntime ? "execution_workspace" : inheritedRuntimeConfig ? "project_workspace" : "none"; const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? ""; useEffect(() => { if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return; setSelectedCompanyId(workspace.companyId, { source: "route_sync" }); }, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]); useEffect(() => { if (!workspace) return; setForm(formStateFromWorkspace(workspace)); setErrorMessage(null); }, [workspace]); useEffect(() => { if (!workspace) return; const crumbs = [ { label: "Projects", href: "/projects" }, ...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []), ...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []), { label: workspace.name }, ]; setBreadcrumbs(crumbs); }, [setBreadcrumbs, workspace, project, projectRef]); const updateWorkspace = useMutation({ mutationFn: (patch: Record) => executionWorkspacesApi.update(workspace!.id, patch), onSuccess: (nextWorkspace) => { queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) }); if (project) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); } if (sourceIssue) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) }); } setErrorMessage(null); }, onError: (error) => { setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace."); }, }); const workspaceOperationsQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!), queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!), enabled: Boolean(workspaceId), }); const controlRuntimeServices = useMutation({ mutationFn: (action: "start" | "stop" | "restart") => executionWorkspacesApi.controlRuntimeServices(workspace!.id, action), onSuccess: (result, action) => { queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) }); 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 (workspaceQuery.isLoading) return

Loading workspace…

; if (workspaceQuery.error) { return (

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

); } if (!workspace || !form || !initialState) return null; const saveChanges = () => { const validationError = validateForm(form); if (validationError) { setErrorMessage(validationError); return; } let patch: Record; try { patch = buildWorkspacePatch(initialState, form); } catch (error) { setErrorMessage(error instanceof Error ? error.message : "Failed to build workspace update."); return; } if (Object.keys(patch).length === 0) return; updateWorkspace.mutate(patch); }; return ( <>
{workspace.mode} {workspace.providerType} {workspace.status}
Execution workspace

{workspace.name}

Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.

setForm((current) => current ? { ...current, name: event.target.value } : current)} placeholder="Execution workspace name" /> setForm((current) => current ? { ...current, branchName: event.target.value } : current)} placeholder="PAP-946-workspace" />
setForm((current) => current ? { ...current, cwd: event.target.value } : current)} placeholder="/absolute/path/to/workspace" /> setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} placeholder="/path/to/worktree or provider ref" />
setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} placeholder="https://github.com/org/repo" /> setForm((current) => current ? { ...current, baseRef: event.target.value } : current)} placeholder="origin/main" />