Add linked issues row to execution workspace detail
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e3f07aad55
commit
d9005405b9
6 changed files with 150 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
||||
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{linkedIssues.length} linked</StatusPill>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{linkedIssuesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading linked issues…</p>
|
||||
) : linkedIssuesQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{linkedIssuesQuery.error instanceof Error
|
||||
? linkedIssuesQuery.error.message
|
||||
: "Failed to load linked issues."}
|
||||
</p>
|
||||
) : linkedIssues.length > 0 ? (
|
||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={issueUrl(issue)}
|
||||
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
|
||||
</div>
|
||||
<StatusPill className="shrink-0">{issue.status}</StatusPill>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
|
||||
<span>{formatDateTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={workspace.id}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue