Persist issue read state and clear unread on open

This commit is contained in:
Dotta
2026-03-06 08:34:19 -06:00
parent 86bd26ee8a
commit 38d3d5fa59
11 changed files with 6062 additions and 44 deletions

View File

@@ -32,6 +32,7 @@ export const issuesApi = {
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),

View File

@@ -361,18 +361,6 @@ export function Inbox() {
}),
enabled: !!selectedCompanyId,
});
const {
data: unreadTouchedIssuesRaw = [],
isLoading: isUnreadTouchedIssuesLoading,
} = useQuery({
queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
unreadForUserId: "me",
status: "backlog,todo,in_progress,in_review,blocked",
}),
enabled: !!selectedCompanyId,
});
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
@@ -395,10 +383,6 @@ export function Inbox() {
() => [...touchedIssuesRaw].sort(sortByRecentExternalComment),
[sortByRecentExternalComment, touchedIssuesRaw],
);
const unreadTouchedIssues = useMemo(
() => [...unreadTouchedIssuesRaw].sort(sortByRecentExternalComment),
[sortByRecentExternalComment, unreadTouchedIssuesRaw],
);
const agentById = useMemo(() => {
const map = new Map<string, string>();
@@ -510,10 +494,10 @@ export function Inbox() {
const hasStale = staleIssues.length > 0;
const hasJoinRequests = joinRequests.length > 0;
const hasTouchedIssues = touchedIssues.length > 0;
const hasUnreadTouchedIssues = unreadTouchedIssues.length > 0;
const unreadTouchedCount = touchedIssues.filter((issue) => issue.isUnreadForMe).length;
const newItemCount =
unreadTouchedIssues.length +
unreadTouchedCount +
joinRequests.length +
actionableApprovals.length +
failedRuns.length +
@@ -532,8 +516,7 @@ export function Inbox() {
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
const showTouchedSection =
tab === "new" ? hasUnreadTouchedIssues : showTouchedCategory && hasTouchedIssues;
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
const showJoinRequestsSection =
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
const showApprovalsSection =
@@ -560,7 +543,6 @@ export function Inbox() {
!isDashboardLoading &&
!isIssuesLoading &&
!isTouchedIssuesLoading &&
!isUnreadTouchedIssuesLoading &&
!isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
@@ -640,7 +622,7 @@ export function Inbox() {
icon={InboxIcon}
message={
tab === "new"
? "No unread updates on issues you're involved in."
? "No issues you're involved in yet."
: "No inbox items match these filters."
}
/>
@@ -654,7 +636,7 @@ export function Inbox() {
Issues I Touched
</h3>
<div className="divide-y divide-border border border-border">
{(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => (
{touchedIssues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
@@ -667,17 +649,15 @@ export function Inbox() {
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
{tab === "all" && (
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
issue.isUnreadForMe
? "bg-blue-500/20 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
}`}
>
{issue.isUnreadForMe ? "Unread" : "Read"}
</span>
)}
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
issue.isUnreadForMe
? "bg-blue-500/20 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
}`}
>
{issue.isUnreadForMe ? "Unread" : "Read"}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`

View File

@@ -160,6 +160,7 @@ export function IssueDetail() {
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
@@ -383,9 +384,23 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
};
const markIssueRead = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onSuccess: () => {
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
},
});
const updateIssue = useMutation({
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
onSuccess: (updated) => {
@@ -490,6 +505,13 @@ export function IssueDetail() {
}
}, [issue, issueId, navigate]);
useEffect(() => {
if (!issue?.id) return;
if (lastMarkedReadIssueIdRef.current === issue.id) return;
lastMarkedReadIssueIdRef.current = issue.id;
markIssueRead.mutate(issue.id);
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (issue) {
openPanel(