From c4838cca6ec2f221be8d1c5bef91703e87b5c3e1 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 24 Mar 2026 07:53:05 -0500 Subject: [PATCH] Render join requests inline in inbox like approvals and other work items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Join requests were displayed in a separate card-style section below the main inbox list. This moves them into the unified work items feed so they sort chronologically alongside issues, approvals, and failed runs—matching the inline treatment hiring requests already receive. Co-Authored-By: Paperclip --- ui/src/lib/inbox.test.ts | 32 ++++++++ ui/src/lib/inbox.ts | 12 +++ ui/src/pages/Inbox.tsx | 161 ++++++++++++++++++++++++--------------- 3 files changed, 144 insertions(+), 61 deletions(-) diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 2a9f629e..fe69813a 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -296,6 +296,7 @@ describe("inbox helpers", () => { }).map((item) => { if (item.kind === "issue") return `issue:${item.issue.id}`; if (item.kind === "approval") return `approval:${item.approval.id}`; + if (item.kind === "join_request") return `join:${item.joinRequest.id}`; return `run:${item.run.id}`; }), ).toEqual([ @@ -305,6 +306,37 @@ describe("inbox helpers", () => { ]); }); + it("mixes join requests into the inbox feed by most recent activity", () => { + const issue = makeIssue("1", true); + issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); + + const joinRequest = makeJoinRequest("join-1"); + joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z"); + + const approval = makeApprovalWithTimestamps( + "approval-oldest", + "pending", + "2026-03-11T02:00:00.000Z", + ); + + expect( + getInboxWorkItems({ + issues: [issue], + approvals: [approval], + joinRequests: [joinRequest], + }).map((item) => { + if (item.kind === "issue") return `issue:${item.issue.id}`; + if (item.kind === "approval") return `approval:${item.approval.id}`; + if (item.kind === "join_request") return `join:${item.joinRequest.id}`; + return `run:${item.run.id}`; + }), + ).toEqual([ + "issue:1", + "join:join-1", + "approval:approval-oldest", + ]); + }); + 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 98de7055..f2719e17 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -28,6 +28,11 @@ export type InboxWorkItem = kind: "failed_run"; timestamp: number; run: HeartbeatRun; + } + | { + kind: "join_request"; + timestamp: number; + joinRequest: JoinRequest; }; export interface InboxBadgeData { @@ -152,10 +157,12 @@ export function getInboxWorkItems({ issues, approvals, failedRuns = [], + joinRequests = [], }: { issues: Issue[]; approvals: Approval[]; failedRuns?: HeartbeatRun[]; + joinRequests?: JoinRequest[]; }): InboxWorkItem[] { return [ ...issues.map((issue) => ({ @@ -173,6 +180,11 @@ export function getInboxWorkItems({ timestamp: normalizeTimestamp(run.createdAt), run, })), + ...joinRequests.map((joinRequest) => ({ + kind: "join_request" as const, + timestamp: normalizeTimestamp(joinRequest.createdAt), + joinRequest, + })), ].sort((a, b) => { const timestampDiff = b.timestamp - a.timestamp; if (timestampDiff !== 0) return timestampDiff; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index f70441f6..9f0a6299 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -36,6 +36,7 @@ import { XCircle, X, RotateCcw, + UserPlus, } from "lucide-react"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; @@ -61,7 +62,6 @@ type InboxCategoryFilter = | "alerts"; type SectionKey = | "work_items" - | "join_requests" | "alerts"; function firstNonEmptyLine(value: string | null | undefined): string | null { @@ -281,6 +281,84 @@ function ApprovalInboxRow({ ); } +function JoinRequestInboxRow({ + joinRequest, + onApprove, + onReject, + isPending, +}: { + joinRequest: JoinRequest; + onApprove: () => void; + onReject: () => void; + isPending: boolean; +}) { + const label = + joinRequest.requestType === "human" + ? "Human join request" + : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`; + + return ( +
+
+
+
+
+ + +
+
+
+ + +
+
+ ); +} + export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -431,14 +509,22 @@ export function Inbox() { return failedRuns; }, [failedRuns, tab, showFailedRunsCategory]); + const joinRequestsForTab = useMemo(() => { + if (tab === "all" && !showJoinRequestsCategory) return []; + if (tab === "recent") return joinRequests; + if (tab === "unread") return joinRequests; + return joinRequests; + }, [joinRequests, tab, showJoinRequestsCategory]); + const workItemsToRender = useMemo( () => getInboxWorkItems({ issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, failedRuns: failedRunsForTab, + joinRequests: joinRequestsForTab, }), - [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab], + [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab], ); const agentName = (id: string | null) => { @@ -602,10 +688,7 @@ export function Inbox() { dashboard.costs.monthUtilizationPercent >= 80 && !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; - const hasJoinRequests = joinRequests.length > 0; const showWorkItemsSection = workItemsToRender.length > 0; - const showJoinRequestsSection = - tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; const showAlertsSection = shouldShowInboxSection({ tab, hasItems: hasAlerts, @@ -616,7 +699,6 @@ export function Inbox() { const visibleSections = [ showAlertsSection ? "alerts" : null, - showJoinRequestsSection ? "join_requests" : null, showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); @@ -757,6 +839,18 @@ export function Inbox() { ); } + if (item.kind === "join_request") { + return ( + approveJoinMutation.mutate(item.joinRequest)} + onReject={() => rejectJoinMutation.mutate(item.joinRequest)} + isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} + /> + ); + } + const issue = item.issue; const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); @@ -806,61 +900,6 @@ export function Inbox() { )} - {showJoinRequestsSection && ( - <> - {showSeparatorBefore("join_requests") && } -
-

- Join Requests -

-
- {joinRequests.map((joinRequest) => ( -
-
-
-

- {joinRequest.requestType === "human" - ? "Human join request" - : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`} -

-

- requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp} -

- {joinRequest.requestEmailSnapshot && ( -

- email: {joinRequest.requestEmailSnapshot} -

- )} - {joinRequest.adapterType && ( -

adapter: {joinRequest.adapterType}

- )} -
-
- - -
-
-
- ))} -
-
- - )} - - {showAlertsSection && ( <> {showSeparatorBefore("alerts") && }