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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:07:55 +00:00
1 parent f5ab828a48
commit 7ba9280c20
12 files changed
+1116

No files matched your search

+2
View File
@@ -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() {
<Route path="board-chat" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
</Route>
<Route path="attention" element={<WhatNeedsMe />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
+14
View File
@@ -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<AttentionFeed>(
`/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`,
),
};
@@ -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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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<IssueThreadInteraction | null>(() => {
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 (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
</div>
);
}
if (error || !interaction) {
return (
<p className="py-3 text-xs text-muted-foreground">
This decision is no longer available — it may have been resolved elsewhere.
</p>
);
}
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={(target, selectedClientKeys, selectedOptionIds) =>
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)
}
/>
);
}
@@ -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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act<T>(cb: () => T): T {
let result: T | undefined;
flushSync(() => {
result = cb();
});
return result as T;
}
let root: ReturnType<typeof createRoot> | 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(<QueryClientProvider client={client}>{element}</QueryClientProvider>),
);
return container;
}
function buildItem(overrides: Partial<AttentionItem> = {}): 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(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded
onToggleExpand={noop}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
item={buildItem({
sourceKind: "review" as AttentionSourceKind,
inlineResolvable: true,
subject: {
kind: "issue",
id: "issue-1",
companyId: "c1",
title: "PR ready for review",
identifier: null,
status: "in_review",
href: "/PAP/issues/PAP-1",
metadata: {},
},
})}
companyId="c1"
expanded
onToggleExpand={noop}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
item={item}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={onDismiss}
/>,
);
// 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();
});
});
+290
View File
@@ -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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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 (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border border-border bg-card",
"transition-colors hover:border-border/80",
)}
data-attention-source={item.sourceKind}
data-attention-severity={item.severity}
>
{/* Severity accent bar */}
<span className={cn("absolute inset-y-0 left-0 w-1", severity.accent)} aria-hidden />
<div className="flex items-start gap-3 py-3 pl-4 pr-3">
{/* Expand affordance / source icon */}
{inline ? (
<button
type="button"
onClick={onToggleExpand}
className="mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring focus-visible:ring-[3px]"
aria-expanded={expanded}
aria-label={expanded ? "Collapse decision" : "Expand decision"}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{meta.label}
</span>
{item.relatedIssue?.identifier && (
<Link
to={item.relatedIssue.href ?? "#"}
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{item.relatedIssue.identifier}
</Link>
)}
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
<Clock className="h-3 w-3" />
{relativeTime(item.activityAt)}
</span>
</div>
<div className="mt-1 flex items-start gap-2">
<div className="min-w-0 flex-1">
{href ? (
<Link
to={href}
className="block truncate text-sm font-medium text-foreground hover:underline"
title={item.subject.title ?? undefined}
>
{item.subject.title ?? meta.label}
</Link>
) : (
<span className="block truncate text-sm font-medium text-foreground">
{item.subject.title ?? meta.label}
</span>
)}
<p className="mt-0.5 text-xs text-muted-foreground">{item.whyNow}</p>
</div>
{/* Deep-link / open for non-inline rows */}
{!inline && href && (
<Button asChild variant="outline" size="xs" className="shrink-0">
<Link to={href}>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
)}
{/* Snooze / dismiss */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0 text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDismiss(item)}>Snooze / dismiss</DropdownMenuItem>
{href && (
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{inline && expanded && (
<div className="border-t border-border/60 bg-muted/20 px-4 py-3">
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
</div>
)}
</div>
);
}
function InlineResolver({
item,
companyId,
agentMap,
currentUserId,
userLabelMap,
}: {
item: AttentionItem;
companyId: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
if (item.sourceKind === "issue_thread_interaction") {
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
if (!issueId) {
return <p className="text-xs text-muted-foreground">Missing issue reference for this decision.</p>;
}
return (
<AttentionInteractionResolver
companyId={companyId}
issueId={issueId}
interactionId={item.subject.id}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
/>
);
}
if (item.sourceKind === "approval") {
return <ApprovalResolver item={item} companyId={companyId} />;
}
if (item.sourceKind === "join_request") {
return <JoinRequestResolver item={item} companyId={companyId} />;
}
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 (
<div className="space-y-3">
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional decision note…"
className="min-h-[64px] text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Request revision
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
</div>
</div>
);
}
function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
};
const approve = useMutation({
mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const reject = useMutation({
mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
});
const pending = approve.isPending || reject.isPending;
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
</div>
);
}
+17
View File
@@ -1,5 +1,6 @@
import {
Inbox,
ListChecks,
CircleDot,
Target,
LayoutDashboard,
@@ -32,9 +33,11 @@ import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { attentionApi } from "../api/attention";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Button } from "@/components/ui/button";
@@ -54,6 +57,13 @@ export function Sidebar() {
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: attentionFeed } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 60_000,
});
const attentionCount = attentionBadgeCount(attentionFeed);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@@ -181,6 +191,13 @@ export function Sidebar() {
);
})()}
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem
to="/attention"
label="What needs me"
icon={ListChecks}
badge={attentionCount}
badgeLabel="decisions"
/>
<SidebarNavItem
to="/inbox"
label="Inbox"
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
import { attentionBadgeCount, isInlineResolvable, severityStyle, sourceMeta } from "./attention";
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
return {
id: "a1",
companyId: "c1",
sourceKind: "approval",
subject: { kind: "approval", id: "s1", companyId: "c1", title: "t", identifier: null, status: null, href: null },
whyNow: "why",
decisionVerbs: [],
inlineResolvable: true,
entryRule: "",
exitRule: "",
dedupKey: "d1",
dismissalKey: "attention:d1",
severity: "medium",
rank: 0,
activityAt: "2026-07-09T12:00:00Z",
createdAt: "2026-07-09T12:00:00Z",
updatedAt: "2026-07-09T12:00:00Z",
relatedIssue: null,
...overrides,
};
}
describe("isInlineResolvable", () => {
it("is true for approvals/interactions/join when server flags inlineResolvable", () => {
for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(true);
}
});
it("is false when the server marks a row non-inline (e.g. board approval)", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "approval", inlineResolvable: false }))).toBe(false);
});
it("is never inline for reviews even when flagged", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: true }))).toBe(false);
});
it("deep-links recovery/failure/budget rows rather than inlining", () => {
for (const kind of ["recovery_action", "failed_run", "budget_alert", "blocker_attention"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(false);
}
});
});
describe("attentionBadgeCount", () => {
it("counts every queue row as a decision (mentions/unread never enter the feed)", () => {
const feed: AttentionFeed = {
companyId: "c1",
generatedAt: "2026-07-09T12:00:00Z",
totalCount: 3,
countsBySourceKind: {} as AttentionFeed["countsBySourceKind"],
items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })],
};
expect(attentionBadgeCount(feed)).toBe(3);
});
it("is zero for an empty or missing feed", () => {
expect(attentionBadgeCount(null)).toBe(0);
expect(attentionBadgeCount(undefined)).toBe(0);
});
});
describe("sourceMeta + severityStyle", () => {
it("labels every catalog source kind", () => {
const kinds: AttentionSourceKind[] = [
"approval",
"issue_thread_interaction",
"join_request",
"recovery_action",
"productivity_review",
"blocker_attention",
"review",
"failed_run",
"budget_alert",
"agent_error_alert",
];
for (const kind of kinds) {
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
expect(sourceMeta(kind).icon).toBeTruthy();
}
});
it("maps escalation severity to distinct accents", () => {
expect(severityStyle("critical").accent).not.toBe(severityStyle("low").accent);
});
});
+80
View File
@@ -0,0 +1,80 @@
import {
AlertTriangle,
Ban,
DollarSign,
Eye,
LifeBuoy,
MessageSquareQuote,
RefreshCw,
ShieldCheck,
UserPlus,
Zap,
type LucideIcon,
} from "lucide-react";
import type { AttentionFeed, AttentionItem, AttentionSeverity, AttentionSourceKind } from "@paperclipai/shared";
/**
* Source kinds the queue can fully resolve in-row. Everything else deep-links
* to its native surface — reviews are *never* inline (converged PAP-12628),
* and the remaining state-derived sources (recovery, failures, budget) expose
* verbs too rich to safely inline here, so they open their surface.
*/
export const INLINE_RESOLVABLE_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> = new Set<AttentionSourceKind>([
"approval",
"issue_thread_interaction",
"join_request",
]);
export function isInlineResolvable(item: AttentionItem): boolean {
return item.inlineResolvable && INLINE_RESOLVABLE_SOURCE_KINDS.has(item.sourceKind);
}
interface SourceMeta {
label: string;
icon: LucideIcon;
}
const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
approval: { label: "Approval", icon: ShieldCheck },
issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote },
join_request: { label: "Join request", icon: UserPlus },
recovery_action: { label: "Recovery", icon: LifeBuoy },
productivity_review: { label: "Productivity review", icon: Zap },
blocker_attention: { label: "Blocked dependency", icon: Ban },
review: { label: "Review", icon: Eye },
failed_run: { label: "Failed run", icon: RefreshCw },
budget_alert: { label: "Budget", icon: DollarSign },
agent_error_alert: { label: "Agent error", icon: AlertTriangle },
};
export function sourceMeta(kind: AttentionSourceKind): SourceMeta {
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle };
}
interface SeverityStyle {
/** Left accent bar + dot color. */
accent: string;
dot: string;
label: string;
}
const SEVERITY_STYLE: Record<AttentionSeverity, SeverityStyle> = {
critical: { accent: "bg-red-500", dot: "bg-red-500", label: "Critical" },
high: { accent: "bg-orange-500", dot: "bg-orange-500", label: "High" },
medium: { accent: "bg-yellow-500", dot: "bg-yellow-500", label: "Medium" },
low: { accent: "bg-blue-500", dot: "bg-blue-500", label: "Low" },
};
export function severityStyle(severity: AttentionSeverity): SeverityStyle {
return SEVERITY_STYLE[severity] ?? SEVERITY_STYLE.low;
}
/**
* Decisions-only badge count. Every feed row *is* a pending decision (the
* server drops anything without a decision verb into Activity, per the §0
* invariant), and mentions/unread never enter the feed — so the row count is
* the decisions-only number. `/inbox` keeps its own unread count untouched.
*/
export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number {
return feed?.items.length ?? 0;
}
+1
View File
@@ -17,6 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"costs",
"usage",
"activity",
"attention",
"inbox",
"board-chat",
"artifacts",
+1
View File
@@ -281,6 +281,7 @@ export const queryKeys = {
["company-search", companyId, q, scope, limit, offset] as const,
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
attention: (companyId: string) => ["attention", companyId] as const,
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
userProfile: (companyId: string, userSlug: string) =>
["user-profile", companyId, userSlug] as const,
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Inbox } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useInboxDismissals } from "../hooks/useInboxBadge";
import { queryKeys } from "../lib/queryKeys";
import { isInlineResolvable } from "../lib/attention";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
export function WhatNeedsMe() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [autoExpandDone, setAutoExpandDone] = useState(false);
const [clearedIds, setClearedIds] = useState<Set<string>>(() => new Set());
const { dismiss, dismissedAtByKey } = useInboxDismissals(selectedCompanyId);
useEffect(() => {
setBreadcrumbs([{ label: "What needs me" }]);
}, [setBreadcrumbs]);
const { data: feed, isLoading, error } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchOnWindowFocus: true,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const agent of agents ?? []) map.set(agent.id, agent);
return map;
}, [agents]);
// Client-side guard: the server already filters dismissed rows, but this
// keeps a just-dismissed row from flashing back before the refetch lands.
const items = useMemo(() => {
const list = feed?.items ?? [];
return list.filter((item) => {
if (clearedIds.has(item.id)) return false;
const dismissedAt = dismissedAtByKey.get(item.dismissalKey);
return !(dismissedAt != null && dismissedAt >= new Date(item.activityAt).getTime());
});
}, [feed, clearedIds, dismissedAtByKey]);
// Auto-expand the topmost inline-capable decision, once, per converged UX.
// Rank never reorders while interacting, so we only seed the default once.
useEffect(() => {
if (autoExpandDone || items.length === 0) return;
const topInline = items.find((item) => isInlineResolvable(item));
if (topInline) setExpandedId(topInline.id);
setAutoExpandDone(true);
}, [items, autoExpandDone]);
const handleDismiss = (item: AttentionItem) => {
dismiss(item.dismissalKey);
setClearedIds((prev) => {
const next = new Set(prev);
next.add(item.id);
return next;
});
if (expandedId === item.id) setExpandedId(null);
};
if (!selectedCompanyId) {
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
}
if (isLoading) {
return <PageSkeleton variant="approvals" />;
}
return (
<div className="mx-auto max-w-3xl space-y-4">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-bold">What needs me</h1>
{items.length > 0 && (
<span className="text-sm text-muted-foreground">
{items.length} {items.length === 1 ? "decision" : "decisions"}
</span>
)}
</div>
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
{items.length === 0 ? (
<ZeroState />
) : (
<div className="space-y-2">
{items.map((item) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={selectedCompanyId}
expanded={expandedId === item.id}
onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
onDismiss={handleDismiss}
agentMap={agentMap}
currentUserId={currentUserId}
/>
))}
</div>
)}
</div>
);
}
function ZeroState() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<div className="mb-4 rounded-full bg-green-500/10 p-4">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<Inbox className="h-4 w-4" />
Nothing needs a decision from you right now.
</p>
</div>
);
}
@@ -0,0 +1,203 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CheckCircle2, Inbox } from "lucide-react";
import type { AttentionItem, AttentionSourceKind, AttentionSeverity } from "@paperclipai/shared";
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
const companyId = "company-storybook";
function item(
id: string,
sourceKind: AttentionSourceKind,
severity: AttentionSeverity,
title: string,
whyNow: string,
overrides: Partial<AttentionItem> = {},
): AttentionItem {
const now = new Date("2026-07-09T12:00:00Z");
return {
id,
companyId,
sourceKind,
subject: {
kind: "issue",
id: `${id}-subject`,
companyId,
title,
identifier: null,
status: "pending",
href: "/PAP/issues/PAP-1000",
metadata: {},
},
whyNow,
decisionVerbs: [
{ id: "approve", label: "Approve", description: null },
{ id: "reject", label: "Reject", description: null },
],
inlineResolvable: false,
entryRule: "",
exitRule: "",
dedupKey: `${id}-dedup`,
dismissalKey: `attention:${id}-dedup`,
severity,
rank: 0,
activityAt: now.toISOString(),
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
relatedIssue: {
kind: "issue",
id: "issue-1000",
companyId,
title: "Ship the attention queue",
identifier: "PAP-1000",
status: "in_progress",
href: "/PAP/issues/PAP-1000",
metadata: {},
},
...overrides,
};
}
const POPULATED: AttentionItem[] = [
item(
"recov-1",
"recovery_action",
"critical",
"Run watchdog escalated — agent stalled 40m",
"Recovery action escalated and needs a human decision.",
{ subject: { kind: "recovery_action", id: "r1", companyId, title: "Run watchdog escalated — agent stalled 40m", identifier: null, status: "escalated", href: "/PAP/issues/PAP-1000", metadata: {} } },
),
item(
"appr-1",
"approval",
"high",
"Hire agent: Research Analyst",
"Approval is pending a board decision.",
{
inlineResolvable: true,
subject: { kind: "approval", id: "approval-1", companyId, title: "Hire agent: Research Analyst", identifier: null, status: "pending", href: "/PAP/approvals/approval-1", metadata: { type: "hire_agent" } },
relatedIssue: null,
decisionVerbs: [
{ id: "approve", label: "Approve", description: null },
{ id: "reject", label: "Reject", description: null },
{ id: "request_revision", label: "Request revision", description: null },
],
},
),
item(
"intx-1",
"issue_thread_interaction",
"medium",
"Which rollout order should we use?",
"Questions need answers on an issue thread.",
{
inlineResolvable: true,
subject: { kind: "interaction", id: "interaction-1", companyId, title: "Which rollout order should we use?", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000#interaction-1", metadata: { kind: "ask_user_questions", issueId: "issue-1000" } },
decisionVerbs: [{ id: "respond", label: "Respond", description: null }],
},
),
item(
"review-1",
"review",
"medium",
"PR ready for review: attention feed endpoint",
"In-review issue is waiting on a human reviewer.",
{
inlineResolvable: false,
decisionVerbs: [
{ id: "approve", label: "Approve", description: null },
{ id: "request_changes", label: "Request changes", description: null },
],
},
),
item(
"join-1",
"join_request",
"medium",
"alex@acme.dev wants to join",
"Join request is pending approval.",
{
inlineResolvable: true,
subject: { kind: "join_request", id: "join-1", companyId, title: "alex@acme.dev wants to join", identifier: null, status: "pending_approval", href: "/PAP/settings/access", metadata: {} },
relatedIssue: null,
},
),
item(
"fail-1",
"failed_run",
"high",
"Deploy pipeline failed after 3 retries",
"Retries are exhausted; a human action is needed.",
{ relatedIssue: null, inlineResolvable: false },
),
item(
"budget-1",
"budget_alert",
"low",
"Company budget crossed 85%",
"Budget crossed the 85% threshold.",
{ relatedIssue: null, inlineResolvable: false },
),
];
function Queue({ items }: { items: AttentionItem[] }) {
const firstInline = items.find((i) => i.inlineResolvable && (i.sourceKind === "approval" || i.sourceKind === "join_request"));
const [expandedId, setExpandedId] = useState<string | null>(firstInline?.id ?? null);
const [cleared, setCleared] = useState<Set<string>>(new Set());
const visible = items.filter((i) => !cleared.has(i.id));
return (
<div className="mx-auto max-w-3xl space-y-4 p-6">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-bold">What needs me</h1>
{visible.length > 0 && (
<span className="text-sm text-muted-foreground">
{visible.length} {visible.length === 1 ? "decision" : "decisions"}
</span>
)}
</div>
{visible.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<div className="mb-4 rounded-full bg-green-500/10 p-4">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<Inbox className="h-4 w-4" />
Nothing needs a decision from you right now.
</p>
</div>
) : (
<div className="space-y-2">
{visible.map((it) => (
<AttentionQueueRow
key={it.id}
item={it}
companyId={companyId}
expanded={expandedId === it.id}
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
onDismiss={(dismissed) => setCleared((prev) => new Set(prev).add(dismissed.id))}
/>
))}
</div>
)}
</div>
);
}
const meta: Meta<typeof Queue> = {
title: "Pages/What needs me",
component: Queue,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof Queue>;
export const Populated: Story = {
args: { items: POPULATED },
};
export const ZeroState: Story = {
args: { items: [] },
};