diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index f2e916bd..fff0ff13 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -9,8 +9,10 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { computeInboxBadgeData, + getRecentTouchedIssues, loadDismissedInboxItems, saveDismissedInboxItems, + getUnreadTouchedIssues, } from "../lib/inbox"; 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, }); - const { data: unreadIssues = [] } = useQuery({ - queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!), + const { data: touchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.listTouchedByMe(companyId!), queryFn: () => issuesApi.list(companyId!, { - unreadForUserId: "me", + touchedByUserId: "me", status: INBOX_ISSUE_STATUSES, }), enabled: !!companyId, }); + const unreadIssues = useMemo( + () => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)), + [touchedIssues], + ); + const { data: heartbeatRuns = [] } = useQuery({ queryKey: queryKeys.heartbeats(companyId!), queryFn: () => heartbeatsApi.list(companyId!), diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index ef23423f..a8480828 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { computeInboxBadgeData, + getRecentTouchedIssues, getUnreadTouchedIssues, loadLastInboxTab, + RECENT_ISSUES_LIMIT, saveLastInboxTab, } from "./inbox"; @@ -220,6 +222,19 @@ describe("inbox helpers", () => { 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", () => { localStorage.clear(); expect(loadLastInboxTab()).toBe("recent"); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index e21dbabf..88447a98 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -96,6 +96,10 @@ export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number { 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[] { return issues.filter((issue) => issue.isUnreadForMe); } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b25eec25..97a191f5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -43,10 +43,9 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, + getRecentTouchedIssues, type InboxTab, - RECENT_ISSUES_LIMIT, saveLastInboxTab, - sortIssuesByMostRecentActivity, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; @@ -329,10 +328,7 @@ export function Inbox() { enabled: !!selectedCompanyId, }); - const touchedIssues = useMemo( - () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), - [touchedIssuesRaw], - ); + const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const unreadTouchedIssues = useMemo( () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], @@ -435,17 +431,20 @@ export function Inbox() { const [fadingOutIssues, setFadingOutIssues] = useState>(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({ mutationFn: (id: string) => issuesApi.markRead(id), onMutate: (id) => { setFadingOutIssues((prev) => new Set(prev).add(id)); }, onSuccess: () => { - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); - } + invalidateInboxIssueQueries(); }, onSettled: (_data, _error, id) => { 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) { return ; } @@ -515,6 +539,10 @@ export function Inbox() { !isRunsLoading; 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 (
@@ -532,8 +560,22 @@ export function Inbox() { /> - {tab === "all" && ( -
+
+ {canMarkAllRead && ( + + )} + + {tab === "all" && ( + <> )} -
- )} + + )} +
{approvalsError &&

{approvalsError.message}

}