mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
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:
1 parent
f5ab828a48
commit
7ba9280c20
12 files changed
+1116
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"costs",
|
||||
"usage",
|
||||
"activity",
|
||||
"attention",
|
||||
"inbox",
|
||||
"board-chat",
|
||||
"artifacts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] },
|
||||
};
|
||||
Reference in new issue
Block a user