[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:
Dotta 2026-06-25 21:38:14 -05:00 committed by GitHub
parent 3d7a4f12f9
commit 569b7affc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1242 additions and 104 deletions

View File

@ -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;

View File

@ -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,

View File

@ -206,6 +206,10 @@ export type {
ExecutionWorkspaceCloseLinkedIssue,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
WorkspaceOverviewItem,
WorkspaceOverviewLinkedIssue,
WorkspaceOverviewPrimaryService,
WorkspaceOverviewResponse,
ProjectWorkspaceRuntimeConfig,
WorkspaceCommandDefinition,
WorkspaceCommandKind,

View File

@ -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;

View File

@ -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>;

View File

@ -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 {

View File

@ -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();
});
});

View File

@ -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);

View File

@ -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);

View File

@ -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}",

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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?: {

View File

@ -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",

View File

@ -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;

View File

@ -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) });

View File

@ -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;
}

View File

@ -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);

View File

@ -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

View File

@ -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) });

View File

@ -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 };

View File

@ -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: [],
});
}

View File

@ -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,

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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>