mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
fix(attention): show semantic confirmation actions
This commit is contained in:
1 parent
5f8c57304a
commit
042bb231d9
4 files changed
+159
-9
No files matched your search
@@ -687,7 +687,13 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Approve the plan",
|
||||
payload: { version: 1, prompt: "Approve plan?", target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId } },
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan?",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId },
|
||||
},
|
||||
createdAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
},
|
||||
@@ -754,6 +760,10 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
const planItem = detailsByKind.get("plan_approval");
|
||||
expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan");
|
||||
expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" });
|
||||
expect(planItem?.decisionVerbs).toEqual([
|
||||
expect.objectContaining({ id: "accept", label: "Approve plan" }),
|
||||
expect.objectContaining({ id: "reject", label: "Request changes" }),
|
||||
]);
|
||||
expect(planItem?.project).toMatchObject({
|
||||
id: projectId,
|
||||
name: "Attention Project",
|
||||
|
||||
@@ -368,7 +368,7 @@ function interactionLabel(kind: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function interactionVerbs(kind: string) {
|
||||
function interactionVerbs(kind: string, payload: Record<string, unknown>) {
|
||||
if (kind === "ask_user_questions") {
|
||||
return decisionVerbs({
|
||||
id: "respond",
|
||||
@@ -376,6 +376,26 @@ function interactionVerbs(kind: string) {
|
||||
description: "Submit answers to the pending questions.",
|
||||
});
|
||||
}
|
||||
if (kind === "request_confirmation") {
|
||||
const acceptLabel = typeof payload.acceptLabel === "string" && payload.acceptLabel.trim()
|
||||
? payload.acceptLabel.trim()
|
||||
: "Confirm";
|
||||
const rejectLabel = typeof payload.rejectLabel === "string" && payload.rejectLabel.trim()
|
||||
? payload.rejectLabel.trim()
|
||||
: "Decline";
|
||||
return decisionVerbs(
|
||||
{
|
||||
id: "accept",
|
||||
label: acceptLabel,
|
||||
description: "Accept the pending confirmation.",
|
||||
},
|
||||
{
|
||||
id: "reject",
|
||||
label: rejectLabel,
|
||||
description: "Decline the pending confirmation.",
|
||||
},
|
||||
);
|
||||
}
|
||||
return decisionVerbs(
|
||||
{
|
||||
id: "accept",
|
||||
@@ -680,7 +700,7 @@ export function attentionService(db: Db) {
|
||||
},
|
||||
},
|
||||
whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`,
|
||||
decisionVerbs: interactionVerbs(interaction.kind),
|
||||
decisionVerbs: interactionVerbs(interaction.kind, payload),
|
||||
inlineResolvable: true,
|
||||
entryRule: "issue_thread_interactions.status = 'pending'",
|
||||
exitRule: "Interaction resolves, expires, fails, or is cancelled.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { AttentionQueueRow } from "./AttentionQueueRow";
|
||||
@@ -25,6 +26,13 @@ vi.mock("../api/approvals", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
acceptInteraction: vi.fn(),
|
||||
rejectInteraction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -329,6 +337,85 @@ describe("AttentionQueueRow", () => {
|
||||
expect(container?.textContent).toContain("Approval approved");
|
||||
});
|
||||
|
||||
it("renders configured confirmation labels and accepts from the compact action area", async () => {
|
||||
const onToggleExpand = vi.fn();
|
||||
vi.mocked(issuesApi.acceptInteraction).mockResolvedValue({} as never);
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: "interaction-1",
|
||||
companyId: "c1",
|
||||
title: "Plan approval",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/issue-1#interaction-interaction-1",
|
||||
metadata: { kind: "request_confirmation", issueId: "issue-1" },
|
||||
},
|
||||
decisionVerbs: [
|
||||
{ id: "accept", label: "Approve plan", description: null },
|
||||
{ id: "reject", label: "Request changes", description: null },
|
||||
],
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const decisionActions = container?.querySelector('[aria-label="Decision actions"]');
|
||||
expect(decisionActions?.textContent).toContain("Approve plan");
|
||||
expect(decisionActions?.textContent).toContain("Request changes");
|
||||
expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Approve plan")?.getAttribute("data-variant")).toBe("default");
|
||||
expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Request changes")?.getAttribute("data-variant")).toBe("outline");
|
||||
|
||||
const approve = Array.from(decisionActions?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "Approve plan",
|
||||
);
|
||||
act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(issuesApi.acceptInteraction).toHaveBeenCalledWith("issue-1", "interaction-1");
|
||||
expect(onToggleExpand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the matching confirmation form when requesting changes from a compact action", () => {
|
||||
const onToggleExpand = vi.fn();
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: "interaction-1",
|
||||
companyId: "c1",
|
||||
title: "Plan approval",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/issue-1#interaction-interaction-1",
|
||||
metadata: { kind: "request_confirmation", issueId: "issue-1" },
|
||||
},
|
||||
decisionVerbs: [{ id: "reject", label: "Request changes", description: null }],
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const requestChanges = Array.from(container?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "Request changes",
|
||||
);
|
||||
act(() => requestChanges?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
expect(onToggleExpand).toHaveBeenCalledOnce();
|
||||
expect(issuesApi.rejectInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("centers thumbnails beside the full card text stack", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/sh
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
@@ -255,7 +256,7 @@ export function AttentionQueueRow({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col items-end gap-1" data-attention-actions="true">
|
||||
{!expanded && <CompactDecisionActions item={item} companyId={companyId} />}
|
||||
{!expanded && <CompactDecisionActions item={item} companyId={companyId} onOpen={onToggleExpand} />}
|
||||
|
||||
<div className="flex items-start justify-end gap-1">
|
||||
{!inline && href && (
|
||||
@@ -293,7 +294,7 @@ export function AttentionQueueRow({
|
||||
);
|
||||
}
|
||||
|
||||
type CompactDecisionAction = "approve" | "reject" | "request_revision";
|
||||
type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision";
|
||||
|
||||
function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null {
|
||||
if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) {
|
||||
@@ -302,10 +303,25 @@ function compactDecisionAction(item: AttentionItem, verbId: string): CompactDeci
|
||||
if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) {
|
||||
return verbId;
|
||||
}
|
||||
if (
|
||||
item.sourceKind === "issue_thread_interaction"
|
||||
&& item.subject.metadata?.kind === "request_confirmation"
|
||||
&& (verbId === "accept" || verbId === "reject")
|
||||
) {
|
||||
return verbId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function CompactDecisionActions({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
||||
function CompactDecisionActions({
|
||||
item,
|
||||
companyId,
|
||||
onOpen,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const actions = item.decisionVerbs
|
||||
@@ -327,6 +343,12 @@ function CompactDecisionActions({ item, companyId }: { item: AttentionItem; comp
|
||||
? accessApi.approveJoinRequest(companyId, item.subject.id)
|
||||
: accessApi.rejectJoinRequest(companyId, item.subject.id);
|
||||
}
|
||||
if (item.sourceKind === "issue_thread_interaction") {
|
||||
const issueId = item.subject.metadata?.issueId;
|
||||
if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision.");
|
||||
if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id);
|
||||
return issuesApi.rejectInteraction(issueId, item.subject.id);
|
||||
}
|
||||
throw new Error("This decision must be completed from its detail view.");
|
||||
},
|
||||
onSuccess: (_result, action) => {
|
||||
@@ -337,7 +359,7 @@ function CompactDecisionActions({ item, companyId }: { item: AttentionItem; comp
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: item.sourceKind === "approval" ? `Approval ${decisionLabel(action)}` : `Join request ${decisionLabel(action)}`,
|
||||
title: compactDecisionSuccessLabel(item.sourceKind, action),
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
@@ -363,6 +385,10 @@ function CompactDecisionActions({ item, companyId }: { item: AttentionItem; comp
|
||||
disabled={decision.isPending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (item.sourceKind === "issue_thread_interaction" && action === "reject") {
|
||||
onOpen();
|
||||
return;
|
||||
}
|
||||
decision.mutate(action);
|
||||
}}
|
||||
>
|
||||
@@ -376,11 +402,18 @@ function CompactDecisionActions({ item, companyId }: { item: AttentionItem; comp
|
||||
|
||||
function decisionLabel(action: CompactDecisionAction): string {
|
||||
if (action === "request_revision") return "sent for revision";
|
||||
return action === "approve" ? "approved" : "rejected";
|
||||
if (action === "accept" || action === "approve") return "approved";
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string {
|
||||
if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`;
|
||||
if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`;
|
||||
return action === "accept" ? "Confirmation accepted" : "Confirmation declined";
|
||||
}
|
||||
|
||||
function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" {
|
||||
const text = `${verb.id} ${verb.label}`.toLowerCase();
|
||||
const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase();
|
||||
if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive";
|
||||
if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default";
|
||||
return "outline";
|
||||
|
||||
Reference in new issue
Block a user