mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Add project workspaces tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6a72faf83b
commit
b75ac76b13
4 changed files with 465 additions and 4 deletions
198
ui/src/lib/project-workspaces-tab.test.ts
Normal file
198
ui/src/lib/project-workspaces-tab.test.ts
Normal file
|
|
@ -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>): 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>): 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>): 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<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
108
ui/src/lib/project-workspaces-tab.ts
Normal file
108
ui/src/lib/project-workspaces-tab.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||
|
||||
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
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 | string | null | undefined>): 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<string, ProjectWorkspaceSummary>();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue