diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c7e75642..ac1e6040 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; +import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; @@ -144,6 +145,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index dcab46c1..76e18846 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string { return `/projects/${projectRouteRef(project)}`; } + +/** Build a project workspace URL scoped under its project. */ +export function projectWorkspaceUrl( + project: { id: string; urlKey?: string | null; name?: string | null }, + workspaceId: string, +): string { + return `${projectUrl(project)}/workspaces/${workspaceId}`; +} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 6f938848..54a1bc48 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -24,7 +24,7 @@ import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; -import { projectRouteRef } from "../lib/utils"; +import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; @@ -208,8 +208,10 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan } function ProjectWorkspacesContent({ + projectRef, summaries, }: { + projectRef: string; summaries: ReturnType; }) { if (summaries.length === 0) { @@ -275,9 +277,21 @@ function ProjectWorkspacesContent({ -
- - {timeAgo(summary.lastUpdatedAt)} +
+ + {summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"} + +
+ + {timeAgo(summary.lastUpdatedAt)} +
@@ -735,7 +749,7 @@ export function ProjectDetail() { workspaceTabError ? (

{workspaceTabError.message}

) : ( - + ) ) : (

Loading workspaces...

diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx new file mode 100644 index 00000000..1a3411e0 --- /dev/null +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -0,0 +1,557 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "@/lib/router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import 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; +}; + +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 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), + }; +} + +function normalizeText(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +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); + + 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."; + } + } + + 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 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 projectQuery = useQuery({ + queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null], + queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId), + enabled: routeProjectRef.length > 0, + }); + + 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."); + }, + }); + + 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 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" + /> + +
+ +
+ +