From 042bb231d9a937d82dd532e4af2dbba9892a9426 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Jul 2026 19:44:34 +0000 Subject: [PATCH] fix(attention): show semantic confirmation actions --- .../src/__tests__/attention-service.test.ts | 12 ++- server/src/services/attention.ts | 24 ++++- ui/src/components/AttentionQueueRow.test.tsx | 87 +++++++++++++++++++ ui/src/components/AttentionQueueRow.tsx | 45 ++++++++-- 4 files changed, 159 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 1c1454c905e..7fa7da5f73b 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -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", diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 0dd562697ba..84a8c2bebe7 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -368,7 +368,7 @@ function interactionLabel(kind: string) { } } -function interactionVerbs(kind: string) { +function interactionVerbs(kind: string, payload: Record) { 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.", diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 1ddc3c13957..09dfdf0ca08 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(
- {!expanded && } + {!expanded && }
{!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";