From 22ae70649bc97f04b648453f0ba6bde35f4fb371 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 17 Mar 2026 16:19:00 -0500 Subject: [PATCH] Mix approvals into inbox activity feed Co-Authored-By: Paperclip --- ui/src/lib/inbox.test.ts | 28 +++- ui/src/lib/inbox.ts | 50 +++++++ ui/src/pages/Inbox.tsx | 302 ++++++++++++++++++++++++--------------- 3 files changed, 260 insertions(+), 120 deletions(-) diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 0576dcf2..4821b6cf 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -3,8 +3,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { - getApprovalsForTab, computeInboxBadgeData, + getApprovalsForTab, + getInboxWorkItems, getRecentTouchedIssues, getUnreadTouchedIssues, 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", () => { expect( shouldShowInboxSection({ diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 9edba8ac..b9a74f72 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -13,6 +13,17 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export type InboxTab = "recent" | "unread" | "all"; export type InboxApprovalFilter = "all" | "actionable" | "resolved"; +export type InboxWorkItem = + | { + kind: "issue"; + timestamp: number; + issue: Issue; + } + | { + kind: "approval"; + timestamp: number; + approval: Approval; + }; export interface InboxBadgeData { 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({ tab, hasItems, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index bf580f33..7339dc1b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; -import { ApprovalCard } from "../components/ApprovalCard"; import { IssueRow } from "../components/IssueRow"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusIcon } from "../components/StatusIcon"; import { StatusBadge } from "../components/StatusBadge"; +import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -40,16 +40,17 @@ import { } from "lucide-react"; import { Identity } from "../components/Identity"; import { PageTabBar } from "../components/PageTabBar"; -import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; +import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getApprovalsForTab, + getInboxWorkItems, getLatestFailedRunsByAgent, getRecentTouchedIssues, InboxApprovalFilter, - type InboxTab, saveLastInboxTab, shouldShowInboxSection, + type InboxTab, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; @@ -61,9 +62,8 @@ type InboxCategoryFilter = | "failed_runs" | "alerts"; type SectionKey = - | "issues_i_touched" + | "work_items" | "join_requests" - | "approvals" | "failed_runs" | "alerts"; @@ -84,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string { 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 { const context = run.contextSnapshot; 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 ( +
+
+ + + + + + + {label} + + + {approvalStatusLabel(approval.status)} + {requesterName ? requested by {requesterName} : null} + updated {timeAgo(approval.updatedAt)} + + + + {showResolutionButtons ? ( +
+ + +
+ ) : null} +
+ {showResolutionButtons ? ( +
+ + +
+ ) : null} +
+ ); +} + export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -336,6 +427,10 @@ export function Inbox() { () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], ); + const issuesToRender = useMemo( + () => (tab === "unread" ? unreadTouchedIssues : touchedIssues), + [tab, touchedIssues, unreadTouchedIssues], + ); const agentById = useMemo(() => { const map = new Map(); @@ -363,20 +458,27 @@ export function Inbox() { return ids; }, [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( () => getApprovalsForTab(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) => { if (!id) return null; @@ -500,33 +602,9 @@ export function Inbox() { !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = touchedIssues.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 showWorkItemsSection = workItemsToRender.length > 0; const showJoinRequestsSection = 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({ tab, hasItems: hasRunFailures, @@ -545,9 +623,8 @@ export function Inbox() { const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, - showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, - showTouchedSection ? "issues_i_touched" : null, + showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); const allLoaded = @@ -653,29 +730,72 @@ export function Inbox() { /> )} - {showApprovalsSection && ( + {showWorkItemsSection && ( <> - {showSeparatorBefore("approvals") && } + {showSeparatorBefore("work_items") && }
-

- {tab === "unread" ? "Approvals Needing Action" : "Approvals"} -

-
- {approvalsToRender.map((approval) => ( - 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} - /> - ))} +
+ {workItemsToRender.map((item) => { + if (item.kind === "approval") { + return ( + approveMutation.mutate(item.approval.id)} + onReject={() => rejectMutation.mutate(item.approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> + ); + } + + const issue = item.issue; + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + return ( + + + + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + 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)}` + } + /> + ); + })}
@@ -816,62 +936,6 @@ export function Inbox() { )} - {showTouchedSection && ( - <> - {showSeparatorBefore("issues_i_touched") && } -
-
- {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { - const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); - const isFading = fadingOutIssues.has(issue.id); - return ( - - - - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - 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)}` - } - /> - ); - })} -
-
- - )}
); }