[codex] Add bounded workspace overview (#8627)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The execution workspace subsystem powers project and workspace views by listing runtime state, branch metadata, issue links, and status summaries. > - The existing workspace views relied on broad list data that can grow expensive as a company accumulates many workspaces and linked issues. > - That makes the Workspaces page and project workspace cards slower than necessary because the UI does not always need the full workspace detail payload up front. > - This pull request adds a bounded overview contract for workspace listings and moves the relevant UI surfaces to that cheaper path. > - The benefit is faster workspace loading while preserving detail fetches for pages that actually need full workspace data. ## Linked Issues or Issue Description No public GitHub issue exists for this change. Inline bug report follows the repository bug template. ### What happened? Workspace index-style screens can load too much execution workspace detail before the user asks for it. Several UI surfaces used fuller workspace data paths for summary displays, which can make workspace loading slower as workspace history grows. ### Expected behavior Overview screens should request a bounded summary payload, while detail screens should keep using the full workspace detail endpoint. ### Steps to reproduce 1. Run Paperclip from source with enough execution workspace history to make workspace lists non-trivial. 2. Open the Workspaces page or a project workspace summary card. 3. Observe that summary UI needs only bounded workspace metadata but can depend on broader workspace payloads. ### Paperclip version or commit `master` at the time this branch was prepared. ### Deployment mode Local dev (`pnpm dev`) ## What Changed - Added shared types, validators, and path constants for bounded execution workspace overviews. - Added server service and route support for overview queries with bounded linked issue/runtime metadata. - Updated workspace overview UI API calls, query keys, breadcrumbs, quicklooks, close dialogs, project summaries, and detail links to consume the cheaper overview shape where appropriate. - Added regression coverage for the new server route/service behavior and the UI overview consumers. - Registered the new workspace overview route in the generated OpenAPI spec. - Kept overview totals aligned with the project join and preserved project slug links in workspace headers. ## Verification - `pnpm exec vitest run server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/execution-workspaces-routes.test.ts ui/src/api/execution-workspaces.test.ts ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/pages/Workspaces.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts server/src/__tests__/execution-workspaces-routes.test.ts server/src/__tests__/execution-workspaces-service.test.ts` - `pnpm test:run:serialized -- --shard-index 1 --shard-count 4` ## Risks Low to medium risk. The change introduces a new overview contract across shared/server/ui layers, so the main risk is a mismatch between summary and detail payload expectations. The added route/service/UI tests cover the intended split, and full detail pages continue using the detail path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding agent with repository tool use and local command execution. Exact served model identifier and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3d7a4f12f9
commit
569b7affc4
|
|
@ -1001,6 +1001,10 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
|||
] as const;
|
||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||
|
||||
export const WORKSPACE_OVERVIEW_DEFAULT_LIMIT = 50;
|
||||
export const WORKSPACE_OVERVIEW_MAX_LIMIT = 100;
|
||||
export const WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Reserved company-scoped route segments that plugin page routes may not claim.
|
||||
*
|
||||
|
|
@ -1022,6 +1026,7 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
|
|||
"costs",
|
||||
"activity",
|
||||
"inbox",
|
||||
"workspaces",
|
||||
"design-guide",
|
||||
"tests",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -180,6 +180,9 @@ export {
|
|||
PLUGIN_API_ROUTE_METHODS,
|
||||
PLUGIN_API_ROUTE_AUTH_MODES,
|
||||
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
||||
WORKSPACE_OVERVIEW_DEFAULT_LIMIT,
|
||||
WORKSPACE_OVERVIEW_MAX_LIMIT,
|
||||
WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
type CompanyStatus,
|
||||
|
|
@ -468,6 +471,10 @@ export type {
|
|||
ExecutionWorkspaceCloseLinkedIssue,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
WorkspaceOverviewItem,
|
||||
WorkspaceOverviewLinkedIssue,
|
||||
WorkspaceOverviewPrimaryService,
|
||||
WorkspaceOverviewResponse,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceCommandDefinition,
|
||||
WorkspaceCommandKind,
|
||||
|
|
@ -1112,6 +1119,7 @@ export {
|
|||
companyArtifactsQuerySchema,
|
||||
companyArtifactsResponseSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceOverviewQuerySchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
executionWorkspaceCloseActionKindSchema,
|
||||
executionWorkspaceCloseActionSchema,
|
||||
|
|
@ -1158,6 +1166,7 @@ export {
|
|||
type UpsertIssueWatchdog,
|
||||
type CompanyArtifactsQuery,
|
||||
type UpdateExecutionWorkspace,
|
||||
type WorkspaceOverviewQuery,
|
||||
type WorkspaceFileListQuery,
|
||||
type WorkspaceFileResourceQuery,
|
||||
type IssueDocumentFormat,
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@ export type {
|
|||
ExecutionWorkspaceCloseLinkedIssue,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceCloseReadinessState,
|
||||
WorkspaceOverviewItem,
|
||||
WorkspaceOverviewLinkedIssue,
|
||||
WorkspaceOverviewPrimaryService,
|
||||
WorkspaceOverviewResponse,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceCommandDefinition,
|
||||
WorkspaceCommandKind,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,60 @@ export interface ExecutionWorkspaceSummary {
|
|||
lastUsedAt: Date;
|
||||
}
|
||||
|
||||
export interface WorkspaceOverviewLinkedIssue {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface WorkspaceOverviewPrimaryService {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
status: WorkspaceRuntimeService["status"];
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
healthStatus: WorkspaceRuntimeService["healthStatus"];
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface WorkspaceOverviewItem {
|
||||
key: string;
|
||||
kind: "execution_workspace";
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
projectId: string;
|
||||
projectUrlKey: string;
|
||||
projectName: string;
|
||||
mode: ExecutionWorkspaceSummary["mode"];
|
||||
strategyType: ExecutionWorkspaceStrategyType;
|
||||
cwd: string | null;
|
||||
branchName: string | null;
|
||||
lastUpdatedAt: Date;
|
||||
projectWorkspaceId: string | null;
|
||||
executionWorkspaceId: string;
|
||||
executionWorkspaceStatus: ExecutionWorkspaceStatus;
|
||||
serviceCount: number;
|
||||
runningServiceCount: number;
|
||||
primaryServiceUrl: string | null;
|
||||
primaryServiceUrlRunning: boolean;
|
||||
primaryService: WorkspaceOverviewPrimaryService | null;
|
||||
hasRuntimeConfig: boolean;
|
||||
linkedIssueCount: number;
|
||||
linkedIssues: WorkspaceOverviewLinkedIssue[];
|
||||
}
|
||||
|
||||
export interface WorkspaceOverviewResponse {
|
||||
items: WorkspaceOverviewItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
WORKSPACE_OVERVIEW_DEFAULT_LIMIT,
|
||||
WORKSPACE_OVERVIEW_MAX_LIMIT,
|
||||
} from "../constants.js";
|
||||
|
||||
export const executionWorkspaceStatusSchema = z.enum([
|
||||
"active",
|
||||
|
|
@ -8,6 +12,23 @@ export const executionWorkspaceStatusSchema = z.enum([
|
|||
"cleanup_failed",
|
||||
]);
|
||||
|
||||
const workspaceOverviewStatusFilterSchema = z.preprocess((value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const rawValues = Array.isArray(value) ? value : [value];
|
||||
const statuses = rawValues.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
});
|
||||
return statuses.length > 0 ? statuses : undefined;
|
||||
}, z.array(executionWorkspaceStatusSchema).optional());
|
||||
|
||||
export const workspaceOverviewQuerySchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
status: workspaceOverviewStatusFilterSchema,
|
||||
limit: z.coerce.number().int().min(1).max(WORKSPACE_OVERVIEW_MAX_LIMIT).optional().default(WORKSPACE_OVERVIEW_DEFAULT_LIMIT),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
}).strict();
|
||||
|
||||
export const executionWorkspaceConfigSchema = z.object({
|
||||
environmentId: z.string().uuid().optional().nullable(),
|
||||
provisionCommand: z.string().optional().nullable(),
|
||||
|
|
@ -129,3 +150,4 @@ export const updateExecutionWorkspaceSchema = z.object({
|
|||
}).strict();
|
||||
|
||||
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;
|
||||
export type WorkspaceOverviewQuery = z.infer<typeof workspaceOverviewQuerySchema>;
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ export {
|
|||
export {
|
||||
executionWorkspaceConfigSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceOverviewQuerySchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
executionWorkspaceCloseActionKindSchema,
|
||||
executionWorkspaceCloseActionSchema,
|
||||
|
|
@ -397,6 +398,7 @@ export {
|
|||
executionWorkspaceCloseReadinessSchema,
|
||||
executionWorkspaceCloseReadinessStateSchema,
|
||||
type UpdateExecutionWorkspace,
|
||||
type WorkspaceOverviewQuery,
|
||||
} from "./execution-workspace.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js";
|
|||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listOverview: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getCloseReadiness: vi.fn(),
|
||||
|
|
@ -57,6 +58,14 @@ describe.sequential("execution workspace routes", () => {
|
|||
explanation: "Allowed by test mock.",
|
||||
});
|
||||
mockExecutionWorkspaceService.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspaceService.listOverview.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
|
|
@ -91,4 +100,31 @@ describe.sequential("execution workspace routes", () => {
|
|||
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates bounded workspace overview queries", async () => {
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/workspace-overview?status=active,idle&limit=25&offset=10");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
expect(mockExecutionWorkspaceService.listOverview).toHaveBeenCalledWith("company-1", {
|
||||
status: ["active", "idle"],
|
||||
limit: 25,
|
||||
offset: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid workspace overview pagination", async () => {
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/workspace-overview?limit=1000");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockExecutionWorkspaceService.listOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -156,6 +157,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
|
|
@ -420,6 +422,314 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("returns a bounded company-scoped workspace overview with service and linked issue summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceAId = "11111111-1111-4111-8111-111111111111";
|
||||
const workspaceBId = "22222222-2222-4222-8222-222222222222";
|
||||
const archivedWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
||||
const otherWorkspaceId = "44444444-4444-4444-8444-444444444444";
|
||||
const crossCompanyProjectWorkspaceId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "OtherCo",
|
||||
issuePrefix: "OTH",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId: otherCompanyId,
|
||||
name: "Other project",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
const otherProject = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(inArray(projects.companyId, [otherCompanyId]))
|
||||
.then((rows) => rows[0]!.id);
|
||||
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: workspaceAId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Active A",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/workspace-a",
|
||||
branchName: "paperclip/a",
|
||||
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-03T10:05:00.000Z"),
|
||||
metadata: {
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: workspaceBId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Active B",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/workspace-b",
|
||||
branchName: "paperclip/b",
|
||||
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-02T10:05:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: archivedWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Archived",
|
||||
status: "archived",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/workspace-archived",
|
||||
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId: otherCompanyId,
|
||||
projectId: otherProject,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Other company",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/workspace-other",
|
||||
lastUsedAt: new Date("2026-06-05T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: crossCompanyProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId: otherProject,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Cross-company project mismatch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
cwd: "/tmp/workspace-cross-company-project",
|
||||
lastUsedAt: new Date("2026-06-06T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(workspaceRuntimeServices).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId: workspaceAId,
|
||||
issueId: null,
|
||||
scopeType: "execution_workspace",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/workspace-a",
|
||||
port: 3100,
|
||||
url: "http://localhost:3100",
|
||||
provider: "local_process",
|
||||
healthStatus: "healthy",
|
||||
updatedAt: new Date("2026-06-03T10:06:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId: workspaceAId,
|
||||
issueId: null,
|
||||
scopeType: "execution_workspace",
|
||||
serviceName: "worker",
|
||||
status: "stopped",
|
||||
lifecycle: "shared",
|
||||
command: "pnpm worker",
|
||||
cwd: "/tmp/workspace-a",
|
||||
provider: "local_process",
|
||||
healthStatus: "unknown",
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
title: `Linked issue ${index + 1}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
identifier: `PAP-${index + 1}`,
|
||||
executionWorkspaceId: workspaceAId,
|
||||
updatedAt: new Date(`2026-06-03T09:0${index}:00.000Z`),
|
||||
})),
|
||||
);
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Hidden linked issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
executionWorkspaceId: workspaceAId,
|
||||
hiddenAt: new Date("2026-06-03T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const overview = await svc.listOverview(companyId, {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(overview.total).toBe(2);
|
||||
expect(overview.items.map((item) => item.workspaceId)).toEqual([workspaceAId, workspaceBId]);
|
||||
expect(overview.items.map((item) => item.workspaceId)).not.toContain(archivedWorkspaceId);
|
||||
expect(overview.items.map((item) => item.workspaceId)).not.toContain(otherWorkspaceId);
|
||||
expect(overview.items.map((item) => item.workspaceId)).not.toContain(crossCompanyProjectWorkspaceId);
|
||||
expect(overview.hasMore).toBe(false);
|
||||
|
||||
const activeA = overview.items[0]!;
|
||||
expect(activeA).toMatchObject({
|
||||
key: `execution:${workspaceAId}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceName: "Active A",
|
||||
projectId,
|
||||
projectUrlKey: "workspaces",
|
||||
projectName: "Workspaces",
|
||||
branchName: "paperclip/a",
|
||||
serviceCount: 2,
|
||||
runningServiceCount: 1,
|
||||
primaryServiceUrl: "http://localhost:3100",
|
||||
primaryServiceUrlRunning: true,
|
||||
hasRuntimeConfig: true,
|
||||
linkedIssueCount: 5,
|
||||
});
|
||||
expect(activeA.primaryService).toMatchObject({
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
url: "http://localhost:3100",
|
||||
port: 3100,
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
expect(activeA.linkedIssues).toHaveLength(4);
|
||||
expect(activeA.linkedIssues.map((issue) => issue.title)).toEqual([
|
||||
"Linked issue 5",
|
||||
"Linked issue 4",
|
||||
"Linked issue 3",
|
||||
"Linked issue 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports status and project filters with stable limit/offset pagination", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectAId = randomUUID();
|
||||
const projectBId = randomUUID();
|
||||
const activeWorkspaceId = "55555555-5555-4555-8555-555555555555";
|
||||
const idleWorkspaceId = "66666666-6666-4666-8666-666666666666";
|
||||
const archivedWorkspaceId = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: projectAId,
|
||||
companyId,
|
||||
name: "Project A",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: projectBId,
|
||||
companyId,
|
||||
name: "Project B",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: activeWorkspaceId,
|
||||
companyId,
|
||||
projectId: projectAId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Newest active",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: idleWorkspaceId,
|
||||
companyId,
|
||||
projectId: projectAId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Older idle",
|
||||
status: "idle",
|
||||
providerType: "git_worktree",
|
||||
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: archivedWorkspaceId,
|
||||
companyId,
|
||||
projectId: projectBId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Archived",
|
||||
status: "archived",
|
||||
providerType: "git_worktree",
|
||||
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const secondPage = await svc.listOverview(companyId, {
|
||||
projectId: projectAId,
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
});
|
||||
|
||||
expect(secondPage.total).toBe(2);
|
||||
expect(secondPage.items.map((item) => item.workspaceId)).toEqual([idleWorkspaceId]);
|
||||
expect(secondPage.hasMore).toBe(false);
|
||||
expect(secondPage.nextOffset).toBeNull();
|
||||
|
||||
const archivedOnly = await svc.listOverview(companyId, {
|
||||
status: ["archived"],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(archivedOnly.total).toBe(1);
|
||||
expect(archivedOnly.items.map((item) => item.workspaceId)).toEqual([archivedWorkspaceId]);
|
||||
});
|
||||
|
||||
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
tempDirs.add(repoRoot);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
findWorkspaceCommandDefinition,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceOverviewQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
|
|
@ -83,6 +84,24 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
|||
res.json(workspaces);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/workspace-overview", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (!(await assertExecutionWorkspaceReadAllowed(req, res, companyId))) return;
|
||||
|
||||
const parsed = workspaceOverviewQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
res.status(422).json({
|
||||
error: "Invalid workspace overview query",
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const overview = await svc.listOverview(companyId, parsed.data);
|
||||
res.json(overview);
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspace = await svc.getById(id);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import {
|
|||
upsertSidebarOrderPreferenceSchema,
|
||||
// Execution workspaces
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceOverviewQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
// Environments
|
||||
createEnvironmentSchema,
|
||||
|
|
@ -3278,6 +3279,18 @@ registry.registerPath({
|
|||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/workspace-overview",
|
||||
tags: ["execution-workspaces"],
|
||||
summary: "List bounded execution workspace overview rows for a company",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
query: workspaceOverviewQuerySchema,
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/execution-workspaces/{id}",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import type {
|
||||
|
|
@ -12,10 +12,17 @@ import type {
|
|||
ExecutionWorkspaceCloseGitReadiness,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
ExecutionWorkspaceConfig,
|
||||
WorkspaceOverviewResponse,
|
||||
WorkspaceOverviewItem,
|
||||
WorkspaceOverviewLinkedIssue,
|
||||
WorkspaceRuntimeDesiredState,
|
||||
WorkspaceRuntimeService,
|
||||
WorkspaceOverviewPrimaryService,
|
||||
WorkspaceOverviewQuery,
|
||||
} from "@paperclipai/shared";
|
||||
import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import {
|
||||
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||
|
|
@ -359,6 +366,39 @@ function toExecutionWorkspaceSummary(
|
|||
};
|
||||
}
|
||||
|
||||
function maxDate(...values: Array<Date | string | null | undefined>): Date {
|
||||
let latest = new Date(0);
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isNaN(date.getTime()) && date.getTime() > latest.getTime()) latest = date;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function toWorkspaceOverviewPrimaryService(
|
||||
service: WorkspaceRuntimeService | null,
|
||||
): WorkspaceOverviewPrimaryService | null {
|
||||
if (!service) return null;
|
||||
return {
|
||||
id: service.id,
|
||||
serviceName: service.serviceName,
|
||||
status: service.status,
|
||||
url: service.url,
|
||||
port: service.port,
|
||||
healthStatus: service.healthStatus,
|
||||
updatedAt: service.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function selectPrimaryOverviewService(services: WorkspaceRuntimeService[]) {
|
||||
return services.find((service) => service.status === "running" && service.url)
|
||||
?? services.find((service) => service.url)
|
||||
?? services.find((service) => service.status === "running")
|
||||
?? services[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||
|
|
@ -394,6 +434,15 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
|||
);
|
||||
}
|
||||
|
||||
type WorkspaceOverviewPageRow = ExecutionWorkspaceRow & {
|
||||
projectName: string;
|
||||
projectWorkspaceMetadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & {
|
||||
executionWorkspaceId: string;
|
||||
};
|
||||
|
||||
export function executionWorkspaceService(db: Db) {
|
||||
function buildListConditions(
|
||||
companyId: string,
|
||||
|
|
@ -424,7 +473,228 @@ export function executionWorkspaceService(db: Db) {
|
|||
return conditions;
|
||||
}
|
||||
|
||||
function buildOverviewConditions(companyId: string, filters: WorkspaceOverviewQuery) {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
if (filters.status.length === 1) conditions.push(eq(executionWorkspaces.status, filters.status[0]!));
|
||||
else conditions.push(inArray(executionWorkspaces.status, filters.status));
|
||||
} else {
|
||||
conditions.push(ne(executionWorkspaces.status, "archived"));
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
return {
|
||||
listOverview: async (
|
||||
companyId: string,
|
||||
filters: WorkspaceOverviewQuery,
|
||||
): Promise<WorkspaceOverviewResponse> => {
|
||||
const conditions = buildOverviewConditions(companyId, filters);
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
const [totalRow, rows] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(executionWorkspaces)
|
||||
.innerJoin(
|
||||
projects,
|
||||
and(
|
||||
eq(projects.id, executionWorkspaces.projectId),
|
||||
eq(projects.companyId, companyId),
|
||||
),
|
||||
)
|
||||
.where(whereClause)
|
||||
.then((result) => result[0] ?? { count: 0 }),
|
||||
db
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
companyId: executionWorkspaces.companyId,
|
||||
projectId: executionWorkspaces.projectId,
|
||||
projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
||||
sourceIssueId: executionWorkspaces.sourceIssueId,
|
||||
mode: executionWorkspaces.mode,
|
||||
strategyType: executionWorkspaces.strategyType,
|
||||
name: executionWorkspaces.name,
|
||||
status: executionWorkspaces.status,
|
||||
cwd: executionWorkspaces.cwd,
|
||||
repoUrl: executionWorkspaces.repoUrl,
|
||||
baseRef: executionWorkspaces.baseRef,
|
||||
branchName: executionWorkspaces.branchName,
|
||||
providerType: executionWorkspaces.providerType,
|
||||
providerRef: executionWorkspaces.providerRef,
|
||||
derivedFromExecutionWorkspaceId: executionWorkspaces.derivedFromExecutionWorkspaceId,
|
||||
lastUsedAt: executionWorkspaces.lastUsedAt,
|
||||
openedAt: executionWorkspaces.openedAt,
|
||||
closedAt: executionWorkspaces.closedAt,
|
||||
cleanupEligibleAt: executionWorkspaces.cleanupEligibleAt,
|
||||
cleanupReason: executionWorkspaces.cleanupReason,
|
||||
metadata: executionWorkspaces.metadata,
|
||||
createdAt: executionWorkspaces.createdAt,
|
||||
updatedAt: executionWorkspaces.updatedAt,
|
||||
projectName: projects.name,
|
||||
projectWorkspaceMetadata: projectWorkspaces.metadata,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.innerJoin(
|
||||
projects,
|
||||
and(
|
||||
eq(projects.id, executionWorkspaces.projectId),
|
||||
eq(projects.companyId, companyId),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
projectWorkspaces,
|
||||
and(
|
||||
eq(projectWorkspaces.id, executionWorkspaces.projectWorkspaceId),
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
),
|
||||
)
|
||||
.where(whereClause)
|
||||
.orderBy(
|
||||
desc(executionWorkspaces.lastUsedAt),
|
||||
desc(executionWorkspaces.updatedAt),
|
||||
asc(executionWorkspaces.id),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset),
|
||||
]);
|
||||
|
||||
const pageRows = rows as WorkspaceOverviewPageRow[];
|
||||
if (pageRows.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: totalRow.count,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceIds = pageRows.map((row) => row.id);
|
||||
const [runtimeServicesByWorkspaceId, linkedIssueCountRows, linkedIssueRows] = await Promise.all([
|
||||
loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, pageRows),
|
||||
db
|
||||
.select({
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.executionWorkspaceId, workspaceIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issues.executionWorkspaceId),
|
||||
db.execute(sql`
|
||||
select
|
||||
ranked.execution_workspace_id as "executionWorkspaceId",
|
||||
ranked.id,
|
||||
ranked.identifier,
|
||||
ranked.title,
|
||||
ranked.status,
|
||||
ranked.priority,
|
||||
ranked.updated_at as "updatedAt"
|
||||
from (
|
||||
select
|
||||
${issues.executionWorkspaceId} as execution_workspace_id,
|
||||
${issues.id} as id,
|
||||
${issues.identifier} as identifier,
|
||||
${issues.title} as title,
|
||||
${issues.status} as status,
|
||||
${issues.priority} as priority,
|
||||
${issues.updatedAt} as updated_at,
|
||||
row_number() over (
|
||||
partition by ${issues.executionWorkspaceId}
|
||||
order by ${issues.updatedAt} desc, ${issues.id} asc
|
||||
) as row_number
|
||||
from ${issues}
|
||||
where ${issues.companyId} = ${companyId}
|
||||
and ${issues.hiddenAt} is null
|
||||
and ${issues.executionWorkspaceId} in (${sql.join(workspaceIds.map((id) => sql`${id}`), sql`, `)})
|
||||
) ranked
|
||||
where ranked.row_number <= ${WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT}
|
||||
order by ranked.execution_workspace_id asc, ranked.row_number asc
|
||||
`),
|
||||
]);
|
||||
|
||||
const linkedIssueCountByWorkspaceId = new Map(
|
||||
linkedIssueCountRows
|
||||
.filter((row) => row.executionWorkspaceId)
|
||||
.map((row) => [row.executionWorkspaceId!, row.count]),
|
||||
);
|
||||
const linkedIssuesByWorkspaceId = new Map<string, WorkspaceOverviewLinkedIssue[]>();
|
||||
for (const issue of linkedIssueRows as unknown as WorkspaceOverviewIssueRow[]) {
|
||||
const existing = linkedIssuesByWorkspaceId.get(issue.executionWorkspaceId) ?? [];
|
||||
existing.push({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
updatedAt: issue.updatedAt,
|
||||
});
|
||||
linkedIssuesByWorkspaceId.set(issue.executionWorkspaceId, existing);
|
||||
}
|
||||
|
||||
const items: WorkspaceOverviewItem[] = pageRows.map((row) => {
|
||||
const runtimeServices = (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService);
|
||||
const runningServiceCount = runtimeServices.filter((service) => service.status === "running").length;
|
||||
const primaryService = selectPrimaryOverviewService(runtimeServices);
|
||||
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
||||
const inheritedProjectRuntimeConfig = usesInheritedProjectRuntimeServices(row)
|
||||
? readProjectWorkspaceRuntimeConfig(row.projectWorkspaceMetadata)
|
||||
: null;
|
||||
const linkedIssues = linkedIssuesByWorkspaceId.get(row.id) ?? [];
|
||||
const primaryServiceSummary = toWorkspaceOverviewPrimaryService(primaryService);
|
||||
|
||||
return {
|
||||
key: `execution:${row.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: row.id,
|
||||
workspaceName: row.name,
|
||||
projectId: row.projectId,
|
||||
projectUrlKey: deriveProjectUrlKey(row.projectName, row.projectId),
|
||||
projectName: row.projectName,
|
||||
mode: row.mode as WorkspaceOverviewItem["mode"],
|
||||
strategyType: row.strategyType as WorkspaceOverviewItem["strategyType"],
|
||||
cwd: row.cwd ?? null,
|
||||
branchName: row.branchName ?? row.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
row.lastUsedAt,
|
||||
row.updatedAt,
|
||||
linkedIssues[0]?.updatedAt,
|
||||
primaryServiceSummary?.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.id,
|
||||
executionWorkspaceStatus: row.status as WorkspaceOverviewItem["executionWorkspaceStatus"],
|
||||
serviceCount: runtimeServices.length,
|
||||
runningServiceCount,
|
||||
primaryServiceUrl: primaryService?.url ?? null,
|
||||
primaryServiceUrlRunning: primaryService?.status === "running",
|
||||
primaryService: primaryServiceSummary,
|
||||
hasRuntimeConfig: Boolean(config?.workspaceRuntime ?? inheritedProjectRuntimeConfig?.workspaceRuntime),
|
||||
linkedIssueCount: linkedIssueCountByWorkspaceId.get(row.id) ?? 0,
|
||||
linkedIssues,
|
||||
};
|
||||
});
|
||||
|
||||
const nextOffset = filters.offset + items.length;
|
||||
const total = totalRow.count;
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
hasMore: nextOffset < total,
|
||||
nextOffset: nextOffset < total ? nextOffset : null,
|
||||
};
|
||||
},
|
||||
|
||||
list: async (companyId: string, filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
|
|
|
|||
|
|
@ -27,4 +27,69 @@ describe("executionWorkspacesApi.listSummaries", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("requests and normalizes the bounded overview payload", async () => {
|
||||
mockApi.get.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
key: "execution:workspace-1",
|
||||
kind: "execution_workspace",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceName: "Workspace 1",
|
||||
projectId: "project-1",
|
||||
projectName: "Paperclip App",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: "/tmp/workspace-1",
|
||||
branchName: "PAP-1",
|
||||
lastUpdatedAt: "2026-06-25T01:00:00.000Z",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspaceStatus: "active",
|
||||
serviceCount: 1,
|
||||
runningServiceCount: 1,
|
||||
primaryServiceUrl: "http://localhost:3100",
|
||||
primaryServiceUrlRunning: true,
|
||||
primaryService: {
|
||||
id: "service-1",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
url: "http://localhost:3100",
|
||||
port: 3100,
|
||||
healthStatus: "healthy",
|
||||
updatedAt: "2026-06-25T01:01:00.000Z",
|
||||
},
|
||||
hasRuntimeConfig: true,
|
||||
linkedIssueCount: 1,
|
||||
linkedIssues: [
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Linked task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
updatedAt: "2026-06-25T01:02:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 10,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
|
||||
const overview = await executionWorkspacesApi.listOverview("company-1", {
|
||||
status: ["active", "idle"],
|
||||
limit: 25,
|
||||
offset: 10,
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/workspace-overview?status=active%2Cidle&limit=25&offset=10",
|
||||
);
|
||||
expect(overview.items[0]!.lastUpdatedAt).toBeInstanceOf(Date);
|
||||
expect(overview.items[0]!.primaryService?.updatedAt).toBeInstanceOf(Date);
|
||||
expect(overview.items[0]!.linkedIssues[0]!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,55 @@
|
|||
import type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceStatus,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
WorkspaceOverviewResponse,
|
||||
WorkspaceOperation,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
|
||||
|
||||
type WorkspaceOverviewFilters = {
|
||||
projectId?: string;
|
||||
status?: ExecutionWorkspaceStatus[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
function normalizeWorkspaceOverview(response: WorkspaceOverviewResponse): WorkspaceOverviewResponse {
|
||||
return {
|
||||
...response,
|
||||
items: response.items.map((item) => ({
|
||||
...item,
|
||||
lastUpdatedAt: new Date(item.lastUpdatedAt),
|
||||
primaryService: item.primaryService
|
||||
? {
|
||||
...item.primaryService,
|
||||
updatedAt: new Date(item.primaryService.updatedAt),
|
||||
}
|
||||
: null,
|
||||
linkedIssues: item.linkedIssues.map((issue) => ({
|
||||
...issue,
|
||||
updatedAt: new Date(issue.updatedAt),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const executionWorkspacesApi = {
|
||||
listOverview: async (companyId: string, filters?: WorkspaceOverviewFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.status?.length) params.set("status", filters.status.join(","));
|
||||
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
|
||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
const qs = params.toString();
|
||||
const response = await api.get<WorkspaceOverviewResponse>(
|
||||
`/companies/${companyId}/workspace-overview${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return normalizeWorkspaceOverview(response);
|
||||
},
|
||||
listSummaries: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }),
|
||||
onSuccess: (workspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(workspace.companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) });
|
||||
pushToast({
|
||||
title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ function useIsQuicklookOpen(id: symbol) {
|
|||
* as the pointer crosses cards on its way somewhere else. */
|
||||
const QUICKLOOK_OPEN_DELAY_MS = 120;
|
||||
|
||||
export type IssueQuicklookIssue = Pick<Issue, "id" | "title" | "updatedAt"> & {
|
||||
identifier?: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
description?: string | null;
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
projectId?: string | null;
|
||||
project?: { name?: string | null } | null;
|
||||
originKind?: string;
|
||||
originId?: string | null;
|
||||
};
|
||||
|
||||
function summarizeIssueDescription(description: string | null | undefined) {
|
||||
if (!description) return null;
|
||||
const summary = description
|
||||
|
|
@ -83,7 +95,7 @@ export function IssueQuicklookCard({
|
|||
linkState,
|
||||
compact = false,
|
||||
}: {
|
||||
issue: Issue;
|
||||
issue: IssueQuicklookIssue;
|
||||
linkTo: RouterDom.To;
|
||||
linkState?: unknown;
|
||||
compact?: boolean;
|
||||
|
|
|
|||
|
|
@ -774,6 +774,7 @@ export function IssueProperties({
|
|||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
|
||||
if (companyId) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import type { ProjectWorkspaceLinkedIssue } from "../lib/project-workspaces-tab";
|
||||
import { IssueQuicklookCard } from "./IssueLinkQuicklook";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
issue: Issue;
|
||||
issue: ProjectWorkspaceLinkedIssue;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -19,6 +19,20 @@ vi.mock("./IssuesQuicklook", () => ({
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
const maybePromise = result as Promise<void> | undefined;
|
||||
if (maybePromise !== undefined && typeof maybePromise.then === "function") {
|
||||
return maybePromise.then(() => {
|
||||
flushSync(() => {});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: overrides.id ?? "issue-1",
|
||||
|
|
@ -57,6 +71,13 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
}
|
||||
|
||||
function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): ProjectWorkspaceSummary {
|
||||
const issues = overrides.issues ?? [
|
||||
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
||||
createIssue({ id: "issue-2", identifier: "PAP-1367" }),
|
||||
createIssue({ id: "issue-3", identifier: "PAP-1362" }),
|
||||
createIssue({ id: "issue-4", identifier: "PAP-1363" }),
|
||||
createIssue({ id: "issue-5", identifier: "PAP-1340" }),
|
||||
];
|
||||
return {
|
||||
key: overrides.key ?? "execution:workspace-1",
|
||||
kind: overrides.kind ?? "execution_workspace",
|
||||
|
|
@ -73,13 +94,8 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
|||
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474",
|
||||
primaryServiceUrlRunning: overrides.primaryServiceUrlRunning ?? false,
|
||||
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
||||
issues: overrides.issues ?? [
|
||||
createIssue({ id: "issue-1", identifier: "PAP-1364" }),
|
||||
createIssue({ id: "issue-2", identifier: "PAP-1367" }),
|
||||
createIssue({ id: "issue-3", identifier: "PAP-1362" }),
|
||||
createIssue({ id: "issue-4", identifier: "PAP-1363" }),
|
||||
createIssue({ id: "issue-5", identifier: "PAP-1340" }),
|
||||
],
|
||||
linkedIssueCount: overrides.linkedIssueCount ?? issues.length,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -233,12 +249,14 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
|||
|
||||
await act(async () => {
|
||||
branchTextButton!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.branchName);
|
||||
expect(branchTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
||||
await act(async () => {
|
||||
pathTextButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.cwd);
|
||||
expect(pathTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
|
@ -246,6 +264,7 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
|||
await act(async () => {
|
||||
branchIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
pathIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.branchName);
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.cwd);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Link } from "@/lib/router";
|
||||
import type { ExecutionWorkspace, Issue } from "@paperclipai/shared";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyText } from "./CopyText";
|
||||
import { IssuesQuicklook } from "./IssuesQuicklook";
|
||||
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import type { ProjectWorkspaceLinkedIssue, ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Copy, ExternalLink, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
|
||||
|
|
@ -45,7 +45,7 @@ export function ProjectWorkspaceSummaryCard({
|
|||
onCloseWorkspace,
|
||||
}: ProjectWorkspaceSummaryCardProps) {
|
||||
const visibleIssues = summary.issues.slice(0, 4);
|
||||
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||
const hiddenIssueCount = Math.max(summary.linkedIssueCount - visibleIssues.length, 0);
|
||||
const workspaceHref =
|
||||
summary.kind === "project_workspace"
|
||||
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
||||
|
|
@ -249,7 +249,7 @@ export function ProjectWorkspaceSummaryCard({
|
|||
);
|
||||
}
|
||||
|
||||
function IssuePill({ issue }: { issue: Issue }) {
|
||||
function IssuePill({ issue }: { issue: ProjectWorkspaceLinkedIssue }) {
|
||||
return (
|
||||
<IssuesQuicklook issue={issue}>
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function ProjectWorkspacesContent({
|
|||
},
|
||||
onSettled: () => {
|
||||
setRuntimeActionKey(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
|
|
@ -104,6 +105,7 @@ export function ProjectWorkspacesContent({
|
|||
if (!open) setClosingWorkspace(null);
|
||||
}}
|
||||
onClosed={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
|
|
|
|||
|
|
@ -11,12 +11,23 @@ export type IssueDetailHeaderSeed = {
|
|||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: Issue["status"];
|
||||
status: string;
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
priority: Issue["priority"];
|
||||
priority: string;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
originKind?: Issue["originKind"];
|
||||
originKind?: string;
|
||||
originId?: string | null;
|
||||
};
|
||||
|
||||
type IssueDetailHeaderSeedSource = Pick<Issue, "id" | "title"> & {
|
||||
identifier?: string | null;
|
||||
status: string;
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
priority: string;
|
||||
projectId?: string | null;
|
||||
project?: { name?: string | null } | null;
|
||||
originKind?: string;
|
||||
originId?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +76,7 @@ function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed
|
|||
);
|
||||
}
|
||||
|
||||
function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
|
||||
function createIssueDetailHeaderSeed(issue: IssueDetailHeaderSeedSource): IssueDetailHeaderSeed {
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier ?? null,
|
||||
|
|
@ -80,7 +91,7 @@ function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
|
|||
};
|
||||
}
|
||||
|
||||
export function withIssueDetailHeaderSeed(state: unknown, issue: Issue): IssueDetailLocationState {
|
||||
export function withIssueDetailHeaderSeed(state: unknown, issue: IssueDetailHeaderSeedSource): IssueDetailLocationState {
|
||||
const headerSeed = createIssueDetailHeaderSeed(issue);
|
||||
if (typeof state !== "object" || state === null) {
|
||||
return { issueDetailHeaderSeed: headerSeed };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
|||
|
||||
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
export type ProjectWorkspaceLinkedIssue = Pick<Issue, "id" | "identifier" | "title" | "updatedAt"> & {
|
||||
status: string;
|
||||
priority: string;
|
||||
description?: string | null;
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
projectId?: string | null;
|
||||
project?: Issue["project"];
|
||||
originKind?: Issue["originKind"];
|
||||
originId?: string | null;
|
||||
};
|
||||
|
||||
export interface ProjectWorkspaceSummary {
|
||||
key: string;
|
||||
kind: "execution_workspace" | "project_workspace";
|
||||
|
|
@ -18,7 +29,8 @@ export interface ProjectWorkspaceSummary {
|
|||
primaryServiceUrl: string | null;
|
||||
primaryServiceUrlRunning: boolean;
|
||||
hasRuntimeConfig: boolean;
|
||||
issues: Issue[];
|
||||
linkedIssueCount: number;
|
||||
issues: ProjectWorkspaceLinkedIssue[];
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | null {
|
||||
|
|
@ -123,6 +135,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
linkedIssueCount: nextIssues.length,
|
||||
issues: nextIssues,
|
||||
});
|
||||
continue;
|
||||
|
|
@ -151,6 +164,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
executionWorkspaceStatus: null,
|
||||
...runtimeSummary,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
linkedIssueCount: nextIssues.length,
|
||||
issues: nextIssues,
|
||||
});
|
||||
}
|
||||
|
|
@ -177,6 +191,7 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
executionWorkspaceStatus: null,
|
||||
...runtimeSummary,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
linkedIssueCount: 0,
|
||||
issues: [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ export const queryKeys = {
|
|||
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||
summaryList: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, "summary", filters ?? {}] as const,
|
||||
overview: (companyId: string, filters?: Record<string, string | number | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, "overview", filters ?? {}] as const,
|
||||
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
|
||||
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
||||
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
||||
|
|
|
|||
|
|
@ -703,6 +703,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
executionWorkspacesApi.controlRuntimeCommands(workspace!.id, request.action, request),
|
||||
onSuccess: (result, request) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
|
||||
setRuntimeActionErrorMessage(null);
|
||||
|
|
@ -1237,6 +1238,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
onOpenChange={setCloseDialogOpen}
|
||||
onClosed={(nextWorkspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(nextWorkspace.companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
|
||||
if (project) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { WorkspaceOverviewItem, WorkspaceOverviewResponse } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
listOverview: vi.fn(),
|
||||
list: vi.fn(),
|
||||
controlRuntimeServices: vi.fn(),
|
||||
getCloseReadiness: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
||||
}));
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
|
||||
}));
|
||||
vi.mock("../components/IssuesQuicklook", () => ({
|
||||
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
const maybePromise = result as Promise<void> | undefined;
|
||||
if (maybePromise !== undefined && typeof maybePromise.then === "function") {
|
||||
return maybePromise.then(() => {
|
||||
flushSync(() => {});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function overviewItem(overrides: Partial<WorkspaceOverviewItem> = {}): WorkspaceOverviewItem {
|
||||
return {
|
||||
key: overrides.key ?? "execution:workspace-1",
|
||||
kind: "execution_workspace",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-1",
|
||||
workspaceName: overrides.workspaceName ?? "Workspace Alpha",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectUrlKey: overrides.projectUrlKey ?? "paperclip-app",
|
||||
projectName: overrides.projectName ?? "Paperclip App",
|
||||
mode: overrides.mode ?? "isolated_workspace",
|
||||
strategyType: overrides.strategyType ?? "git_worktree",
|
||||
cwd: overrides.cwd ?? "/tmp/workspace-alpha",
|
||||
branchName: overrides.branchName ?? "PAP-11916-workspaces",
|
||||
lastUpdatedAt: overrides.lastUpdatedAt ?? new Date("2026-06-25T01:00:00.000Z"),
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? overrides.workspaceId ?? "workspace-1",
|
||||
executionWorkspaceStatus: overrides.executionWorkspaceStatus ?? "active",
|
||||
serviceCount: overrides.serviceCount ?? 1,
|
||||
runningServiceCount: overrides.runningServiceCount ?? 1,
|
||||
primaryServiceUrl: overrides.primaryServiceUrl ?? "http://localhost:3100",
|
||||
primaryServiceUrlRunning: overrides.primaryServiceUrlRunning ?? true,
|
||||
primaryService: overrides.primaryService ?? null,
|
||||
hasRuntimeConfig: overrides.hasRuntimeConfig ?? true,
|
||||
linkedIssueCount: overrides.linkedIssueCount ?? 2,
|
||||
linkedIssues: overrides.linkedIssues ?? [
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-11916",
|
||||
title: "Use workspace overview data on /workspaces",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
updatedAt: new Date("2026-06-25T01:05:00.000Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function overviewResponse(overrides: Partial<WorkspaceOverviewResponse> = {}): WorkspaceOverviewResponse {
|
||||
const items = overrides.items ?? [overviewItem()];
|
||||
return {
|
||||
items,
|
||||
total: overrides.total ?? items.length,
|
||||
limit: overrides.limit ?? 50,
|
||||
offset: overrides.offset ?? 0,
|
||||
hasMore: overrides.hasMore ?? false,
|
||||
nextOffset: overrides.nextOffset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushQueries() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Workspaces", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
mockExecutionWorkspacesApi.listOverview.mockResolvedValue(overviewResponse());
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses the bounded overview endpoint and renders grouped workspace cards with linked task summaries", async () => {
|
||||
mockExecutionWorkspacesApi.listOverview
|
||||
.mockResolvedValueOnce(overviewResponse({
|
||||
items: [overviewItem()],
|
||||
total: 2,
|
||||
hasMore: true,
|
||||
nextOffset: 50,
|
||||
}))
|
||||
.mockResolvedValueOnce(overviewResponse({
|
||||
items: [
|
||||
overviewItem({
|
||||
key: "execution:workspace-2",
|
||||
workspaceId: "workspace-2",
|
||||
workspaceName: "Workspace Beta",
|
||||
executionWorkspaceId: "workspace-2",
|
||||
runningServiceCount: 0,
|
||||
linkedIssueCount: 1,
|
||||
linkedIssues: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-11917",
|
||||
title: "Verify /workspaces performance improvement",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
updatedAt: new Date("2026-06-25T01:06:00.000Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
offset: 50,
|
||||
}));
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Workspaces />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushQueries();
|
||||
|
||||
expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled();
|
||||
expect(mockExecutionWorkspacesApi.listOverview).toHaveBeenCalledWith("company-1", { offset: 0 });
|
||||
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Paperclip App");
|
||||
expect(container.textContent).toContain("Workspace Alpha");
|
||||
expect(container.textContent).toContain("PAP-11916");
|
||||
expect(container.textContent).toContain("+1 more");
|
||||
expect(container.textContent).toContain("Showing 1 of 2 workspaces.");
|
||||
expect(container.querySelector('a[href="/projects/paperclip-app/workspaces"]')).not.toBeNull();
|
||||
|
||||
const loadMoreButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Load more");
|
||||
expect(loadMoreButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
loadMoreButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushQueries();
|
||||
|
||||
expect(mockExecutionWorkspacesApi.listOverview).toHaveBeenLastCalledWith("company-1", { offset: 50 });
|
||||
expect(container.textContent).toContain("Workspace Beta");
|
||||
expect(container.textContent).toContain("PAP-11917");
|
||||
});
|
||||
|
||||
it("keeps the isolated-workspaces feature flag redirect", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Workspaces />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushQueries();
|
||||
|
||||
expect(container.textContent).toContain("/issues");
|
||||
expect(mockExecutionWorkspacesApi.listOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,74 +1,78 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { Link, Navigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import type { WorkspaceOverviewItem } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { buildProjectWorkspaceSummaries, type ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
|
||||
type ProjectWorkspaceGroup = {
|
||||
project: Project;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectRef: string;
|
||||
summaries: ProjectWorkspaceSummary[];
|
||||
lastUpdatedAt: Date;
|
||||
runningServiceCount: number;
|
||||
};
|
||||
|
||||
function buildProjectWorkspaceGroups(input: {
|
||||
projects: Project[];
|
||||
issues: Issue[];
|
||||
executionWorkspaces: ExecutionWorkspace[];
|
||||
}): ProjectWorkspaceGroup[] {
|
||||
const issuesByProjectId = new Map<string, Issue[]>();
|
||||
for (const issue of input.issues) {
|
||||
if (!issue.projectId) continue;
|
||||
const existing = issuesByProjectId.get(issue.projectId) ?? [];
|
||||
existing.push(issue);
|
||||
issuesByProjectId.set(issue.projectId, existing);
|
||||
}
|
||||
function overviewItemToSummary(item: WorkspaceOverviewItem): ProjectWorkspaceSummary {
|
||||
return {
|
||||
key: item.key,
|
||||
kind: item.kind,
|
||||
workspaceId: item.workspaceId,
|
||||
workspaceName: item.workspaceName,
|
||||
cwd: item.cwd,
|
||||
branchName: item.branchName,
|
||||
lastUpdatedAt: item.lastUpdatedAt,
|
||||
projectWorkspaceId: item.projectWorkspaceId,
|
||||
executionWorkspaceId: item.executionWorkspaceId,
|
||||
executionWorkspaceStatus: item.executionWorkspaceStatus,
|
||||
serviceCount: item.serviceCount,
|
||||
runningServiceCount: item.runningServiceCount,
|
||||
primaryServiceUrl: item.primaryServiceUrl,
|
||||
primaryServiceUrlRunning: item.primaryServiceUrlRunning,
|
||||
hasRuntimeConfig: item.hasRuntimeConfig,
|
||||
linkedIssueCount: item.linkedIssueCount,
|
||||
issues: item.linkedIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const executionWorkspacesByProjectId = new Map<string, ExecutionWorkspace[]>();
|
||||
for (const workspace of input.executionWorkspaces) {
|
||||
if (!workspace.projectId) continue;
|
||||
const existing = executionWorkspacesByProjectId.get(workspace.projectId) ?? [];
|
||||
existing.push(workspace);
|
||||
executionWorkspacesByProjectId.set(workspace.projectId, existing);
|
||||
}
|
||||
|
||||
return input.projects
|
||||
.map((project) => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: issuesByProjectId.get(project.id) ?? [],
|
||||
executionWorkspaces: executionWorkspacesByProjectId.get(project.id) ?? [],
|
||||
});
|
||||
if (summaries.length === 0) return null;
|
||||
return {
|
||||
project,
|
||||
projectRef: projectRouteRef(project),
|
||||
summaries,
|
||||
lastUpdatedAt: summaries.reduce(
|
||||
(latest, summary) => summary.lastUpdatedAt.getTime() > latest.getTime() ? summary.lastUpdatedAt : latest,
|
||||
new Date(0),
|
||||
),
|
||||
runningServiceCount: summaries.reduce((count, summary) => count + summary.runningServiceCount, 0),
|
||||
};
|
||||
})
|
||||
.filter((group): group is ProjectWorkspaceGroup => group !== null)
|
||||
.sort((a, b) => {
|
||||
const runningDiff = b.runningServiceCount - a.runningServiceCount;
|
||||
if (runningDiff !== 0) return runningDiff;
|
||||
const updatedDiff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return updatedDiff !== 0 ? updatedDiff : a.project.name.localeCompare(b.project.name);
|
||||
function buildProjectWorkspaceGroups(items: WorkspaceOverviewItem[]): ProjectWorkspaceGroup[] {
|
||||
const groups = new Map<string, ProjectWorkspaceGroup>();
|
||||
for (const item of items) {
|
||||
const existing = groups.get(item.projectId);
|
||||
const summary = overviewItemToSummary(item);
|
||||
if (existing) {
|
||||
existing.summaries.push(summary);
|
||||
if (summary.lastUpdatedAt.getTime() > existing.lastUpdatedAt.getTime()) {
|
||||
existing.lastUpdatedAt = summary.lastUpdatedAt;
|
||||
}
|
||||
existing.runningServiceCount += summary.runningServiceCount;
|
||||
continue;
|
||||
}
|
||||
groups.set(item.projectId, {
|
||||
projectId: item.projectId,
|
||||
projectName: item.projectName,
|
||||
projectRef: projectRouteRef({ id: item.projectId, name: item.projectName, urlKey: item.projectUrlKey }),
|
||||
summaries: [summary],
|
||||
lastUpdatedAt: summary.lastUpdatedAt,
|
||||
runningServiceCount: summary.runningServiceCount,
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const runningDiff = b.runningServiceCount - a.runningServiceCount;
|
||||
if (runningDiff !== 0) return runningDiff;
|
||||
const updatedDiff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return updatedDiff !== 0 ? updatedDiff : a.projectName.localeCompare(b.projectName);
|
||||
});
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
|
|
@ -80,25 +84,13 @@ export function Workspaces() {
|
|||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||
|
||||
const { data: projects = [], isLoading: projectsLoading, error: projectsError } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "__workspaces__", "disabled"],
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const { data: issues = [], isLoading: issuesLoading, error: issuesError } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.issues.list(selectedCompanyId) : ["issues", "__workspaces__", "disabled"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const {
|
||||
data: executionWorkspaces = [],
|
||||
isLoading: executionWorkspacesLoading,
|
||||
error: executionWorkspacesError,
|
||||
} = useQuery({
|
||||
const overviewQuery = useInfiniteQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||
: ["execution-workspaces", "__workspaces__", "disabled"],
|
||||
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||
? queryKeys.executionWorkspaces.overview(selectedCompanyId)
|
||||
: ["execution-workspaces", "__workspaces-overview__", "disabled"],
|
||||
queryFn: ({ pageParam }) => executionWorkspacesApi.listOverview(selectedCompanyId!, { offset: pageParam as number }),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
|
||||
|
|
@ -106,12 +98,16 @@ export function Workspaces() {
|
|||
setBreadcrumbs([{ label: "Workspaces" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => buildProjectWorkspaceGroups({ projects, issues, executionWorkspaces }),
|
||||
[executionWorkspaces, issues, projects],
|
||||
const overviewPages = overviewQuery.data?.pages ?? [];
|
||||
const overviewItems = useMemo(
|
||||
() => overviewPages.flatMap((page) => page.items),
|
||||
[overviewPages],
|
||||
);
|
||||
const dataLoading = projectsLoading || issuesLoading || executionWorkspacesLoading;
|
||||
const error = (projectsError ?? issuesError ?? executionWorkspacesError) as Error | null;
|
||||
const groups = useMemo(() => buildProjectWorkspaceGroups(overviewItems), [overviewItems]);
|
||||
const firstPage = overviewPages[0] ?? null;
|
||||
const totalWorkspaceCount = firstPage?.total ?? overviewItems.length;
|
||||
const dataLoading = overviewQuery.isLoading;
|
||||
const error = overviewQuery.error as Error | null;
|
||||
|
||||
if (experimentalSettingsQuery.isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (!isolatedWorkspacesEnabled) return <Navigate to="/issues" replace />;
|
||||
|
|
@ -129,20 +125,15 @@ export function Workspaces() {
|
|||
) : (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => (
|
||||
<section key={group.project.id} className="space-y-3">
|
||||
<section key={group.projectId} className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/projects/${group.projectRef}/workspaces`}
|
||||
className="text-base font-semibold hover:underline"
|
||||
>
|
||||
{group.project.name}
|
||||
{group.projectName}
|
||||
</Link>
|
||||
{group.project.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{group.project.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"}
|
||||
|
|
@ -150,12 +141,28 @@ export function Workspaces() {
|
|||
</div>
|
||||
<ProjectWorkspacesContent
|
||||
companyId={selectedCompanyId!}
|
||||
projectId={group.project.id}
|
||||
projectId={group.projectId}
|
||||
projectRef={group.projectRef}
|
||||
summaries={group.summaries}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
{overviewQuery.hasNextPage ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {overviewItems.length} of {totalWorkspaceCount} workspaces.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void overviewQuery.fetchNextPage()}
|
||||
disabled={overviewQuery.isFetchingNextPage}
|
||||
>
|
||||
{overviewQuery.isFetchingNextPage ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue