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:
parent
49c7fb7fbd
commit
2c406d3b8c
3 changed files with 327 additions and 40 deletions
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue