From f3ac949ca8fcef460891f0b0c985a2c359a3eb72 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 24 May 2026 18:09:58 -0700 Subject: [PATCH] PAPA-422: surface accepted-plan decomposition state in operator UI Add a GET endpoint and corresponding UI panel that surfaces issue_plan_decomposition records so operators can see when an accepted plan revision has been decomposed and which child ids were created, without doing raw DB forensics. - Add GET /api/issues/:id/accepted-plan-decompositions - Service helper listAcceptedPlanDecompositions enriches each record with the plan revision number and per-child summaries - New IssuePlanDecompositionsSection renders on planning issues with status, child counts, child issue links, idempotent-reuse hint, and a deep link to the plan document - Activity row labels for issue.accepted_plan_decomposition_updated describe whether children were created, reused, or both - Regression test covering listAcceptedPlanDecompositions shape Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/issue.ts | 15 ++ server/src/__tests__/issues-service.test.ts | 37 ++++ server/src/routes/issues.ts | 12 ++ server/src/services/issues.ts | 61 +++++++ ui/src/api/issues.ts | 3 + .../IssuePlanDecompositionsSection.tsx | 160 ++++++++++++++++++ ui/src/lib/activity-format.ts | 35 ++++ ui/src/lib/queryKeys.ts | 2 + ui/src/pages/IssueDetail.tsx | 9 + 11 files changed, 338 insertions(+) create mode 100644 ui/src/components/IssuePlanDecompositionsSection.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3bba4d9512b..3f0bb3206ec 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -442,6 +442,8 @@ export type { AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, AcceptedPlanDecompositionResult, + AcceptedPlanDecompositionChildIssue, + AcceptedPlanDecompositionSummary, IssueThreadInteractionBase, SuggestTasksInteraction, AskUserQuestionsInteraction, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8a1f5aaa7d4..ee1125dfa9a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -211,6 +211,8 @@ export type { AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, AcceptedPlanDecompositionResult, + AcceptedPlanDecompositionChildIssue, + AcceptedPlanDecompositionSummary, IssueThreadInteractionBase, SuggestTasksInteraction, AskUserQuestionsInteraction, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 807311fc337..9637d977dde 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -179,6 +179,21 @@ export interface AcceptedPlanDecompositionResult { newlyCreatedChildIssueIds: string[]; } +export interface AcceptedPlanDecompositionChildIssue { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AcceptedPlanDecompositionSummary extends AcceptedPlanDecomposition { + acceptedPlanRevisionNumber: number | null; + childIssues: AcceptedPlanDecompositionChildIssue[]; +} + export interface IssueRelationIssueSummary { id: string; identifier: string | null; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 9b4d4aacd65..04566116e81 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3475,4 +3475,41 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { status: 409, }); }); + + it("lists persisted decompositions with child issue summaries", async () => { + const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue(); + + const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId); + expect(initial).toEqual([]); + + const result = await svc.decomposeAcceptedPlan(sourceIssueId, { + acceptedPlanRevisionId, + children: [ + { + title: "Surface decomposition status in operator UI", + status: "todo", + workMode: "standard", + priority: "medium", + }, + { + title: "Add regression coverage", + status: "todo", + workMode: "standard", + priority: "medium", + }, + ], + actorAgentId: assigneeAgentId, + }); + + const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId); + expect(decompositions).toHaveLength(1); + const [record] = decompositions; + expect(record?.status).toBe("completed"); + expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId); + expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number"); + expect(record?.childIssues.map((child) => child.id).sort()).toEqual( + [...result.childIssueIds].sort(), + ); + expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8b40f1f3bd7..c7bc674e481 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3333,6 +3333,18 @@ export function issueRoutes( res.status(201).json(issue); }); + router.get("/issues/:id/accepted-plan-decompositions", async (req, res) => { + const sourceIssueId = req.params.id as string; + const sourceIssue = await svc.getById(sourceIssueId); + if (!sourceIssue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, sourceIssue.companyId); + const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssue.id); + res.json(decompositions); + }); + router.post("/issues/:id/accepted-plan-decompositions", validate(createAcceptedPlanDecompositionSchema), async (req, res) => { const sourceIssueId = req.params.id as string; const sourceIssue = await svc.getById(sourceIssueId); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 772d9692801..43c929dad34 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4376,6 +4376,67 @@ export function issueService(db: Db) { }; }, + listAcceptedPlanDecompositions: async (sourceIssueId: string) => { + const sourceIssue = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, sourceIssueId)) + .then((rows) => rows[0] ?? null); + if (!sourceIssue) return []; + + const rows = await db + .select({ + decomposition: issuePlanDecompositions, + revisionNumber: documentRevisions.revisionNumber, + }) + .from(issuePlanDecompositions) + .leftJoin( + documentRevisions, + eq(documentRevisions.id, issuePlanDecompositions.acceptedPlanRevisionId), + ) + .where(and( + eq(issuePlanDecompositions.companyId, sourceIssue.companyId), + eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id), + )) + .orderBy(desc(issuePlanDecompositions.createdAt)); + + if (rows.length === 0) return []; + + const allChildIds = new Set(); + for (const row of rows) { + for (const childId of normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds)) { + allChildIds.add(childId); + } + } + + const childIssueRows = allChildIds.size > 0 + ? await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issues) + .where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, Array.from(allChildIds)))) + : []; + const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row])); + + return rows.map((row) => { + const childIds = normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds); + return { + ...row.decomposition, + acceptedPlanRevisionNumber: row.revisionNumber ?? null, + childIssues: childIds + .map((childId) => childIssueMap.get(childId) ?? null) + .filter((entry): entry is NonNullable => entry !== null), + }; + }); + }, + create: async ( companyId: string, data: IssueCreateInput, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 2e35416a3be..55925ed50ca 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,5 @@ import type { + AcceptedPlanDecompositionSummary, AskUserQuestionsAnswer, Approval, CreateIssueTreeHold, @@ -201,6 +202,8 @@ export const issuesApi = { }, listInteractions: (id: string) => api.get(`/issues/${id}/interactions`), + listAcceptedPlanDecompositions: (id: string) => + api.get(`/issues/${id}/accepted-plan-decompositions`), createInteraction: (id: string, data: Record) => api.post(`/issues/${id}/interactions`, data), acceptInteraction: ( diff --git a/ui/src/components/IssuePlanDecompositionsSection.tsx b/ui/src/components/IssuePlanDecompositionsSection.tsx new file mode 100644 index 00000000000..9ba29a10540 --- /dev/null +++ b/ui/src/components/IssuePlanDecompositionsSection.tsx @@ -0,0 +1,160 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Agent, AcceptedPlanDecompositionSummary } from "@paperclipai/shared"; +import { ChevronRight, GitBranch, Repeat, CheckCircle2, Loader2 } from "lucide-react"; +import { Link } from "@/lib/router"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { cn, formatDateTime, relativeTime } from "../lib/utils"; + +interface IssuePlanDecompositionsSectionProps { + issueId: string; + issueIdentifier: string | null; + agentMap?: Map; +} + +function StatusBadge({ status }: { status: AcceptedPlanDecompositionSummary["status"] }) { + if (status === "completed") { + return ( + + + Completed + + ); + } + return ( + + + In flight + + ); +} + +export function IssuePlanDecompositionsSection({ + issueId, + issueIdentifier, + agentMap, +}: IssuePlanDecompositionsSectionProps) { + const { data: decompositions } = useQuery({ + queryKey: queryKeys.issues.acceptedPlanDecompositions(issueId), + queryFn: () => issuesApi.listAcceptedPlanDecompositions(issueId), + }); + + const items = useMemo(() => decompositions ?? [], [decompositions]); + if (items.length === 0) return null; + + return ( +
+
+

Plan decomposition

+ + {items.length === 1 ? "1 accepted plan revision" : `${items.length} accepted plan revisions`} + +
+ +
    + {items.map((record) => { + const requested = record.requestedChildCount ?? 0; + const created = record.childIssues?.length ?? 0; + const ownerName = record.ownerAgentId + ? agentMap?.get(record.ownerAgentId)?.name ?? "agent" + : null; + const revisionLabel = + record.acceptedPlanRevisionNumber != null + ? `revision ${record.acceptedPlanRevisionNumber}` + : `revision ${record.acceptedPlanRevisionId.slice(0, 8)}`; + const completedAt = + record.completedAt && typeof record.completedAt === "string" + ? record.completedAt + : record.completedAt instanceof Date + ? record.completedAt.toISOString() + : null; + const updatedAt = + typeof record.updatedAt === "string" + ? record.updatedAt + : record.updatedAt instanceof Date + ? record.updatedAt.toISOString() + : null; + const startedAt = + typeof record.createdAt === "string" + ? record.createdAt + : record.createdAt instanceof Date + ? record.createdAt.toISOString() + : null; + + return ( +
  • +
    + + + Plan {revisionLabel} + + ยท + + + {created} of {requested} child {requested === 1 ? "issue" : "issues"} created + + {record.status === "completed" && requested > 0 ? ( + + + Idempotent claim + + ) : null} +
    + +
    + {ownerName ? Owner: {ownerName} : null} + {startedAt ? ( + Started {relativeTime(startedAt)} + ) : null} + {completedAt ? ( + Completed {relativeTime(completedAt)} + ) : updatedAt ? ( + Updated {relativeTime(updatedAt)} + ) : null} + {issueIdentifier ? ( + + Plan document + + ) : null} +
    + + {record.childIssues && record.childIssues.length > 0 ? ( +
      + {record.childIssues.map((child) => ( +
    • + + + {child.identifier ?? child.id.slice(0, 8)} + + + {child.title} + + + +
    • + ))} +
    + ) : null} +
  • + ); + })} +
+
+ ); +} diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index b27ef58a92f..ff09c225901 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -48,6 +48,7 @@ const ACTIVITY_ROW_VERBS: Record = { "issue.successful_run_handoff_required": "flagged missing next step on", "issue.successful_run_handoff_resolved": "recorded next step chosen on", "issue.successful_run_handoff_escalated": "escalated missing next step on", + "issue.accepted_plan_decomposition_updated": "updated accepted-plan decomposition on", "issue.recovery_action_opened": "opened a recovery action on", "issue.recovery_action_resolved": "resolved the recovery action on", "issue.recovery_action_escalated": "escalated the recovery action on", @@ -110,6 +111,7 @@ const ISSUE_ACTIVITY_LABELS: Record = { "issue.recovery_action_opened": "Opened a source-scoped recovery action", "issue.recovery_action_resolved": "Resolved the recovery action", "issue.recovery_action_escalated": "Escalated the recovery action", + "issue.accepted_plan_decomposition_updated": "updated the accepted-plan decomposition", "agent.created": "created an agent", "agent.updated": "updated the agent", "agent.paused": "paused the agent", @@ -189,6 +191,34 @@ function formatChangedEntityLabel( return `${labels.length} ${plural}`; } +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; +} + +function readStringArrayLength(value: unknown): number { + if (!Array.isArray(value)) return 0; + return value.filter((entry) => typeof entry === "string" && entry.length > 0).length; +} + +function formatAcceptedPlanDecompositionDetail(details: ActivityDetails): string | null { + if (!details) return null; + const status = typeof details.status === "string" ? details.status : null; + const requested = readNumber(details.requestedChildCount); + const totalChildren = readStringArrayLength(details.childIssueIds); + const newlyCreated = readStringArrayLength(details.newlyCreatedChildIssueIds); + const reused = Math.max(0, totalChildren - newlyCreated); + const parts: string[] = []; + if (newlyCreated > 0) parts.push(`created ${newlyCreated} new`); + if (reused > 0) parts.push(`reused ${reused} existing`); + if (parts.length === 0 && requested !== null) parts.push(`${requested} requested`); + const summary = parts.length > 0 ? parts.join(", ") : null; + if (status === "completed" && summary) return `decomposition completed (${summary})`; + if (status === "completed") return "decomposition completed"; + if (status === "in_flight" && summary) return `decomposition in flight (${summary})`; + return summary; +} + function formatIssueUpdatedVerb(details: ActivityDetails): string | null { if (!details) return null; const previous = asRecord(details._previous) ?? {}; @@ -332,6 +362,11 @@ export function formatIssueActivityAction( }); if (structuredChange) return structuredChange; + if (action === "issue.accepted_plan_decomposition_updated") { + const detail = formatAcceptedPlanDecompositionDetail(details); + if (detail) return detail; + } + if (action.startsWith("issue.monitor_") && details) { const serviceName = typeof details.serviceName === "string" && details.serviceName.trim() ? details.serviceName.trim() diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b41f55838b8..ee598b8460b 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -54,6 +54,8 @@ export const queryKeys = { detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, interactions: (issueId: string) => ["issues", "interactions", issueId] as const, + acceptedPlanDecompositions: (issueId: string) => + ["issues", "accepted-plan-decompositions", issueId] as const, feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const, costSummary: (issueId: string, options: { excludeRoot?: boolean } = {}) => options.excludeRoot diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index e674029a57a..311320e40f6 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -66,6 +66,7 @@ import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; +import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -3713,6 +3714,14 @@ export function IssueDetail() { )} + {issue.workMode === "planning" ? ( + + ) : null} +