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") && }