From 7ba9280c20129c51e31dcc9435bd04e06baa46a9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 9 Jul 2026 15:57:59 +0000 Subject: [PATCH] Cherry-pick What-needs-me attention queue page (PAP-13208) Brings the What-needs-me page + AttentionQueueRow + AttentionInteractionResolver onto the task-org branch so the C3 per-item verdict card (PAP-13249) can wire into it. Original commit a40c2c6ce (now in master). Co-Authored-By: Paperclip --- ui/src/App.tsx | 2 + ui/src/api/attention.ts | 14 + .../AttentionInteractionResolver.tsx | 130 ++++++++ ui/src/components/AttentionQueueRow.test.tsx | 147 +++++++++ ui/src/components/AttentionQueueRow.tsx | 290 ++++++++++++++++++ ui/src/components/Sidebar.tsx | 17 + ui/src/lib/attention.test.ts | 91 ++++++ ui/src/lib/attention.ts | 80 +++++ ui/src/lib/company-routes.ts | 1 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/WhatNeedsMe.tsx | 140 +++++++++ .../stories/what-needs-me.stories.tsx | 203 ++++++++++++ 12 files changed, 1116 insertions(+) create mode 100644 ui/src/api/attention.ts create mode 100644 ui/src/components/AttentionInteractionResolver.tsx create mode 100644 ui/src/components/AttentionQueueRow.test.tsx create mode 100644 ui/src/components/AttentionQueueRow.tsx create mode 100644 ui/src/lib/attention.test.ts create mode 100644 ui/src/lib/attention.ts create mode 100644 ui/src/pages/WhatNeedsMe.tsx create mode 100644 ui/storybook/stories/what-needs-me.stories.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 076484b3021..b2e3610574a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -37,6 +37,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; +import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { BoardChat } from "./pages/BoardChat"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; @@ -214,6 +215,7 @@ function boardRoutes() { } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts new file mode 100644 index 00000000000..e53b63f4c89 --- /dev/null +++ b/ui/src/api/attention.ts @@ -0,0 +1,14 @@ +import type { AttentionFeed } from "@paperclipai/shared"; +import { api } from "./client"; + +export const attentionApi = { + /** + * Fetch the ranked "What needs me" attention feed for a company. The server + * unions every attention source (approvals, interactions, recovery, reviews, + * failures, budget…) into one ranked queue with the §0 contract. + */ + list: (companyId: string, options: { includeDismissed?: boolean } = {}) => + api.get( + `/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`, + ), +}; diff --git a/ui/src/components/AttentionInteractionResolver.tsx b/ui/src/components/AttentionInteractionResolver.tsx new file mode 100644 index 00000000000..bea029b2c92 --- /dev/null +++ b/ui/src/components/AttentionInteractionResolver.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { + isIssueThreadInteraction, + type AskUserQuestionsAnswer, + type AskUserQuestionsInteraction, + type IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, + type RequestConfirmationInteraction, + type SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; + +interface AttentionInteractionResolverProps { + companyId: string; + issueId: string; + interactionId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + /** Called after a resolution so the parent can refresh the feed. */ + onResolved?: () => void; +} + +/** + * Lazily fetches the full issue-thread interaction referenced by an attention + * row and renders the existing {@link IssueThreadInteractionCard} inline, so + * confirmations and questions are answerable in-row without leaving the queue + * (converged PAP-12628). Reviews never reach here — they deep-link. + */ +export function AttentionInteractionResolver({ + companyId, + issueId, + interactionId, + agentMap, + currentUserId, + userLabelMap, + onResolved, +}: AttentionInteractionResolverProps) { + const queryClient = useQueryClient(); + + const { data: interactions, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.interactions(issueId), + queryFn: () => issuesApi.listInteractions(issueId), + enabled: !!issueId, + }); + + const interaction = useMemo(() => { + const match = (interactions ?? []).find((entry) => entry.id === interactionId); + return match && isIssueThreadInteraction(match) ? match : null; + }, [interactions, interactionId]); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + onResolved?.(); + }; + + const acceptMutation = useMutation({ + mutationFn: (input: { + interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; + selectedClientKeys?: string[]; + selectedOptionIds?: string[]; + }) => + issuesApi.acceptInteraction(issueId, input.interaction.id, { + selectedClientKeys: input.selectedClientKeys, + selectedOptionIds: input.selectedOptionIds, + }), + onSuccess: invalidate, + }); + + const rejectMutation = useMutation({ + mutationFn: (input: { interactionId: string; reason?: string }) => + issuesApi.rejectInteraction(issueId, input.interactionId, input.reason), + onSuccess: invalidate, + }); + + const respondMutation = useMutation({ + mutationFn: (input: { interactionId: string; answers: AskUserQuestionsAnswer[] }) => + issuesApi.respondToInteraction(issueId, input.interactionId, { answers: input.answers }), + onSuccess: invalidate, + }); + + const cancelMutation = useMutation({ + mutationFn: (input: { interactionId: string }) => + issuesApi.cancelInteraction(issueId, input.interactionId), + onSuccess: invalidate, + }); + + if (isLoading) { + return ( +
+ Loading decision… +
+ ); + } + + if (error || !interaction) { + return ( +

+ This decision is no longer available — it may have been resolved elsewhere. +

+ ); + } + + return ( + + acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined) + } + onRejectInteraction={(target, reason) => + rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined) + } + onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) => + respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined) + } + onCancelInteraction={(target: AskUserQuestionsInteraction) => + cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined) + } + /> + ); +} diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx new file mode 100644 index 00000000000..bb9c7f7cf18 --- /dev/null +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { AnchorHTMLAttributes, ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared"; +import { AttentionQueueRow } from "./AttentionQueueRow"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(cb: () => T): T { + let result: T | undefined; + flushSync(() => { + result = cb(); + }); + return result as T; +} + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => + root?.render({element}), + ); + return container; +} + +function buildItem(overrides: Partial = {}): AttentionItem { + return { + id: "a1", + companyId: "c1", + sourceKind: "approval", + subject: { + kind: "approval", + id: "approval-1", + companyId: "c1", + title: "Hire agent: Research Analyst", + identifier: null, + status: "pending", + href: "/PAP/approvals/approval-1", + metadata: {}, + }, + whyNow: "Approval is pending a board decision.", + decisionVerbs: [], + inlineResolvable: true, + entryRule: "", + exitRule: "", + dedupKey: "approval:approval-1", + dismissalKey: "attention:approval:approval-1", + severity: "high", + rank: 0, + activityAt: "2026-07-09T12:00:00Z", + createdAt: "2026-07-09T12:00:00Z", + updatedAt: "2026-07-09T12:00:00Z", + relatedIssue: null, + ...overrides, + }; +} + +const noop = () => {}; + +describe("AttentionQueueRow", () => { + it("renders an inline approval resolver when expanded", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Approve"); + expect(el.textContent).toContain("Request revision"); + expect(el.textContent).toContain("Reject"); + // Inline rows show an expand chevron, not an "Open" deep-link. + expect(el.textContent).not.toContain("Open"); + }); + + it("does not inline a review — it deep-links instead", () => { + const el = render( + , + ); + expect(el.textContent).toContain("Open"); + // No approval buttons should render for a review row. + expect(el.textContent).not.toContain("Request revision"); + }); + + it("fires onDismiss from the row menu action", () => { + const onDismiss = vi.fn(); + const item = buildItem(); + render( + , + ); + // The dropdown trigger + item live in a portal; invoke the handler contract + // directly via the rendered menu after opening is environment-flaky in + // jsdom, so assert the wiring by locating the trigger exists. + const trigger = container?.querySelector('[aria-label="Row actions"]'); + expect(trigger).toBeTruthy(); + }); +}); diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx new file mode 100644 index 00000000000..74ddb8d61ee --- /dev/null +++ b/ui/src/components/AttentionQueueRow.tsx @@ -0,0 +1,290 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + ChevronDown, + ChevronRight, + Clock, + ExternalLink, + Loader2, + MoreHorizontal, +} from "lucide-react"; +import type { Agent, AttentionItem } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { accessApi } from "../api/access"; +import { approvalsApi } from "../api/approvals"; +import { queryKeys } from "../lib/queryKeys"; +import { isInlineResolvable, severityStyle, sourceMeta } from "../lib/attention"; +import { cn, relativeTime } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { AttentionInteractionResolver } from "./AttentionInteractionResolver"; + +interface AttentionQueueRowProps { + item: AttentionItem; + companyId: string; + expanded: boolean; + onToggleExpand: () => void; + onDismiss: (item: AttentionItem) => void; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +} + +export function AttentionQueueRow({ + item, + companyId, + expanded, + onToggleExpand, + onDismiss, + agentMap, + currentUserId, + userLabelMap, +}: AttentionQueueRowProps) { + const meta = sourceMeta(item.sourceKind); + const severity = severityStyle(item.severity); + const Icon = meta.icon; + const inline = isInlineResolvable(item); + const href = item.subject.href; + + return ( +
+ {/* Severity accent bar */} + + +
+ {/* Expand affordance / source icon */} + {inline ? ( + + ) : ( + + + + )} + +
+
+ + + {meta.label} + + {item.relatedIssue?.identifier && ( + e.stopPropagation()} + > + {item.relatedIssue.identifier} + + )} + + + {relativeTime(item.activityAt)} + +
+ +
+
+ {href ? ( + + {item.subject.title ?? meta.label} + + ) : ( + + {item.subject.title ?? meta.label} + + )} +

{item.whyNow}

+
+ + {/* Deep-link / open for non-inline rows */} + {!inline && href && ( + + )} + + {/* Snooze / dismiss */} + + + + + + onDismiss(item)}>Snooze / dismiss + {href && ( + + Open source + + )} + + +
+
+
+ + {inline && expanded && ( +
+ +
+ )} +
+ ); +} + +function InlineResolver({ + item, + companyId, + agentMap, + currentUserId, + userLabelMap, +}: { + item: AttentionItem; + companyId: string; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +}) { + if (item.sourceKind === "issue_thread_interaction") { + const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id; + if (!issueId) { + return

Missing issue reference for this decision.

; + } + return ( + + ); + } + + if (item.sourceKind === "approval") { + return ; + } + + if (item.sourceKind === "join_request") { + return ; + } + + return null; +} + +function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(""); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); + }; + const approve = useMutation({ + mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const reject = useMutation({ + mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const revise = useMutation({ + mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined), + onSuccess: invalidate, + }); + const pending = approve.isPending || reject.isPending || revise.isPending; + + return ( +
+