Mix approvals into inbox activity feed
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,8 +3,9 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
getApprovalsForTab,
|
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
getApprovalsForTab,
|
||||||
|
getInboxWorkItems,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
@@ -271,6 +272,31 @@ describe("inbox helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mixes approvals into the inbox feed by most recent activity", () => {
|
||||||
|
const newerIssue = makeIssue("1", true);
|
||||||
|
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||||
|
|
||||||
|
const olderIssue = makeIssue("2", false);
|
||||||
|
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
|
||||||
|
|
||||||
|
const approval = makeApprovalWithTimestamps(
|
||||||
|
"approval-between",
|
||||||
|
"pending",
|
||||||
|
"2026-03-11T03:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getInboxWorkItems({
|
||||||
|
issues: [olderIssue, newerIssue],
|
||||||
|
approvals: [approval],
|
||||||
|
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`),
|
||||||
|
).toEqual([
|
||||||
|
"issue:1",
|
||||||
|
"approval:approval-between",
|
||||||
|
"issue:2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("can include sections on recent without forcing them to be unread", () => {
|
it("can include sections on recent without forcing them to be unread", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldShowInboxSection({
|
shouldShowInboxSection({
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
|||||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
export type InboxTab = "recent" | "unread" | "all";
|
export type InboxTab = "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
|
export type InboxWorkItem =
|
||||||
|
| {
|
||||||
|
kind: "issue";
|
||||||
|
timestamp: number;
|
||||||
|
issue: Issue;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "approval";
|
||||||
|
timestamp: number;
|
||||||
|
approval: Approval;
|
||||||
|
};
|
||||||
|
|
||||||
export interface InboxBadgeData {
|
export interface InboxBadgeData {
|
||||||
inbox: number;
|
inbox: number;
|
||||||
@@ -126,6 +137,45 @@ export function getApprovalsForTab(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function approvalActivityTimestamp(approval: Approval): number {
|
||||||
|
const updatedAt = normalizeTimestamp(approval.updatedAt);
|
||||||
|
if (updatedAt > 0) return updatedAt;
|
||||||
|
return normalizeTimestamp(approval.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInboxWorkItems({
|
||||||
|
issues,
|
||||||
|
approvals,
|
||||||
|
}: {
|
||||||
|
issues: Issue[];
|
||||||
|
approvals: Approval[];
|
||||||
|
}): InboxWorkItem[] {
|
||||||
|
return [
|
||||||
|
...issues.map((issue) => ({
|
||||||
|
kind: "issue" as const,
|
||||||
|
timestamp: issueLastActivityTimestamp(issue),
|
||||||
|
issue,
|
||||||
|
})),
|
||||||
|
...approvals.map((approval) => ({
|
||||||
|
kind: "approval" as const,
|
||||||
|
timestamp: approvalActivityTimestamp(approval),
|
||||||
|
approval,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const timestampDiff = b.timestamp - a.timestamp;
|
||||||
|
if (timestampDiff !== 0) return timestampDiff;
|
||||||
|
|
||||||
|
if (a.kind === "issue" && b.kind === "issue") {
|
||||||
|
return sortIssuesByMostRecentActivity(a.issue, b.issue);
|
||||||
|
}
|
||||||
|
if (a.kind === "approval" && b.kind === "approval") {
|
||||||
|
return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.kind === "approval" ? -1 : 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldShowInboxSection({
|
export function shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems,
|
hasItems,
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { ApprovalCard } from "../components/ApprovalCard";
|
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -40,16 +40,17 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
|
getInboxWorkItems,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
InboxApprovalFilter,
|
InboxApprovalFilter,
|
||||||
type InboxTab,
|
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
|
type InboxTab,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
@@ -61,9 +62,8 @@ type InboxCategoryFilter =
|
|||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
type SectionKey =
|
type SectionKey =
|
||||||
| "issues_i_touched"
|
| "work_items"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
|
|
||||||
@@ -84,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string {
|
|||||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
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 {
|
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
const context = run.contextSnapshot;
|
const context = run.contextSnapshot;
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
@@ -235,6 +239,93 @@ function FailedRunCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ApprovalInboxRow({
|
||||||
|
approval,
|
||||||
|
requesterName,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
approval: Approval;
|
||||||
|
requesterName: string | null;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||||
|
const label = typeLabel[approval.type] ?? approval.type;
|
||||||
|
const showResolutionButtons =
|
||||||
|
approval.type !== "budget_override_required" &&
|
||||||
|
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:py-2">
|
||||||
|
<div className="flex items-start gap-3 sm:items-center">
|
||||||
|
<Link
|
||||||
|
to={`/approvals/${approval.id}`}
|
||||||
|
className="flex min-w-0 flex-1 items-start gap-3 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Inbox() {
|
export function Inbox() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
@@ -336,6 +427,10 @@ export function Inbox() {
|
|||||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||||
[touchedIssues],
|
[touchedIssues],
|
||||||
);
|
);
|
||||||
|
const issuesToRender = useMemo(
|
||||||
|
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
|
||||||
|
[tab, touchedIssues, unreadTouchedIssues],
|
||||||
|
);
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -363,20 +458,27 @@ export function Inbox() {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [heartbeatRuns]);
|
}, [heartbeatRuns]);
|
||||||
|
|
||||||
const allApprovals = useMemo(
|
|
||||||
() => getApprovalsForTab(approvals ?? [], "recent", "all"),
|
|
||||||
[approvals],
|
|
||||||
);
|
|
||||||
|
|
||||||
const actionableApprovals = useMemo(
|
|
||||||
() => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)),
|
|
||||||
[allApprovals],
|
|
||||||
);
|
|
||||||
|
|
||||||
const approvalsToRender = useMemo(
|
const approvalsToRender = useMemo(
|
||||||
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
|
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
|
||||||
[approvals, tab, allApprovalFilter],
|
[approvals, tab, allApprovalFilter],
|
||||||
);
|
);
|
||||||
|
const showJoinRequestsCategory =
|
||||||
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
|
const showTouchedCategory =
|
||||||
|
allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched";
|
||||||
|
const showApprovalsCategory =
|
||||||
|
allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
||||||
|
const showFailedRunsCategory =
|
||||||
|
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||||
|
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||||
|
const workItemsToRender = useMemo(
|
||||||
|
() =>
|
||||||
|
getInboxWorkItems({
|
||||||
|
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||||
|
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||||
|
}),
|
||||||
|
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
|
||||||
|
);
|
||||||
|
|
||||||
const agentName = (id: string | null) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@@ -500,33 +602,9 @@ export function Inbox() {
|
|||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||||
|
|
||||||
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 showTouchedSection = shouldShowInboxSection({
|
|
||||||
tab,
|
|
||||||
hasItems: tab === "unread" ? unreadTouchedIssues.length > 0 : hasTouchedIssues,
|
|
||||||
showOnRecent: hasTouchedIssues,
|
|
||||||
showOnUnread: unreadTouchedIssues.length > 0,
|
|
||||||
showOnAll: showTouchedCategory && hasTouchedIssues,
|
|
||||||
});
|
|
||||||
const showJoinRequestsSection =
|
const showJoinRequestsSection =
|
||||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||||
const showApprovalsSection = shouldShowInboxSection({
|
|
||||||
tab,
|
|
||||||
hasItems: approvalsToRender.length > 0,
|
|
||||||
showOnRecent: approvalsToRender.length > 0,
|
|
||||||
showOnUnread: actionableApprovals.length > 0,
|
|
||||||
showOnAll: showApprovalsCategory && approvalsToRender.length > 0,
|
|
||||||
});
|
|
||||||
const showFailedRunsSection = shouldShowInboxSection({
|
const showFailedRunsSection = shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems: hasRunFailures,
|
hasItems: hasRunFailures,
|
||||||
@@ -545,9 +623,8 @@ export function Inbox() {
|
|||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showApprovalsSection ? "approvals" : null,
|
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
showTouchedSection ? "issues_i_touched" : null,
|
showWorkItemsSection ? "work_items" : null,
|
||||||
].filter((key): key is SectionKey => key !== null);
|
].filter((key): key is SectionKey => key !== null);
|
||||||
|
|
||||||
const allLoaded =
|
const allLoaded =
|
||||||
@@ -653,29 +730,72 @@ export function Inbox() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showApprovalsSection && (
|
{showWorkItemsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("approvals") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
|
{workItemsToRender.map((item) => {
|
||||||
</h3>
|
if (item.kind === "approval") {
|
||||||
<div className="grid gap-3">
|
return (
|
||||||
{approvalsToRender.map((approval) => (
|
<ApprovalInboxRow
|
||||||
<ApprovalCard
|
key={`approval:${item.approval.id}`}
|
||||||
key={approval.id}
|
approval={item.approval}
|
||||||
approval={approval}
|
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||||
requesterAgent={
|
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||||
approval.requestedByAgentId
|
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||||
? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onApprove={() => approveMutation.mutate(approval.id)}
|
|
||||||
onReject={() => rejectMutation.mutate(approval.id)}
|
|
||||||
detailLink={`/approvals/${approval.id}`}
|
|
||||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = item.issue;
|
||||||
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
|
return (
|
||||||
|
<IssueRow
|
||||||
|
key={`issue:${issue.id}`}
|
||||||
|
issue={issue}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
desktopMetaLeading={(
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline-flex">
|
||||||
|
<PriorityIcon priority={issue.priority} />
|
||||||
|
</span>
|
||||||
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
|
<StatusIcon status={issue.status} />
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{liveIssueIds.has(issue.id) && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
mobileMeta={
|
||||||
|
issue.lastExternalCommentAt
|
||||||
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
: `updated ${timeAgo(issue.updatedAt)}`
|
||||||
|
}
|
||||||
|
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||||
|
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||||
|
trailingMeta={
|
||||||
|
issue.lastExternalCommentAt
|
||||||
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
: `updated ${timeAgo(issue.updatedAt)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -816,62 +936,6 @@ export function Inbox() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTouchedSection && (
|
|
||||||
<>
|
|
||||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
|
||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
|
||||||
return (
|
|
||||||
<IssueRow
|
|
||||||
key={issue.id}
|
|
||||||
issue={issue}
|
|
||||||
issueLinkState={issueLinkState}
|
|
||||||
desktopMetaLeading={(
|
|
||||||
<>
|
|
||||||
<span className="hidden sm:inline-flex">
|
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
</span>
|
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
|
||||||
<StatusIcon status={issue.status} />
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
{liveIssueIds.has(issue.id) && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
mobileMeta={
|
|
||||||
issue.lastExternalCommentAt
|
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`
|
|
||||||
}
|
|
||||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
|
||||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
|
||||||
trailingMeta={
|
|
||||||
issue.lastExternalCommentAt
|
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user