Align inbox badge with visible unread items

This commit is contained in:
Dotta
2026-03-11 09:02:23 -05:00
parent 345c7f4a88
commit 57d8d01079
4 changed files with 87 additions and 18 deletions

View File

@@ -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!),

View File

@@ -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");

View File

@@ -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);
} }

View File

@@ -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>}