forked from farhoodlabs/paperclip
517fe5093e
The archive mutation was only using CSS opacity to hide items while the network request was in flight. When the query refetch completed or the archiving timer expired, the item could reappear. Now we optimistically remove the item from React Query caches on mutate, snapshot previous data for rollback on error, and sync with the server in onSettled. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2051 lines
75 KiB
TypeScript
2051 lines
75 KiB
TypeScript
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate } from "@/lib/router";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { accessApi } from "../api/access";
|
|
import { authApi } from "../api/auth";
|
|
import { ApiError } from "../api/client";
|
|
import { dashboardApi } from "../api/dashboard";
|
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
|
import { issuesApi } from "../api/issues";
|
|
import { agentsApi } from "../api/agents";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import {
|
|
armIssueDetailInboxQuickArchive,
|
|
createIssueDetailLocationState,
|
|
createIssueDetailPath,
|
|
} from "../lib/issueDetailBreadcrumb";
|
|
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { IssueRow } from "../components/IssueRow";
|
|
import { SwipeToArchive } from "../components/SwipeToArchive";
|
|
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { cn } from "../lib/utils";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { Identity } from "../components/Identity";
|
|
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Tabs } from "@/components/ui/tabs";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Inbox as InboxIcon,
|
|
AlertTriangle,
|
|
XCircle,
|
|
X,
|
|
RotateCcw,
|
|
UserPlus,
|
|
Columns3,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { PageTabBar } from "../components/PageTabBar";
|
|
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
|
import {
|
|
ACTIONABLE_APPROVAL_STATUSES,
|
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
|
getAvailableInboxIssueColumns,
|
|
getApprovalsForTab,
|
|
getInboxWorkItems,
|
|
getInboxKeyboardSelectionIndex,
|
|
getLatestFailedRunsByAgent,
|
|
getRecentTouchedIssues,
|
|
isMineInboxTab,
|
|
loadInboxIssueColumns,
|
|
normalizeInboxIssueColumns,
|
|
resolveIssueWorkspaceName,
|
|
resolveInboxSelectionIndex,
|
|
saveInboxIssueColumns,
|
|
InboxApprovalFilter,
|
|
type InboxIssueColumn,
|
|
saveLastInboxTab,
|
|
shouldShowInboxSection,
|
|
type InboxTab,
|
|
type InboxWorkItem,
|
|
} from "../lib/inbox";
|
|
import { useDismissedInboxItems, useReadInboxItems } 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;
|
|
}
|
|
|
|
|
|
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
|
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
|
|
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
|
status: "Status",
|
|
id: "ID",
|
|
assignee: "Assignee",
|
|
project: "Project",
|
|
workspace: "Workspace",
|
|
labels: "Tags",
|
|
updated: "Last updated",
|
|
};
|
|
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
|
status: "Issue state chip on the left edge.",
|
|
id: "Ticket identifier like PAP-1009.",
|
|
assignee: "Assigned agent or board user.",
|
|
project: "Linked project pill with its color.",
|
|
workspace: "Execution or project workspace used for the issue.",
|
|
labels: "Issue labels and tags.",
|
|
updated: "Latest visible activity time.",
|
|
};
|
|
|
|
export function InboxIssueMetaLeading({
|
|
issue,
|
|
isLive,
|
|
showStatus = true,
|
|
showIdentifier = true,
|
|
}: {
|
|
issue: Issue;
|
|
isLive: boolean;
|
|
showStatus?: boolean;
|
|
showIdentifier?: boolean;
|
|
}) {
|
|
return (
|
|
<>
|
|
{showStatus ? (
|
|
<span className="hidden shrink-0 sm:inline-flex">
|
|
<StatusIcon status={issue.status} />
|
|
</span>
|
|
) : null}
|
|
{showIdentifier ? (
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
) : null}
|
|
{isLive && (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
|
"bg-blue-500/10",
|
|
)}
|
|
>
|
|
<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={cn(
|
|
"relative inline-flex h-2 w-2 rounded-full",
|
|
"bg-blue-500",
|
|
)}
|
|
/>
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"hidden text-[11px] font-medium sm:inline",
|
|
"text-blue-600 dark:text-blue-400",
|
|
)}
|
|
>
|
|
Live
|
|
</span>
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function issueActivityText(issue: Issue): string {
|
|
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
|
}
|
|
|
|
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
|
return columns
|
|
.map((column) => {
|
|
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
|
return "minmax(6rem, 7rem)";
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
export function InboxIssueTrailingColumns({
|
|
issue,
|
|
columns,
|
|
projectName,
|
|
projectColor,
|
|
workspaceName,
|
|
assigneeName,
|
|
currentUserId,
|
|
}: {
|
|
issue: Issue;
|
|
columns: InboxIssueColumn[];
|
|
projectName: string | null;
|
|
projectColor: string | null;
|
|
workspaceName: string | null;
|
|
assigneeName: string | null;
|
|
currentUserId: string | null;
|
|
}) {
|
|
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
|
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
|
|
|
return (
|
|
<span
|
|
className="grid items-center gap-2"
|
|
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
|
>
|
|
{columns.map((column) => {
|
|
if (column === "assignee") {
|
|
if (issue.assigneeAgentId) {
|
|
return (
|
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
|
<Identity
|
|
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
|
size="sm"
|
|
className="min-w-0"
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (issue.assigneeUserId) {
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
|
{userLabel}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
Unassigned
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "project") {
|
|
if (projectName) {
|
|
const accentColor = projectColor ?? "#64748b";
|
|
return (
|
|
<span
|
|
key={column}
|
|
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
|
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
|
style={{ backgroundColor: accentColor }}
|
|
/>
|
|
<span className="truncate">{projectName}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
No project
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "labels") {
|
|
if ((issue.labels ?? []).length > 0) {
|
|
return (
|
|
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
|
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
|
style={{
|
|
color: pickTextColorForPillBg(label.color, 0.12),
|
|
}}
|
|
>
|
|
<span className="truncate">{label.name}</span>
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 2 ? (
|
|
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
|
+{(issue.labels ?? []).length - 2}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
|
}
|
|
|
|
if (column === "workspace") {
|
|
if (!workspaceName) {
|
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
{workspaceName}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
|
{activityText}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function FailedRunInboxRow({
|
|
run,
|
|
issueById,
|
|
agentName: linkedAgentName,
|
|
issueLinkState,
|
|
onDismiss,
|
|
onRetry,
|
|
isRetrying,
|
|
unreadState = null,
|
|
onMarkRead,
|
|
onArchive,
|
|
archiveDisabled,
|
|
selected = false,
|
|
className,
|
|
}: {
|
|
run: HeartbeatRun;
|
|
issueById: Map<string, Issue>;
|
|
agentName: string | null;
|
|
issueLinkState: unknown;
|
|
onDismiss: () => void;
|
|
onRetry: () => void;
|
|
isRetrying: boolean;
|
|
unreadState?: NonIssueUnreadState;
|
|
onMarkRead?: () => void;
|
|
onArchive?: () => void;
|
|
archiveDisabled?: boolean;
|
|
selected?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const issueId = readIssueIdFromRun(run);
|
|
const issue = issueId ? issueById.get(issueId) ?? null : null;
|
|
const displayError = runFailureMessage(run);
|
|
const showUnreadSlot = unreadState !== null;
|
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
|
|
|
return (
|
|
<div className={cn(
|
|
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
|
|
className,
|
|
)}>
|
|
<div className="flex items-start gap-2 sm:items-center">
|
|
{showUnreadSlot ? (
|
|
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
|
{showUnreadDot ? (
|
|
<button
|
|
type="button"
|
|
onClick={onMarkRead}
|
|
className={cn(
|
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
|
"hover:bg-blue-500/20",
|
|
)}
|
|
aria-label="Mark as read"
|
|
>
|
|
<span className={cn(
|
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
|
"bg-blue-600 dark:bg-blue-400",
|
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
|
)} />
|
|
</button>
|
|
) : onArchive ? (
|
|
<button
|
|
type="button"
|
|
onClick={onArchive}
|
|
disabled={archiveDisabled}
|
|
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
|
aria-label="Dismiss from inbox"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
) : null}
|
|
<Link
|
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{!showUnreadSlot && <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>
|
|
{!showUnreadSlot && (
|
|
<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>
|
|
{!showUnreadSlot && (
|
|
<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,
|
|
unreadState = null,
|
|
onMarkRead,
|
|
onArchive,
|
|
archiveDisabled,
|
|
selected = false,
|
|
className,
|
|
}: {
|
|
approval: Approval;
|
|
requesterName: string | null;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
isPending: boolean;
|
|
unreadState?: NonIssueUnreadState;
|
|
onMarkRead?: () => void;
|
|
onArchive?: () => void;
|
|
archiveDisabled?: boolean;
|
|
selected?: boolean;
|
|
className?: string;
|
|
}) {
|
|
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);
|
|
const showUnreadSlot = unreadState !== null;
|
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
|
|
|
return (
|
|
<div className={cn(
|
|
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
|
|
className,
|
|
)}>
|
|
<div className="flex items-start gap-2 sm:items-center">
|
|
{showUnreadSlot ? (
|
|
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
|
{showUnreadDot ? (
|
|
<button
|
|
type="button"
|
|
onClick={onMarkRead}
|
|
className={cn(
|
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
|
"hover:bg-blue-500/20",
|
|
)}
|
|
aria-label="Mark as read"
|
|
>
|
|
<span className={cn(
|
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
|
"bg-blue-600 dark:bg-blue-400",
|
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
|
)} />
|
|
</button>
|
|
) : onArchive ? (
|
|
<button
|
|
type="button"
|
|
onClick={onArchive}
|
|
disabled={archiveDisabled}
|
|
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
|
aria-label="Dismiss from inbox"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
) : null}
|
|
<Link
|
|
to={`/approvals/${approval.id}`}
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{!showUnreadSlot && <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,
|
|
unreadState = null,
|
|
onMarkRead,
|
|
onArchive,
|
|
archiveDisabled,
|
|
selected = false,
|
|
className,
|
|
}: {
|
|
joinRequest: JoinRequest;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
isPending: boolean;
|
|
unreadState?: NonIssueUnreadState;
|
|
onMarkRead?: () => void;
|
|
onArchive?: () => void;
|
|
archiveDisabled?: boolean;
|
|
selected?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const label =
|
|
joinRequest.requestType === "human"
|
|
? "Human join request"
|
|
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
|
|
const showUnreadSlot = unreadState !== null;
|
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
|
|
|
return (
|
|
<div className={cn(
|
|
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
|
|
className,
|
|
)}>
|
|
<div className="flex items-start gap-2 sm:items-center">
|
|
{showUnreadSlot ? (
|
|
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
|
{showUnreadDot ? (
|
|
<button
|
|
type="button"
|
|
onClick={onMarkRead}
|
|
className={cn(
|
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
|
"hover:bg-blue-500/20",
|
|
)}
|
|
aria-label="Mark as read"
|
|
>
|
|
<span className={cn(
|
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
|
"bg-blue-600 dark:bg-blue-400",
|
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
|
)} />
|
|
</button>
|
|
) : onArchive ? (
|
|
<button
|
|
type="button"
|
|
onClick={onArchive}
|
|
disabled={archiveDisabled}
|
|
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
|
aria-label="Dismiss from inbox"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
) : null}
|
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
|
{!showUnreadSlot && <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 { keyboardShortcutsEnabled } = useGeneralSettings();
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
retry: false,
|
|
});
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
|
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
|
|
|
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
|
const tab: InboxTab =
|
|
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
|
? pathSegment
|
|
: "mine";
|
|
const canArchiveFromTab = isMineInboxTab(tab);
|
|
const issueLinkState = useMemo(
|
|
() =>
|
|
createIssueDetailLocationState(
|
|
"Inbox",
|
|
`${location.pathname}${location.search}${location.hash}`,
|
|
"inbox",
|
|
),
|
|
[location.pathname, location.search, location.hash],
|
|
);
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
|
const { data: executionWorkspaces = [] } = useQuery({
|
|
queryKey: selectedCompanyId
|
|
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
|
: ["execution-workspaces", "__disabled__"],
|
|
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Inbox" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
useEffect(() => {
|
|
saveLastInboxTab(tab);
|
|
setSelectedIndex(-1);
|
|
setSearchQuery("");
|
|
}, [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: mineIssuesRaw = [],
|
|
isLoading: isMineIssuesLoading,
|
|
} = useQuery({
|
|
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!),
|
|
queryFn: () =>
|
|
issuesApi.list(selectedCompanyId!, {
|
|
touchedByUserId: "me",
|
|
inboxArchivedByUserId: "me",
|
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
|
}),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const {
|
|
data: touchedIssuesRaw = [],
|
|
isLoading: isTouchedIssuesLoading,
|
|
} = useQuery({
|
|
queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
|
queryFn: () =>
|
|
issuesApi.list(selectedCompanyId!, {
|
|
touchedByUserId: "me",
|
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
|
}),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
|
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
|
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
|
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
|
const unreadTouchedIssues = useMemo(
|
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
|
[touchedIssues],
|
|
);
|
|
const issuesToRender = useMemo(
|
|
() => {
|
|
if (tab === "mine") return mineIssues;
|
|
if (tab === "unread") return unreadTouchedIssues;
|
|
return touchedIssues;
|
|
},
|
|
[tab, mineIssues, 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 projectById = useMemo(() => {
|
|
const map = new Map<string, { name: string; color: string | null }>();
|
|
for (const project of projects ?? []) {
|
|
map.set(project.id, { name: project.name, color: project.color });
|
|
}
|
|
return map;
|
|
}, [projects]);
|
|
const projectWorkspaceById = useMemo(() => {
|
|
const map = new Map<string, { name: string }>();
|
|
for (const project of projects ?? []) {
|
|
for (const workspace of project.workspaces ?? []) {
|
|
map.set(workspace.id, { name: workspace.name });
|
|
}
|
|
}
|
|
return map;
|
|
}, [projects]);
|
|
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const project of projects ?? []) {
|
|
const defaultWorkspaceId =
|
|
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
|
?? project.primaryWorkspace?.id
|
|
?? null;
|
|
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
|
|
}
|
|
return map;
|
|
}, [projects]);
|
|
const executionWorkspaceById = useMemo(() => {
|
|
const map = new Map<string, {
|
|
name: string;
|
|
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
|
projectWorkspaceId: string | null;
|
|
}>();
|
|
for (const workspace of executionWorkspaces) {
|
|
map.set(workspace.id, {
|
|
name: workspace.name,
|
|
mode: workspace.mode,
|
|
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
|
});
|
|
}
|
|
return map;
|
|
}, [executionWorkspaces]);
|
|
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
|
const availableIssueColumns = useMemo(
|
|
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
|
[isolatedWorkspacesEnabled],
|
|
);
|
|
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
|
const visibleTrailingIssueColumns = useMemo(
|
|
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
|
[availableIssueColumnSet, visibleIssueColumnSet],
|
|
);
|
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
|
|
|
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(() => {
|
|
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
|
if (tab === "mine") {
|
|
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
|
|
}
|
|
return filtered;
|
|
}, [approvals, tab, allApprovalFilter, dismissed]);
|
|
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 === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
|
|
return joinRequests;
|
|
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
|
|
|
|
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 filteredWorkItems = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
if (!q) return workItemsToRender;
|
|
return workItemsToRender.filter((item) => {
|
|
if (item.kind === "issue") {
|
|
const issue = item.issue;
|
|
if (issue.title.toLowerCase().includes(q)) return true;
|
|
if (issue.identifier?.toLowerCase().includes(q)) return true;
|
|
if (issue.description?.toLowerCase().includes(q)) return true;
|
|
if (isolatedWorkspacesEnabled) {
|
|
const workspaceName = resolveIssueWorkspaceName(issue, {
|
|
executionWorkspaceById,
|
|
projectWorkspaceById,
|
|
defaultProjectWorkspaceIdByProjectId,
|
|
});
|
|
if (workspaceName?.toLowerCase().includes(q)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (item.kind === "approval") {
|
|
const a = item.approval;
|
|
const label = approvalLabel(a.type, a.payload as Record<string, unknown> | null);
|
|
if (label.toLowerCase().includes(q)) return true;
|
|
if (a.type.toLowerCase().includes(q)) return true;
|
|
return false;
|
|
}
|
|
if (item.kind === "failed_run") {
|
|
const run = item.run;
|
|
const name = agentById.get(run.agentId);
|
|
if (name?.toLowerCase().includes(q)) return true;
|
|
const msg = runFailureMessage(run);
|
|
if (msg.toLowerCase().includes(q)) return true;
|
|
const issueId = readIssueIdFromRun(run);
|
|
if (issueId) {
|
|
const issue = issueById.get(issueId);
|
|
if (issue?.title.toLowerCase().includes(q)) return true;
|
|
if (issue?.identifier?.toLowerCase().includes(q)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (item.kind === "join_request") {
|
|
const jr = item.joinRequest;
|
|
if (jr.agentName?.toLowerCase().includes(q)) return true;
|
|
if (jr.capabilities?.toLowerCase().includes(q)) return true;
|
|
return false;
|
|
}
|
|
return false;
|
|
});
|
|
}, [
|
|
workItemsToRender,
|
|
searchQuery,
|
|
agentById,
|
|
defaultProjectWorkspaceIdByProjectId,
|
|
executionWorkspaceById,
|
|
issueById,
|
|
isolatedWorkspacesEnabled,
|
|
projectWorkspaceById,
|
|
]);
|
|
|
|
const agentName = (id: string | null) => {
|
|
if (!id) return null;
|
|
return agentById.get(id) ?? null;
|
|
};
|
|
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
|
const normalized = normalizeInboxIssueColumns(next);
|
|
setVisibleIssueColumns(normalized);
|
|
saveInboxIssueColumns(normalized);
|
|
}, []);
|
|
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
|
if (enabled) {
|
|
setIssueColumns([...visibleIssueColumns, column]);
|
|
return;
|
|
}
|
|
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
|
}, [setIssueColumns, visibleIssueColumns]);
|
|
|
|
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(result.message ?? "Retry was skipped.");
|
|
}
|
|
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 [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
|
|
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
|
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
|
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
const invalidateInboxIssueQueries = () => {
|
|
if (!selectedCompanyId) return;
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
|
};
|
|
|
|
const archiveIssueMutation = useMutation({
|
|
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
|
onMutate: async (id) => {
|
|
setActionError(null);
|
|
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
|
|
|
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
|
const queryKeys_ = [
|
|
queryKeys.issues.listMineByMe(selectedCompanyId!),
|
|
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
|
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
|
];
|
|
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
|
|
|
// Snapshot previous data for rollback
|
|
const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const);
|
|
|
|
// Optimistically remove the issue from all inbox query caches
|
|
for (const qk of queryKeys_) {
|
|
queryClient.setQueryData(qk, (old: unknown) => {
|
|
if (!Array.isArray(old)) return old;
|
|
return old.filter((issue: { id: string }) => issue.id !== id);
|
|
});
|
|
}
|
|
|
|
return { previousData };
|
|
},
|
|
onError: (err, id, context) => {
|
|
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
|
setArchivingIssueIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
// Restore previous query data on failure
|
|
if (context?.previousData) {
|
|
for (const [qk, data] of context.previousData) {
|
|
queryClient.setQueryData(qk, data);
|
|
}
|
|
}
|
|
},
|
|
onSettled: (_data, error, id) => {
|
|
// Clean up archiving state and refetch to sync with server
|
|
setArchivingIssueIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
invalidateInboxIssueQueries();
|
|
},
|
|
});
|
|
|
|
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);
|
|
},
|
|
});
|
|
|
|
const markUnreadMutation = useMutation({
|
|
mutationFn: (id: string) => issuesApi.markUnread(id),
|
|
onSuccess: () => {
|
|
invalidateInboxIssueQueries();
|
|
},
|
|
});
|
|
|
|
const handleMarkNonIssueRead = useCallback((key: string) => {
|
|
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
|
markItemRead(key);
|
|
setTimeout(() => {
|
|
setFadingNonIssueItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(key);
|
|
return next;
|
|
});
|
|
}, 300);
|
|
}, [markItemRead]);
|
|
|
|
const handleArchiveNonIssue = useCallback((key: string) => {
|
|
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
|
setTimeout(() => {
|
|
dismiss(key);
|
|
setArchivingNonIssueIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(key);
|
|
return next;
|
|
});
|
|
}, 200);
|
|
}, [dismiss]);
|
|
|
|
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
|
if (!canArchiveFromTab) return null;
|
|
const isRead = readItems.has(key);
|
|
const isFading = fadingNonIssueItems.has(key);
|
|
if (isFading) return "fading";
|
|
if (!isRead) return "visible";
|
|
return "hidden";
|
|
};
|
|
|
|
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
|
|
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
|
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
|
if (item.kind === "failed_run") return `run:${item.run.id}`;
|
|
return `join:${item.joinRequest.id}`;
|
|
}, []);
|
|
|
|
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
|
useEffect(() => {
|
|
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
|
|
}, [filteredWorkItems.length]);
|
|
|
|
// Use refs for keyboard handler to avoid stale closures
|
|
const kbStateRef = useRef({
|
|
workItems: filteredWorkItems,
|
|
selectedIndex,
|
|
canArchive: canArchiveFromTab,
|
|
archivingIssueIds,
|
|
archivingNonIssueIds,
|
|
fadingOutIssues,
|
|
readItems,
|
|
});
|
|
kbStateRef.current = {
|
|
workItems: filteredWorkItems,
|
|
selectedIndex,
|
|
canArchive: canArchiveFromTab,
|
|
archivingIssueIds,
|
|
archivingNonIssueIds,
|
|
fadingOutIssues,
|
|
readItems,
|
|
};
|
|
|
|
const kbActionsRef = useRef({
|
|
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
|
archiveNonIssue: handleArchiveNonIssue,
|
|
markRead: (id: string) => markReadMutation.mutate(id),
|
|
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
|
markNonIssueRead: handleMarkNonIssueRead,
|
|
markNonIssueUnread: markItemUnread,
|
|
navigate,
|
|
});
|
|
kbActionsRef.current = {
|
|
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
|
archiveNonIssue: handleArchiveNonIssue,
|
|
markRead: (id: string) => markReadMutation.mutate(id),
|
|
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
|
markNonIssueRead: handleMarkNonIssueRead,
|
|
markNonIssueUnread: markItemUnread,
|
|
navigate,
|
|
};
|
|
|
|
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
|
useEffect(() => {
|
|
if (!keyboardShortcutsEnabled) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.defaultPrevented) return;
|
|
|
|
// Don't capture when typing in inputs/textareas or with modifier keys
|
|
const target = e.target;
|
|
if (
|
|
!(target instanceof HTMLElement) ||
|
|
isKeyboardShortcutTextInputTarget(target) ||
|
|
hasBlockingShortcutDialog(document) ||
|
|
e.metaKey ||
|
|
e.ctrlKey ||
|
|
e.altKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const st = kbStateRef.current;
|
|
const act = kbActionsRef.current;
|
|
|
|
// Keyboard shortcuts are only active on the "mine" tab
|
|
if (!st.canArchive) return;
|
|
|
|
const itemCount = st.workItems.length;
|
|
if (itemCount === 0) return;
|
|
|
|
switch (e.key) {
|
|
case "j": {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
|
break;
|
|
}
|
|
case "k": {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
|
break;
|
|
}
|
|
case "a":
|
|
case "y": {
|
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
|
e.preventDefault();
|
|
const item = st.workItems[st.selectedIndex];
|
|
if (item.kind === "issue") {
|
|
if (!st.archivingIssueIds.has(item.issue.id)) {
|
|
act.archiveIssue(item.issue.id);
|
|
}
|
|
} else {
|
|
const key = getWorkItemKey(item);
|
|
if (!st.archivingNonIssueIds.has(key)) {
|
|
act.archiveNonIssue(key);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "U": {
|
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
|
e.preventDefault();
|
|
const item = st.workItems[st.selectedIndex];
|
|
if (item.kind === "issue") {
|
|
act.markUnreadIssue(item.issue.id);
|
|
} else {
|
|
act.markNonIssueUnread(getWorkItemKey(item));
|
|
}
|
|
break;
|
|
}
|
|
case "r": {
|
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
|
e.preventDefault();
|
|
const item = st.workItems[st.selectedIndex];
|
|
if (item.kind === "issue") {
|
|
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
|
act.markRead(item.issue.id);
|
|
}
|
|
} else {
|
|
const key = getWorkItemKey(item);
|
|
if (!st.readItems.has(key)) {
|
|
act.markNonIssueRead(key);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "Enter": {
|
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
|
e.preventDefault();
|
|
const item = st.workItems[st.selectedIndex];
|
|
if (item.kind === "issue") {
|
|
const pathId = item.issue.identifier ?? item.issue.id;
|
|
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
|
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
|
|
} else if (item.kind === "approval") {
|
|
act.navigate(`/approvals/${item.approval.id}`);
|
|
} else if (item.kind === "failed_run") {
|
|
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
|
|
|
|
// Scroll selected item into view
|
|
useEffect(() => {
|
|
if (selectedIndex < 0 || !listRef.current) return;
|
|
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
|
|
const row = rows[selectedIndex];
|
|
if (row) row.scrollIntoView({ block: "nearest" });
|
|
}, [selectedIndex]);
|
|
|
|
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 = filteredWorkItems.length > 0;
|
|
const showAlertsSection = shouldShowInboxSection({
|
|
tab,
|
|
hasItems: hasAlerts,
|
|
showOnMine: 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 &&
|
|
!isMineIssuesLoading &&
|
|
!isTouchedIssuesLoading &&
|
|
!isRunsLoading;
|
|
|
|
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
|
const markAllReadIssues = (tab === "mine" ? mineIssues : unreadTouchedIssues)
|
|
.filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
|
|
const unreadIssueIds = markAllReadIssues
|
|
.map((issue) => issue.id);
|
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
|
<PageTabBar
|
|
items={[
|
|
{
|
|
value: "mine",
|
|
label: "Mine",
|
|
},
|
|
{
|
|
value: "recent",
|
|
label: "Recent",
|
|
},
|
|
{ value: "unread", label: "Unread" },
|
|
{ value: "all", label: "All" },
|
|
]}
|
|
/>
|
|
</Tabs>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
type="search"
|
|
placeholder="Search inbox…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
|
|
/>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
|
Show / hide columns
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
|
<div className="space-y-1">
|
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
|
Desktop issue rows
|
|
</div>
|
|
<div className="text-sm font-medium text-foreground">
|
|
Choose which inbox columns stay visible
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{availableIssueColumns.map((column) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={column}
|
|
checked={visibleIssueColumnSet.has(column)}
|
|
onSelect={(event) => event.preventDefault()}
|
|
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
|
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
|
>
|
|
<span className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{inboxIssueColumnLabels[column]}
|
|
</span>
|
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
|
{inboxIssueColumnDescriptions[column]}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
|
className="rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
Reset defaults
|
|
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{canMarkAllRead && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 shrink-0"
|
|
onClick={() => setShowMarkAllReadConfirm(true)}
|
|
disabled={markAllReadMutation.isPending}
|
|
>
|
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
|
</Button>
|
|
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Mark all as read?</DialogTitle>
|
|
<DialogDescription>
|
|
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setShowMarkAllReadConfirm(false);
|
|
markAllReadMutation.mutate(unreadIssueIds);
|
|
}}
|
|
>
|
|
Mark all as read
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{tab === "all" && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<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>
|
|
)}
|
|
|
|
{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={searchQuery.trim() ? Search : InboxIcon}
|
|
message={
|
|
searchQuery.trim()
|
|
? "No inbox items match your search."
|
|
: tab === "mine"
|
|
? "Inbox zero."
|
|
: 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 ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
|
{filteredWorkItems.flatMap((item, index) => {
|
|
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
|
<div
|
|
key={`sel-${key}`}
|
|
data-inbox-item
|
|
className="relative"
|
|
onClick={() => setSelectedIndex(index)}
|
|
>
|
|
{child}
|
|
</div>
|
|
);
|
|
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
const showTodayDivider =
|
|
index > 0 &&
|
|
item.timestamp > 0 &&
|
|
item.timestamp < todayCutoff &&
|
|
filteredWorkItems[index - 1].timestamp >= todayCutoff;
|
|
const elements: ReactNode[] = [];
|
|
if (showTodayDivider) {
|
|
elements.push(
|
|
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
|
<div className="flex-1 border-t border-zinc-600" />
|
|
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
Earlier
|
|
</span>
|
|
</div>,
|
|
);
|
|
}
|
|
const isSelected = selectedIndex === index;
|
|
|
|
if (item.kind === "approval") {
|
|
const approvalKey = `approval:${item.approval.id}`;
|
|
const isArchiving = archivingNonIssueIds.has(approvalKey);
|
|
const row = (
|
|
<ApprovalInboxRow
|
|
key={approvalKey}
|
|
approval={item.approval}
|
|
selected={isSelected}
|
|
requesterName={agentName(item.approval.requestedByAgentId)}
|
|
onApprove={() => approveMutation.mutate(item.approval.id)}
|
|
onReject={() => rejectMutation.mutate(item.approval.id)}
|
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
|
unreadState={nonIssueUnreadState(approvalKey)}
|
|
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
|
archiveDisabled={isArchiving}
|
|
className={
|
|
isArchiving
|
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
|
: "transition-all duration-200 ease-out"
|
|
}
|
|
/>
|
|
);
|
|
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
|
<SwipeToArchive
|
|
key={approvalKey}
|
|
selected={isSelected}
|
|
disabled={isArchiving}
|
|
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
|
>
|
|
{row}
|
|
</SwipeToArchive>
|
|
) : row));
|
|
return elements;
|
|
}
|
|
|
|
if (item.kind === "failed_run") {
|
|
const runKey = `run:${item.run.id}`;
|
|
const isArchiving = archivingNonIssueIds.has(runKey);
|
|
const row = (
|
|
<FailedRunInboxRow
|
|
key={runKey}
|
|
run={item.run}
|
|
selected={isSelected}
|
|
issueById={issueById}
|
|
agentName={agentName(item.run.agentId)}
|
|
issueLinkState={issueLinkState}
|
|
onDismiss={() => dismiss(runKey)}
|
|
onRetry={() => retryRunMutation.mutate(item.run)}
|
|
isRetrying={retryingRunIds.has(item.run.id)}
|
|
unreadState={nonIssueUnreadState(runKey)}
|
|
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
|
archiveDisabled={isArchiving}
|
|
className={
|
|
isArchiving
|
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
|
: "transition-all duration-200 ease-out"
|
|
}
|
|
/>
|
|
);
|
|
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
|
<SwipeToArchive
|
|
key={runKey}
|
|
selected={isSelected}
|
|
disabled={isArchiving}
|
|
onArchive={() => handleArchiveNonIssue(runKey)}
|
|
>
|
|
{row}
|
|
</SwipeToArchive>
|
|
) : row));
|
|
return elements;
|
|
}
|
|
|
|
if (item.kind === "join_request") {
|
|
const joinKey = `join:${item.joinRequest.id}`;
|
|
const isArchiving = archivingNonIssueIds.has(joinKey);
|
|
const row = (
|
|
<JoinRequestInboxRow
|
|
key={joinKey}
|
|
joinRequest={item.joinRequest}
|
|
selected={isSelected}
|
|
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
|
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
|
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
|
unreadState={nonIssueUnreadState(joinKey)}
|
|
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
|
archiveDisabled={isArchiving}
|
|
className={
|
|
isArchiving
|
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
|
: "transition-all duration-200 ease-out"
|
|
}
|
|
/>
|
|
);
|
|
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
|
<SwipeToArchive
|
|
key={joinKey}
|
|
selected={isSelected}
|
|
disabled={isArchiving}
|
|
onArchive={() => handleArchiveNonIssue(joinKey)}
|
|
>
|
|
{row}
|
|
</SwipeToArchive>
|
|
) : row));
|
|
return elements;
|
|
}
|
|
|
|
const issue = item.issue;
|
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
|
const isFading = fadingOutIssues.has(issue.id);
|
|
const isArchiving = archivingIssueIds.has(issue.id);
|
|
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
|
const row = (
|
|
<IssueRow
|
|
key={`issue:${issue.id}`}
|
|
issue={issue}
|
|
issueLinkState={issueLinkState}
|
|
selected={isSelected}
|
|
className={
|
|
isArchiving
|
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
|
: "transition-all duration-200 ease-out"
|
|
}
|
|
desktopMetaLeading={
|
|
<InboxIssueMetaLeading
|
|
issue={issue}
|
|
isLive={liveIssueIds.has(issue.id)}
|
|
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
|
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
|
/>
|
|
}
|
|
mobileMeta={issueActivityText(issue).toLowerCase()}
|
|
unreadState={
|
|
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
|
}
|
|
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
|
onArchive={
|
|
canArchiveFromTab
|
|
? () => archiveIssueMutation.mutate(issue.id)
|
|
: undefined
|
|
}
|
|
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
|
desktopTrailing={
|
|
visibleTrailingIssueColumns.length > 0 ? (
|
|
<InboxIssueTrailingColumns
|
|
issue={issue}
|
|
columns={visibleTrailingIssueColumns}
|
|
projectName={issueProject?.name ?? null}
|
|
projectColor={issueProject?.color ?? null}
|
|
workspaceName={resolveIssueWorkspaceName(issue, {
|
|
executionWorkspaceById,
|
|
projectWorkspaceById,
|
|
defaultProjectWorkspaceIdByProjectId,
|
|
})}
|
|
assigneeName={agentName(issue.assigneeAgentId)}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
);
|
|
|
|
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
|
<SwipeToArchive
|
|
key={`issue:${issue.id}`}
|
|
selected={isSelected}
|
|
disabled={isArchiving || archiveIssueMutation.isPending}
|
|
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
|
>
|
|
{row}
|
|
</SwipeToArchive>
|
|
) : row));
|
|
return elements;
|
|
})}
|
|
</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>
|
|
);
|
|
}
|