Align inbox badge with visible unread items
This commit is contained in:
@@ -9,8 +9,10 @@ import { issuesApi } from "../api/issues";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
getRecentTouchedIssues,
|
||||||
loadDismissedInboxItems,
|
loadDismissedInboxItems,
|
||||||
saveDismissedInboxItems,
|
saveDismissedInboxItems,
|
||||||
|
getUnreadTouchedIssues,
|
||||||
} 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";
|
||||||
@@ -70,16 +72,21 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: unreadIssues = [] } = useQuery({
|
const { data: touchedIssues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!),
|
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
issuesApi.list(companyId!, {
|
issuesApi.list(companyId!, {
|
||||||
unreadForUserId: "me",
|
touchedByUserId: "me",
|
||||||
status: INBOX_ISSUE_STATUSES,
|
status: INBOX_ISSUE_STATUSES,
|
||||||
}),
|
}),
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unreadIssues = useMemo(
|
||||||
|
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
|
||||||
|
[touchedIssues],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: heartbeatRuns = [] } = useQuery({
|
const { data: heartbeatRuns = [] } = useQuery({
|
||||||
queryKey: queryKeys.heartbeats(companyId!),
|
queryKey: queryKeys.heartbeats(companyId!),
|
||||||
queryFn: () => heartbeatsApi.list(companyId!),
|
queryFn: () => heartbeatsApi.list(companyId!),
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ 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 {
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
|
RECENT_ISSUES_LIMIT,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
|
|
||||||
@@ -220,6 +222,19 @@ describe("inbox helpers", () => {
|
|||||||
expect(issues).toHaveLength(2);
|
expect(issues).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("limits recent touched issues before unread badge counting", () => {
|
||||||
|
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||||
|
const issue = makeIssue(String(index + 1), index < 3);
|
||||||
|
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
|
||||||
|
return issue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentIssues = getRecentTouchedIssues(issues);
|
||||||
|
|
||||||
|
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
|
||||||
|
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults the remembered inbox tab to recent and persists all", () => {
|
it("defaults the remembered inbox tab to recent and persists all", () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
expect(loadLastInboxTab()).toBe("recent");
|
expect(loadLastInboxTab()).toBe("recent");
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
|
|||||||
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
|
||||||
|
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
|
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
|
||||||
return issues.filter((issue) => issue.isUnreadForMe);
|
return issues.filter((issue) => issue.isUnreadForMe);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
|||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
|
getRecentTouchedIssues,
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
RECENT_ISSUES_LIMIT,
|
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
sortIssuesByMostRecentActivity,
|
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
@@ -329,10 +328,7 @@ export function Inbox() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const touchedIssues = useMemo(
|
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||||
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
|
||||||
[touchedIssuesRaw],
|
|
||||||
);
|
|
||||||
const unreadTouchedIssues = useMemo(
|
const unreadTouchedIssues = useMemo(
|
||||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||||
[touchedIssues],
|
[touchedIssues],
|
||||||
@@ -435,17 +431,20 @@ export function Inbox() {
|
|||||||
|
|
||||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const invalidateInboxIssueQueries = () => {
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||||
|
};
|
||||||
|
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||||
onMutate: (id) => {
|
onMutate: (id) => {
|
||||||
setFadingOutIssues((prev) => new Set(prev).add(id));
|
setFadingOutIssues((prev) => new Set(prev).add(id));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (selectedCompanyId) {
|
invalidateInboxIssueQueries();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSettled: (_data, _error, id) => {
|
onSettled: (_data, _error, id) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -458,6 +457,31 @@ export function Inbox() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const markAllReadMutation = useMutation({
|
||||||
|
mutationFn: async (issueIds: string[]) => {
|
||||||
|
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
|
||||||
|
},
|
||||||
|
onMutate: (issueIds) => {
|
||||||
|
setFadingOutIssues((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const issueId of issueIds) next.add(issueId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateInboxIssueQueries();
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, issueIds) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setFadingOutIssues((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const issueId of issueIds) next.delete(issueId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||||
}
|
}
|
||||||
@@ -515,6 +539,10 @@ export function Inbox() {
|
|||||||
!isRunsLoading;
|
!isRunsLoading;
|
||||||
|
|
||||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||||
|
const unreadIssueIds = unreadTouchedIssues
|
||||||
|
.filter((issue) => !fadingOutIssues.has(issue.id))
|
||||||
|
.map((issue) => issue.id);
|
||||||
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -532,8 +560,22 @@ export function Inbox() {
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === "all" && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{canMarkAllRead && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||||
|
disabled={markAllReadMutation.isPending}
|
||||||
|
>
|
||||||
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "all" && (
|
||||||
|
<>
|
||||||
<Select
|
<Select
|
||||||
value={allCategoryFilter}
|
value={allCategoryFilter}
|
||||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||||
@@ -566,8 +608,9 @@ export function Inbox() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||||
|
|||||||
Reference in New Issue
Block a user