mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
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>
963 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|