Mix approvals into inbox activity feed

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-17 16:19:00 -05:00
parent c121f4d4a7
commit 22ae70649b
3 changed files with 260 additions and 120 deletions

View File

@@ -3,8 +3,9 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { import {
getApprovalsForTab,
computeInboxBadgeData, computeInboxBadgeData,
getApprovalsForTab,
getInboxWorkItems,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
loadLastInboxTab, loadLastInboxTab,
@@ -271,6 +272,31 @@ describe("inbox helpers", () => {
]); ]);
}); });
it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-between",
"pending",
"2026-03-11T03:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [olderIssue, newerIssue],
approvals: [approval],
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`),
).toEqual([
"issue:1",
"approval:approval-between",
"issue:2",
]);
});
it("can include sections on recent without forcing them to be unread", () => { it("can include sections on recent without forcing them to be unread", () => {
expect( expect(
shouldShowInboxSection({ shouldShowInboxSection({

View File

@@ -13,6 +13,17 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "recent" | "unread" | "all"; export type InboxTab = "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItem =
| {
kind: "issue";
timestamp: number;
issue: Issue;
}
| {
kind: "approval";
timestamp: number;
approval: Approval;
};
export interface InboxBadgeData { export interface InboxBadgeData {
inbox: number; inbox: number;
@@ -126,6 +137,45 @@ export function getApprovalsForTab(
}); });
} }
export function approvalActivityTimestamp(approval: Approval): number {
const updatedAt = normalizeTimestamp(approval.updatedAt);
if (updatedAt > 0) return updatedAt;
return normalizeTimestamp(approval.createdAt);
}
export function getInboxWorkItems({
issues,
approvals,
}: {
issues: Issue[];
approvals: Approval[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
kind: "issue" as const,
timestamp: issueLastActivityTimestamp(issue),
issue,
})),
...approvals.map((approval) => ({
kind: "approval" as const,
timestamp: approvalActivityTimestamp(approval),
approval,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
if (a.kind === "approval" && b.kind === "approval") {
return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval);
}
return a.kind === "approval" ? -1 : 1;
});
}
export function shouldShowInboxSection({ export function shouldShowInboxSection({
tab, tab,
hasItems, hasItems,

View File

@@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard";
import { IssueRow } from "../components/IssueRow"; import { IssueRow } from "../components/IssueRow";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -40,16 +40,17 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { import {
ACTIONABLE_APPROVAL_STATUSES, ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
InboxApprovalFilter, InboxApprovalFilter,
type InboxTab,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
type InboxTab,
} from "../lib/inbox"; } from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxItems } from "../hooks/useInboxBadge";
@@ -61,9 +62,8 @@ type InboxCategoryFilter =
| "failed_runs" | "failed_runs"
| "alerts"; | "alerts";
type SectionKey = type SectionKey =
| "issues_i_touched" | "work_items"
| "join_requests" | "join_requests"
| "approvals"
| "failed_runs" | "failed_runs"
| "alerts"; | "alerts";
@@ -84,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string {
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error."; return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
} }
function approvalStatusLabel(status: Approval["status"]): string {
return status.replaceAll("_", " ");
}
function readIssueIdFromRun(run: HeartbeatRun): string | null { function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot; const context = run.contextSnapshot;
if (!context) return null; if (!context) return null;
@@ -235,6 +239,93 @@ function FailedRunCard({
); );
} }
function ApprovalInboxRow({
approval,
requesterName,
onApprove,
onReject,
isPending,
}: {
approval: Approval;
requesterName: string | null;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type;
const showResolutionButtons =
approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:py-2">
<div className="flex items-start gap-3 sm:items-center">
<Link
to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-3 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="capitalize">{approvalStatusLabel(approval.status)}</span>
{requesterName ? <span>requested by {requesterName}</span> : null}
<span>updated {timeAgo(approval.updatedAt)}</span>
</span>
</span>
</Link>
{showResolutionButtons ? (
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
{showResolutionButtons ? (
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
);
}
export function Inbox() { export function Inbox() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@@ -336,6 +427,10 @@ export function Inbox() {
() => touchedIssues.filter((issue) => issue.isUnreadForMe), () => touchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues], [touchedIssues],
); );
const issuesToRender = useMemo(
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
[tab, touchedIssues, unreadTouchedIssues],
);
const agentById = useMemo(() => { const agentById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -363,20 +458,27 @@ export function Inbox() {
return ids; return ids;
}, [heartbeatRuns]); }, [heartbeatRuns]);
const allApprovals = useMemo(
() => getApprovalsForTab(approvals ?? [], "recent", "all"),
[approvals],
);
const actionableApprovals = useMemo(
() => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)),
[allApprovals],
);
const approvalsToRender = useMemo( const approvalsToRender = useMemo(
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter), () => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
[approvals, tab, allApprovalFilter], [approvals, tab, allApprovalFilter],
); );
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched";
const showApprovalsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "approvals";
const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const workItemsToRender = useMemo(
() =>
getInboxWorkItems({
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
}),
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
);
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
if (!id) return null; if (!id) return null;
@@ -500,33 +602,9 @@ export function Inbox() {
!dismissed.has("alert:budget"); !dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasAlerts = showAggregateAgentError || showBudgetAlert;
const hasJoinRequests = joinRequests.length > 0; const hasJoinRequests = joinRequests.length > 0;
const hasTouchedIssues = touchedIssues.length > 0; const showWorkItemsSection = workItemsToRender.length > 0;
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched";
const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals";
const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const showTouchedSection = shouldShowInboxSection({
tab,
hasItems: tab === "unread" ? unreadTouchedIssues.length > 0 : hasTouchedIssues,
showOnRecent: hasTouchedIssues,
showOnUnread: unreadTouchedIssues.length > 0,
showOnAll: showTouchedCategory && hasTouchedIssues,
});
const showJoinRequestsSection = const showJoinRequestsSection =
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showApprovalsSection = shouldShowInboxSection({
tab,
hasItems: approvalsToRender.length > 0,
showOnRecent: approvalsToRender.length > 0,
showOnUnread: actionableApprovals.length > 0,
showOnAll: showApprovalsCategory && approvalsToRender.length > 0,
});
const showFailedRunsSection = shouldShowInboxSection({ const showFailedRunsSection = shouldShowInboxSection({
tab, tab,
hasItems: hasRunFailures, hasItems: hasRunFailures,
@@ -545,9 +623,8 @@ export function Inbox() {
const visibleSections = [ const visibleSections = [
showFailedRunsSection ? "failed_runs" : null, showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null, showAlertsSection ? "alerts" : null,
showApprovalsSection ? "approvals" : null,
showJoinRequestsSection ? "join_requests" : null, showJoinRequestsSection ? "join_requests" : null,
showTouchedSection ? "issues_i_touched" : null, showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null); ].filter((key): key is SectionKey => key !== null);
const allLoaded = const allLoaded =
@@ -653,29 +730,72 @@ export function Inbox() {
/> />
)} )}
{showApprovalsSection && ( {showWorkItemsSection && (
<> <>
{showSeparatorBefore("approvals") && <Separator />} {showSeparatorBefore("work_items") && <Separator />}
<div> <div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <div className="overflow-hidden rounded-xl border border-border bg-card">
{tab === "unread" ? "Approvals Needing Action" : "Approvals"} {workItemsToRender.map((item) => {
</h3> if (item.kind === "approval") {
<div className="grid gap-3"> return (
{approvalsToRender.map((approval) => ( <ApprovalInboxRow
<ApprovalCard key={`approval:${item.approval.id}`}
key={approval.id} approval={item.approval}
approval={approval} requesterName={agentName(item.approval.requestedByAgentId)}
requesterAgent={ onApprove={() => approveMutation.mutate(item.approval.id)}
approval.requestedByAgentId onReject={() => rejectMutation.mutate(item.approval.id)}
? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null
: null
}
onApprove={() => approveMutation.mutate(approval.id)}
onReject={() => rejectMutation.mutate(approval.id)}
detailLink={`/approvals/${approval.id}`}
isPending={approveMutation.isPending || rejectMutation.isPending} isPending={approveMutation.isPending || rejectMutation.isPending}
/> />
))} );
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)}
trailingMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
/>
);
})}
</div> </div>
</div> </div>
</> </>
@@ -816,62 +936,6 @@ export function Inbox() {
</> </>
)} )}
{showTouchedSection && (
<>
{showSeparatorBefore("issues_i_touched") && <Separator />}
<div>
<div>
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)}
trailingMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
/>
);
})}
</div>
</div>
</>
)}
</div> </div>
); );
} }