bug-inbox-channel-filter-empty.md13.5 KBView on GitHub # Bug: Important and Other drop every chat channel
## Symptom
On the **Important** tab, picking a chat channel:
| Channel badge | What the user saw | What existed |
| --- | --- | --- |
| Slack | "Nothing here yet — No slack messages." | 3 Slack rows |
| LinkedIn | "Nothing here yet — No linkedin messages." | 35 LinkedIn rows |
| All | Email rows only, no Slack/LinkedIn at all | all of the above |
Observed on **<email>**, 2026-09-01, reported via <email>. Cedar MCP
reads the same Slack/LinkedIn data fine, which is consistent: the data is present and the mirror
is healthy.
The empty state is stated **confidently** — no spinner, no error, no "this tab can't show
Slack". `ready: true`, zero rows. That is the part that makes it a bug rather than a quirk: the
feed answers a question it never asked the sources.
## Impact
Anyone on the `important_other` layout had **no route to chat anywhere in the product**. That
layout has no plain Inbox tab to fall back to — the `default` stub exists only under `inbox` and
`stacked` ([use-inboxes.ts:64](../modules/threads/hooks/use-inboxes.ts#L64)) — so with Important
and Other both refusing chat, and any topical split refusing it by design, every tab such an
account has is email-only.
Unaffected, and confirmed working throughout: the plain Inbox tab and CRM-filtered tabs
(`Active Pipeline`). A **topical** split (Calendar, GitHub, Marketing) also returns nothing for a
chat badge, but that is intended — see the Fix — and is addressed as a UX guard rather than as
this defect.
## Root cause
`mailOnly` was carrying two claims at once: *this tab's rule is a Gmail query*, and *sources that
cannot evaluate it must be gated out*. `participatingChannels`
([feed-scope.ts:56](../../../apps/server/src/services/inbox/feed-scope.ts#L56)) acts on the
second.
For a **topical** split those two claims coincide — a Slack source cannot evaluate
`label:"Cedar/AI/marketing"`, so contributing its whole unfiltered stream would fill a Marketing
tab with unrelated chat. For **Important and Other** they do not. Those tabs assert nothing about
subject; they partition the whole inbox by how much attention it deserves. Being unable to rank a
Slack thread by importance is not a reason to withhold it, and `systemSplitFeedScope` marking
them `mailOnly: true` is the defect: a partition silently lost a dimension.
A second, milder problem sits on top of it, and it is what turned a gated channel into a
confident lie. The rule is applied unchanged to an **explicit single-channel badge**, where it means
something quite different. `inbox.getFeedScope` always asks with `channel: 'all'` and hands back
the resolved set; the client narrows it:
```ts
// use-inbox-items.ts:330
return opts.channel === 'all' ? scope.channels : scope.channels.filter((c) => c === opts.channel);
```
`scope.channels` is `['email']`, so filtering it by `'slack'` yields `[]`. With no participating
channels, `mergeChannelFeeds({})` returns `{ items: [], ready: true }`
([feed-merge.ts:82](../../../apps/server/src/services/inbox/feed-merge.ts#L82)), `isLoading` is
false, and `InboxList` renders the empty state
([InboxList.tsx:274](../modules/inbox/components/InboxList.tsx#L274)).
The two filters are documented as orthogonal and composable — "the channel badge and the inbox
tab are ORTHOGONAL filters that compose" ([mail.tsx:84](../modules/threads/mail.tsx#L84)). They
compose everywhere except at this intersection, where the product is silently empty.
## Walkthrough, with real data
All snapshots below are live — the browser's own composition (`inbox.getFeedScope` + one query per
channel + the shared `mergeChannelFeeds`) driven headlessly against a real API.
**Step 1 — the tab resolves to a mail-only rule.**
`mail.tsx` sends `inboxId={routeInbox.record?.id}`; on Important that is the literal
`'important'`. It is a client-side stub and is never persisted, so the record lookup in
`getInboxFeedScope` misses and `systemSplitFeedScope` answers instead — and it answered
`mailOnly: true, channels: ['email']`.
**Step 2 — the scope collapses to email.**
```
=== tab: important === (identical for `other`)
getFeedScope -> {"channels":["email"],"mailOnly":true}
```
**Step 3 — the badge narrows it to nothing, and the route refuses too.**
```
badge=slack participating=[] listChannelItems -> 0 items
badge=linkedin participating=[] listChannelItems -> 0 items
```
Refused twice: the client never issues the query, and `channelParticipates` in
`listChannelItems` would have returned `{ items: [] }` had it done so.
**Step 4 — the same account, same composition, with no `inboxId`** — which is what rules out the
server, the mirror and the merge:
```
=== tab: (no inboxId — whole unibox) ===
getFeedScope -> {"channels":["email","linkedin","whatsapp","slack"],"mailOnly":false}
badge=slack participating=["slack"] listChannelItems -> 3 items
badge=linkedin participating=["linkedin"] listChannelItems -> 34 items
```
The data was reachable the whole time — just not from any tab this account has.
**The same rule, reached the other way.** A persisted topical split arrives at an identical scope
through `getInboxFeedScope` rather than the stub path, which is how the root cause was isolated
(<email>'s `GitHub` tab, `inbox-5v91400g`, `query` set and `conversationFilter`
unset → `mailOnly: true`):
```
===== inbox=GitHub channel=slack ===== participating -> [] rendered 0, ready: true
===== inbox=GitHub channel=all ===== participating -> ["email"] rendered 50, all email
===== inbox=default channel=all ===== rendered 90 {"email":50,"slack":10,"linkedin":30}
===== inbox=default channel=slack ===== rendered 38 {"slack":38}
```
Same mechanism, but on a topical split the outcome is intended — see the Fix.
## Evidence
- **Composition driver** (the browser's data path, run headlessly):
`pnpm --filter @zero/server exec tsx src/mail-admin/repro-client-feed.ts --for <email> --channel <all|slack|linkedin> --limit 50 --inbox <default|inbox-5v91400g>`
- **Stored inbox records** (`user_settings.settings.inboxes`, user `ZepBiImpQq5VZVbmHmGlK58GO6druMuO`):
four inboxes; `Agent Drafts` / `GitHub` / `Marketing` each carry a `query` and no
`conversationFilter` → `mailOnly: true`. `Active Pipeline` carries a `conversationFilter` →
`mailOnly: false`.
- **Settings**: `inboxLayout = "inbox"`, `activeInboxId = "default"`.
- **Not a failure path.** Axiom `cedar-local`, 15:00–16:25 UTC 2026-09-01 (the reported window):
zero `error`/`warn` rows. No channel query failed, so the partial-failure path added in
`a437a9a86` is not involved. The `[trpc] malformed request body` warnings in the same dataset
are `files.searchForLink` / `connections.list` with `body_bytes: 0` — a separate, unrelated
issue.
- **Server data is present**: `mail-admin load-feed --channel slack` returns Slack rows;
`linkedin_accounts` has one `connected` seat, so the LinkedIn connect prompt
([use-inbox-channel-connect.ts](../modules/inbox/hooks/use-inbox-channel-connect.ts)) is not
what is being rendered.
## Blast radius
- Every user with a Gmail-query split tab. Cheap to check per account:
inboxes with `query` set and `conversationFilter` unset.
- **Everyone on the `important_other` layout**, who had no working tab at all —
`systemSplitFeedScope` made both stubs mail-only, and that layout has no plain Inbox tab to
fall back to (the `default` stub exists only under `inbox` and `stacked`,
[use-inboxes.ts:64](../modules/threads/hooks/use-inboxes.ts#L64)). This is the case the fix
below reclassifies rather than merely hides.
- **Confirmed second account: <email>.** `important_other` layout; both his custom
tabs (`Calendar`, `Google`) are Gmail queries with `channels: ['email']` and no CRM rule, and
Important / Other were mail-only. So **every tab he had was affected**, over real data (825
Slack messages, 57 LinkedIn chats, 87 linked containers) — and he never reported it.
This is why the bug reads as account-specific and is not. An account with a working Inbox tab
sees the badge work on one tab and not another, which is reportable. An account with no such
tab sees a badge that has simply never worked anywhere, which reads as "not for me".
- `channelParticipates` in `inbox.listChannelItems`
([inbox.ts:172](../../../apps/server/src/trpc/routes/inbox.ts#L172)) applies the same gate
server-side, so the route would return `{ items: [] }` even if the client did issue the query.
Any fix has to move both, or they disagree.
- Not affected: the plain Inbox tab, CRM-filtered tabs, and the dedicated
`/linkedin/inbox` + `/whatsapp/inbox` routes (different components).
## Fix
Two parts, because the symptom had two causes: one tab class was gated when it should not have
been, and the rest render an empty intersection as though it were an answer.
### 1. Important and Other are not mail-only
The `mailOnly` gate conflated two different things: *this rule is a Gmail query* and *sources
that cannot evaluate it must stay out*. Those coincide for a TOPICAL split — Calendar, GitHub,
Marketing each claim a subject, and unfiltered LinkedIn chats under a Calendar tab are wrong.
They do not coincide for Important and Other, which claim no subject: they re-slice the whole
inbox by how much attention it deserves. A channel they cannot speak about should pass through,
not be dropped.
[inbox-definitions.ts](../../../apps/server/src/services/mail/inboxes/inbox-definitions.ts) —
`systemSplitFeedScope` now returns `mailOnly: false`. `channels` deliberately stays `['email']`:
that field is the **opt-in**, and `listChannelItems` forwards `compiledQuery` to a source only
when that source opted in. Keeping it narrow is what makes the channels participate *whole and
unfiltered* rather than being filtered by a Gmail query they cannot evaluate. Important is then
exactly "the same as Inbox, combined" — Important-filtered email, plus each chat channel entire,
merged on time.
Verified against this branch's API, for <email>:
```
=== tab: important === (identical for `other`, and for the plain unibox)
getFeedScope -> {"channels":["email","linkedin","whatsapp","slack"],"mailOnly":false}
badge=slack participating=["slack"] listChannelItems -> 3 items
badge=linkedin participating=["linkedin"] listChannelItems -> 35 items
slack important=3 important+compiledQuery=3 plain unibox=3 -> query did NOT leak
linkedin important=35 important+compiledQuery=35 plain unibox=35 -> query did NOT leak
```
The second line is the one that matters for correctness: passing Important's compiled Gmail
query alongside `inboxId: 'important'` changes nothing, because the opt-in kept it off the chat
sources.
### 2. A genuinely unanswerable badge is no longer offered
Topical splits keep the gate — that is what `mailOnly` is for, and it is unchanged. What changed
is that their empty intersection is no longer presented as a result.
- [channel-availability.ts](../modules/inbox/lib/channel-availability.ts) — `isChannelFilterAvailable`
asks the resolved participation set from `getFeedScope` whether a badge is answerable;
`resolveChannelFilter` maps an unanswerable one to `all`. An undefined set means "not known
yet", not "not allowed", so nothing is disabled while the scope resolves.
- [ChannelSelector.tsx](../modules/inbox/components/ChannelSelector.tsx) — a badge the tab cannot
answer is `disabled` and reports no pick. `All` and `Email` are always offerable.
- [mail.tsx](../modules/threads/mail.tsx) — reads the same `inbox.getFeedScope` the feed reads
(same key and input, so react-query serves both from one request; needed here because the menu
must know its options on the `Email` badge too, where the feed is not mounted), and resolves an
already-active unanswerable badge to `all`. That closes the routes around the menu: `?channel=slack`
in a shared link, and the remembered pick restored from `localStorage` followed by a click onto a
Gmail-query tab. The URL is written and `localStorage` is not, so the standing preference
survives and returning to a tab that can answer Slack restores Slack.
**`All` on a topical split is deliberately unchanged.** There it still means "everything this
tab's rule can answer", which is email. Widening it would re-introduce exactly what the
`mailOnly` gate was added to stop. On Important and Other, `All` now means every channel
combined, which is what part 1 makes true.
Regression guards:
- [channelFilterAvailability.test.tsx](../tests/modules/inbox/channelFilterAvailability.test.tsx) —
11 cases. Two fail against the previous behaviour: Slack is offered on a mail-only tab, and
clicking it reports a pick.
- [system-split-feed-scope.test.ts](../../../apps/server/src/services/mail/inboxes/__tests__/system-split-feed-scope.test.ts) —
asserts Important / Other resolve to the same participation set as the plain unibox, that each
chat badge survives the client's narrowing, that the opt-in stays `['email']` so the Gmail
query cannot reach a chat source, and that a topical split is still gated.
## Monitoring
No `sherlock_monitors` row. `evaluateMonitor` grades **agent executions**
([evaluate-monitor.ts](../../../apps/server/src/services/sherlock/evaluate-monitor.ts)), and this
defect is a client-side rendering rule that produces none — a monitor could only ever answer
`not_applicable`, while consuming one of the cron's 20 slots per run. The jest suite above is the
right instrument for this class of bug. Investigation recorded as
`sherlock_investigations.1ac4b726-40e9-4994-8f86-b0918c60ab2c`.