diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 2fb153f3ce3..98cfc6a8fd1 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -10,6 +10,8 @@ describe("issue thread interaction schemas", () => { prompt: "Apply this plan?", acceptLabel: "Apply", rejectLabel: "Revise", + rejectRequiresReason: true, + rejectReasonLabel: "What needs to change?", detailsMarkdown: "The current plan document will be accepted as-is.", supersedeOnUserComment: true, }, @@ -22,6 +24,8 @@ describe("issue thread interaction schemas", () => { prompt: "Apply this plan?", acceptLabel: "Apply", rejectLabel: "Revise", + rejectRequiresReason: true, + rejectReasonLabel: "What needs to change?", supersedeOnUserComment: true, }, }); diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 88ecebeb191..08cacfc9b56 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -360,6 +360,8 @@ export interface RequestConfirmationPayload { prompt: string; acceptLabel?: string | null; rejectLabel?: string | null; + rejectRequiresReason?: boolean; + rejectReasonLabel?: string | null; detailsMarkdown?: string | null; supersedeOnUserComment?: boolean; target?: RequestConfirmationTarget | null; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 9f594654ca2..904c282d083 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -333,6 +333,8 @@ export const requestConfirmationPayloadSchema = z.object({ prompt: z.string().trim().min(1).max(1000), acceptLabel: z.string().trim().min(1).max(80).nullable().optional(), rejectLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectRequiresReason: z.boolean().optional(), + rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(), detailsMarkdown: z.string().max(20000).nullable().optional(), supersedeOnUserComment: z.boolean().optional(), target: requestConfirmationTargetSchema.nullable().optional(), diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 48368e62911..960c605830e 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -561,6 +561,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }, resolvedByUserId: "local-board", }); + + const requiresReason = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Decline only with a reason?", + rejectRequiresReason: true, + }, + }, { + userId: "local-board", + }); + + await expect(interactionsSvc.rejectInteraction({ + id: issueId, + companyId, + }, requiresReason.id, {}, { + userId: "local-board", + })).rejects.toThrow("A decline reason is required for this confirmation"); }); it("expires supersedable request confirmations when a user comments", async () => { diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index acf6267d791..51a41fd1d42 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -440,6 +440,12 @@ export function issueThreadInteractionService(db: Db) { return expired; } + const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction; + const reason = args.input.reason?.trim() ?? ""; + if (interaction.payload.rejectRequiresReason === true && reason.length === 0) { + throw unprocessable("A decline reason is required for this confirmation"); + } + const now = new Date(); const [updated] = await db .update(issueThreadInteractions) @@ -448,7 +454,7 @@ export function issueThreadInteractionService(db: Db) { result: { version: 1, outcome: "rejected", - reason: args.input.reason?.trim() || null, + reason: reason || null, }, resolvedByAgentId: args.actor.agentId ?? null, resolvedByUserId: args.actor.userId ?? null, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 5667af7b5fa..c2704128df1 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -12,7 +12,6 @@ import type { IssueLabel, IssueThreadInteraction, IssueWorkProduct, - SuggestTasksInteraction, UpsertIssueDocument, } from "@paperclipai/shared"; import { api } from "./client"; @@ -111,9 +110,9 @@ export const issuesApi = { interactionId: string, data?: { selectedClientKeys?: string[] }, ) => - api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), + api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => - api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), + api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), respondToInteraction: ( id: string, interactionId: string, diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 12ba7d5c6b1..058bf5ad501 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -49,6 +49,7 @@ import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction, + RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; import { isIssueThreadInteraction } from "../lib/issue-thread-interactions"; @@ -123,11 +124,11 @@ interface IssueChatMessageContext { interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( @@ -278,11 +279,11 @@ interface IssueChatThreadProps { stoppingRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 16ed40ea66d..8d7482f07f3 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -1,14 +1,17 @@ // @vitest-environment jsdom import { act } from "react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; +import { ThemeProvider } from "../context/ThemeContext"; import { TooltipProvider } from "./ui/tooltip"; import { pendingAskUserQuestionsInteraction, + pendingRequestConfirmationInteraction, pendingSuggestedTasksInteraction, + staleTargetRequestConfirmationInteraction, rejectedSuggestedTasksInteraction, } from "../fixtures/issueThreadInteractionFixtures"; @@ -17,6 +20,12 @@ let container: HTMLDivElement | null = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => ( + {children} + ), +})); + function renderCard( props: Partial> = {}, ) { @@ -27,10 +36,12 @@ function renderCard( act(() => { root?.render( - + + + , ); }); @@ -95,4 +106,84 @@ describe("IssueThreadInteractionCard", () => { expect(host.textContent).toContain("No reason provided."); }); + + it("requires a decline reason when the request confirmation payload asks for one", async () => { + const onRejectInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: pendingRequestConfirmationInteraction, + onRejectInteraction, + }); + + const declineButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Request revisions"), + ); + expect(declineButton).toBeTruthy(); + + await act(async () => { + declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const saveButton = Array.from(host.querySelectorAll("button")).filter((button) => + button.textContent?.includes("Request revisions"), + ).at(-1); + expect(saveButton?.hasAttribute("disabled")).toBe(true); + expect(host.textContent).toContain("A decline reason is required."); + + const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(textarea, "Needs a smaller phase split"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + }); + const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) => + button.textContent?.includes("Request revisions"), + ).at(-1); + expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false); + await act(async () => { + enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onRejectInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + "Needs a smaller phase split", + ); + }); + + it("invokes the confirm callback with pending request confirmations", async () => { + const onAcceptInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: pendingRequestConfirmationInteraction, + onAcceptInteraction, + }); + + const confirmButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Approve plan"), + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + ); + }); + + it("renders request confirmation target links and stale-target expiry", () => { + const host = renderCard({ + interaction: staleTargetRequestConfirmationInteraction, + }); + + const targetLink = host.querySelector("a"); + expect(host.textContent).toContain("Expired by target change"); + expect(host.textContent).toContain("Document: plan rev 3"); + expect(targetLink?.getAttribute("href")).toContain("#document-plan"); + expect(host.textContent).not.toContain("Approve plan"); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index b9762e25007..ee5686b97f7 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import type { Agent } from "@paperclipai/shared"; -import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { @@ -11,6 +11,7 @@ import { type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type IssueThreadInteraction, + type RequestConfirmationInteraction, type SuggestTasksInteraction, type SuggestTasksResultCreatedTask, type SuggestedTaskDraft, @@ -30,11 +31,11 @@ interface IssueThreadInteractionCardProps { currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction, + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( @@ -80,7 +81,16 @@ function statusLabel(status: IssueThreadInteraction["status"]) { } function interactionKindLabel(kind: IssueThreadInteraction["kind"]) { - return kind === "suggest_tasks" ? "Suggested tasks" : "Ask user questions"; + switch (kind) { + case "suggest_tasks": + return "Suggested tasks"; + case "ask_user_questions": + return "Ask user questions"; + case "request_confirmation": + return "Request confirmation"; + default: + return kind; + } } function statusIcon(status: IssueThreadInteraction["status"]) { @@ -816,6 +826,230 @@ function AskUserQuestionsCard({ ); } +function RequestConfirmationTargetLink({ + interaction, +}: { + interaction: RequestConfirmationInteraction; +}) { + const target = interaction.payload.target ?? interaction.result?.staleTarget ?? null; + if (!target) return null; + + if (target.type === "issue_document") { + const issueId = target.issueId ?? interaction.issueId; + return ( + + + + Document: {target.key} + {target.revisionNumber ? ` rev ${target.revisionNumber}` : ""} + + + + ); + } + + return null; +} + +function RequestConfirmationResolution({ + interaction, +}: { + interaction: RequestConfirmationInteraction; +}) { + const outcome = interaction.result?.outcome; + + if (interaction.status === "accepted") { + return ( +
+
+ Confirmed +
+

The request was confirmed and is now disabled in the thread.

+
+ ); + } + + if (interaction.status === "rejected") { + return ( +
+
+ Declined +
+

+ {interaction.result?.reason || "No reason provided."} +

+
+ ); + } + + if (interaction.status === "expired") { + const expiredByComment = outcome === "superseded_by_comment"; + return ( +
+
+ {expiredByComment ? "Expired by comment" : "Expired by target change"} +
+

+ {expiredByComment + ? "A board comment superseded this confirmation before it was resolved." + : "The requested target changed before this confirmation was resolved."} +

+
+ ); + } + + return null; +} + +function RequestConfirmationCard({ + interaction, + onAcceptInteraction, + onRejectInteraction, +}: { + interaction: RequestConfirmationInteraction; + onAcceptInteraction?: ( + interaction: RequestConfirmationInteraction, + ) => Promise | void; + onRejectInteraction?: ( + interaction: RequestConfirmationInteraction, + reason?: string, + ) => Promise | void; +}) { + const [rejecting, setRejecting] = useState(false); + const [working, setWorking] = useState<"accept" | "reject" | null>(null); + const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? ""); + const [actionError, setActionError] = useState(null); + const rejectRequiresReason = interaction.payload.rejectRequiresReason === true; + const trimmedRejectReason = rejectReason.trim(); + const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0; + + useEffect(() => { + setRejectReason(interaction.result?.reason ?? ""); + setActionError(null); + if (interaction.status !== "pending") { + setRejecting(false); + setWorking(null); + } + }, [interaction.id, interaction.result?.reason, interaction.status]); + + async function handleAccept() { + if (!onAcceptInteraction) return; + setWorking("accept"); + setActionError(null); + try { + await onAcceptInteraction(interaction); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Unable to confirm this request"); + } finally { + setWorking(null); + } + } + + async function handleReject() { + if (!onRejectInteraction || !canReject) return; + setWorking("reject"); + setActionError(null); + try { + await onRejectInteraction(interaction, trimmedRejectReason || undefined); + setRejecting(false); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Unable to decline this request"); + } finally { + setWorking(null); + } + } + + return ( +
+
+
+ {interaction.payload.prompt} +
+ {interaction.payload.detailsMarkdown ? ( +
+ {interaction.payload.detailsMarkdown} +
+ ) : null} + +
+ + {interaction.status === "pending" ? ( +
+
+ + +
+ + {rejecting ? ( +
+