Skip to content

Add unread Signal-view thread count badge to email sidebar - #5416

Closed
jbecke wants to merge 1 commit into
mainfrom
claude/email-unread-badge-signal-pg9oi2
Closed

Add unread Signal-view thread count badge to email sidebar#5416
jbecke wants to merge 1 commit into
mainfrom
claude/email-unread-badge-signal-pg9oi2

Conversation

@jbecke

@jbecke jbecke commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new /email/links/unread-counts endpoint 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)

  • Added InboxUnreadSignalCount domain model to represent unread counts per inbox
  • Implemented unread_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)
  • Added get_user_unread_signal_counts() service method that:
    • Fetches user's accessible inboxes
    • Queries counts from repository
    • Zero-fills caught-up inboxes (repo omits them, service includes them)
  • Created new unread_counts_router with GET /email/links/unread-counts handler
  • Added comprehensive test coverage for grouping, filtering, and edge cases
  • Created database index idx_email_threads_unread_signal_count to optimize the count query

Frontend (TypeScript/SolidJS)

  • Added useEmailUnreadCountsQuery() hook with 30s stale time and 60s refetch interval
  • Added useEmailUnreadCounts() hook providing forLink() and total() accessors
  • Added invalidateEmailUnreadCounts() for manual invalidation after read/unread actions
  • Implemented SidebarUnreadBadge component that:
    • Renders nothing at zero count
    • Shows single digit in slim mode (9+), double digit in expanded mode (99+)
    • Positions over icon in slim mode, next to label in expanded mode
  • Integrated badge into sidebar email links
  • Updated thread mutation handlers to invalidate counts after marking as read/unread

API & Generated Code

  • Added ListUnreadCountsResponse and ApiInboxUnreadCount OpenAPI schemas
  • Generated TypeScript client methods and types
  • Updated SDK types and client implementations

Implementation Details

  • The count predicate mirrors the Signal tab's candidate scan to ensure badge and tab always agree
  • Inboxes with zero unread threads are omitted from the repository response but included in the service response (zero-filled) so clients can clear stale badges
  • The endpoint respects user authorization—only returns counts for inboxes the user can access
  • Query uses GROUP BY link_id to efficiently aggregate counts per inbox
  • Frontend uses Cached extractor for authorization to avoid redundant checks

https://claude.ai/code/session_01Eo5vmVSYUewmRqzEAGcboN

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

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added unread email counts to inbox navigation.
    • Counts appear on the collapsed inbox link and individual inbox rows, with responsive formatting for sidebar layouts.
    • Added accessible labels and hidden zero-count badges.
    • Added SDK support for retrieving per-inbox and aggregate unread counts.
  • Bug Fixes

    • Unread counts now refresh after messages are marked read or unread and after email classification changes.

Walkthrough

Added 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)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes the changes but does not follow the required conventional commits format because it lacks a prefix such as feat:. Change the title to a conventional commit format, such as "feat: add unread Signal-view count badge to email sidebar".
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the unread-count endpoint, backend and frontend changes, testing, and database index.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 1a4fe27 and de44724.

⛔ Files ignored due to path filters (8)
  • .sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json is excluded by !**/.sqlx/**
  • apps/web/src/lib/service-clients/service-email/generated/client.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • apps/web/src/lib/service-clients/service-email/generated/schemas/apiInboxUnreadCount.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • apps/web/src/lib/service-clients/service-email/generated/schemas/index.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • apps/web/src/lib/service-clients/service-email/generated/schemas/listUnreadCountsResponse.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • packages/sdk/generated/email/index.ts is excluded by !**/generated/**
  • packages/sdk/generated/email/sdk.gen.ts is excluded by !**/generated/**, !**/*.gen.ts
  • packages/sdk/generated/email/types.gen.ts is excluded by !**/generated/**, !**/*.gen.ts
📒 Files selected for processing (25)
  • apps/web/src/components/app/app-sidebar/sidebar.tsx
  • apps/web/src/lib/queries/email/keys.ts
  • apps/web/src/lib/queries/email/thread.ts
  • apps/web/src/lib/queries/email/unread-counts.ts
  • apps/web/src/lib/service-clients/service-email/client.ts
  • apps/web/src/lib/service-clients/service-email/openapi.json
  • crates/complete_graph/src/schema/test.rs
  • crates/email/src/domain/models/link.rs
  • crates/email/src/domain/models/mod.rs
  • crates/email/src/domain/ports.rs
  • crates/email/src/domain/service/user.rs
  • crates/email/src/domain/service/user/test.rs
  • crates/email/src/inbound/axum.rs
  • crates/email/src/inbound/axum/unread_counts_router.rs
  • crates/email/src/outbound/email_pg_repo/link.rs
  • crates/email/src/outbound/email_pg_repo/mod.rs
  • crates/email/src/outbound/email_pg_repo/test/mod.rs
  • crates/email/src/outbound/email_pg_repo/test/unread_counts.rs
  • crates/graphql_email/src/user_query/test.rs
  • crates/macro_db_client/migrations/20260804183545_email_threads_unread_signal_count_index.sql
  • packages/sdk/specs/email.json
  • packages/sdk/src/entities/email/link.ts
  • packages/sdk/src/entities/email/namespace.ts
  • services/email_service/src/api/email/links/mod.rs
  • services/email_service/src/api/swagger.rs

Comment on lines +1981 to +1983
// Only worth asking once we know the user has an inbox at all.
const unread = useEmailUnreadCounts(() => links().length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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 -n

Repository: 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 -n

Repository: 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 -n

Repository: 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 synoet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad approach, we can do something better with gql, not worth doing right now

@synoet synoet closed this Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants