Add unread Signal-view thread count badge to email sidebar - #5416
Conversation
The sidebar gave no indication that mail was waiting — you had to open the list to find out. Add a count badge that follows the Email link's existing inbox fan-out: collapsed, the Email row carries the total across every connected inbox; expanded, each account row carries its own. Counts are Signal only. The predicate mirrors the Signal tab's candidate scan (the inbox view's `inbox_visible` + inbound-message requirement, plus the denormalized `email_threads.is_signal` the Importance filter reads), so the number always matches the tab a click lands on and noise never nags. Backend: `GET /email/links/unread-counts` on a new hexagonal router. The domain service resolves the caller's accessible inboxes and zero-fills, so a caught-up inbox reports 0 rather than vanishing from the response and leaving the client to infer absence from a missing key. A partial index narrower than `idx_email_threads_signal_view` keeps the count index-only as read history grows. Frontend: the counts query polls on a short interval (mail arriving from the provider doesn't invalidate anything the web app watches) and is invalidated directly on read/unread and on sender Signal/Noise reclassification, which moves threads across the boundary being counted. Also wraps the endpoint in the SDK as `Link.unreadSignalCount()` and `email.unreadSignalCount()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Eo5vmVSYUewmRqzEAGcboN
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdded unread Signal-thread counts for accessible email inboxes. PostgreSQL now filters and groups qualifying threads, while the email service returns per-inbox counts and an aggregate total. The API, OpenAPI specifications, web client, and SDK expose the counts. The web query polls and invalidates counts after email mutations. The sidebar displays aggregate counts when collapsed and per-inbox counts when expanded. Database and service tests cover filtering, grouping, zero counts, and empty inbox sets. 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/components/app/app-sidebar/sidebar.tsx`:
- Around line 1981-1983: Wrap SidebarMailLink, including its
useEmailUnreadCounts call, in a dedicated Suspense boundary with an appropriate
sidebar-specific fallback. Keep the existing links().length > 0 gating unchanged
and ensure the parent layout boundary is no longer relied on for unread-count
loading.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1eb1c28d-5a64-4e22-bcad-a52017d1fc3c
⛔ Files ignored due to path filters (8)
.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.jsonis excluded by!**/.sqlx/**apps/web/src/lib/service-clients/service-email/generated/client.tsis excluded by!**/generated/**,!apps/web/src/lib/service-clients/**/generated/**apps/web/src/lib/service-clients/service-email/generated/schemas/apiInboxUnreadCount.tsis excluded by!**/generated/**,!apps/web/src/lib/service-clients/**/generated/**apps/web/src/lib/service-clients/service-email/generated/schemas/index.tsis excluded by!**/generated/**,!apps/web/src/lib/service-clients/**/generated/**apps/web/src/lib/service-clients/service-email/generated/schemas/listUnreadCountsResponse.tsis excluded by!**/generated/**,!apps/web/src/lib/service-clients/**/generated/**packages/sdk/generated/email/index.tsis excluded by!**/generated/**packages/sdk/generated/email/sdk.gen.tsis excluded by!**/generated/**,!**/*.gen.tspackages/sdk/generated/email/types.gen.tsis excluded by!**/generated/**,!**/*.gen.ts
📒 Files selected for processing (25)
apps/web/src/components/app/app-sidebar/sidebar.tsxapps/web/src/lib/queries/email/keys.tsapps/web/src/lib/queries/email/thread.tsapps/web/src/lib/queries/email/unread-counts.tsapps/web/src/lib/service-clients/service-email/client.tsapps/web/src/lib/service-clients/service-email/openapi.jsoncrates/complete_graph/src/schema/test.rscrates/email/src/domain/models/link.rscrates/email/src/domain/models/mod.rscrates/email/src/domain/ports.rscrates/email/src/domain/service/user.rscrates/email/src/domain/service/user/test.rscrates/email/src/inbound/axum.rscrates/email/src/inbound/axum/unread_counts_router.rscrates/email/src/outbound/email_pg_repo/link.rscrates/email/src/outbound/email_pg_repo/mod.rscrates/email/src/outbound/email_pg_repo/test/mod.rscrates/email/src/outbound/email_pg_repo/test/unread_counts.rscrates/graphql_email/src/user_query/test.rscrates/macro_db_client/migrations/20260804183545_email_threads_unread_signal_count_index.sqlpackages/sdk/specs/email.jsonpackages/sdk/src/entities/email/link.tspackages/sdk/src/entities/email/namespace.tsservices/email_service/src/api/email/links/mod.rsservices/email_service/src/api/swagger.rs
| // Only worth asking once we know the user has an inbox at all. | ||
| const unread = useEmailUnreadCounts(() => links().length > 0); | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
fd -t f 'unread-counts.ts' apps/web/src/lib/queries/email
ast-grep outline apps/web/src/lib/queries/email/unread-counts.ts --items all
sed -n '1,220p' apps/web/src/lib/queries/email/unread-counts.ts
rg -n -C 5 \
'<Suspense|SidebarMailLink|useEmailUnreadCounts' \
apps/web/src -g '*.tsx'Repository: macro-inc/macro
Length of output: 50372
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- Sidebar imports and query call site ---"
sed -n '70,105p;1950,2010p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -n
echo "--- App root and Layout Suspense nesting around authenticated sidebar area ---"
sed -n '690,720p' apps/web/src/routes/Root.tsx | cat -n
sed -n '415,445p' apps/web/src/components/app/Layout.tsx | cat -n
sed -n '530,545p;570,585p' apps/web/src/components/app/Layout.tsx | cat -n
echo "--- Sidebar render path outline ---"
ast-grep outline apps/web/src/components/app/app-sidebar/sidebar.tsx --match Sidebar --view expanded || true
echo "--- Other email query call sites ---"
rg -n 'useEmail(Links|UnreadCounts|.*Query)?|setSuspenseQuery|Suspense' apps/web/src/lib/queries/email apps/web/src/components/app/app-sidebar/sidebar.tsx apps/web/src/routes -g '*.tsx' -g '*.ts'Repository: macro-inc/macro
Length of output: 13997
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- locate AppSidebar usages ---"
rg -n -C 8 '<AppSidebar|from ["'\''].*sidebar[["'\'']' apps/web/src -g '*.tsx' -g '*.ts'
echo "--- AppSidebar rendering section ---"
sed -n '1060,1135p;1460,1490p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -nRepository: macro-inc/macro
Length of output: 324
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- locate AppSidebar usages ---"
rg -n -C 8 '<AppSidebar' apps/web/src -g '*.tsx' -g '*.ts' || true
echo "--- locate sidebar file imports/usages ---"
rg -n 'app-sidebar/sidebar|from ["'\'']`@app-sidebar/sidebar`' apps/web/src -g '*.tsx' -g '*.ts' || true
echo "--- AppSidebar rendering section ---"
sed -n '1060,1135p;1460,1490p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -nRepository: macro-inc/macro
Length of output: 7061
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- Authenticated Layout Suspense / AppSidebar boundary ---"
sed -n '425,490p' apps/web/src/components/app/Layout.tsx | cat -n
echo "--- Sidebar middle render section around final Suspense blocks ---"
sed -n '1465,1515p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -nRepository: macro-inc/macro
Length of output: 5043
Wrap the sidebar unread-count query in its own Suspense boundary.
useEmailUnreadCounts() is a new query call site, and the parent Suspense only wraps the authenticated Layout content so it is not a deliberate sidebar-specific fallback. Wrap SidebarMailLink with a Suspense boundary that handles unread-count loading.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/app/app-sidebar/sidebar.tsx` around lines 1981 -
1983, Wrap SidebarMailLink, including its useEmailUnreadCounts call, in a
dedicated Suspense boundary with an appropriate sidebar-specific fallback. Keep
the existing links().length > 0 gating unchanged and ensure the parent layout
boundary is no longer relied on for unread-count loading.
Source: Path instructions
synoet
left a comment
There was a problem hiding this comment.
bad approach, we can do something better with gql, not worth doing right now
Summary
Adds a new
/email/links/unread-countsendpoint that returns per-inbox unread Signal-view thread counts, enabling the sidebar to display an unread badge for each connected email inbox. The badge shows only unread Signal threads (important mail), excluding noise, archived, and outbound-only threads.Key Changes
Backend (Rust)
InboxUnreadSignalCountdomain model to represent unread counts per inboxunread_signal_counts_for_links()repository method with SQL query that filters on:inbox_visible(not archived)is_signal(important mail only, not noise)NOT is_read(unread)latest_inbound_message_ts IS NOT NULL(has inbound messages)get_user_unread_signal_counts()service method that:unread_counts_routerwithGET /email/links/unread-countshandleridx_email_threads_unread_signal_countto optimize the count queryFrontend (TypeScript/SolidJS)
useEmailUnreadCountsQuery()hook with 30s stale time and 60s refetch intervaluseEmailUnreadCounts()hook providingforLink()andtotal()accessorsinvalidateEmailUnreadCounts()for manual invalidation after read/unread actionsSidebarUnreadBadgecomponent that:API & Generated Code
ListUnreadCountsResponseandApiInboxUnreadCountOpenAPI schemasImplementation Details
GROUP BY link_idto efficiently aggregate counts per inboxCachedextractor for authorization to avoid redundant checkshttps://claude.ai/code/session_01Eo5vmVSYUewmRqzEAGcboN