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 ( +
- {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}
- )} -