From b75ac76b13ca2fa29469c62037e800e5fcf3e48a Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 13:00:25 -0500 Subject: [PATCH 01/25] Add project workspaces tab Co-Authored-By: Paperclip --- ui/src/App.tsx | 1 + ui/src/lib/project-workspaces-tab.test.ts | 198 ++++++++++++++++++++++ ui/src/lib/project-workspaces-tab.ts | 108 ++++++++++++ ui/src/pages/ProjectDetail.tsx | 162 +++++++++++++++++- 4 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 ui/src/lib/project-workspaces-tab.test.ts create mode 100644 ui/src/lib/project-workspaces-tab.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 38b5f4bc..c7e75642 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -144,6 +144,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts new file mode 100644 index 00000000..e111e154 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab"; + +function createProjectWorkspace(overrides: Partial): ProjectWorkspace { + return { + id: overrides.id ?? "workspace-default", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + name: overrides.name ?? "paperclip", + sourceType: overrides.sourceType ?? "local_path", + cwd: overrides.cwd ?? "/repo", + repoUrl: overrides.repoUrl ?? null, + repoRef: overrides.repoRef ?? null, + defaultRef: overrides.defaultRef ?? null, + visibility: overrides.visibility ?? "default", + setupCommand: overrides.setupCommand ?? null, + cleanupCommand: overrides.cleanupCommand ?? null, + remoteProvider: overrides.remoteProvider ?? null, + remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null, + sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null, + metadata: overrides.metadata ?? null, + isPrimary: overrides.isPrimary ?? false, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + }; +} + +function createIssue(overrides: Partial): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + goalId: overrides.goalId ?? null, + parentId: overrides.parentId ?? null, + title: overrides.title ?? "Issue", + description: overrides.description ?? null, + status: overrides.status ?? "todo", + priority: overrides.priority ?? "medium", + assigneeAgentId: overrides.assigneeAgentId ?? null, + assigneeUserId: overrides.assigneeUserId ?? null, + checkoutRunId: overrides.checkoutRunId ?? null, + executionRunId: overrides.executionRunId ?? null, + executionAgentNameKey: overrides.executionAgentNameKey ?? null, + executionLockedAt: overrides.executionLockedAt ?? null, + createdByAgentId: overrides.createdByAgentId ?? null, + createdByUserId: overrides.createdByUserId ?? null, + issueNumber: overrides.issueNumber ?? null, + identifier: overrides.identifier ?? null, + requestDepth: overrides.requestDepth ?? 0, + billingCode: overrides.billingCode ?? null, + assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + executionWorkspacePreference: overrides.executionWorkspacePreference ?? null, + executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + cancelledAt: overrides.cancelledAt ?? null, + hiddenAt: overrides.hiddenAt ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + } as Issue; +} + +function createExecutionWorkspace(overrides: Partial): ExecutionWorkspace { + return { + id: overrides.id ?? "exec-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default", + sourceIssueId: overrides.sourceIssueId ?? null, + mode: overrides.mode ?? "isolated_workspace", + strategyType: overrides.strategyType ?? "git_worktree", + name: overrides.name ?? "PAP-893", + status: overrides.status ?? "active", + cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893", + repoUrl: overrides.repoUrl ?? null, + baseRef: overrides.baseRef ?? "public-gh/master", + branchName: overrides.branchName ?? "PAP-893-workspaces-tab", + providerType: overrides.providerType ?? "git_worktree", + providerRef: overrides.providerRef ?? null, + derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"), + openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"), + closedAt: overrides.closedAt ?? null, + cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, + cleanupReason: overrides.cleanupReason ?? null, + metadata: overrides.metadata ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), + }; +} + +describe("buildProjectWorkspaceSummaries", () => { + const primaryWorkspace = createProjectWorkspace({ + id: "workspace-default", + isPrimary: true, + name: "paperclip", + }); + const featureWorkspace = createProjectWorkspace({ + id: "workspace-feature", + name: "feature-checkout", + repoRef: "feature/workspaces", + updatedAt: new Date("2026-03-25T09:00:00Z"), + }); + const project = { + workspaces: [primaryWorkspace, featureWorkspace], + primaryWorkspace, + } satisfies Pick; + + it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-primary", + projectWorkspaceId: primaryWorkspace.id, + updatedAt: new Date("2026-03-26T08:00:00Z"), + }), + createIssue({ + id: "issue-feature-older", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-800", + updatedAt: new Date("2026-03-25T10:00:00Z"), + }), + createIssue({ + id: "issue-feature-newer", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-801", + updatedAt: new Date("2026-03-25T11:00:00Z"), + }), + createIssue({ + id: "issue-exec", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-1", + identifier: "PAP-893", + updatedAt: new Date("2026-03-26T11:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-1", + name: "PAP-893", + branchName: "PAP-893-workspaces-tab", + lastUsedAt: new Date("2026-03-26T10:30:00Z"), + }), + ], + }); + + expect(summaries).toHaveLength(2); + expect(summaries[0]).toMatchObject({ + key: "execution:exec-1", + kind: "execution_workspace", + workspaceName: "PAP-893", + branchName: "PAP-893-workspaces-tab", + executionWorkspaceId: "exec-1", + }); + expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]); + + expect(summaries[1]).toMatchObject({ + key: "project:workspace-feature", + kind: "project_workspace", + workspaceName: "feature-checkout", + branchName: "feature/workspaces", + projectWorkspaceId: "workspace-feature", + }); + expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([ + "issue-feature-newer", + "issue-feature-older", + ]); + }); + + it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-exec-derived", + projectWorkspaceId: featureWorkspace.id, + executionWorkspaceId: "exec-2", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-2", + projectWorkspaceId: featureWorkspace.id, + name: "feature-branch run", + }), + ], + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.key).toBe("execution:exec-2"); + }); +}); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts new file mode 100644 index 00000000..fef9a6d0 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.ts @@ -0,0 +1,108 @@ +import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared"; + +type ProjectWorkspaceLike = Pick; + +export interface ProjectWorkspaceSummary { + key: string; + kind: "execution_workspace" | "project_workspace"; + workspaceId: string; + workspaceName: string; + branchName: string | null; + lastUpdatedAt: Date; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; + issues: Issue[]; +} + +function toDate(value: Date | string | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function maxDate(...values: Array): Date { + let latest = new Date(0); + for (const value of values) { + const date = toDate(value); + if (date && date.getTime() > latest.getTime()) latest = date; + } + return latest; +} + +function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { + return project.primaryWorkspace?.id + ?? project.workspaces.find((workspace) => workspace.isPrimary)?.id + ?? project.workspaces[0]?.id + ?? null; +} + +export function buildProjectWorkspaceSummaries(input: { + project: ProjectWorkspaceLike; + issues: Issue[]; + executionWorkspaces: ExecutionWorkspace[]; +}): ProjectWorkspaceSummary[] { + const primaryId = primaryWorkspaceId(input.project); + const executionWorkspacesById = new Map( + input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const projectWorkspacesById = new Map( + input.project.workspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const summaries = new Map(); + + for (const issue of input.issues) { + if (issue.executionWorkspaceId) { + const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); + if (!executionWorkspace) continue; + + const existing = summaries.get(`execution:${executionWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`execution:${executionWorkspace.id}`, { + key: `execution:${executionWorkspace.id}`, + kind: "execution_workspace", + workspaceId: executionWorkspace.id, + workspaceName: executionWorkspace.name, + branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, + lastUpdatedAt: maxDate( + existing?.lastUpdatedAt, + executionWorkspace.lastUsedAt, + executionWorkspace.updatedAt, + issue.updatedAt, + ), + projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, + executionWorkspaceId: executionWorkspace.id, + issues: nextIssues, + }); + continue; + } + + if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue; + const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId); + if (!projectWorkspace) continue; + + const existing = summaries.get(`project:${projectWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`project:${projectWorkspace.id}`, { + key: `project:${projectWorkspace.id}`, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + issues: nextIssues, + }); + } + + return [...summaries.values()].sort((a, b) => { + const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); + return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); + }); +} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 0691d93a..095d1b89 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; +import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace, type Issue, type Project } 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"; @@ -20,14 +22,17 @@ import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; -import { projectRouteRef, cn } from "../lib/utils"; +import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; +import { projectRouteRef } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; +import { Clock3, GitBranch, Rows3 } from "lucide-react"; /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "configuration" | "budget"; +type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -44,6 +49,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu if (tab === "configuration") return "configuration"; if (tab === "budget") return "budget"; if (tab === "issues") return "list"; + if (tab === "workspaces") return "workspaces"; return null; } @@ -200,6 +206,88 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan ); } +function ProjectWorkspacesContent({ + summaries, +}: { + summaries: ReturnType; +}) { + if (summaries.length === 0) { + return

No non-default workspace activity yet.

; + } + + return ( +
+ {summaries.map((summary) => { + const visibleIssues = summary.issues.slice(0, 3); + const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); + + return ( +
+
+
+
+ {summary.executionWorkspaceId ? ( + + {summary.workspaceName} + + ) : ( +
{summary.workspaceName}
+ )} + + {summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"} + +
+ +
+ + + {summary.branchName ?? "No branch info"} + + + + {summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"} + +
+ +
+ {visibleIssues.map((issue) => ( + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} + + ))} + {hiddenIssueCount > 0 ? ( + + ... and {hiddenIssueCount} more + + ) : null} +
+
+ +
+ + {timeAgo(summary.lastUpdatedAt)} +
+
+
+ ); + })} +
+ ); +} + /* ── Main project page ── */ export function ProjectDetail() { @@ -241,6 +329,10 @@ export function ProjectDetail() { 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, @@ -259,6 +351,39 @@ export function ProjectDetail() { [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; @@ -345,6 +470,10 @@ export function ProjectDetail() { 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 }); @@ -455,6 +584,10 @@ export function ProjectDetail() { 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; @@ -470,6 +603,12 @@ export function ProjectDetail() { if (cachedTab === "budget") { return ; } + if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) { + return ; + } + if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) { + return ; + } if (isProjectPluginTab(cachedTab)) { return ; } @@ -491,6 +630,8 @@ export function ProjectDetail() { } 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") { @@ -561,6 +702,7 @@ export function ProjectDetail() { items={[ { value: "list", label: "Issues" }, { value: "overview", label: "Overview" }, + ...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []), { value: "configuration", label: "Configuration" }, { value: "budget", label: "Budget" }, ...pluginTabItems.map((item) => ({ @@ -589,6 +731,18 @@ export function ProjectDetail() { )} + {activeTab === "workspaces" ? ( + workspaceTabDecisionLoaded ? ( + workspaceTabError ? ( +

{workspaceTabError.message}

+ ) : ( + + ) + ) : ( +

Loading workspaces...

+ ) + ) : null} + {activeTab === "configuration" && (
Date: Thu, 26 Mar 2026 16:17:33 -0500 Subject: [PATCH 02/25] Adjust workspace row columns Co-Authored-By: Paperclip --- ui/src/pages/ProjectDetail.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 095d1b89..df928f02 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,7 +1,7 @@ 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, type Issue, type Project } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -226,8 +226,8 @@ function ProjectWorkspacesContent({ key={summary.key} className="border-b border-border px-4 py-3 last:border-b-0" > -
-
+
+
{summary.executionWorkspaceId ? (
+
-
+
+
+ Issues +
+
{visibleIssues.map((issue) => (
-
+
{timeAgo(summary.lastUpdatedAt)}
From 0ff778ec298aeb277696a375564f394045bbadb8 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:20:40 -0500 Subject: [PATCH 03/25] Exclude default shared workspaces from tab Co-Authored-By: Paperclip --- ui/src/lib/project-workspaces-tab.test.ts | 27 +++++++++++++++++++++++ ui/src/lib/project-workspaces-tab.ts | 15 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index e111e154..a037a7eb 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -195,4 +195,31 @@ describe("buildProjectWorkspaceSummaries", () => { expect(summaries).toHaveLength(1); expect(summaries[0]?.key).toBe("execution:exec-2"); }); + + it("excludes issues that only use the default shared workspace", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-default-shared", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-shared-default", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-shared-default", + mode: "shared_workspace", + strategyType: "project_primary", + projectWorkspaceId: primaryWorkspace.id, + branchName: null, + baseRef: null, + providerType: "local_fs", + }), + ], + }); + + expect(summaries).toHaveLength(0); + }); }); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index fef9a6d0..abc5a6f9 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -36,6 +36,16 @@ function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { ?? null; } +function isDefaultSharedExecutionWorkspace(input: { + executionWorkspace: ExecutionWorkspace; + issue: Issue; + primaryWorkspaceId: string | null; +}) { + const linkedProjectWorkspaceId = + input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; + return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId; +} + export function buildProjectWorkspaceSummaries(input: { project: ProjectWorkspaceLike; issues: Issue[]; @@ -54,6 +64,11 @@ export function buildProjectWorkspaceSummaries(input: { if (issue.executionWorkspaceId) { const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); if (!executionWorkspace) continue; + if (isDefaultSharedExecutionWorkspace({ + executionWorkspace, + issue, + primaryWorkspaceId: primaryId, + })) continue; const existing = summaries.get(`execution:${executionWorkspace.id}`); const nextIssues = [...(existing?.issues ?? []), issue].sort( From b7b5d8dae3105c67608540c5f6fc50ac8f7385c1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:38:17 -0500 Subject: [PATCH 04/25] Polish workspace issue badges Co-Authored-By: Paperclip --- ui/src/pages/ProjectDetail.tsx | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index df928f02..df2ac9e5 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -28,7 +28,7 @@ import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Clock3, GitBranch, Rows3 } from "lucide-react"; +import { Clock3, GitBranch } from "lucide-react"; /* ── Top-level tab types ── */ @@ -228,49 +228,31 @@ function ProjectWorkspacesContent({ >
-
- {summary.executionWorkspaceId ? ( - - {summary.workspaceName} - - ) : ( -
{summary.workspaceName}
- )} - - {summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"} - -
+
{summary.workspaceName}
{summary.branchName ?? "No branch info"} - - - {summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"} -
- Issues + Issues ({summary.issues.length})
{visibleIssues.map((issue) => ( {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} ))} {hiddenIssueCount > 0 ? ( From 15e0e2ece9509d6fae47942002e390376ac06ada Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 17:10:38 -0500 Subject: [PATCH 05/25] Add workspace path copy control Co-Authored-By: Paperclip --- ui/src/lib/project-workspaces-tab.ts | 3 +++ ui/src/pages/ProjectDetail.tsx | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index abc5a6f9..1f5846c5 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -7,6 +7,7 @@ export interface ProjectWorkspaceSummary { kind: "execution_workspace" | "project_workspace"; workspaceId: string; workspaceName: string; + cwd: string | null; branchName: string | null; lastUpdatedAt: Date; projectWorkspaceId: string | null; @@ -80,6 +81,7 @@ export function buildProjectWorkspaceSummaries(input: { kind: "execution_workspace", workspaceId: executionWorkspace.id, workspaceName: executionWorkspace.name, + cwd: executionWorkspace.cwd ?? null, branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, lastUpdatedAt: maxDate( existing?.lastUpdatedAt, @@ -108,6 +110,7 @@ export function buildProjectWorkspaceSummaries(input: { kind: "project_workspace", workspaceId: projectWorkspace.id, workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), projectWorkspaceId: projectWorkspace.id, diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index df2ac9e5..6f938848 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -16,6 +16,7 @@ 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"; @@ -28,7 +29,7 @@ import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Clock3, GitBranch } from "lucide-react"; +import { Clock3, Copy, GitBranch } from "lucide-react"; /* ── Top-level tab types ── */ @@ -236,6 +237,17 @@ function ProjectWorkspacesContent({ {summary.branchName ?? "No branch info"}
+ + {summary.cwd ? ( +
+ + {summary.cwd} + + + + +
+ ) : null}
From bb1732dd11f9393defdc4b5496a78d47e2b68409 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:51:58 -0500 Subject: [PATCH 06/25] Add project workspace detail page Co-Authored-By: Paperclip --- ui/src/App.tsx | 2 + ui/src/lib/utils.ts | 8 + ui/src/pages/ProjectDetail.tsx | 24 +- ui/src/pages/ProjectWorkspaceDetail.tsx | 557 ++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 ui/src/pages/ProjectWorkspaceDetail.tsx 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" + /> + +
+ +
+ +