mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
Fix confirmation card UX review findings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a5d1c2d5e4
commit
3a94e79870
15 files changed
+648
-77
No files matched your search
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,7 @@ export type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsResult,
|
||||
RequestConfirmationIssueDocumentTarget,
|
||||
RequestConfirmationCustomTarget,
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -155,6 +155,7 @@ export {
|
||||
askUserQuestionsAnswerSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
requestConfirmationIssueDocumentTargetSchema,
|
||||
requestConfirmationCustomTargetSchema,
|
||||
requestConfirmationTargetSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user