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" && (