diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f64896909a..085fb13245 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b730c57c8b..493d552b4b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7a84aa78c6..aaf07216be 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -206,6 +206,10 @@ export type { ExecutionWorkspaceCloseLinkedIssue, ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, + WorkspaceOverviewItem, + WorkspaceOverviewLinkedIssue, + WorkspaceOverviewPrimaryService, + WorkspaceOverviewResponse, ProjectWorkspaceRuntimeConfig, WorkspaceCommandDefinition, WorkspaceCommandKind, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 13ba4154bc..4e7fdcc2d2 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -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; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 98a0615593..a937d44590 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -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; +export type WorkspaceOverviewQuery = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b04e1533d7..f81ddf60a8 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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 { diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index 73c5e05920..1353357acd 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -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(); + }); }); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index fc8692fd90..710486dcce 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -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); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index bf79b44455..91b90eb4a2 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e8849da6c0..08714bb109 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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}", diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ee5eb48af9..d099ca6726 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -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 { + 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 | null) ?? null)?.workspaceRuntime; @@ -394,6 +434,15 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( ); } +type WorkspaceOverviewPageRow = ExecutionWorkspaceRow & { + projectName: string; + projectWorkspaceMetadata: Record | 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 => { + const conditions = buildOverviewConditions(companyId, filters); + const whereClause = and(...conditions); + + const [totalRow, rows] = await Promise.all([ + db + .select({ count: sql`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`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(); + 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 | 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; diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index ba91f66674..89df04a5e2 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -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); + }); }); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index cd5b734175..9b737d74df 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -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( + `/companies/${companyId}/workspace-overview${qs ? `?${qs}` : ""}`, + ); + return normalizeWorkspaceOverview(response); + }, listSummaries: ( companyId: string, filters?: { diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index fbc03120cf..6f98114f45 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -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", diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index f74e55722c..27ba64e062 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -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 & { + 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; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c67014bd18..3224ecf76b 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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) }); diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index f8a12ce67d..9403966f4a 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -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; } diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx index 8f66abb1f2..63b272c316 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx @@ -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) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + const maybePromise = result as Promise | undefined; + if (maybePromise !== undefined && typeof maybePromise.then === "function") { + return maybePromise.then(() => { + flushSync(() => {}); + }); + } + return result; +} + function createIssue(overrides: Partial = {}): Issue { return { id: overrides.id ?? "issue-1", @@ -57,6 +71,13 @@ function createIssue(overrides: Partial = {}): Issue { } function createSummary(overrides: Partial = {}): 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 = {}): 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); diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx index 865b77dfd1..1dc968b8c4 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx @@ -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 ( { 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) }); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index 79edbc1e6b..5c15230465 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -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 & { + 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 }; diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index ed184b5700..484123317f 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -2,6 +2,17 @@ import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared"; type ProjectWorkspaceLike = Pick; +export type ProjectWorkspaceLinkedIssue = Pick & { + 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: [], }); } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b1c427459e..a1dc760039 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -130,6 +130,8 @@ export const queryKeys = { ["execution-workspaces", companyId, filters ?? {}] as const, summaryList: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, "summary", filters ?? {}] as const, + overview: (companyId: string, filters?: Record) => + ["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, diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 8434289ed8..7d7b75192b 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -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) { diff --git a/ui/src/pages/Workspaces.test.tsx b/ui/src/pages/Workspaces.test.tsx new file mode 100644 index 0000000000..44e3cae217 --- /dev/null +++ b/ui/src/pages/Workspaces.test.tsx @@ -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 }) => {children}, + Navigate: ({ to }: { to: string }) =>
{to}
, +})); +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) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + const maybePromise = result as Promise | undefined; + if (maybePromise !== undefined && typeof maybePromise.then === "function") { + return maybePromise.then(() => { + flushSync(() => {}); + }); + } + return result; +} + +function overviewItem(overrides: Partial = {}): 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 { + 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( + + + , + ); + }); + 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( + + + , + ); + }); + await flushQueries(); + + expect(container.textContent).toContain("/issues"); + expect(mockExecutionWorkspacesApi.listOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/Workspaces.tsx b/ui/src/pages/Workspaces.tsx index 1143252540..52b3097b7d 100644 --- a/ui/src/pages/Workspaces.tsx +++ b/ui/src/pages/Workspaces.tsx @@ -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(); - 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(); - 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(); + 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 ; if (!isolatedWorkspacesEnabled) return ; @@ -129,20 +125,15 @@ export function Workspaces() { ) : (
{groups.map((group) => ( -
+
- {group.project.name} + {group.projectName} - {group.project.description ? ( -

- {group.project.description} -

- ) : null}
{group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"} @@ -150,12 +141,28 @@ export function Workspaces() {
))} + {overviewQuery.hasNextPage ? ( +
+

+ Showing {overviewItems.length} of {totalWorkspaceCount} workspaces. +

+ +
+ ) : null}
)}