From d9005405b97e6758712baac4d027c2a281cbe625 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 22:21:24 -0500 Subject: [PATCH] Add linked issues row to execution workspace detail Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 84 +++++++++++++++++++++ server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 4 + ui/src/api/issues.ts | 2 + ui/src/lib/queryKeys.ts | 2 + ui/src/pages/ExecutionWorkspaceDetail.tsx | 57 ++++++++++++++ 6 files changed, 150 insertions(+) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index c5aef4b3..ee79d514 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -5,9 +5,11 @@ import { agents, companies, createDb, + executionWorkspaces, issueComments, issueInboxArchives, issues, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -40,6 +42,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(issueInboxArchives); await db.delete(activityLog); await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projects); await db.delete(agents); await db.delete(companies); }); @@ -219,6 +223,86 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); }); + it("filters issues by execution workspace id", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const targetWorkspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const linkedIssueId = randomUUID(); + const otherLinkedIssueId = randomUUID(); + const unlinkedIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(executionWorkspaces).values([ + { + id: targetWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Target workspace", + status: "active", + providerType: "local_fs", + }, + { + id: otherWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Other workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values([ + { + id: linkedIssueId, + companyId, + projectId, + title: "Linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: targetWorkspaceId, + }, + { + id: otherLinkedIssueId, + companyId, + projectId, + title: "Other linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: otherWorkspaceId, + }, + { + id: unlinkedIssueId, + companyId, + projectId, + title: "Unlinked issue", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId }); + + expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]); + }); + it("hides archived inbox issues until new external activity arrives", async () => { const companyId = randomUUID(); const userId = "user-1"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 11ec162c..1fb20fa5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -275,6 +275,7 @@ export function issueRoutes(db: Db, storage: StorageService) { inboxArchivedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 086f4658..29f6ca49 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -70,6 +70,7 @@ export interface IssueFilters { inboxArchivedByUserId?: string; unreadForUserId?: string; projectId?: string; + executionWorkspaceId?: string; parentId?: string; labelId?: string; originKind?: string; @@ -647,6 +648,9 @@ export function issueService(db: Db) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.executionWorkspaceId) { + conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); + } if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 436c6dfd..7f47817f 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -24,6 +24,7 @@ export const issuesApi = { inboxArchivedByUserId?: string; unreadForUserId?: string; labelId?: string; + executionWorkspaceId?: string; originKind?: string; originId?: string; includeRoutineExecutions?: boolean; @@ -40,6 +41,7 @@ export const issuesApi = { if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.labelId) params.set("labelId", filters.labelId); + if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); if (filters?.originId) params.set("originId", filters.originId); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 582a6177..61d01d39 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -39,6 +39,8 @@ export const queryKeys = { labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, + listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => + ["issues", companyId, "execution-workspace", executionWorkspaceId] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index a68f6c38..c0db7f88 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -249,6 +249,14 @@ export function ExecutionWorkspaceDetail() { enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId), }); const derivedWorkspace = derivedWorkspaceQuery.data ?? null; + const linkedIssuesQuery = useQuery({ + queryKey: workspace + ? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) + : ["issues", "__execution-workspace__", "__none__"], + queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }), + enabled: Boolean(workspace?.companyId), + }); + const linkedIssues = linkedIssuesQuery.data ?? []; const linkedProjectWorkspace = useMemo( () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, @@ -785,6 +793,55 @@ export function ExecutionWorkspaceDetail() { + +
+
+
+
Linked issues
+

Issues using this workspace

+

+ Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it. +

+
+ {linkedIssues.length} linked +
+ + {linkedIssuesQuery.isLoading ? ( +

Loading linked issues…

+ ) : linkedIssuesQuery.error ? ( +

+ {linkedIssuesQuery.error instanceof Error + ? linkedIssuesQuery.error.message + : "Failed to load linked issues."} +

+ ) : linkedIssues.length > 0 ? ( +
+ {linkedIssues.map((issue) => ( + +
+
+
+ {issue.identifier ?? issue.id.slice(0, 8)} +
+
{issue.title}
+
+ {issue.status} +
+
+ {issue.priority} + {formatDateTime(issue.updatedAt)} +
+ + ))} +
+ ) : ( +

No issues are currently linked to this execution workspace.

+ )} +