Fix confirmation card UX review findings

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 17:33:57 -05:00
1 parent a5d1c2d5e4
commit 3a94e79870
15 files changed
+648 -77

No files matched your search

+1
View File
@@ -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];
+2
View File
@@ -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,
@@ -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",
});
});
});
+1
View File
@@ -125,6 +125,7 @@ export type {
AskUserQuestionsAnswer,
AskUserQuestionsResult,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationCustomTarget,
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
+16 -1
View File
@@ -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;
+1
View File
@@ -155,6 +155,7 @@ export {
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationCustomTargetSchema,
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
+21 -1
View File
@@ -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(),
@@ -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",
@@ -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",
+4 -1
View File
@@ -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;
@@ -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",
@@ -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.",
);
});
});
+164 -57
View File
@@ -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 = (
<>
<GitBranch className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate">{requestConfirmationTargetLabel(target)}</span>
</>
);
if (!href) return <span className={className}>{content}</span>;
if (/^https?:\/\//i.test(href)) {
return (
<Link
to={`/issues/${issueId}#document-${encodeURIComponent(target.key)}`}
className="inline-flex items-center gap-2 rounded-sm border border-border/70 bg-transparent px-3 py-2 text-xs font-medium text-foreground transition-colors hover:border-sky-500/70 hover:bg-sky-500/10"
>
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="min-w-0 truncate">
Document: {target.key}
{target.revisionNumber ? ` rev ${target.revisionNumber}` : ""}
</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</Link>
<a href={href} target="_blank" rel="noreferrer" className={className}>
{content}
</a>
);
}
return null;
return (
<Link to={href} className={className}>
{content}
</Link>
);
}
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 (
<div className="rounded-sm border border-emerald-500/60 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-900 dark:text-emerald-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
Confirmed
</div>
<p className="mt-1 leading-6">The request was confirmed and is now disabled in the thread.</p>
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Confirmed</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
);
}
if (interaction.status === "rejected") {
return (
<div className="rounded-sm border border-rose-500/60 bg-rose-500/10 px-4 py-3 text-sm text-rose-900 dark:text-rose-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-700">
Declined
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
<p className={cn(
"mt-1 leading-6",
!interaction.result?.reason && "text-rose-900/75",
)}>
{interaction.result?.reason || "No reason provided."}
</p>
{interaction.result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{interaction.result.reason}
</blockquote>
) : null}
</div>
);
}
if (interaction.status === "expired") {
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="mt-1 leading-6">
<p className="leading-6">
{expiredByComment
? "A board comment superseded this confirmation before it was resolved."
: "The requested target changed before this confirmation was resolved."}
</p>
{expiredByComment && interaction.result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${interaction.result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
interaction={interaction}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
) : null}
</div>
);
}
if (interaction.status === "failed") {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
}
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<string | null>(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 (
<div className="space-y-4">
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="text-sm font-semibold leading-6 text-foreground">
{interaction.payload.prompt}
</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
{interaction.status === "pending" ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="text-sm leading-6 text-foreground">
{interaction.payload.prompt}
</div>
) : null}
<RequestConfirmationTargetLink interaction={interaction} />
</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
interaction={interaction}
target={interaction.payload.target}
/>
</div>
) : null}
{interaction.status === "pending" ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant={rejecting ? "outline" : "default"}
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
@@ -1000,7 +1081,14 @@ function RequestConfirmationCard({
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => setRejecting((current) => !current)}
onClick={() => {
if (!allowDeclineReason) {
void handleReject();
return;
}
setRejectAttempted(false);
setRejecting((current) => !current);
}}
>
{interaction.payload.rejectLabel ?? "Decline"}
</Button>
@@ -1011,17 +1099,33 @@ function RequestConfirmationCard({
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder={interaction.payload.rejectReasonLabel ?? "Add a short reason for declining this request"}
className="min-h-24 bg-background text-sm"
placeholder={declineReasonPlaceholder}
aria-invalid={rejectAttempted && declineReasonInvalid}
className={cn(
"min-h-24 bg-background text-sm",
rejectAttempted && declineReasonInvalid
&& "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{rejectRequiresReason && !canReject ? (
{rejectAttempted && declineReasonInvalid ? (
<p className="text-xs text-destructive">A decline reason is required.</p>
) : null}
<div className="flex justify-end">
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
}}
>
Cancel decline
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null || !canReject}
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
@@ -1090,10 +1194,13 @@ export function IssueThreadInteractionCard({
<span className="text-current/60">/</span>
{statusLabel(interaction.status)}
</span>
{interaction.continuationPolicy === "wake_assignee" ? (
{interaction.continuationPolicy === "wake_assignee"
|| interaction.continuationPolicy === "wake_assignee_on_accept" ? (
<span className="inline-flex items-center gap-1 rounded-sm border border-border/70 bg-transparent px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-foreground/70">
<ListChecks className="h-3.5 w-3.5" />
Wakes assignee
{interaction.continuationPolicy === "wake_assignee_on_accept"
? "Wakes on confirm"
: "Wakes assignee"}
</span>
) : null}
</div>
@@ -309,6 +309,42 @@ export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInterac
export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({});
export const genericPendingRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-generic-pending",
title: "Confirm next step",
summary: "The assignee needs a lightweight yes or no before continuing.",
continuationPolicy: "none",
payload: {
version: 1,
prompt: "Continue with the current approach?",
},
});
export const optionalDeclineRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-optional-decline",
continuationPolicy: "none",
payload: {
version: 1,
prompt: "Use the smaller implementation path?",
acceptLabel: "Confirm",
rejectLabel: "Decline",
rejectRequiresReason: false,
declineReasonPlaceholder: "Optional: tell the agent what you'd change.",
},
});
export const disabledDeclineReasonRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-no-decline-reason",
continuationPolicy: "none",
payload: {
version: 1,
prompt: "Close this low-risk follow-up as unnecessary?",
acceptLabel: "Close it",
rejectLabel: "Keep it",
allowDeclineReason: false,
},
});
export const acceptedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-accepted",
status: "accepted",
@@ -321,6 +357,33 @@ export const acceptedRequestConfirmationInteraction = createRequestConfirmationI
},
});
export const planApprovalAcceptedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-plan-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:34:00.000Z"),
updatedAt: new Date("2026-04-20T14:34:00.000Z"),
payload: {
version: 1,
prompt: "Approve the plan and let the assignee start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
rejectRequiresReason: true,
declineReasonPlaceholder: "Optional: what would you like revised?",
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 4,
},
},
result: {
version: 1,
outcome: "accepted",
},
});
export const rejectedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-rejected",
status: "rejected",
@@ -334,6 +397,19 @@ export const rejectedRequestConfirmationInteraction = createRequestConfirmationI
},
});
export const rejectedNoReasonRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-rejected-no-reason",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:37:00.000Z"),
updatedAt: new Date("2026-04-20T14:37:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: null,
},
});
export const commentExpiredRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-expired-comment",
status: "expired",
@@ -353,6 +429,20 @@ export const staleTargetRequestConfirmationInteraction = createRequestConfirmati
resolvedByAgentId: "agent-codex",
resolvedAt: new Date("2026-04-20T14:40:00.000Z"),
updatedAt: new Date("2026-04-20T14:40:00.000Z"),
payload: {
version: 1,
prompt: "Approve the plan and let the assignee start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request revisions",
rejectRequiresReason: true,
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "44444444-4444-4444-8444-444444444444",
revisionNumber: 4,
},
},
result: {
version: 1,
outcome: "stale_target",
@@ -366,6 +456,12 @@ export const staleTargetRequestConfirmationInteraction = createRequestConfirmati
},
});
export const failedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-failed",
status: "failed",
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
});
export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({
id: "comment-thread-board",
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
@@ -8,15 +8,20 @@ import {
answeredAskUserQuestionsInteraction,
acceptedRequestConfirmationInteraction,
commentExpiredRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
genericPendingRequestConfirmationInteraction,
issueThreadInteractionComments,
issueThreadInteractionEvents,
issueThreadInteractionFixtureMeta,
issueThreadInteractionLiveRuns,
issueThreadInteractionTranscriptsByRunId,
mixedIssueThreadInteractions,
optionalDeclineRequestConfirmationInteraction,
pendingAskUserQuestionsInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
planApprovalAcceptedRequestConfirmationInteraction,
rejectedNoReasonRequestConfirmationInteraction,
rejectedRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
staleTargetRequestConfirmationInteraction,
@@ -186,6 +191,33 @@ function InteractiveRequestConfirmationCard() {
);
}
function AutoOpenDeclineRequestConfirmationCard({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const declineButton = Array.from(ref.current?.querySelectorAll("button") ?? [])
.find((button) => button.textContent?.includes(interaction.payload.rejectLabel ?? "Decline"));
declineButton?.click();
}, [interaction]);
return (
<div ref={ref}>
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => undefined}
onRejectInteraction={() => undefined}
/>
</div>
);
}
const meta = {
title: "Chat & Comments/Issue Thread Interactions",
parameters: {
@@ -287,6 +319,61 @@ export const RequestConfirmationPending: Story = {
<StoryFrame>
<ScenarioCard
title="Pending request confirmation"
description="A generic confirmation can render without a target or custom labels."
>
<IssueThreadInteractionCard
interaction={genericPendingRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => undefined}
onRejectInteraction={() => undefined}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationPendingWithTarget: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending request confirmation with target"
description="The watched plan document renders as a compact target chip."
>
<IssueThreadInteractionCard
interaction={pendingRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => undefined}
onRejectInteraction={() => undefined}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationPendingDecliningOptional: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending optional decline"
description="The decline textarea is visible, but a reason is optional."
>
<AutoOpenDeclineRequestConfirmationCard
interaction={optionalDeclineRequestConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationPendingRequireReason: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending required decline reason"
description="A plan approval waits for an explicit board decision and requires a decline reason."
>
<InteractiveRequestConfirmationCard />
@@ -295,11 +382,11 @@ export const RequestConfirmationPending: Story = {
),
};
export const RequestConfirmationAccepted: Story = {
export const RequestConfirmationConfirmed: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted request confirmation"
title="Confirmed request confirmation"
description="The resolved state remains visible without active controls."
>
<IssueThreadInteractionCard
@@ -313,11 +400,11 @@ export const RequestConfirmationAccepted: Story = {
),
};
export const RequestConfirmationRejected: Story = {
export const RequestConfirmationDeclinedWithReason: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Rejected request confirmation"
title="Declined request confirmation"
description="The decline reason stays attached to the request in the thread."
>
<IssueThreadInteractionCard
@@ -331,6 +418,24 @@ export const RequestConfirmationRejected: Story = {
),
};
export const RequestConfirmationDeclinedNoReason: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Declined without a reason"
description="The card stays compact when no decline reason was provided."
>
<IssueThreadInteractionCard
interaction={rejectedNoReasonRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationExpiredByComment: Story = {
render: () => (
<StoryFrame>
@@ -367,6 +472,58 @@ export const RequestConfirmationExpiredByTargetChange: Story = {
),
};
export const RequestConfirmationPlanApprovalPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending plan approval"
description="The plan-approval variant keeps the approval labels and target chip visible."
>
<InteractiveRequestConfirmationCard />
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationPlanApprovalConfirmed: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Confirmed plan approval"
description="The resolved plan approval reads as a compact receipt."
>
<IssueThreadInteractionCard
interaction={planApprovalAcceptedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationFailed: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Failed request confirmation"
description="The failed state provides explicit recovery copy."
>
<IssueThreadInteractionCard
interaction={failedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
export const ReviewSurface: Story = {
render: () => (
<StoryFrame>