fix(attention): show semantic confirmation actions

This commit is contained in:
Dotta committed 2026-07-10 20:08:18 +00:00
1 parent 5f8c57304a
commit 042bb231d9
4 files changed
+159 -9

No files matched your search

+11 -1
View File
@@ -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",
+22 -2
View File
@@ -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
+39 -6
View File
@@ -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";