From 8262855eb09462f0676104dcffa9a68bec1df87c Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 13:00:25 -0500 Subject: [PATCH 01/70] 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 38b5f4bc88b..c7e75642946 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 00000000000..e111e154ca6 --- /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 00000000000..fef9a6d027a --- /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 0691d93a551..095d1b89fae 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/70] 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 095d1b89fae..df928f02101 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 8ca39b6d2b1fcb40a851ed77b85e4fc2096f9825 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:20:40 -0500 Subject: [PATCH 03/70] 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 e111e154ca6..a037a7ebd97 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 fef9a6d027a..abc5a6f982a 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 52d00295ed10dade06b41103d7d2d8ef51b423ed Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:38:17 -0500 Subject: [PATCH 04/70] 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 df928f02101..df2ac9e5481 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 8ef714e40af4879b037081ae37d2f455dbf2cc7d Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:45:01 -0500 Subject: [PATCH 05/70] Fix inbox swipe-to-archive click-through Co-Authored-By: Paperclip --- ui/src/components/SwipeToArchive.test.tsx | 125 ++++++++++++++++++++++ ui/src/components/SwipeToArchive.tsx | 9 ++ 2 files changed, 134 insertions(+) create mode 100644 ui/src/components/SwipeToArchive.test.tsx diff --git a/ui/src/components/SwipeToArchive.test.tsx b/ui/src/components/SwipeToArchive.test.tsx new file mode 100644 index 00000000000..068673363e9 --- /dev/null +++ b/ui/src/components/SwipeToArchive.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SwipeToArchive } from "./SwipeToArchive"; + +// Tell React this environment uses act() for event flushing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function dispatchTouchEvent( + node: Element, + type: "touchstart" | "touchmove" | "touchend", + coords: { x: number; y: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + const touchPoint = { clientX: coords.x, clientY: coords.y }; + + Object.defineProperty(event, "touches", { + configurable: true, + value: type === "touchend" ? [] : [touchPoint], + }); + Object.defineProperty(event, "changedTouches", { + configurable: true, + value: [touchPoint], + }); + + node.dispatchEvent(event); +} + +describe("SwipeToArchive", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + container.remove(); + }); + + it("suppresses descendant clicks after a horizontal swipe and archives the row", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const wrapper = container.firstElementChild as HTMLDivElement; + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 }); + Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 }); + + act(() => { + dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 }); + }); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(onArchive).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("does not suppress a normal tap click", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onArchive).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index 17ea3707986..141d439c477 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -23,6 +23,7 @@ export function SwipeToArchive({ const startPointRef = useRef<{ x: number; y: number } | null>(null); const widthRef = useRef(0); const timeoutRef = useRef(null); + const suppressClickRef = useRef(false); const [offsetX, setOffsetX] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false); @@ -68,6 +69,7 @@ export function SwipeToArchive({ widthRef.current = node?.offsetWidth ?? 0; setLockedHeight(node?.offsetHeight ?? null); setIsCollapsing(false); + suppressClickRef.current = false; startPointRef.current = { x: touch.clientX, y: touch.clientY }; }; @@ -86,6 +88,7 @@ export function SwipeToArchive({ startPointRef.current = null; return; } + suppressClickRef.current = true; } if (deltaX >= 0) { @@ -127,6 +130,12 @@ export function SwipeToArchive({ onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} + onClickCapture={(event) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }} > {approvalsError &&

{approvalsError.message}

} From 83308a8ffb4c018be6bd176e4425283794df6654 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 18:22:15 -0500 Subject: [PATCH 14/70] Add filtered Paperclip commit exports Co-Authored-By: Paperclip --- scripts/paperclip-commit-metrics.ts | 162 +++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts index e23cff2f894..5f989ca1db2 100644 --- a/scripts/paperclip-commit-metrics.ts +++ b/scripts/paperclip-commit-metrics.ts @@ -21,8 +21,11 @@ const PAPERCLIP_NAME = "paperclip"; interface CliOptions { cacheFile: string; end: Date; + excludeOwners: string[]; + exportFormat: "csv" | "json"; includePrivate: boolean; json: boolean; + output: string | null; query: string; refreshSearch: boolean; refreshStats: boolean; @@ -130,6 +133,9 @@ interface Summary { searchField: CliOptions["searchField"]; start: string; }; + filters: { + excludedOwners: string[]; + }; repos: { count: number; sample: string[]; @@ -159,7 +165,13 @@ async function main() { cache.updatedAt = new Date().toISOString(); await saveCache(options.cacheFile, cache); - const summary = buildSummary(cache, options, sortedShas, fetchedThisRun); + const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options)); + const summary = buildSummary(cache, options, filteredShas, fetchedThisRun); + + if (options.output) { + await writeExport(options.output, options.exportFormat, cache, filteredShas, summary); + } + if (options.json) { console.log(JSON.stringify(summary, null, 2)); return; @@ -172,8 +184,11 @@ function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { cacheFile: DEFAULT_CACHE_FILE, end: new Date(), + excludeOwners: [], + exportFormat: "csv", includePrivate: false, json: false, + output: null, query: DEFAULT_QUERY, refreshSearch: false, refreshStats: false, @@ -193,12 +208,26 @@ function parseArgs(argv: string[]): CliOptions { case "--end": options.end = parseDateArg(requireValue(argv, ++index, arg), arg); break; + case "--exclude-owner": + options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase()); + break; + case "--export-format": { + const value = requireValue(argv, ++index, arg); + if (value !== "csv" && value !== "json") { + throw new Error(`Invalid --export-format value: ${value}`); + } + options.exportFormat = value; + break; + } case "--include-private": options.includePrivate = true; break; case "--json": options.json = true; break; + case "--output": + options.output = requireValue(argv, ++index, arg); + break; case "--query": options.query = requireValue(argv, ++index, arg); break; @@ -288,10 +317,13 @@ Options: --query Commit search string (default: ${DEFAULT_QUERY}) --search-field author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD}) --include-private Include repos visible to the current token + --exclude-owner Exclude repositories owned by this GitHub owner/org (repeatable) --cache-file Cache path (default: ${DEFAULT_CACHE_FILE}) --skip-stats Skip additions/deletions enrichment --stats-fetch-limit Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT}) --stats-concurrency Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY}) + --output Write the full filtered result set to a file + --export-format csv | json for --output exports (default: csv) --refresh-search Ignore cached search windows --refresh-stats Re-fetch cached commit stats --json Print JSON summary @@ -443,6 +475,39 @@ function buildSearchQuery(options: CliOptions, start: Date, end: Date): string { return `${options.query} ${qualifiers.join(" ")}`.trim(); } +function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] { + if (options.excludeOwners.length === 0) { + return shas; + } + + const excludedOwners = new Set(options.excludeOwners); + return shas.filter((sha) => { + const commit = cache.commits[sha]; + if (!commit) { + return false; + } + return !excludedOwners.has(getRepoOwner(commit.repositoryFullName)); + }); +} + +function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { + return [...shas].sort((leftSha, rightSha) => { + const left = cache.commits[leftSha]; + const right = cache.commits[rightSha]; + const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0; + const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0; + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? ""); + if (repoCompare !== 0) { + return repoCompare; + } + return leftSha.localeCompare(rightSha); + }); +} + function formatQueryDate(value: Date): string { return value.toISOString().replace(".000Z", "Z"); } @@ -521,6 +586,10 @@ function normalizeOptional(value: string | null | undefined): string | null { return trimmed ? trimmed : null; } +function getRepoOwner(repositoryFullName: string): string { + return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? ""; +} + async function enrichCommitStats( client: GitHubClient, cache: CacheFile, @@ -617,6 +686,9 @@ function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fet searchField: options.searchField, start: options.start.toISOString(), }, + filters: { + excludedOwners: [...options.excludeOwners].sort(), + }, repos: { count: repoNames.size, sample: repoSample, @@ -635,6 +707,9 @@ function printSummary(summary: Summary) { console.log("Paperclip commit metrics"); console.log(`Query: ${summary.detectedQuery}`); console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`); + if (summary.filters.excludedOwners.length > 0) { + console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`); + } console.log(`Commits: ${summary.totals.commits}`); console.log(`Distinct repos: ${summary.repos.count}`); console.log(`Distinct contributors: ${summary.contributors.count}`); @@ -660,6 +735,91 @@ function printSummary(summary: Summary) { } } +async function writeExport( + outputPath: string, + format: CliOptions["exportFormat"], + cache: CacheFile, + shas: string[], + summary: Summary, +): Promise { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + if (format === "json") { + const report = { + summary, + commits: shas.map((sha) => buildExportRow(cache, sha)), + }; + await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8"); + return; + } + + const header = [ + "committedAt", + "repository", + "repositoryUrl", + "sha", + "commitUrl", + "authorLogin", + "authorName", + "authorEmail", + "contributors", + "additions", + "deletions", + "totalChanges", + ]; + const rows = [header.join(",")]; + for (const sha of shas) { + const row = buildExportRow(cache, sha); + rows.push( + [ + row.committedAt, + row.repository, + row.repositoryUrl, + row.sha, + row.commitUrl, + row.authorLogin, + row.authorName, + row.authorEmail, + row.contributors, + String(row.additions), + String(row.deletions), + String(row.totalChanges), + ] + .map(escapeCsv) + .join(","), + ); + } + await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8"); +} + +function buildExportRow(cache: CacheFile, sha: string) { + const commit = cache.commits[sha]; + if (!commit) { + throw new Error(`Missing cached commit for sha ${sha}`); + } + const stats = cache.stats[sha]; + return { + additions: stats?.additions ?? 0, + authorEmail: commit.authorEmail ?? "", + authorLogin: commit.authorLogin ?? "", + authorName: commit.authorName ?? "", + commitUrl: commit.htmlUrl, + committedAt: commit.committedAt ?? "", + contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "), + deletions: stats?.deletions ?? 0, + repository: commit.repositoryFullName, + repositoryUrl: commit.repositoryUrl, + sha: commit.sha, + totalChanges: stats?.total ?? 0, + }; +} + +function escapeCsv(value: string): string { + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + function makeWindowKey(start: Date, end: Date): string { return `${start.toISOString()}..${end.toISOString()}`; } From 1a0897a55981a6a5697ea3fa69e9aca119b1b6f9 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 19:18:38 -0500 Subject: [PATCH 15/70] fix: clamp mention dropdown position to viewport on mobile The portal-rendered mention dropdown could appear off-screen on mobile devices. Clamp top/left to keep it within the viewport and cap width to 100vw - 16px. Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 68469761baa..e04ceda21d1 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef {mentionActive && filteredMentions.length > 0 && createPortal(
{filteredMentions.map((option, i) => (
+ {tab === "all" && ( +
+ + + {showApprovalsCategory && ( + + )} +
+ )} + {approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} From d434dc77c33745ba18457d35dd57661d7bdc3f5a Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 20:06:29 -0500 Subject: [PATCH 17/70] Add agent Mine inbox API surface Co-Authored-By: Paperclip --- packages/shared/src/constants.ts | 10 +++++ packages/shared/src/index.ts | 4 ++ packages/shared/src/validators/agent.ts | 8 ++++ packages/shared/src/validators/index.ts | 2 + .../agent-permissions-routes.test.ts | 39 +++++++++++++++++++ server/src/routes/agents.ts | 18 +++++++++ skills/paperclip/SKILL.md | 1 + skills/paperclip/references/api-reference.md | 29 ++++++++++++++ ui/src/pages/Inbox.tsx | 7 ++-- 9 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0c5aa424fdd..6ace12a4654 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [ ] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +export const INBOX_MINE_ISSUE_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", +] as const; +export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","); + export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 891011f7a39..982a825c186 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,8 @@ export { AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, GOAL_LEVELS, @@ -344,6 +346,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -356,6 +359,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 8c29150b358..288ae683fd0 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -4,6 +4,7 @@ import { AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; import { envConfigSchema } from "./secret.js"; @@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({ export type CreateAgentKey = z.infer; +export const agentMineInboxQuerySchema = z.object({ + userId: z.string().trim().min(1), + status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER), +}); + +export type AgentMineInboxQuery = z.infer; + export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3f33bcebf0c..1ab217937dc 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -85,6 +85,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -97,6 +98,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 08941f77b8d..7bd79f76a17 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { agentRoutes } from "../routes/agents.js"; import { errorHandler } from "../middleware/index.js"; @@ -272,4 +273,42 @@ describe("agent permission routes", () => { expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("agent_creator"); }); + + it("exposes a dedicated agent route for the inbox mine view", async () => { + mockIssueService.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + + const app = createApp({ + type: "agent", + agentId, + companyId, + runId: "run-1", + source: "agent_key", + }); + + const res = await request(app) + .get("/api/agents/me/inbox/mine") + .query({ userId: "board-user" }); + + expect(res.status).toBe(200); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: INBOX_MINE_ISSUE_STATUS_FILTER, + }); + expect(res.body).toEqual([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f642eb10f59..d133b3608e4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, + agentMineInboxQuerySchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -997,6 +998,23 @@ export function agentRoutes(db: Db) { ); }); + router.get("/agents/me/inbox/mine", async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { + res.status(401).json({ error: "Agent authentication required" }); + return; + } + + const query = agentMineInboxQuerySchema.parse(req.query); + const issuesSvc = issueService(db); + const rows = await issuesSvc.list(req.actor.companyId, { + touchedByUserId: query.userId, + inboxArchivedByUserId: query.userId, + status: query.status, + }); + + res.json(rows); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 407f08da8a6..142ee63adfc 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path | ----------------------------------------- | ------------------------------------------------------------------------------------------ | | My identity | `GET /api/agents/me` | | My compact inbox | `GET /api/agents/me/inbox-lite` | +| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 632937250e1..aea4250ce02 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -226,6 +226,34 @@ PATCH /api/issues/issue-99 { "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } ``` +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + --- ## Worked Example: Manager Heartbeat @@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled` | Method | Path | Description | | ------ | ---------------------------------- | ------------------------------------ | | GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | | GET | `/api/agents/:agentId` | Agent details + chain of command | | GET | `/api/companies/:companyId/agents` | List all agents in company | | GET | `/api/companies/:companyId/org` | Org chart tree | diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index d2fe26a6bee..8e429dca9de 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; import { ApiError } from "../api/client"; @@ -68,8 +69,6 @@ type SectionKey = | "work_items" | "alerts"; -const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -595,7 +594,7 @@ export function Inbox() { issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", - status: INBOX_ISSUE_STATUSES, + status: INBOX_MINE_ISSUE_STATUS_FILTER, }), enabled: !!selectedCompanyId, }); @@ -607,7 +606,7 @@ export function Inbox() { queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", - status: INBOX_ISSUE_STATUSES, + status: INBOX_MINE_ISSUE_STATUS_FILTER, }), enabled: !!selectedCompanyId, }); From f1964e511fe5324318dda9bfd8845daf95e90480 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 21:34:40 -0500 Subject: [PATCH 18/70] Add cli/README.md with absolute image URLs for npm The root README uses relative doc/assets/ paths which work on GitHub but break on npmjs.com since those files aren't in the published tarball. This adds a cli-specific README with absolute raw.githubusercontent.com URLs so images render on npm. Co-Authored-By: Paperclip --- cli/README.md | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 cli/README.md diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000000..c5a6ce1343b --- /dev/null +++ b/cli/README.md @@ -0,0 +1,290 @@ +

+ Paperclip — runs your business +

+ +

+ Quickstart · + Docs · + GitHub · + Discord +

+ +

+ MIT License + Stars + Discord +

+ +
+ +
+ +
+ +
+ +## What is Paperclip? + +# Open-source orchestration for zero-human companies + +**If OpenClaw is an _employee_, Paperclip is the _company_** + +Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard. + +It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination. + +**Manage business goals, not pull requests.** + +| | Step | Example | +| ------ | --------------- | ------------------------------------------------------------------ | +| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ | +| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. | +| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. | + +
+ +> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds. + +
+ +
+ + + + + + + + + + +
Works
with
OpenClaw
OpenClaw
Claude
Claude Code
Codex
Codex
Cursor
Cursor
Bash
Bash
HTTP
HTTP
+ +If it can receive a heartbeat, it's hired. + +
+ +
+ +## Paperclip is right for you if + +- ✅ You want to build **autonomous AI companies** +- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal +- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing +- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed +- ✅ You want to **monitor costs** and enforce budgets +- ✅ You want a process for managing agents that **feels like using a task manager** +- ✅ You want to manage your autonomous businesses **from your phone** + +
+ +## Features + + + + + + + + + + + + + + + + + +
+

🔌 Bring Your Own Agent

+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. +
+

🎯 Goal Alignment

+Every task traces back to the company mission. Agents know what to do and why. +
+

💓 Heartbeats

+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. +
+

💰 Cost Control

+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. +
+

🏢 Multi-Company

+One deployment, many companies. Complete data isolation. One control plane for your portfolio. +
+

🎫 Ticket System

+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. +
+

🛡️ Governance

+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. +
+

📊 Org Chart

+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. +
+

📱 Mobile Ready

+Monitor and manage your autonomous businesses from anywhere. +
+ +
+ +## Problems Paperclip solves + +| Without Paperclip | With Paperclip | +| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | +| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | +| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. | +| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | +| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | +| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. | + +
+ +## Why Paperclip is special + +Paperclip handles the hard orchestration details correctly. + +| | | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. | +| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. | +| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. | +| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. | +| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. | +| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. | +| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. | + +
+ +## What Paperclip is not + +| | | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Not a chatbot.** | Agents have jobs, not chat windows. | +| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | +| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. | +| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. | +| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. | +| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. | + +
+ +## Quickstart + +Open source. Self-hosted. No Paperclip account required. + +```bash +npx paperclipai onboard --yes +``` + +Or manually: + +```bash +git clone https://github.com/paperclipai/paperclip.git +cd paperclip +pnpm install +pnpm dev +``` + +This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required. + +> **Requirements:** Node.js 20+, pnpm 9.15+ + +
+ +## FAQ + +**What does a typical setup look like?** +Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest. + +If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it. + +**Can I run multiple companies?** +Yes. A single deployment can run an unlimited number of companies with complete data isolation. + +**How is Paperclip different from agents like OpenClaw or Claude Code?** +Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability. + +**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?** +Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you. + +(Bring-your-own-ticket-system is on the Roadmap) + +**Do agents run continuously?** +By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates. + +
+ +## Development + +```bash +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching +pnpm dev:server # Server only +pnpm build # Build all +pnpm typecheck # Type checking +pnpm test:run # Run tests +pnpm db:generate # Generate DB migration +pnpm db:migrate # Apply migrations +``` + +See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide. + +
+ +## Roadmap + +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App + +
+ +## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + +## Contributing + +We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details. + +
+ +## Community + +- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community +- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests +- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC + +
+ +## License + +MIT © 2026 Paperclip + +## Star History + +[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left) + +
+ +--- + +

+ +

+ +

+ Open source under MIT. Built for people who want to run companies, not babysit agents. +

From 4ca3ae066f11d70edef65466f95748706d746bd7 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 27 Mar 2026 07:40:56 -0500 Subject: [PATCH 19/70] Fix invisible keyboard selection highlight in inbox Replace ring-2 outline (clipped by overflow-hidden container) with bg-accent background color for the selected item. Visible in both light and dark modes. Co-Authored-By: Paperclip --- ui/src/pages/Inbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 8e429dca9de..e5e5092877c 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1197,7 +1197,7 @@ export function Inbox() {
setSelectedIndex(index)} > {child} From 1abc027cbef39cf645b8d64f418a63d6adf604be Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 27 Mar 2026 07:54:26 -0500 Subject: [PATCH 20/70] Style markdown links with underline and pointer cursor Links in both rendered markdown (.paperclip-markdown) and the MDXEditor (.paperclip-mdxeditor-content) now display with underline text-decoration and cursor:pointer by default. Mention chips are excluded from underline styling to preserve their pill appearance. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/index.css | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/ui/src/index.css b/ui/src/index.css index b0f839ec96d..c220e8cd605 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -343,6 +343,17 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); + text-decoration: underline; + text-underline-offset: 0.15em; + cursor: pointer; +} + +.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); +} + .paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; @@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); - text-decoration: none; -} - -.paperclip-markdown a:hover { text-decoration: underline; text-underline-offset: 0.15em; + cursor: pointer; +} + +.paperclip-markdown a.paperclip-mention-chip { + text-decoration: none; } .dark .paperclip-markdown a { From f4893cbde27c3f405a6212a3b86e6f5a99707fc7 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 27 Mar 2026 08:02:26 -0500 Subject: [PATCH 21/70] fix(ui): harden issue breadcrumb source routing Co-Authored-By: Paperclip --- ui/src/components/IssueRow.tsx | 3 +- ui/src/lib/issueDetailBreadcrumb.test.ts | 34 ++++++++++++++ ui/src/lib/issueDetailBreadcrumb.ts | 56 +++++++++++++++++++++--- ui/src/pages/Inbox.tsx | 5 ++- ui/src/pages/IssueDetail.tsx | 17 ++++--- ui/src/pages/Issues.tsx | 1 + 6 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 ui/src/lib/issueDetailBreadcrumb.test.ts diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d3bcfcffa78..f23736fa685 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; +import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -45,7 +46,7 @@ export function IssueRow({ return ( { + it("prefers the full breadcrumb from route state", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({ + label: "Inbox", + href: "/inbox/mine", + }); + }); + + it("falls back to the source query param when route state is unavailable", () => { + expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({ + label: "Inbox", + href: "/inbox", + }); + }); + + it("adds the source query param when building an issue detail path", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox"); + }); + + it("reuses the current source query param when state has been dropped", () => { + expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues"); + }); +}); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index ba330eb3c36..1f940ef8871 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -1,3 +1,5 @@ +type IssueDetailSource = "issues" | "inbox"; + type IssueDetailBreadcrumb = { label: string; href: string; @@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = { type IssueDetailLocationState = { issueDetailBreadcrumb?: IssueDetailBreadcrumb; + issueDetailSource?: IssueDetailSource; }; +const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; + function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; return typeof candidate.label === "string" && typeof candidate.href === "string"; } -export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { - return { issueDetailBreadcrumb: { label, href } }; +function isIssueDetailSource(value: unknown): value is IssueDetailSource { + return value === "issues" || value === "inbox"; } -export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { +function readIssueDetailSource(state: unknown): IssueDetailSource | null { if (typeof state !== "object" || state === null) return null; - const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; - return isIssueDetailBreadcrumb(candidate) ? candidate : null; + const source = (state as IssueDetailLocationState).issueDetailSource; + return isIssueDetailSource(source) ? source : null; +} + +function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null { + if (!search) return null; + const params = new URLSearchParams(search); + const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM); + return isIssueDetailSource(source) ? source : null; +} + +function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb { + if (source === "inbox") return { label: "Inbox", href: "/inbox" }; + return { label: "Issues", href: "/issues" }; +} + +export function createIssueDetailLocationState( + label: string, + href: string, + source?: IssueDetailSource, +): IssueDetailLocationState { + return { + issueDetailBreadcrumb: { label, href }, + issueDetailSource: source, + }; +} + +export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string { + const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search); + if (!source) return `/issues/${issuePathId}`; + const params = new URLSearchParams(); + params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); + return `/issues/${issuePathId}?${params.toString()}`; +} + +export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null { + if (typeof state === "object" && state !== null) { + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + if (isIssueDetailBreadcrumb(candidate)) return candidate; + } + + const source = readIssueDetailSourceFromSearch(search); + return source ? breadcrumbForSource(source) : null; } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index e5e5092877c..810e4bd45b0 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -12,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; @@ -526,6 +526,7 @@ export function Inbox() { createIssueDetailLocationState( "Inbox", `${location.pathname}${location.search}${location.hash}`, + "inbox", ), [location.pathname, location.search, location.hash], ); @@ -1019,7 +1020,7 @@ export function Inbox() { const item = workItemsToRender[selectedIndex]; if (item.kind === "issue") { const pathId = item.issue.identifier ?? item.issue.id; - navigate(`/issues/${pathId}`, { state: issueLinkState }); + navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState }); } else if (item.kind === "approval") { navigate(`/approvals/${item.approval.id}`); } else if (item.kind === "failed_run") { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 1aaa0b8721d..7bb616ceac4 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -14,7 +14,7 @@ import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { queryKeys } from "../lib/queryKeys"; -import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; @@ -270,8 +270,8 @@ export function IssueDetail() { const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const sourceBreadcrumb = useMemo( - () => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" }, - [location.state], + () => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" }, + [location.state, location.search], ); // Filter out runs already shown by the live widget to avoid duplication @@ -581,9 +581,12 @@ export function IssueDetail() { // Redirect to identifier-based URL if navigated via UUID useEffect(() => { if (issue?.identifier && issueId !== issue.identifier) { - navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state }); + navigate(createIssueDetailPath(issue.identifier, location.state, location.search), { + replace: true, + state: location.state, + }); } - }, [issue, issueId, navigate, location.state]); + }, [issue, issueId, navigate, location.state, location.search]); useEffect(() => { if (!issue?.id) return; @@ -695,7 +698,7 @@ export function IssueDetail() { {i > 0 && } ( diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 2b6e48b0dd2..607197856dd 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -70,6 +70,7 @@ export function Issues() { createIssueDetailLocationState( "Issues", `${location.pathname}${location.search}${location.hash}`, + "issues", ), [location.pathname, location.search, location.hash], ); From c1cec67597b854c9e8cbb1376b333c4beb7a6f73 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:46:34 -0500 Subject: [PATCH 22/70] Add optimistic issue comment rendering Co-Authored-By: Paperclip --- ui/src/api/issues.ts | 7 +- ui/src/components/CommentThread.tsx | 31 ++-- ui/src/lib/optimistic-issue-comments.test.ts | 140 +++++++++++++++++ ui/src/lib/optimistic-issue-comments.ts | 98 ++++++++++++ ui/src/pages/IssueDetail.tsx | 153 +++++++++++++++++-- 5 files changed, 407 insertions(+), 22 deletions(-) create mode 100644 ui/src/lib/optimistic-issue-comments.test.ts create mode 100644 ui/src/lib/optimistic-issue-comments.ts diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 9b19baa4db4..33ba63becc0 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export type IssueUpdateResponse = Issue & { + comment?: IssueComment | null; +}; + export const issuesApi = { list: ( companyId: string, @@ -60,7 +64,8 @@ export const issuesApi = { api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), - update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + update: (id: string, data: Record) => + api.patch(`/issues/${id}`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cdf0ddd24cf..3595883e813 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -15,6 +15,8 @@ import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending"; } interface LinkedRunItem { @@ -169,11 +171,14 @@ const TimelineList = memo(function TimelineList({ const comment = item.comment; const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; return (
{comment.authorAgentId ? ( @@ -187,7 +192,7 @@ const TimelineList = memo(function TimelineList({ )} - {companyId ? ( + {companyId && !isPending ? ( ) : null} - - {formatDateTime(comment.createdAt)} - + {isPending ? ( + Sending... + ) : ( + + {formatDateTime(comment.createdAt)} + + )}
{comment.body} - {companyId ? ( + {companyId && !isPending ? (
) : null} - {comment.runId && ( + {comment.runId && !isPending && (
{comment.runAgentId ? ( { + it("creates a pending optimistic comment for the current user", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toMatch(/^optimistic-/); + expect(comment.clientId).toBe(comment.id); + expect(comment.clientStatus).toBe("pending"); + expect(comment.authorUserId).toBe("board-1"); + expect(comment.authorAgentId).toBeNull(); + }); + + it("merges optimistic comments into the server thread in chronological order", () => { + const merged = mergeIssueComments( + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Second", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + [ + { + id: "optimistic-1", + clientId: "optimistic-1", + clientStatus: "pending", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "First", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ); + + expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); + }); + + it("upserts confirmed comments without creating duplicates", () => { + const next = upsertIssueComment( + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Original", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Updated", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:05.000Z"), + }, + ); + + expect(next).toHaveLength(1); + expect(next[0]?.body).toBe("Updated"); + }); + + it("applies optimistic reopen and reassignment updates to the issue cache", () => { + const next = applyOptimisticIssueCommentUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix comment flow", + description: null, + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + reopen: true, + reassignment: { + assigneeAgentId: null, + assigneeUserId: "board-2", + }, + }, + ); + + expect(next?.status).toBe("todo"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + }); +}); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts new file mode 100644 index 00000000000..20bc1bfe95e --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -0,0 +1,98 @@ +import type { Issue, IssueComment } from "@paperclipai/shared"; + +export interface IssueCommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface OptimisticIssueComment extends IssueComment { + clientId: string; + clientStatus: "pending"; +} + +export type IssueTimelineComment = IssueComment | OptimisticIssueComment; + +function toTimestamp(value: Date | string) { + return new Date(value).getTime(); +} + +export function sortIssueComments(comments: T[]) { + return [...comments].sort((a, b) => { + const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (createdAtDiff !== 0) return createdAtDiff; + return a.id.localeCompare(b.id); + }); +} + +export function createOptimisticIssueComment(params: { + companyId: string; + issueId: string; + body: string; + authorUserId: string | null; +}): OptimisticIssueComment { + const now = new Date(); + const clientId = `optimistic-${crypto.randomUUID()}`; + return { + id: clientId, + clientId, + clientStatus: "pending", + companyId: params.companyId, + issueId: params.issueId, + authorAgentId: null, + authorUserId: params.authorUserId, + body: params.body, + createdAt: now, + updatedAt: now, + }; +} + +export function mergeIssueComments( + comments: IssueComment[] | undefined, + optimisticComments: OptimisticIssueComment[], +): IssueTimelineComment[] { + const merged = [...(comments ?? [])]; + const existingIds = new Set(merged.map((comment) => comment.id)); + for (const comment of optimisticComments) { + if (!existingIds.has(comment.id)) { + merged.push(comment); + } + } + return sortIssueComments(merged); +} + +export function upsertIssueComment( + comments: IssueComment[] | undefined, + nextComment: IssueComment, +): IssueComment[] { + const current = comments ?? []; + const existingIndex = current.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) { + return sortIssueComments([...current, nextComment]); + } + + const updated = [...current]; + updated[existingIndex] = nextComment; + return sortIssueComments(updated); +} + +export function applyOptimisticIssueCommentUpdate( + issue: Issue | undefined, + params: { + reopen?: boolean; + reassignment?: IssueCommentReassignment; + }, +) { + if (!issue) return issue; + const nextIssue: Issue = { ...issue }; + + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + nextIssue.status = "todo"; + } + + if (params.reassignment) { + nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId; + nextIssue.assigneeUserId = params.reassignment.assigneeUserId; + } + + return nextIssue; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7bb616ceac4..5cf552b2e06 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -15,6 +15,14 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; +import { + applyOptimisticIssueCommentUpdate, + createOptimisticIssueComment, + mergeIssueComments, + upsertIssueComment, + type IssueCommentReassignment, + type OptimisticIssueComment, +} from "../lib/optimistic-issue-comments"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; @@ -55,12 +63,9 @@ import { Trash2, } from "lucide-react"; import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, IssueAttachment } from "@paperclipai/shared"; +import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; -type CommentReassignment = { - assigneeAgentId: string | null; - assigneeUserId: string | null; -}; +type CommentReassignment = IssueCommentReassignment; const ACTION_LABELS: Record = { "issue.created": "created the issue", @@ -213,6 +218,7 @@ export function IssueDetail() { }); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); + const [optimisticComments, setOptimisticComments] = useState([]); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); @@ -386,8 +392,18 @@ export function IssueDetail() { ); const suggestedAssigneeValue = useMemo( - () => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId), - [issue, comments, currentUserId], + () => + suggestedCommentAssigneeValue( + issue ?? {}, + mergeIssueComments(comments ?? [], optimisticComments), + currentUserId, + ), + [issue, comments, optimisticComments, currentUserId], + ); + + const threadComments = useMemo( + () => mergeIssueComments(comments ?? [], optimisticComments), + [comments, optimisticComments], ); const commentsWithRunMeta = useMemo(() => { @@ -406,11 +422,11 @@ export function IssueDetail() { runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, }); } - return (comments ?? []).map((comment) => { + return threadComments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); return meta ? { ...comment, ...meta } : comment; }); - }, [activity, comments, linkedRuns]); + }, [activity, threadComments, linkedRuns]); const issueCostSummary = useMemo(() => { let input = 0; @@ -491,7 +507,62 @@ export function IssueDetail() { const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), - onSuccess: () => { + onMutate: async ({ body, reopen }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const optimisticComment = issue + ? createOptimisticIssueComment({ + companyId: issue.companyId, + issueId: issue.id, + body, + authorUserId: currentUserId, + }) + : null; + + if (optimisticComment) { + setOptimisticComments((current) => [...current, optimisticComment]); + } + if (previousIssue) { + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + applyOptimisticIssueCommentUpdate(previousIssue, { reopen }), + ); + } + + return { + optimisticCommentId: optimisticComment?.clientId ?? null, + previousIssue, + }; + }, + onSuccess: (comment, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + queryClient.setQueryData( + queryKeys.issues.comments(issueId!), + (current) => upsertIssueComment(current, comment), + ); + }, + onError: (err, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + if (context?.previousIssue) { + queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); + } + pushToast({ + title: "Comment failed", + body: err instanceof Error ? err.message : "Unable to post comment", + tone: "error", + }); + }, + onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, @@ -513,7 +584,67 @@ export function IssueDetail() { assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), }), - onSuccess: () => { + onMutate: async ({ body, reopen, reassignment }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); + const optimisticComment = issue + ? createOptimisticIssueComment({ + companyId: issue.companyId, + issueId: issue.id, + body, + authorUserId: currentUserId, + }) + : null; + + if (optimisticComment) { + setOptimisticComments((current) => [...current, optimisticComment]); + } + if (previousIssue) { + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }), + ); + } + + return { + optimisticCommentId: optimisticComment?.clientId ?? null, + previousIssue, + }; + }, + onSuccess: (result, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + + const { comment, ...nextIssue } = result; + queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); + if (comment) { + queryClient.setQueryData( + queryKeys.issues.comments(issueId!), + (current) => upsertIssueComment(current, comment), + ); + } + }, + onError: (err, _variables, context) => { + if (context?.optimisticCommentId) { + setOptimisticComments((current) => + current.filter((entry) => entry.clientId !== context.optimisticCommentId), + ); + } + if (context?.previousIssue) { + queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); + } + pushToast({ + title: "Comment failed", + body: err instanceof Error ? err.message : "Unable to post comment", + tone: "error", + }); + }, + onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, From 9e72d8ee099c6f5d967f521fb0330be07338e23b Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:51:58 -0500 Subject: [PATCH 23/70] 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 c7e75642946..ac1e6040892 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 dcab46c14f4..76e18846ac1 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 6f9388485bb..54a1bc48794 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 00000000000..1a3411e0cbc --- /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" + /> + +
+ +
+ +