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,
|
agents,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
executionWorkspaces,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueInboxArchives,
|
issueInboxArchives,
|
||||||
issues,
|
issues,
|
||||||
|
projects,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
|
|
@ -40,6 +42,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projects);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
});
|
});
|
||||||
|
|
@ -219,6 +223,86 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
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 () => {
|
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const userId = "user-1";
|
const userId = "user-1";
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
inboxArchivedByUserId,
|
inboxArchivedByUserId,
|
||||||
unreadForUserId,
|
unreadForUserId,
|
||||||
projectId: req.query.projectId as string | undefined,
|
projectId: req.query.projectId as string | undefined,
|
||||||
|
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
|
||||||
parentId: req.query.parentId as string | undefined,
|
parentId: req.query.parentId as string | undefined,
|
||||||
labelId: req.query.labelId as string | undefined,
|
labelId: req.query.labelId as string | undefined,
|
||||||
originKind: req.query.originKind as string | undefined,
|
originKind: req.query.originKind as string | undefined,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export interface IssueFilters {
|
||||||
inboxArchivedByUserId?: string;
|
inboxArchivedByUserId?: string;
|
||||||
unreadForUserId?: string;
|
unreadForUserId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
|
|
@ -647,6 +648,9 @@ export function issueService(db: Db) {
|
||||||
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
||||||
}
|
}
|
||||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
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?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||||
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export const issuesApi = {
|
||||||
inboxArchivedByUserId?: string;
|
inboxArchivedByUserId?: string;
|
||||||
unreadForUserId?: string;
|
unreadForUserId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
|
|
@ -40,6 +41,7 @@ export const issuesApi = {
|
||||||
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
|
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
|
||||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
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?.originKind) params.set("originKind", filters.originKind);
|
||||||
if (filters?.originId) params.set("originId", filters.originId);
|
if (filters?.originId) params.set("originId", filters.originId);
|
||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export const queryKeys = {
|
||||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||||
listByProject: (companyId: string, projectId: string) =>
|
listByProject: (companyId: string, projectId: string) =>
|
||||||
["issues", companyId, "project", projectId] as const,
|
["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,
|
detail: (id: string) => ["issues", "detail", id] as const,
|
||||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,14 @@ export function ExecutionWorkspaceDetail() {
|
||||||
enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId),
|
enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId),
|
||||||
});
|
});
|
||||||
const derivedWorkspace = derivedWorkspaceQuery.data ?? null;
|
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(
|
const linkedProjectWorkspace = useMemo(
|
||||||
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
|
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
|
||||||
|
|
@ -785,6 +793,55 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<ExecutionWorkspaceCloseDialog
|
<ExecutionWorkspaceCloseDialog
|
||||||
workspaceId={workspace.id}
|
workspaceId={workspace.id}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue