mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Implement request confirmation cards
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user