unread-filter.md6.4 KBView on GitHub # ⇧U — the unread-only filter
Superhuman's ⇧U narrows the split inbox you are on to its unread mail and badges that split
"Unread". This is Cedar's version of it, with one difference that matters: Cedar's inbox is
omni-channel, so the filter has to hold for **every channel in the view at once** — email,
LinkedIn, WhatsApp and Slack — or the one merged list would say "Unread" while carrying read
chats.
Press ⇧U on any inbox tab (or a Gmail system folder) to filter it; press it again — or click
the badge — to show everything. Nothing else changes: the same rows, the same actions, the
same keys.
## What the filter is
**A view filter keyed by folder slug, not an inbox setting.** The state is
`unreadOnlyByFolder` on the mail slice — `{ inbox: true }` means the `/mail/inbox` (and
`/inbox/inbox`) tab is filtered. The slug is what every surface that has to agree about the
filter already has: the tab strip reads it off the URL, the thread list resolves the same slug
to an inbox, and the stacked layout renders several inboxes under one slug and filters them
together.
**Session-scoped.** Deliberately not persisted. A filter that survived a reload would greet
the user with a mailbox missing most of their mail and no memory of having asked for that.
**Per inbox.** Filtering `Important` and moving to `Other` leaves `Other` unfiltered, and
coming back to `Important` finds it as you left it — both tabs say so in the strip.
## How it reaches each source
| Surface | Path | How it filters |
| --- | --- | --- |
| Email list (`MailList`, stacked sections) | `useThreads` | `is:unread` appended to the compiled Gmail query; the mirror promotes it to a leading label clause (`UNREAD = ANY(label_ids)`), so the filtered read seeks the label index rather than scanning |
| Tab counts | `useInboxCounts` → `mail.getSplitCounts` | the same appended query, with a **different `queryHash`** — the hash is a cache key the server groups splits by, so a filtered tab that kept the unfiltered hash would report and lend the wrong count |
| Unibox (`InboxList`, any non-email channel) | `inbox.listItems` `unreadOnly` flag | email source appends `is:unread`; each chat source gets a predicate over its own read state |
The unibox takes a **flag rather than a query clause** because a Gmail clause only speaks for
email. Server-side (`services/inbox/feed.ts`, `feed-containers.ts`) each channel source adds:
```sql
AND COALESCE(
(SELECT s.unread FROM cedar_inbox_item_state s WHERE s.item_id = … AND s.user_id = …),
<the channel's own unread> -- derived count > 0, or the stored is_read flag
)
```
The `cedar_inbox_item_state` half is not optional: it is the SQL twin of the overlay
`attachItemStates` applies after the read (`unread: s.unread ?? i.unread`). A rep who pressed
`u` on a read Slack channel has a row there and nothing in the channel's derived count knows
it — filtering on the source expression alone would drop that row while its badge said unread.
Filtering happens at the **source**, not over the assembled page: the unibox's "Load more" is a
button, so a page trimmed after the fact would hand the user two rows and a cursor.
## It lands on the next frame, not on the round trip
The filtered list is a **new cache key**, so on its own ⇧U would spend a round trip showing
nothing (or, worse, the unfiltered rows) before the filter appeared — a page load, not a lens.
It does not have to. The rows on screen already carry what the filter asks about: every
`mail.listThreads` page carries `$raw.labels`, and every `inbox.listItems` item carries
`unread`. So the new key is seeded with **the unread subset of what is already cached**, as
TanStack `placeholderData`, and the server's page replaces it when it arrives — reaching
further back than the loaded window, so it only ever adds.
Three rules make that safe:
1. **The placeholder may only under-report.** A row whose read state cannot be determined is
dropped, never kept — showing a read row under an "Unread" badge is the one visible lie.
2. **An empty placeholder is not an empty inbox.** "No unread among the rows we happened to be
holding" is a fact about the cache, so both lists spin in that case rather than flash an
inbox-zero the server is about to contradict.
3. **Only ⇧U may reuse the rows.** The unibox's placeholder is gated on
`sameRequestApartFromUnread`: a blanket keep-previous-data would also hold Slack rows on
screen while LinkedIn loads — a stale answer to a *different* question. The unread toggle is
the only input change that leaves the question the same.
The tab counts re-key too; they keep the previous numbers for that one round trip rather than
blanking every tab.
## Where it does NOT apply
`supportsUnreadFilter` (modules/threads/lib/unread-filter.ts) is the list. The `mail-list`
hotkey scope is wider than the thread list — it covers every `/mail/*` and `/inbox/*` path,
sibling routes included — so on `compose`, `agenda`, `conversation-inbox` and `scheduled` (a
KV-backed virtual folder) the keypress is a **no-op**. A badge over a list that ignores it
would be worse than no filter at all.
## The badge is the way out
A filtered inbox looks exactly like an empty one. So the badge is not decoration:
- it sits next to the inbox's name in the tab strip (and next to the folder name on Done /
Sent / Spam, which have no tab),
- it is clickable — clearing the filter without knowing the shortcut,
- and both empty states say which is which: "No unread here — press ⇧U to show everything"
rather than congratulating someone on an inbox zero they reached by hiding their mail.
## Files
- `modules/threads/lib/unread-filter.ts` — query composition, hash, route support, and the
optimistic page filters (all pure)
- `modules/threads/hooks/use-unread-filter.ts` — the flag for the route on screen
- `modules/threads/threadList/store/mailSlice.ts` — `unreadOnlyByFolder`
- `config/shortcuts.ts` — the ⇧U binding; handler in `threadList/utils/mail-list-hotkeys.tsx`
- `apps/server/src/services/inbox/{feed,feed-containers,item-state}.ts` — the channel half
## Tests
- `apps/mail/tests/modules/threads/unread-filter.test.ts` — composition, hash, route support, state
- `apps/server/src/services/inbox/__tests__/feed-unread-filter.test.ts` — the SQL every source emits
- `apps/server/src/services/inbox/__test__/feed-unread-db.test.ts` — real DB, read-only: every row
a filtered feed returns is unread, on every channel (skipped without `DATABASE_URL`)