diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 04ec0b35e54..90e97db13d5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -157,6 +157,7 @@ export type IssueThreadInteractionStatus = (typeof ISSUE_THREAD_INTERACTION_STAT export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [ "none", "wake_assignee", + "wake_assignee_on_accept", ] as const; export type IssueThreadInteractionContinuationPolicy = (typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0514d182738..3fcf5e4a51f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -317,6 +317,7 @@ export type { AskUserQuestionsAnswer, AskUserQuestionsResult, RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, @@ -595,6 +596,7 @@ export { askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, requestConfirmationTargetSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 98cfc6a8fd1..2419353b39f 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -12,6 +12,7 @@ describe("issue thread interaction schemas", () => { rejectLabel: "Revise", rejectRequiresReason: true, rejectReasonLabel: "What needs to change?", + declineReasonPlaceholder: "Optional: tell the agent what you'd change.", detailsMarkdown: "The current plan document will be accepted as-is.", supersedeOnUserComment: true, }, @@ -26,6 +27,8 @@ describe("issue thread interaction schemas", () => { rejectLabel: "Revise", rejectRequiresReason: true, rejectReasonLabel: "What needs to change?", + allowDeclineReason: true, + declineReasonPlaceholder: "Optional: tell the agent what you'd change.", supersedeOnUserComment: true, }, }); @@ -34,10 +37,11 @@ describe("issue thread interaction schemas", () => { it("accepts issue document targets for request_confirmation interactions", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", - continuationPolicy: "wake_assignee", + continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Accept the latest plan revision?", + allowDeclineReason: false, target: { type: "issue_document", issueId: "11111111-1111-4111-8111-111111111111", @@ -45,6 +49,8 @@ describe("issue thread interaction schemas", () => { key: "plan", revisionId: "33333333-3333-4333-8333-333333333333", revisionNumber: 2, + label: "Plan v2", + href: "/issues/PAP-123#document-plan", }, }, }); @@ -55,6 +61,34 @@ describe("issue thread interaction schemas", () => { type: "issue_document", key: "plan", revisionNumber: 2, + label: "Plan v2", + href: "/issues/PAP-123#document-plan", + }); + }); + + it("accepts custom targets for request_confirmation interactions", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the external checklist?", + target: { + type: "custom", + key: "external-checklist", + revisionId: "checklist-v1", + revisionNumber: 1, + label: "Checklist v1", + href: "https://example.com/checklist", + }, + }, + }); + + expect(parsed.kind).toBe("request_confirmation"); + if (parsed.kind !== "request_confirmation") return; + expect(parsed.payload.target).toMatchObject({ + type: "custom", + key: "external-checklist", + label: "Checklist v1", }); }); }); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 187ecf5d472..7a6433751ee 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -125,6 +125,7 @@ export type { AskUserQuestionsAnswer, AskUserQuestionsResult, RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 08cacfc9b56..f105ab5c8ed 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -351,9 +351,22 @@ export interface RequestConfirmationIssueDocumentTarget { key: string; revisionId: string; revisionNumber?: number | null; + label?: string | null; + href?: string | null; } -export type RequestConfirmationTarget = RequestConfirmationIssueDocumentTarget; +export interface RequestConfirmationCustomTarget { + type: "custom"; + key: string; + revisionId?: string | null; + revisionNumber?: number | null; + label?: string | null; + href?: string | null; +} + +export type RequestConfirmationTarget = + | RequestConfirmationIssueDocumentTarget + | RequestConfirmationCustomTarget; export interface RequestConfirmationPayload { version: 1; @@ -362,6 +375,8 @@ export interface RequestConfirmationPayload { rejectLabel?: string | null; rejectRequiresReason?: boolean; rejectReasonLabel?: string | null; + allowDeclineReason?: boolean; + declineReasonPlaceholder?: string | null; detailsMarkdown?: string | null; supersedeOnUserComment?: boolean; target?: RequestConfirmationTarget | null; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f2af85ea983..b7ae92fd058 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -155,6 +155,7 @@ export { askUserQuestionsAnswerSchema, askUserQuestionsResultSchema, requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, requestConfirmationTargetSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 904c282d083..e47c02a7dbf 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -315,7 +315,17 @@ export const askUserQuestionsResultSchema = z.object({ summaryMarkdown: z.string().max(20000).nullable().optional(), }); -export const requestConfirmationIssueDocumentTargetSchema = z.object({ +const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => { + const lower = value.toLowerCase(); + return !lower.startsWith("javascript:") && !lower.startsWith("data:"); +}, "href must not use javascript: or data: URLs"); + +const requestConfirmationTargetBaseSchema = z.object({ + label: z.string().trim().min(1).max(120).nullable().optional(), + href: requestConfirmationHrefSchema.nullable().optional(), +}); + +export const requestConfirmationIssueDocumentTargetSchema = requestConfirmationTargetBaseSchema.extend({ type: z.literal("issue_document"), issueId: z.string().uuid().nullable().optional(), documentId: z.string().uuid().nullable().optional(), @@ -324,8 +334,16 @@ export const requestConfirmationIssueDocumentTargetSchema = z.object({ revisionNumber: z.number().int().positive().nullable().optional(), }); +export const requestConfirmationCustomTargetSchema = requestConfirmationTargetBaseSchema.extend({ + type: z.literal("custom"), + key: z.string().trim().min(1).max(120), + revisionId: z.string().trim().min(1).max(255).nullable().optional(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [ requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, ]); export const requestConfirmationPayloadSchema = z.object({ @@ -335,6 +353,8 @@ export const requestConfirmationPayloadSchema = z.object({ rejectLabel: z.string().trim().min(1).max(80).nullable().optional(), rejectRequiresReason: z.boolean().optional(), rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(), + allowDeclineReason: z.boolean().optional().default(true), + declineReasonPlaceholder: z.string().trim().min(1).max(240).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-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 075e89b8560..8db09e587da 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -360,7 +360,7 @@ describe("issue thread interaction routes", () => { ); }); - it("accepts request confirmations and wakes the current assignee when configured", async () => { + it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => { mockInteractionService.acceptInteraction.mockResolvedValueOnce({ interaction: { id: "interaction-3", @@ -368,7 +368,7 @@ describe("issue thread interaction routes", () => { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", kind: "request_confirmation", status: "accepted", - continuationPolicy: "wake_assignee", + continuationPolicy: "wake_assignee_on_accept", idempotencyKey: null, sourceCommentId: null, sourceRunId: "run-3", @@ -414,7 +414,7 @@ describe("issue thread interaction routes", () => { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", kind: "request_confirmation", status: "rejected", - continuationPolicy: "wake_assignee", + continuationPolicy: "wake_assignee_on_accept", idempotencyKey: null, sourceCommentId: null, sourceRunId: "run-3", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 960c605830e..a3290b6a7ae 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -754,6 +754,14 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(accepted.interaction).toMatchObject({ id: created.id, status: "expired", + payload: { + target: { + type: "issue_document", + key: "plan", + revisionId: nextRevisionId, + revisionNumber: 2, + }, + }, result: { version: 1, outcome: "stale_target", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 65fc74d8c57..dbd7c8b69c9 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -204,7 +204,10 @@ function queueResolvedInteractionContinuationWakeup(input: { actor: { actorType: "user" | "agent"; actorId: string }; source: string; }) { - if (input.interaction.continuationPolicy !== "wake_assignee") return; + if ( + input.interaction.continuationPolicy !== "wake_assignee" + && input.interaction.continuationPolicy !== "wake_assignee_on_accept" + ) return; if (input.interaction.kind === "request_confirmation" && input.interaction.status !== "accepted") return; if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 51a41fd1d42..86a05a671e8 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -250,9 +250,7 @@ async function getIssueDocumentTargetSnapshot(db: Db | any, args: { issueId: string; target: RequestConfirmationTarget; }) { - if (args.target.type !== "issue_document") { - throw unprocessable(`Unsupported request confirmation target: ${(args.target as { type: string }).type}`); - } + if (args.target.type !== "issue_document") return null; const targetIssueId = args.target.issueId ?? args.issueId; const row = await db .select({ @@ -282,12 +280,49 @@ async function getIssueDocumentTargetSnapshot(db: Db | any, args: { return row; } +function buildIssueDocumentTargetFromSnapshot(args: { + issueId: string; + snapshot: { + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + } | null; +}): RequestConfirmationTarget | null { + if (!args.snapshot?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.snapshot.issueId ?? args.issueId, + documentId: args.snapshot.documentId, + key: args.snapshot.key, + revisionId: args.snapshot.latestRevisionId, + revisionNumber: args.snapshot.latestRevisionNumber, + }; +} + +function buildIssueDocumentTargetFromDocument(args: { + issueId: string; + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null; +}): RequestConfirmationTarget | null { + if (!args.document?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.issueId, + documentId: args.document.id, + key: args.document.key, + revisionId: args.document.latestRevisionId, + revisionNumber: args.document.latestRevisionNumber ?? null, + }; +} + async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: { companyId: string; issueId: string; target?: RequestConfirmationTarget | null; }) { if (!args.target) return; + if (args.target.type !== "issue_document") return; const snapshot = await getIssueDocumentTargetSnapshot(db, { companyId: args.companyId, issueId: args.issueId, @@ -309,6 +344,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; const target = interaction.payload.target ?? null; if (!target) return null; + if (target.type !== "issue_document") return null; const snapshot = await getIssueDocumentTargetSnapshot(db, { companyId: args.row.companyId, @@ -322,10 +358,20 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { if (isCurrent) return null; const now = new Date(); + const currentTarget = buildIssueDocumentTargetFromSnapshot({ + issueId: args.row.issueId, + snapshot, + }); const [updated] = await db .update(issueThreadInteractions) .set({ status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, result: { version: 1, outcome: "stale_target", @@ -907,11 +953,22 @@ export function issueThreadInteractionService(db: Db) { const now = new Date(); const expired: IssueThreadInteraction[] = []; for (const row of staleRows) { - const target = (hydrateInteraction(row) as RequestConfirmationInteraction).payload.target ?? null; + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + const currentTarget = buildIssueDocumentTargetFromDocument({ + issueId: issue.id, + document, + }); const [updated] = await db .update(issueThreadInteractions) .set({ status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, result: { version: 1, outcome: "stale_target", diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 8d7482f07f3..fe9d14d7183 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -9,6 +9,9 @@ import { ThemeProvider } from "../context/ThemeContext"; import { TooltipProvider } from "./ui/tooltip"; import { pendingAskUserQuestionsInteraction, + commentExpiredRequestConfirmationInteraction, + disabledDeclineReasonRequestConfirmationInteraction, + failedRequestConfirmationInteraction, pendingRequestConfirmationInteraction, pendingSuggestedTasksInteraction, staleTargetRequestConfirmationInteraction, @@ -126,11 +129,17 @@ describe("IssueThreadInteractionCard", () => { const saveButton = Array.from(host.querySelectorAll("button")).filter((button) => button.textContent?.includes("Request revisions"), ).at(-1); - expect(saveButton?.hasAttribute("disabled")).toBe(true); + expect(saveButton?.hasAttribute("disabled")).toBe(false); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(host.textContent).toContain("A decline reason is required."); const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null; expect(textarea).toBeTruthy(); + expect(textarea?.getAttribute("aria-invalid")).toBe("true"); await act(async () => { const valueSetter = Object.getOwnPropertyDescriptor( @@ -175,15 +184,75 @@ describe("IssueThreadInteractionCard", () => { ); }); + it("labels accept-only continuation policies in the card header", () => { + const host = renderCard({ + interaction: { + ...pendingRequestConfirmationInteraction, + continuationPolicy: "wake_assignee_on_accept", + }, + }); + + expect(host.textContent).toContain("Wakes on confirm"); + }); + it("renders request confirmation target links and stale-target expiry", () => { const host = renderCard({ interaction: staleTargetRequestConfirmationInteraction, }); - const targetLink = host.querySelector("a"); + const targetLinks = host.querySelectorAll("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).toContain("Plan v3"); + expect(host.textContent).toContain("Plan v4"); + expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan"); + expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan"); expect(host.textContent).not.toContain("Approve plan"); }); + + it("renders a jump link for confirmations expired by comment", () => { + const host = renderCard({ + interaction: commentExpiredRequestConfirmationInteraction, + }); + + const jumpLink = Array.from(host.querySelectorAll("a")).find((link) => + link.textContent?.includes("Jump to comment"), + ); + + expect(jumpLink?.getAttribute("href")).toBe( + "#comment-22222222-2222-4222-8222-222222222222", + ); + }); + + it("declines immediately when decline reasons are disabled", async () => { + const onRejectInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: disabledDeclineReasonRequestConfirmationInteraction, + onRejectInteraction, + }); + + const declineButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Keep it"), + ); + expect(declineButton).toBeTruthy(); + + await act(async () => { + declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(host.querySelector("textarea")).toBeNull(); + expect(onRejectInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + undefined, + ); + }); + + it("renders explicit copy for failed request confirmations", () => { + const host = renderCard({ + interaction: failedRequestConfirmationInteraction, + }); + + expect(host.textContent).toContain( + "This request could not be resolved. Try again or create a new request.", + ); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index ee5686b97f7..c65f8b4f2f9 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, FileText, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { @@ -12,6 +12,7 @@ import { type AskUserQuestionsInteraction, type IssueThreadInteraction, type RequestConfirmationInteraction, + type RequestConfirmationTarget, type SuggestTasksInteraction, type SuggestTasksResultCreatedTask, type SuggestedTaskDraft, @@ -87,7 +88,7 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) { case "ask_user_questions": return "Ask user questions"; case "request_confirmation": - return "Request confirmation"; + return "Confirmation"; default: return kind; } @@ -826,32 +827,69 @@ function AskUserQuestionsCard({ ); } -function RequestConfirmationTargetLink({ +function requestConfirmationTargetLabel(target: RequestConfirmationTarget) { + if (target.label) return target.label; + const revision = target.revisionNumber ? ` v${target.revisionNumber}` : ""; + if (target.type === "issue_document" && target.key === "plan") { + return `Plan${revision}`; + } + return `${target.key}${revision}`; +} + +function requestConfirmationTargetHref({ interaction, + target, }: { interaction: RequestConfirmationInteraction; + target: RequestConfirmationTarget; }) { - const target = interaction.payload.target ?? interaction.result?.staleTarget ?? null; - if (!target) return null; - + if (target.href) return target.href; if (target.type === "issue_document") { const issueId = target.issueId ?? interaction.issueId; + return `/issues/${issueId}#document-${encodeURIComponent(target.key)}`; + } + return null; +} + +function RequestConfirmationTargetChip({ + interaction, + target, + tone = "default", +}: { + interaction: RequestConfirmationInteraction; + target: RequestConfirmationTarget | null | undefined; + tone?: "default" | "subtle"; +}) { + if (!target) return null; + + const href = requestConfirmationTargetHref({ interaction, target }); + const className = cn( + "inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]", + tone === "default" + ? "border-border/70 bg-transparent text-foreground" + : "border-border/60 bg-transparent text-muted-foreground", + href && "transition-colors hover:border-sky-500/70 hover:bg-sky-500/10", + ); + const content = ( + <> + + {requestConfirmationTargetLabel(target)} + + ); + + if (!href) return {content}; + if (/^https?:\/\//i.test(href)) { return ( - - - - Document: {target.key} - {target.revisionNumber ? ` rev ${target.revisionNumber}` : ""} - - - + + {content} + ); } - - return null; + return ( + + {content} + + ); } function RequestConfirmationResolution({ @@ -860,50 +898,77 @@ function RequestConfirmationResolution({ interaction: RequestConfirmationInteraction; }) { const outcome = interaction.result?.outcome; + const target = interaction.payload.target ?? null; + const staleTarget = interaction.result?.staleTarget ?? null; if (interaction.status === "accepted") { return ( -
-
- Confirmed -
-

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

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

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

+ {interaction.result?.reason ? ( +
+ {interaction.result.reason} +
+ ) : null}
); } if (interaction.status === "expired") { const expiredByComment = outcome === "superseded_by_comment"; + const expiredByTargetChange = outcome === "stale_target"; 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."}

+ {expiredByComment && interaction.result?.commentId ? ( + + ) : null} + {expiredByTargetChange ? ( +
+ + {staleTarget && target ? ( + + ) : null} + +
+ ) : null}
); } + if (interaction.status === "failed") { + return ( +

+ This request could not be resolved. Try again or create a new request. +

+ ); + } + return null; } @@ -924,13 +989,22 @@ function RequestConfirmationCard({ const [rejecting, setRejecting] = useState(false); const [working, setWorking] = useState<"accept" | "reject" | null>(null); const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? ""); + const [rejectAttempted, setRejectAttempted] = useState(false); const [actionError, setActionError] = useState(null); const rejectRequiresReason = interaction.payload.rejectRequiresReason === true; + const allowDeclineReason = interaction.payload.allowDeclineReason !== false; const trimmedRejectReason = rejectReason.trim(); const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0; + const declineReasonInvalid = rejectRequiresReason && !canReject; + const declineReasonPlaceholder = + interaction.payload.declineReasonPlaceholder + ?? (interaction.payload.acceptLabel === "Approve plan" + ? "Optional: what would you like revised?" + : "Optional: tell the agent what you'd change."); useEffect(() => { setRejectReason(interaction.result?.reason ?? ""); + setRejectAttempted(false); setActionError(null); if (interaction.status !== "pending") { setRejecting(false); @@ -944,22 +1018,23 @@ function RequestConfirmationCard({ setActionError(null); try { await onAcceptInteraction(interaction); - } catch (error) { - setActionError(error instanceof Error ? error.message : "Unable to confirm this request"); + } catch { + setActionError("Try again"); } finally { setWorking(null); } } async function handleReject() { + setRejectAttempted(true); 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"); + } catch { + setActionError("Try again"); } finally { setWorking(null); } @@ -967,23 +1042,29 @@ function RequestConfirmationCard({ return (
-
-
- {interaction.payload.prompt} -
- {interaction.payload.detailsMarkdown ? ( -
- {interaction.payload.detailsMarkdown} + {interaction.status === "pending" ? ( +
+
+ {interaction.payload.prompt}
- ) : null} - -
+ {interaction.payload.detailsMarkdown ? ( +
+ {interaction.payload.detailsMarkdown} +
+ ) : null} + +
+ ) : null} {interaction.status === "pending" ? (
@@ -1011,17 +1099,33 @@ function RequestConfirmationCard({