Extend read/dismissed functionality to all inbox item types

Approvals, failed runs, and join requests now have the same
unread dot + archive X pattern as issues in the Mine tab:
- Click the blue dot to mark as read, then X appears on hover
- Desktop: animated dismiss with scale/slide transition
- Mobile: swipe-to-archive via SwipeToArchive wrapper
- Dismissed items are filtered out of Mine tab
- Badge count excludes dismissed approvals and join requests
- localStorage-backed read/dismiss state for non-issue items

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 10:16:19 -05:00
parent 49c7fb7fbd
commit 2c406d3b8c
3 changed files with 327 additions and 40 deletions

View file

@ -12,6 +12,9 @@ import {
getRecentTouchedIssues, getRecentTouchedIssues,
loadDismissedInboxItems, loadDismissedInboxItems,
saveDismissedInboxItems, saveDismissedInboxItems,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
} from "../lib/inbox"; } from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
@ -40,6 +43,30 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss }; return { dismissed, dismiss };
} }
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== READ_ITEMS_KEY) return;
setReadItems(loadReadInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const markRead = (id: string) => {
setReadItems((prev) => {
const next = new Set(prev);
next.add(id);
saveReadInboxItems(next);
return next;
});
};
return { readItems, markRead };
}
export function useInboxBadge(companyId: string | null | undefined) { export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems(); const { dismissed } = useDismissedInboxItems();

View file

@ -10,6 +10,7 @@ export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "mine" | "recent" | "unread" | "all"; export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export type InboxApprovalFilter = "all" | "actionable" | "resolved";
@ -61,6 +62,23 @@ export function saveDismissedInboxItems(ids: Set<string>) {
} }
} }
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveReadInboxItems(ids: Set<string>) {
try {
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function loadLastInboxTab(): InboxTab { export function loadLastInboxTab(): InboxTab {
try { try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
@ -237,12 +255,17 @@ export function computeInboxBadgeData({
mineIssues: Issue[]; mineIssues: Issue[];
dismissed: Set<string>; dismissed: Set<string>;
}): InboxBadgeData { }): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) => const actionableApprovals = approvals.filter(
ACTIONABLE_APPROVAL_STATUSES.has(approval.status), (approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
).length; ).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`), (run) => !dismissed.has(`run:${run.id}`),
).length; ).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
).length;
const visibleMineIssues = mineIssues.length; const visibleMineIssues = mineIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0; const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
@ -258,10 +281,10 @@ export function computeInboxBadgeData({
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert); const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return { return {
inbox: actionableApprovals + joinRequests.length + failedRuns + visibleMineIssues + alerts, inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
approvals: actionableApprovals, approvals: actionableApprovals,
failedRuns, failedRuns,
joinRequests: joinRequests.length, joinRequests: visibleJoinRequests,
mineIssues: visibleMineIssues, mineIssues: visibleMineIssues,
alerts, alerts,
}; };

View file

@ -18,6 +18,7 @@ import { IssueRow } from "../components/IssueRow";
import { SwipeToArchive } from "../components/SwipeToArchive"; import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
@ -52,7 +53,7 @@ import {
shouldShowInboxSection, shouldShowInboxSection,
type InboxTab, type InboxTab,
} from "../lib/inbox"; } from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
type InboxCategoryFilter = type InboxCategoryFilter =
| "everything" | "everything"
@ -95,6 +96,8 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
} }
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
function FailedRunInboxRow({ function FailedRunInboxRow({
run, run,
issueById, issueById,
@ -103,6 +106,11 @@ function FailedRunInboxRow({
onDismiss, onDismiss,
onRetry, onRetry,
isRetrying, isRetrying,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: { }: {
run: HeartbeatRun; run: HeartbeatRun;
issueById: Map<string, Issue>; issueById: Map<string, Issue>;
@ -111,13 +119,23 @@ function FailedRunInboxRow({
onDismiss: () => void; onDismiss: () => void;
onRetry: () => void; onRetry: () => void;
isRetrying: boolean; isRetrying: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) { }) {
const issueId = readIssueIdFromRun(run); const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null; const issue = issueId ? issueById.get(issueId) ?? null : null;
const displayError = runFailureMessage(run); const displayError = runFailureMessage(run);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return ( 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={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"> <div className="flex items-start gap-2 sm:items-center">
<Link <Link
to={`/agents/${run.agentId}/runs/${run.id}`} to={`/agents/${run.agentId}/runs/${run.id}`}
@ -161,15 +179,46 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"} {isRetrying ? "Retrying…" : "Retry"}
</Button> </Button>
<button {!showUnreadSlot && (
type="button" <button
onClick={onDismiss} type="button"
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100" onClick={onDismiss}
aria-label="Dismiss" 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> <X className="h-4 w-4" />
</button>
)}
</div> </div>
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="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 bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); 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>
)}
</div> </div>
<div className="mt-3 flex gap-2 sm:hidden"> <div className="mt-3 flex gap-2 sm:hidden">
<Button <Button
@ -183,14 +232,16 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"} {isRetrying ? "Retrying…" : "Retry"}
</Button> </Button>
<button {!showUnreadSlot && (
type="button" <button
onClick={onDismiss} type="button"
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground" onClick={onDismiss}
aria-label="Dismiss" className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> aria-label="Dismiss"
<X className="h-4 w-4" /> >
</button> <X className="h-4 w-4" />
</button>
)}
</div> </div>
</div> </div>
); );
@ -202,21 +253,36 @@ function ApprovalInboxRow({
onApprove, onApprove,
onReject, onReject,
isPending, isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: { }: {
approval: Approval; approval: Approval;
requesterName: string | null; requesterName: string | null;
onApprove: () => void; onApprove: () => void;
onReject: () => void; onReject: () => void;
isPending: boolean; isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) { }) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null); const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons = const showResolutionButtons =
approval.type !== "budget_override_required" && approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status); ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return ( 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={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"> <div className="flex items-start gap-2 sm:items-center">
<Link <Link
to={`/approvals/${approval.id}`} to={`/approvals/${approval.id}`}
@ -259,6 +325,35 @@ function ApprovalInboxRow({
</Button> </Button>
</div> </div>
) : null} ) : null}
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="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 bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); 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>
)}
</div> </div>
{showResolutionButtons ? ( {showResolutionButtons ? (
<div className="mt-3 flex gap-2 sm:hidden"> <div className="mt-3 flex gap-2 sm:hidden">
@ -290,19 +385,34 @@ function JoinRequestInboxRow({
onApprove, onApprove,
onReject, onReject,
isPending, isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: { }: {
joinRequest: JoinRequest; joinRequest: JoinRequest;
onApprove: () => void; onApprove: () => void;
onReject: () => void; onReject: () => void;
isPending: boolean; isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) { }) {
const label = const label =
joinRequest.requestType === "human" joinRequest.requestType === "human"
? "Human join request" ? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`; : `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return ( 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={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"> <div className="flex items-start gap-2 sm:items-center">
<div className="flex min-w-0 flex-1 items-start gap-2"> <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-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -339,6 +449,35 @@ function JoinRequestInboxRow({
Reject Reject
</Button> </Button>
</div> </div>
{showUnreadSlot && (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMarkRead?.(); }}
className="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 bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); 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>
)}
</div> </div>
<div className="mt-3 flex gap-2 sm:hidden"> <div className="mt-3 flex gap-2 sm:hidden">
<Button <Button
@ -373,6 +512,7 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems(); const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine"; const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab = const tab: InboxTab =
@ -515,10 +655,13 @@ export function Inbox() {
return ids; return ids;
}, [heartbeatRuns]); }, [heartbeatRuns]);
const approvalsToRender = useMemo( const approvalsToRender = useMemo(() => {
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter), let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
[approvals, tab, allApprovalFilter], if (tab === "mine") {
); filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]);
const showJoinRequestsCategory = const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory = const showTouchedCategory =
@ -535,11 +678,9 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => { const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return []; if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "mine") return joinRequests; if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
if (tab === "recent") return joinRequests;
if (tab === "unread") return joinRequests;
return joinRequests; return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory]); }, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
const workItemsToRender = useMemo( const workItemsToRender = useMemo(
() => () =>
@ -650,6 +791,8 @@ export function Inbox() {
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set()); const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set()); 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 invalidateInboxIssueQueries = () => { const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return; if (!selectedCompanyId) return;
@ -732,6 +875,39 @@ export function Inbox() {
}, },
}); });
const handleMarkNonIssueRead = (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);
};
const handleArchiveNonIssue = (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);
};
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (tab !== "mine") return null;
const isRead = readItems.has(key);
const isFading = fadingNonIssueItems.has(key);
if (isFading) return "fading";
if (!isRead) return "visible";
return "hidden";
};
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />; return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
} }
@ -876,50 +1052,111 @@ export function Inbox() {
<div> <div>
<div className="overflow-hidden rounded-xl border border-border bg-card"> <div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => { {workItemsToRender.map((item) => {
const isMineTab = tab === "mine";
if (item.kind === "approval") { if (item.kind === "approval") {
return ( const approvalKey = `approval:${item.approval.id}`;
const isArchiving = archivingNonIssueIds.has(approvalKey);
const row = (
<ApprovalInboxRow <ApprovalInboxRow
key={`approval:${item.approval.id}`} key={approvalKey}
approval={item.approval} approval={item.approval}
requesterName={agentName(item.approval.requestedByAgentId)} requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)} onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)} onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending} isPending={approveMutation.isPending || rejectMutation.isPending}
unreadState={nonIssueUnreadState(approvalKey)}
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
onArchive={isMineTab ? () => 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"
}
/> />
); );
return isMineTab ? (
<SwipeToArchive
key={approvalKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
{row}
</SwipeToArchive>
) : row;
} }
if (item.kind === "failed_run") { if (item.kind === "failed_run") {
return ( const runKey = `run:${item.run.id}`;
const isArchiving = archivingNonIssueIds.has(runKey);
const row = (
<FailedRunInboxRow <FailedRunInboxRow
key={`run:${item.run.id}`} key={runKey}
run={item.run} run={item.run}
issueById={issueById} issueById={issueById}
agentName={agentName(item.run.agentId)} agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)} onDismiss={() => dismiss(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)} onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)} isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
onMarkRead={() => handleMarkNonIssueRead(runKey)}
onArchive={isMineTab ? () => 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"
}
/> />
); );
return isMineTab ? (
<SwipeToArchive
key={runKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
{row}
</SwipeToArchive>
) : row;
} }
if (item.kind === "join_request") { if (item.kind === "join_request") {
return ( const joinKey = `join:${item.joinRequest.id}`;
const isArchiving = archivingNonIssueIds.has(joinKey);
const row = (
<JoinRequestInboxRow <JoinRequestInboxRow
key={`join:${item.joinRequest.id}`} key={joinKey}
joinRequest={item.joinRequest} joinRequest={item.joinRequest}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)} onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)} onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
unreadState={nonIssueUnreadState(joinKey)}
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
onArchive={isMineTab ? () => 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"
}
/> />
); );
return isMineTab ? (
<SwipeToArchive
key={joinKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
{row}
</SwipeToArchive>
) : row;
} }
const issue = item.issue; const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id);
const isMineTab = tab === "mine";
const isArchiving = archivingIssueIds.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id);
const row = ( const row = (
<IssueRow <IssueRow