Implement request confirmation cards

This commit is contained in:
Dotta committed 2026-04-21 17:33:57 -05:00
1 parent 4acaebddc0
commit a5d1c2d5e4
13 files changed
+706 -36

No files matched your search

@@ -10,6 +10,8 @@ describe("issue thread interaction schemas", () => {
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
rejectRequiresReason: true,
rejectReasonLabel: "What needs to change?",
detailsMarkdown: "The current plan document will be accepted as-is.",
supersedeOnUserComment: true,
},
@@ -22,6 +24,8 @@ describe("issue thread interaction schemas", () => {
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
rejectRequiresReason: true,
rejectReasonLabel: "What needs to change?",
supersedeOnUserComment: true,
},
});
+2
View File
@@ -360,6 +360,8 @@ export interface RequestConfirmationPayload {
prompt: string;
acceptLabel?: string | null;
rejectLabel?: string | null;
rejectRequiresReason?: boolean;
rejectReasonLabel?: string | null;
detailsMarkdown?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
+2
View File
@@ -333,6 +333,8 @@ export const requestConfirmationPayloadSchema = z.object({
prompt: z.string().trim().min(1).max(1000),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectRequiresReason: z.boolean().optional(),
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
detailsMarkdown: z.string().max(20000).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
@@ -561,6 +561,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
},
resolvedByUserId: "local-board",
});
const requiresReason = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Decline only with a reason?",
rejectRequiresReason: true,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.rejectInteraction({
id: issueId,
companyId,
}, requiresReason.id, {}, {
userId: "local-board",
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("expires supersedable request confirmations when a user comments", async () => {
@@ -440,6 +440,12 @@ export function issueThreadInteractionService(db: Db) {
return expired;
}
const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction;
const reason = args.input.reason?.trim() ?? "";
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
throw unprocessable("A decline reason is required for this confirmation");
}
const now = new Date();
const [updated] = await db
.update(issueThreadInteractions)
@@ -448,7 +454,7 @@ export function issueThreadInteractionService(db: Db) {
result: {
version: 1,
outcome: "rejected",
reason: args.input.reason?.trim() || null,
reason: reason || null,
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
+2 -3
View File
@@ -12,7 +12,6 @@ import type {
IssueLabel,
IssueThreadInteraction,
IssueWorkProduct,
SuggestTasksInteraction,
UpsertIssueDocument,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -111,9 +110,9 @@ export const issuesApi = {
interactionId: string,
data?: { selectedClientKeys?: string[] },
) =>
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
respondToInteraction: (
id: string,
interactionId: string,
+5 -4
View File
@@ -49,6 +49,7 @@ import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
IssueThreadInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { isIssueThreadInteraction } from "../lib/issue-thread-interactions";
@@ -123,11 +124,11 @@ interface IssueChatMessageContext {
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -278,11 +279,11 @@ interface IssueChatThreadProps {
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -1,14 +1,17 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
pendingAskUserQuestionsInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
@@ -17,6 +20,12 @@ let container: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}));
function renderCard(
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
) {
@@ -27,10 +36,12 @@ function renderCard(
act(() => {
root?.render(
<TooltipProvider>
<IssueThreadInteractionCard
interaction={pendingAskUserQuestionsInteraction}
{...props}
/>
<ThemeProvider>
<IssueThreadInteractionCard
interaction={pendingAskUserQuestionsInteraction}
{...props}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
@@ -95,4 +106,84 @@ describe("IssueThreadInteractionCard", () => {
expect(host.textContent).toContain("No reason provided.");
});
it("requires a decline reason when the request confirmation payload asks for one", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onRejectInteraction,
});
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Request revisions"),
);
expect(declineButton).toBeTruthy();
await act(async () => {
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(saveButton?.hasAttribute("disabled")).toBe(true);
expect(host.textContent).toContain("A decline reason is required.");
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(textarea, "Needs a smaller phase split");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_confirmation" }),
"Needs a smaller phase split",
);
});
it("invokes the confirm callback with pending request confirmations", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onAcceptInteraction,
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Approve plan"),
);
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_confirmation" }),
);
});
it("renders request confirmation target links and stale-target expiry", () => {
const host = renderCard({
interaction: staleTargetRequestConfirmationInteraction,
});
const targetLink = host.querySelector("a");
expect(host.textContent).toContain("Expired by target change");
expect(host.textContent).toContain("Document: plan rev 3");
expect(targetLink?.getAttribute("href")).toContain("#document-plan");
expect(host.textContent).not.toContain("Approve plan");
});
});
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react";
import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
@@ -11,6 +11,7 @@ import {
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
type SuggestedTaskDraft,
@@ -30,11 +31,11 @@ interface IssueThreadInteractionCardProps {
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction,
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -80,7 +81,16 @@ function statusLabel(status: IssueThreadInteraction["status"]) {
}
function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return kind === "suggest_tasks" ? "Suggested tasks" : "Ask user questions";
switch (kind) {
case "suggest_tasks":
return "Suggested tasks";
case "ask_user_questions":
return "Ask user questions";
case "request_confirmation":
return "Request confirmation";
default:
return kind;
}
}
function statusIcon(status: IssueThreadInteraction["status"]) {
@@ -816,6 +826,230 @@ function AskUserQuestionsCard({
);
}
function RequestConfirmationTargetLink({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
const target = interaction.payload.target ?? interaction.result?.staleTarget ?? null;
if (!target) return null;
if (target.type === "issue_document") {
const issueId = target.issueId ?? interaction.issueId;
return (
<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>
);
}
return null;
}
function RequestConfirmationResolution({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
const outcome = interaction.result?.outcome;
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>
);
}
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>
<p className={cn(
"mt-1 leading-6",
!interaction.result?.reason && "text-rose-900/75",
)}>
{interaction.result?.reason || "No reason provided."}
</p>
</div>
);
}
if (interaction.status === "expired") {
const expiredByComment = outcome === "superseded_by_comment";
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="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">
{expiredByComment
? "A board comment superseded this confirmation before it was resolved."
: "The requested target changed before this confirmation was resolved."}
</p>
</div>
);
}
return null;
}
function RequestConfirmationCard({
interaction,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: RequestConfirmationInteraction;
onAcceptInteraction?: (
interaction: RequestConfirmationInteraction,
) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
const [actionError, setActionError] = useState<string | null>(null);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
const trimmedRejectReason = rejectReason.trim();
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
useEffect(() => {
setRejectReason(interaction.result?.reason ?? "");
setActionError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
}, [interaction.id, interaction.result?.reason, interaction.status]);
async function handleAccept() {
if (!onAcceptInteraction) return;
setWorking("accept");
setActionError(null);
try {
await onAcceptInteraction(interaction);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Unable to confirm this request");
} finally {
setWorking(null);
}
}
async function handleReject() {
if (!onRejectInteraction || !canReject) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
setRejecting(false);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Unable to decline this request");
} finally {
setWorking(null);
}
}
return (
<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>
</div>
) : null}
<RequestConfirmationTargetLink interaction={interaction} />
</div>
{interaction.status === "pending" ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Confirming...
</>
) : (
interaction.payload.acceptLabel ?? "Confirm"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => setRejecting((current) => !current)}
>
{interaction.payload.rejectLabel ?? "Decline"}
</Button>
</div>
{rejecting ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<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"
/>
{rejectRequiresReason && !canReject ? (
<p className="text-xs text-destructive">A decline reason is required.</p>
) : null}
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null || !canReject}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
interaction.payload.rejectLabel ?? "Decline"
)}
</Button>
</div>
</div>
) : null}
{actionError ? (
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
</div>
) : (
<RequestConfirmationResolution interaction={interaction} />
)}
</div>
);
}
export function IssueThreadInteractionCard({
interaction,
agentMap,
@@ -868,7 +1102,9 @@ export function IssueThreadInteractionCard({
{interaction.title
?? (interaction.kind === "suggest_tasks"
? "Suggested task tree"
: interaction.payload.title ?? "Questions for the operator")}
: interaction.kind === "ask_user_questions"
? interaction.payload.title ?? "Questions for the operator"
: "Confirmation requested")}
</div>
{interaction.summary ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
@@ -900,11 +1136,17 @@ export function IssueThreadInteractionCard({
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : (
) : interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsCard
interaction={interaction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
/>
) : (
<RequestConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
)}
</div>
@@ -6,6 +6,7 @@ import type {
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -177,6 +178,49 @@ function createAskUserQuestionsInteraction(
};
}
function createRequestConfirmationInteraction(
overrides: Partial<RequestConfirmationInteraction>,
): RequestConfirmationInteraction {
return {
id: "interaction-confirmation-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "request_confirmation",
title: "Approve the proposed plan",
summary:
"The assignee is waiting on a direct board decision before continuing from the plan document.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:30:00.000Z"),
updatedAt: new Date("2026-04-20T14:30:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Approve the plan and let the assignee start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request revisions",
rejectRequiresReason: true,
rejectReasonLabel: "Describe the plan changes needed before approval",
detailsMarkdown:
"This confirmation watches the `plan` document revision so stale approvals are blocked if the plan changes.",
supersedeOnUserComment: true,
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 3,
},
},
result: null,
...overrides,
};
}
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
@@ -263,6 +307,65 @@ export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInterac
},
});
export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({});
export const acceptedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:34:00.000Z"),
updatedAt: new Date("2026-04-20T14:34:00.000Z"),
result: {
version: 1,
outcome: "accepted",
},
});
export const rejectedRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:36:00.000Z"),
updatedAt: new Date("2026-04-20T14:36:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: "Split the migration and UI work into separate reviewable steps.",
},
});
export const commentExpiredRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-expired-comment",
status: "expired",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:38:00.000Z"),
updatedAt: new Date("2026-04-20T14:38:00.000Z"),
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "22222222-2222-4222-8222-222222222222",
},
});
export const staleTargetRequestConfirmationInteraction = createRequestConfirmationInteraction({
id: "interaction-confirmation-expired-target",
status: "expired",
resolvedByAgentId: "agent-codex",
resolvedAt: new Date("2026-04-20T14:40:00.000Z"),
updatedAt: new Date("2026-04-20T14:40:00.000Z"),
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 3,
},
},
});
export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({
id: "comment-thread-board",
@@ -333,5 +436,6 @@ export const issueThreadInteractionTranscriptsByRunId = new Map<
export const mixedIssueThreadInteractions = [
acceptedSuggestedTasksInteraction,
pendingRequestConfirmationInteraction,
pendingAskUserQuestionsInteraction,
];
+23 -1
View File
@@ -10,6 +10,11 @@ export type {
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationTarget,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksPayload,
@@ -21,6 +26,7 @@ import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestConfirmationInteraction,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
@@ -39,7 +45,11 @@ export function isIssueThreadInteraction(
return typeof candidate.id === "string"
&& typeof candidate.companyId === "string"
&& typeof candidate.issueId === "string"
&& (candidate.kind === "suggest_tasks" || candidate.kind === "ask_user_questions");
&& (
candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
);
}
export function buildIssueThreadInteractionSummary(
@@ -61,6 +71,18 @@ export function buildIssueThreadInteractionSummary(
return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`;
}
if (interaction.kind === "request_confirmation") {
if (interaction.status === "accepted") return "Confirmed request";
if (interaction.status === "rejected") return "Declined request";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Confirmation expired after comment";
if (outcome === "stale_target") return "Confirmation expired after target changed";
return "Confirmation expired";
}
return "Requested confirmation";
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+19 -11
View File
@@ -120,10 +120,12 @@ import {
type IssueAttachment,
type IssueComment,
type IssueThreadInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
runAgentId?: string | null;
@@ -543,10 +545,10 @@ type IssueDetailChatTabProps = {
interruptingQueuedRunId: string | null;
onImageClick: (src: string) => void;
onAcceptInteraction: (
interaction: SuggestTasksInteraction,
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
) => Promise<void>;
onRejectInteraction: (interaction: SuggestTasksInteraction, reason?: string) => Promise<void>;
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
onSubmitInteractionAnswers: (
interaction: IssueThreadInteraction,
answers: AskUserQuestionsAnswer[],
@@ -1547,20 +1549,26 @@ export function IssueDetail() {
interaction,
selectedClientKeys,
}: {
interaction: SuggestTasksInteraction;
interaction: ActionableIssueThreadInteraction;
selectedClientKeys?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
if (resolvedCompanyId && issue?.id) {
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) });
}
invalidateIssueDetail();
invalidateIssueCollections();
const createdCount = interaction.result?.createdTasks?.length ?? 0;
const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0;
const createdCount = interaction.kind === "suggest_tasks"
? interaction.result?.createdTasks?.length ?? 0
: 0;
const skippedCount = interaction.kind === "suggest_tasks"
? interaction.result?.skippedClientKeys?.length ?? 0
: 0;
pushToast({
title: skippedCount > 0
title: interaction.kind === "request_confirmation"
? "Request confirmed"
: skippedCount > 0
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
: "Suggested tasks accepted",
tone: "success",
@@ -1575,14 +1583,14 @@ export function IssueDetail() {
},
});
const rejectInteraction = useMutation({
mutationFn: ({ interaction, reason }: { interaction: SuggestTasksInteraction; reason?: string }) =>
mutationFn: ({ interaction, reason }: { interaction: ActionableIssueThreadInteraction; reason?: string }) =>
issuesApi.rejectInteraction(issueId!, interaction.id, reason),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
invalidateIssueDetail();
invalidateIssueCollections();
pushToast({
title: "Suggestion rejected",
title: interaction.kind === "request_confirmation" ? "Request declined" : "Suggestion rejected",
tone: "success",
});
},
@@ -2349,12 +2357,12 @@ export function IssueDetail() {
await interruptQueuedComment.mutateAsync(runId);
}, [interruptQueuedComment]);
const handleAcceptInteraction = useCallback(async (
interaction: SuggestTasksInteraction,
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
) => {
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
}, [acceptInteraction]);
const handleRejectInteraction = useCallback(async (interaction: SuggestTasksInteraction, reason?: string) => {
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
await rejectInteraction.mutateAsync({ interaction, reason });
}, [rejectInteraction]);
const handleSubmitInteractionAnswers = useCallback(async (
@@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import {
acceptedSuggestedTasksInteraction,
answeredAskUserQuestionsInteraction,
acceptedRequestConfirmationInteraction,
commentExpiredRequestConfirmationInteraction,
issueThreadInteractionComments,
issueThreadInteractionEvents,
issueThreadInteractionFixtureMeta,
@@ -13,12 +15,16 @@ import {
issueThreadInteractionTranscriptsByRunId,
mixedIssueThreadInteractions,
pendingAskUserQuestionsInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
rejectedRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
staleTargetRequestConfirmationInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@/lib/issue-thread-interactions";
import { storybookAgentMap } from "../fixtures/paperclipData";
@@ -155,13 +161,38 @@ function InteractiveAskUserQuestionsCard() {
);
}
function InteractiveRequestConfirmationCard() {
const [interaction, setInteraction] = useState<RequestConfirmationInteraction>(
pendingRequestConfirmationInteraction,
);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => setInteraction(acceptedRequestConfirmationInteraction)}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejectedRequestConfirmationInteraction,
result: {
version: 1,
outcome: "rejected",
reason: reason || rejectedRequestConfirmationInteraction.result?.reason || null,
},
})}
/>
);
}
const meta = {
title: "Chat & Comments/Issue Thread Interactions",
parameters: {
docs: {
description: {
component:
"Prototype-only interaction cards for `suggest_tasks` and `ask_user_questions`, shown both in isolation and inside the real `IssueChatThread` feed.",
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
},
},
},
@@ -251,14 +282,99 @@ export const AskUserQuestionsAnswered: Story = {
),
};
export const RequestConfirmationPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending request confirmation"
description="A plan approval waits for an explicit board decision and requires a decline reason."
>
<InteractiveRequestConfirmationCard />
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationAccepted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted request confirmation"
description="The resolved state remains visible without active controls."
>
<IssueThreadInteractionCard
interaction={acceptedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationRejected: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Rejected request confirmation"
description="The decline reason stays attached to the request in the thread."
>
<IssueThreadInteractionCard
interaction={rejectedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationExpiredByComment: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired by comment"
description="A board comment superseded the request before resolution."
>
<IssueThreadInteractionCard
interaction={commentExpiredRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const RequestConfirmationExpiredByTargetChange: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired by target change"
description="The watched plan document moved to a newer revision before approval."
>
<IssueThreadInteractionCard
interaction={staleTargetRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const ReviewSurface: Story = {
render: () => (
<StoryFrame>
<section className="paperclip-story__frame p-6">
<div className="paperclip-story__label">Thread interactions</div>
<div className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
This prototype keeps the whole system non-persistent while pressure-testing the two planned
interaction kinds directly inside the issue chat surface. The card language leans closer to
This review surface pressure-tests the thread interaction kinds directly inside the issue
chat surface. The card language leans closer to
annotated review sheets than generic admin widgets so the objects feel like first-class work
artifacts in the thread.
</div>
@@ -319,10 +435,62 @@ export const ReviewSurface: Story = {
</div>
</Section>
<Section eyebrow="Request Confirmation" title="Plan approval and compact resolution states">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard
title="Plan approval"
description="The pending card links to the watched plan revision and requires a reason when declined."
>
<InteractiveRequestConfirmationCard />
</ScenarioCard>
<ScenarioCard
title="Accepted"
description="Accepted confirmations stay visible as resolved work artifacts."
>
<IssueThreadInteractionCard
interaction={acceptedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Rejected"
description="Rejected confirmations keep the board's decline reason attached."
>
<IssueThreadInteractionCard
interaction={rejectedRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Expired states"
description="Comment and target-change expiry states are compact and disabled."
>
<div className="space-y-4">
<IssueThreadInteractionCard
interaction={commentExpiredRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
<IssueThreadInteractionCard
interaction={staleTargetRequestConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</div>
</ScenarioCard>
</div>
</Section>
<Section eyebrow="Mixed Feed" title="Interaction cards in the real issue thread">
<ScenarioCard
title="IssueChatThread composition"
description="Comments, timeline events, accepted task suggestions, a pending question form, and an active run share the same feed without persistence work."
description="Comments, timeline events, accepted task suggestions, a pending confirmation, a pending question form, and an active run share the same feed."
>
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(14,165,233,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_42%),var(--background)] p-5 shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<IssueChatThread
@@ -349,7 +517,7 @@ export const ReviewSurface: Story = {
docs: {
description: {
story:
"Covers the prototype states called out in [PAP-1709](/PAP/issues/PAP-1709): suggested-task previews, collapsed descendants, rejection reasons, multi-question answers, and a mixed issue thread.",
"Covers the prototype states called out in [PAP-1709](/PAP/issues/PAP-1709): suggested-task previews, collapsed descendants, rejection reasons, request confirmations, multi-question answers, and a mixed issue thread.",
},
},
},