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)}`
- }
- />
- );
- })}
-
-
- >
- )}
);
}