paperclip/ui/src/pages/Inbox.tsx
dotta c4838cca6e Render join requests inline in inbox like approvals and other work items
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 <noreply@paperclip.ing>
2026-03-24 11:48:43 -05:00

963 lines
35 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { dashboardApi } from "../api/dashboard";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow";
import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Inbox as InboxIcon,
AlertTriangle,
XCircle,
X,
RotateCcw,
UserPlus,
} from "lucide-react";
import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab,
getInboxWorkItems,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
InboxApprovalFilter,
saveLastInboxTab,
shouldShowInboxSection,
type InboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
type SectionKey =
| "work_items"
| "alerts";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
return line ?? null;
}
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;
const issueId = context["issueId"];
if (typeof issueId === "string" && issueId.length > 0) return issueId;
const taskId = context["taskId"];
if (typeof taskId === "string" && taskId.length > 0) return taskId;
return null;
}
function FailedRunInboxRow({
run,
issueById,
agentName: linkedAgentName,
issueLinkState,
onDismiss,
onRetry,
isRetrying,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
agentName: string | null;
issueLinkState: unknown;
onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
}) {
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const displayError = runFailureMessage(run);
return (
<div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{issue ? (
<>
<span className="font-mono text-muted-foreground mr-1.5">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.title}
</>
) : (
<>Failed run{linkedAgentName ? `${linkedAgentName}` : ""}</>
)}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<StatusBadge status={run.status} />
{linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
<span className="truncate max-w-[300px]">{displayError}</span>
<span>{timeAgo(run.createdAt)}</span>
</span>
</span>
</Link>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
onClick={onRetry}
disabled={isRetrying}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2.5"
onClick={onRetry}
disabled={isRetrying}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
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 = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons =
approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="capitalize">{approvalStatusLabel(approval.status)}</span>
{requesterName ? <span>requested by {requesterName}</span> : null}
<span>updated {timeAgo(approval.updatedAt)}</span>
</span>
</span>
</Link>
{showResolutionButtons ? (
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
{showResolutionButtons ? (
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
);
}
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 (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<UserPlus className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}</span>
{joinRequest.adapterType && <span>adapter: {joinRequest.adapterType}</span>}
</span>
</span>
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
</div>
);
}
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "recent";
const tab: InboxTab =
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Inbox",
`${location.pathname}${location.search}${location.hash}`,
),
[location.pathname, location.search, location.hash],
);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
useEffect(() => {
saveLastInboxTab(tab);
}, [tab]);
const {
data: approvals,
isLoading: isApprovalsLoading,
error: approvalsError,
} = useQuery({
queryKey: queryKeys.approvals.list(selectedCompanyId!),
queryFn: () => approvalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const {
data: joinRequests = [],
isLoading: isJoinRequestsLoading,
} = useQuery({
queryKey: queryKeys.access.joinRequests(selectedCompanyId!),
queryFn: async () => {
try {
return await accessApi.listJoinRequests(selectedCompanyId!, "pending_approval");
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 401)) {
return [];
}
throw err;
}
},
enabled: !!selectedCompanyId,
retry: false,
});
const { data: dashboard, isLoading: isDashboardLoading } = useQuery({
queryKey: queryKeys.dashboard(selectedCompanyId!),
queryFn: () => dashboardApi.summary(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: issues, isLoading: isIssuesLoading } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const {
data: touchedIssuesRaw = [],
isLoading: isTouchedIssuesLoading,
} = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!),
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me",
status: "backlog,todo,in_progress,in_review,blocked,done",
}),
enabled: !!selectedCompanyId,
});
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const unreadTouchedIssues = useMemo(
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues],
);
const issuesToRender = useMemo(
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
[tab, touchedIssues, unreadTouchedIssues],
);
const agentById = useMemo(() => {
const map = new Map<string, string>();
for (const agent of agents ?? []) map.set(agent.id, agent.name);
return map;
}, [agents]);
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues ?? []) map.set(issue.id, issue);
return map;
}, [issues]);
const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns, dismissed],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of heartbeatRuns ?? []) {
if (run.status !== "running" && run.status !== "queued") continue;
const issueId = readIssueIdFromRun(run);
if (issueId) ids.add(issueId);
}
return ids;
}, [heartbeatRuns]);
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 failedRunsForTab = useMemo(() => {
if (tab === "all" && !showFailedRunsCategory) return [];
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, joinRequestsForTab],
);
const agentName = (id: string | null) => {
if (!id) return null;
return agentById.get(id) ?? null;
};
const approveMutation = useMutation({
mutationFn: (id: string) => approvalsApi.approve(id),
onSuccess: (_approval, id) => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
navigate(`/approvals/${id}?resolved=approved`);
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to approve");
},
});
const rejectMutation = useMutation({
mutationFn: (id: string) => approvalsApi.reject(id),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reject");
},
});
const approveJoinMutation = useMutation({
mutationFn: (joinRequest: JoinRequest) =>
accessApi.approveJoinRequest(selectedCompanyId!, joinRequest.id),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to approve join request");
},
});
const rejectJoinMutation = useMutation({
mutationFn: (joinRequest: JoinRequest) =>
accessApi.rejectJoinRequest(selectedCompanyId!, joinRequest.id),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reject join request");
},
});
const [retryingRunIds, setRetryingRunIds] = useState<Set<string>>(new Set());
const retryRunMutation = useMutation({
mutationFn: async (run: HeartbeatRun) => {
const payload: Record<string, unknown> = {};
const context = run.contextSnapshot as Record<string, unknown> | null;
if (context) {
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
}
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
payload,
});
if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable.");
}
return { newRun: result, originalRun: run };
},
onMutate: (run) => {
setRetryingRunIds((prev) => new Set(prev).add(run.id));
},
onSuccess: ({ newRun, originalRun }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) });
navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`);
},
onSettled: (_data, _error, run) => {
if (!run) return;
setRetryingRunIds((prev) => {
const next = new Set(prev);
next.delete(run.id);
return next;
});
},
});
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
};
const markReadMutation = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onMutate: (id) => {
setFadingOutIssues((prev) => new Set(prev).add(id));
},
onSuccess: () => {
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, id) => {
setTimeout(() => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 300);
},
});
const markAllReadMutation = useMutation({
mutationFn: async (issueIds: string[]) => {
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
},
onMutate: (issueIds) => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.add(issueId);
return next;
});
},
onSuccess: () => {
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, issueIds) => {
setTimeout(() => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.delete(issueId);
return next;
});
}, 300);
},
});
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
const showBudgetAlert =
!!dashboard &&
dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = workItemsToRender.length > 0;
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
showOnRecent: hasAlerts,
showOnUnread: hasAlerts,
showOnAll: showAlertsCategory && hasAlerts,
});
const visibleSections = [
showAlertsSection ? "alerts" : null,
showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null);
const allLoaded =
!isJoinRequestsLoading &&
!isApprovalsLoading &&
!isDashboardLoading &&
!isIssuesLoading &&
!isTouchedIssuesLoading &&
!isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
const unreadIssueIds = unreadTouchedIssues
.filter((issue) => !fadingOutIssues.has(issue.id))
.map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
{canMarkAllRead && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
)}
</div>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
</SelectContent>
</Select>
{showApprovalsCategory && (
<Select
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!allLoaded && visibleSections.length === 0 && (
<PageSkeleton variant="inbox" />
)}
{allLoaded && visibleSections.length === 0 && (
<EmptyState
icon={InboxIcon}
message={
tab === "unread"
? "No new inbox items."
: tab === "recent"
? "No recent inbox items."
: "No inbox items match these filters."
}
/>
)}
{showWorkItemsSection && (
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => {
if (item.kind === "approval") {
return (
<ApprovalInboxRow
key={`approval:${item.approval.id}`}
approval={item.approval}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending}
/>
);
}
if (item.kind === "failed_run") {
return (
<FailedRunInboxRow
key={`run:${item.run.id}`}
run={item.run}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
/>
);
}
if (item.kind === "join_request") {
return (
<JoinRequestInboxRow
key={`join:${item.joinRequest.id}`}
joinRequest={item.joinRequest}
onApprove={() => 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);
return (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
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)}`
}
/>
);
})}
</div>
</div>
</>
)}
{showAlertsSection && (
<>
{showSeparatorBefore("alerts") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Alerts
</h3>
<div className="divide-y divide-border border border-border">
{showAggregateAgentError && (
<div className="group/alert relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
<Link
to="/agents"
className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
<span className="text-sm">
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
</span>
</Link>
<button
type="button"
onClick={() => dismiss("alert:agent-errors")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{showBudgetAlert && (
<div className="group/alert relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
<Link
to="/costs"
className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
<span className="text-sm">
Budget at{" "}
<span className="font-medium">{dashboard!.costs.monthUtilizationPercent}%</span>{" "}
utilization this month
</span>
</Link>
<button
type="button"
onClick={() => dismiss("alert:budget")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
</>
)}
</div>
);
}