import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; import { CopyText } from "../components/CopyText"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react"; /* ── Top-level tab types ── */ type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; function isProjectPluginTab(value: string | null): value is ProjectPluginTab { return typeof value === "string" && value.startsWith("plugin:"); } function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { const segments = pathname.split("/").filter(Boolean); const projectsIdx = segments.indexOf("projects"); if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; const tab = segments[projectsIdx + 2]; if (tab === "overview") return "overview"; if (tab === "configuration") return "configuration"; if (tab === "budget") return "budget"; if (tab === "issues") return "list"; if (tab === "workspaces") return "workspaces"; return null; } /* ── Overview tab content ── */ function OverviewContent({ project, onUpdate, imageUploadHandler, }: { project: { description: string | null; status: string; targetDate: string | null }; onUpdate: (data: Record) => void; imageUploadHandler?: (file: File) => Promise; }) { return (
onUpdate({ description })} as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." multiline imageUploadHandler={imageUploadHandler} />
Status
{project.targetDate && (
Target Date

{project.targetDate}

)}
); } /* ── Color picker popover ── */ function ColorPicker({ currentColor, onSelect, }: { currentColor: string; onSelect: (color: string) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); return (
)} ); } /* ── List (issues) tab content ── */ function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) { const queryClient = useQueryClient(); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: !!companyId, }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(companyId), queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), enabled: !!companyId, refetchInterval: 5000, }); const liveIssueIds = useMemo(() => { const ids = new Set(); for (const run of liveRuns ?? []) { if (run.issueId) ids.add(run.issueId); } return ids; }, [liveRuns]); const { data: issues, isLoading, error } = useQuery({ queryKey: queryKeys.issues.listByProject(companyId, projectId), queryFn: () => issuesApi.list(companyId, { projectId }), enabled: !!companyId, }); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); }, }); return ( updateIssue.mutate({ id, data })} /> ); } function ProjectWorkspacesContent({ companyId, projectId, projectRef, summaries, }: { companyId: string; projectId: string; projectRef: string; summaries: ReturnType; }) { const queryClient = useQueryClient(); const [runtimeActionKey, setRuntimeActionKey] = useState(null); const [closingWorkspace, setClosingWorkspace] = useState<{ id: string; name: string; status: ExecutionWorkspace["status"]; } | null>(null); const controlWorkspaceRuntime = useMutation({ mutationFn: async (input: { key: string; kind: "project_workspace" | "execution_workspace"; workspaceId: string; action: "start" | "stop" | "restart"; }) => { setRuntimeActionKey(`${input.key}:${input.action}`); if (input.kind === "project_workspace") { return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId); } return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action); }, onSettled: () => { setRuntimeActionKey(null); queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); }, }); if (summaries.length === 0) { return

No non-default workspace activity yet.

; } const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed"); const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed"); const renderSummaryRow = (summary: ReturnType[number]) => { const visibleIssues = summary.issues.slice(0, 3); const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); const workspaceHref = summary.kind === "project_workspace" ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId) : `/execution-workspaces/${summary.workspaceId}`; return (
{summary.workspaceName}
{summary.branchName ?? "No branch info"} {summary.runningServiceCount}/{summary.serviceCount} services running {summary.executionWorkspaceStatus ? ( {summary.executionWorkspaceStatus} ) : null}
{summary.primaryServiceUrl ? ( {summary.primaryServiceUrl} ) : null} {summary.cwd ? (
{summary.cwd}
) : null}
Issues ({summary.issues.length})
{visibleIssues.map((issue) => ( {issue.identifier ?? issue.id.slice(0, 8)} {issue.title} ))} {hiddenIssueCount > 0 ? ( ... and {hiddenIssueCount} more ) : null}
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? ( ) : null}
{timeAgo(summary.lastUpdatedAt)}
); }; return ( <>
{activeSummaries.map(renderSummaryRow)}
{cleanupFailedSummaries.length > 0 ? (
Cleanup attention needed
{cleanupFailedSummaries.map(renderSummaryRow)}
) : null}
{closingWorkspace ? ( { if (!open) setClosingWorkspace(null); }} onClosed={() => { queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); setClosingWorkspace(null); }} /> ) : null} ); } /* ── Main project page ── */ export function ProjectDetail() { const { companyPrefix, projectId, filter } = useParams<{ companyPrefix?: string; projectId: string; filter?: string; }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const [fieldSaveStates, setFieldSaveStates] = useState>>({}); const fieldSaveRequestIds = useRef>>({}); const fieldSaveTimers = useRef>>>({}); const routeProjectRef = projectId ?? ""; 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 activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null; const pluginTabFromSearch = useMemo(() => { const tab = new URLSearchParams(location.search).get("tab"); return isProjectPluginTab(tab) ? tab : null; }, [location.search]); const activeTab = activeRouteTab ?? pluginTabFromSearch; const { data: project, isLoading, error } = useQuery({ queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null], queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId), enabled: canFetchProject, }); const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const projectLookupRef = project?.id ?? routeProjectRef; const resolvedCompanyId = project?.companyId ?? selectedCompanyId; const experimentalSettingsQuery = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); const { slots: pluginDetailSlots, isLoading: pluginDetailSlotsLoading, } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "project", companyId: resolvedCompanyId, enabled: !!resolvedCompanyId, }); const pluginTabItems = useMemo( () => pluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab, label: slot.displayName, slot, })), [pluginDetailSlots], ); const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true; const workspaceTabProjectId = project?.id ?? null; const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({ queryKey: workspaceTabProjectId && resolvedCompanyId ? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId) : ["issues", "__workspace-tab__", "disabled"], queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }), enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled), }); const { data: workspaceTabExecutionWorkspaces = [], isLoading: isWorkspaceTabExecutionWorkspacesLoading, error: workspaceTabExecutionWorkspacesError, } = useQuery({ queryKey: workspaceTabProjectId && resolvedCompanyId ? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId }) : ["execution-workspaces", "__workspace-tab__", "disabled"], queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }), enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled), }); const workspaceSummaries = useMemo(() => { if (!project || !isolatedWorkspacesEnabled) return []; return buildProjectWorkspaceSummaries({ project, issues: workspaceTabIssues, executionWorkspaces: workspaceTabExecutionWorkspaces, }); }, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]); const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0; const workspaceTabDecisionLoaded = experimentalSettingsQuery.isFetched && (!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading)); const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null; useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; setSelectedCompanyId(project.companyId, { source: "route_sync" }); }, [project?.companyId, selectedCompanyId, setSelectedCompanyId]); const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) }); } }; const updateProject = useMutation({ mutationFn: (data: Record) => projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId), onSuccess: invalidateProject, }); const archiveProject = useMutation({ mutationFn: (archived: boolean) => projectsApi.update( projectLookupRef, { archivedAt: archived ? new Date().toISOString() : null }, resolvedCompanyId ?? lookupCompanyId, ), onSuccess: (updatedProject, archived) => { invalidateProject(); const name = updatedProject?.name ?? project?.name ?? "Project"; if (archived) { pushToast({ title: `"${name}" has been archived`, tone: "success" }); navigate("/dashboard"); } else { pushToast({ title: `"${name}" has been unarchived`, tone: "success" }); } }, onError: (_, archived) => { pushToast({ title: archived ? "Failed to archive project" : "Failed to unarchive project", tone: "error", }); }, }); const uploadImage = useMutation({ mutationFn: async (file: File) => { if (!resolvedCompanyId) throw new Error("No company selected"); return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`); }, }); const { data: budgetOverview } = useQuery({ queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"), queryFn: () => budgetsApi.overview(resolvedCompanyId!), enabled: !!resolvedCompanyId, refetchInterval: 30_000, staleTime: 5_000, }); useEffect(() => { setBreadcrumbs([ { label: "Projects", href: "/projects" }, { label: project?.name ?? routeProjectRef ?? "Project" }, ]); }, [setBreadcrumbs, project, routeProjectRef]); useEffect(() => { if (!project) return; if (routeProjectRef === canonicalProjectRef) return; if (isProjectPluginTab(activeTab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true }); return; } if (activeTab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); return; } if (activeTab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true }); return; } if (activeTab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true }); return; } if (activeTab === "workspaces") { navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true }); return; } if (activeTab === "list") { if (filter) { navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); return; } navigate(`/projects/${canonicalProjectRef}/issues`, { replace: true }); return; } navigate(`/projects/${canonicalProjectRef}`, { replace: true }); }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]); useEffect(() => { closePanel(); return () => closePanel(); }, [closePanel]); useEffect(() => { return () => { Object.values(fieldSaveTimers.current).forEach((timer) => { if (timer) clearTimeout(timer); }); }; }, []); const setFieldState = useCallback((field: ProjectConfigFieldKey, state: ProjectFieldSaveState) => { setFieldSaveStates((current) => ({ ...current, [field]: state })); }, []); const scheduleFieldReset = useCallback((field: ProjectConfigFieldKey, delayMs: number) => { const existing = fieldSaveTimers.current[field]; if (existing) clearTimeout(existing); fieldSaveTimers.current[field] = setTimeout(() => { setFieldSaveStates((current) => { const next = { ...current }; delete next[field]; return next; }); delete fieldSaveTimers.current[field]; }, delayMs); }, []); const updateProjectField = useCallback(async (field: ProjectConfigFieldKey, data: Record) => { const requestId = (fieldSaveRequestIds.current[field] ?? 0) + 1; fieldSaveRequestIds.current[field] = requestId; setFieldState(field, "saving"); try { await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId); invalidateProject(); if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "saved"); scheduleFieldReset(field, 1800); } catch (error) { if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "error"); scheduleFieldReset(field, 3000); throw error; } }, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]); const projectBudgetSummary = useMemo(() => { const matched = budgetOverview?.policies.find( (policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef), ); if (matched) return matched; return { policyId: "", companyId: resolvedCompanyId ?? "", scopeType: "project", scopeId: project?.id ?? routeProjectRef, scopeName: project?.name ?? "Project", metric: "billed_cents", windowKind: "lifetime", amount: 0, observedAmount: 0, remainingAmount: 0, utilizationPercent: 0, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, isActive: false, status: "ok", paused: Boolean(project?.pausedAt), pauseReason: project?.pauseReason ?? null, windowStart: new Date(), windowEnd: new Date(), } satisfies BudgetPolicySummary; }, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]); const budgetMutation = useMutation({ mutationFn: (amount: number) => budgetsApi.upsertPolicy(resolvedCompanyId!, { scopeType: "project", scopeId: project?.id ?? routeProjectRef, amount, windowKind: "lifetime", }), onSuccess: () => { if (!resolvedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) }); }, }); if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) { return ; } if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) { return ; } // Redirect bare /projects/:id to cached tab or default /issues if (routeProjectRef && activeTab === null) { let cachedTab: string | null = null; if (project?.id) { try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {} } if (cachedTab === "overview") { return ; } if (cachedTab === "configuration") { return ; } if (cachedTab === "budget") { return ; } if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) { return ; } if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) { return ; } if (isProjectPluginTab(cachedTab)) { return ; } return ; } if (isLoading) return ; if (error) return

{error.message}

; if (!project) return null; const handleTabChange = (tab: ProjectTab) => { // Cache the active tab per project if (project?.id) { try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {} } if (isProjectPluginTab(tab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`); return; } if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); } else if (tab === "workspaces") { navigate(`/projects/${canonicalProjectRef}/workspaces`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); } else if (tab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`); } else { navigate(`/projects/${canonicalProjectRef}/issues`); } }; return (
updateProject.mutate({ color })} />
updateProject.mutate({ name })} as="h2" className="text-xl font-bold" /> {project.pauseReason === "budget" ? (
Paused by budget hard stop
) : null}
handleTabChange(value as ProjectTab)}> ({ value: item.value, label: item.label, })), ]} align="start" value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)} /> {activeTab === "overview" && ( updateProject.mutate(data)} imageUploadHandler={async (file) => { const asset = await uploadImage.mutateAsync(file); return asset.contentPath; }} /> )} {activeTab === "list" && project?.id && resolvedCompanyId && ( )} {activeTab === "workspaces" ? ( workspaceTabDecisionLoaded ? ( workspaceTabError ? (

{workspaceTabError.message}

) : ( ) ) : (

Loading workspaces...

) ) : null} {activeTab === "configuration" && (
updateProject.mutate(data)} onFieldUpdate={updateProjectField} getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"} onArchive={(archived) => archiveProject.mutate(archived)} archivePending={archiveProject.isPending} />
)} {activeTab === "budget" && resolvedCompanyId ? (
budgetMutation.mutate(amount)} />
) : null} {activePluginTab && ( )}
); }