unified-inbox-slice-design.md7.4 KBView on GitHub
# Unified Inbox Slice — store-backed omni-channel feed with cross-channel selection

## Intro

The unified inbox (`/inbox` with a non-email channel, i.e. `showUnifiedFeed`) currently
renders through a **thin, store-less path** that diverges from the rich email thread path.
This doc unifies them: every channel becomes a first-class store slice like email's
`threadSlice`, the feed is a single merged/ordered id list, and selection (`s` / `⇧S` range,
`⌘S` toggle, bulk actions) works **across channels** — select a Slack + LinkedIn + email row
together and mark them all done.

## Current state

Two parallel architectures:

**Email (rich):** `mail.listThreads` → [use-threads.ts](../modules/threads/threadList/hooks/use-threads.ts)
→ [threadSlice.ts](../modules/threads/threadList/store/threadSlice.ts): `currentThreadList: ThreadSummary[]`
(order) + `threadData: Record<threadId, ThreadData>` (rich map). `MailList` renders each row as
`<Thread>`, reading `useThreadData(id)` — labels, AOP, tracking, hover actions, opens. Selection
(`bulkSelected`, range-select anchor logic, checkboxes) is built into `Thread` + `mail-list-hotkeys`.

**Unibox (thin):** `inbox.listItems` → [assembleInboxFeed](../../server/src/services/inbox/feed.ts)
reads mirror tables, returns a flat `InboxItem[]`. [use-inbox-items.ts](../modules/inbox/hooks/use-inbox-items.ts)
is a plain `useInfiniteQuery` — **no store slice**. [InboxList](../modules/inbox/components/InboxList.tsx)
maps items → [InboxRow](../modules/inbox/components/InboxRow.tsx) (thin: avatar, name, subtitle,
snippet, 3 hover buttons). **No labels, no selection, no bulk actions.** Even email items render
through `InboxRow` (via `fetchEmailItems` → `emailThreadToItem`, which strips the rich data).

Consequences: email rows lose labels/opens/tracking; there is no `s`/range-select; bulk actions
don't exist for the feed.

## Designed state

Keep `assembleInboxFeed` as the **single server source of merged order + pagination** (it already
interleaves channels by `sortedAt` with a unified cursor). Add a client store slice and unify
rendering + selection.

### `inboxSlice` (new)

```
inboxItems:    Record<string, InboxItem>   // channel items (li:/wa:/slack:) keyed by id
inboxOrder:    string[]                     // merged, time-ordered ids of ALL feed rows (email + channel)
inboxChannelById: Record<string, InboxChannel>  // id → channel, for render/selection dispatch

setInboxFeed(items)      // first page: reset maps + order; email items also populate threadSlice
appendInboxFeed(items)   // next page: extend
getInboxItem(id)
getUnifiedList(): {id}[]  // inboxOrder as {id}[] so the existing range/select index logic reuses it
removeInboxItems(ids) / restoreInboxItems(snapshot)
patchInboxItem(id, patch)
```

Email keeps living in `threadSlice` (so `<Thread>` renders it). Channel items live in `inboxItems`.
`inboxOrder` is the one merged list both slices' rows are drawn from. Selection reuses the existing
`bulkSelected` / `selectedThreadId` / `focusedIndex` — the ids in `bulkSelected` are unified ids
(email thread id OR channel id), which is what makes cross-channel selection free.

### Rich email in the unibox

`assembleInboxFeed`'s email items are enriched to carry the thread preview metadata (the same
`listThreadsFromDb` rows already hold it — `emailThreadToItem` just drops it). On the client,
`setInboxFeed` feeds those into `threadSlice.batchPopulateThreadMetadata`, so `<Thread>` can render
an email row in the unibox with full fidelity.

### Rendering

`InboxList` iterates `inboxOrder`. For each id: email → `<Thread message={{id}}/>`; channel →
`InboxRow` upgraded to participate in selection (checkbox on hover/selected, click-to-select,
selected background). Both row types emit the same hover event and read `bulkSelected`.

### Selection & bulk actions (cross-channel)

`mail-list-hotkeys` selection handlers (`selectUnderCursor` `s`, `bulkSelectUnderCursor` `⌘S`,
`rangeSelectUnderCursor` `⇧S`, `selectAll`, `extendSelection*`) switch their item source from
`getCurrentThreadList()` to the **unified list** when in the unibox. Bulk actions (`e`/`w`/`!` and
mark-done) iterate `bulkSelected` and dispatch per id via `inboxChannelById`: email → optimistic
Gmail action; channel → `inbox.setItemState`.

## Critical files

- [threadSlice.ts](../modules/threads/threadList/store/threadSlice.ts) — the model to mirror; `batchPopulateThreadMetadata`, `bulkSelected`, range-select.
- NEW `apps/mail/modules/inbox/store/inboxSlice.ts` — the channel slice + merged order.
- [use-inbox-items.ts](../modules/inbox/hooks/use-inbox-items.ts) — populate the slice from the query.
- [InboxList.tsx](../modules/inbox/components/InboxList.tsx) / [InboxRow.tsx](../modules/inbox/components/InboxRow.tsx) — render the merged order; selection UI on rows.
- [mail-list-hotkeys.tsx](../modules/threads/threadList/utils/mail-list-hotkeys.tsx) — unified selection + bulk dispatch.
- [feed.ts](../../server/src/services/inbox/feed.ts) / [feed-core.ts](../../server/src/services/inbox/feed-core.ts) — enrich email items with thread metadata.
- [use-inbox-item-actions.ts](../modules/inbox/hooks/use-inbox-item-actions.ts) — channel optimistic actions used by bulk dispatch.

## Phased implementation

- [x] **Phase 1 — inboxSlice foundation.** Add `inboxSlice` (maps + merged order + actions), register it in the store, and populate it from `use-inbox-items`. `InboxList` reads order/items from the slice instead of raw query data. No visual change. Verify the feed still renders across channels.
- [x] **Phase 2 — cross-channel selection.** Checkbox + click-to-select + selected background on `InboxRow`; wire `s` / `⇧S` / `⌘S` / select-all / extend to the unified list; `bulkSelected` spans channels. Verify range-select across a Slack + LinkedIn + email row.
- [x] **Phase 3 — bulk actions.** `e`/`w`/`!` and mark-done operate on the whole `bulkSelected`, dispatching per channel (email → Gmail optimistic, channel → setItemState), with the exit animation. Verify a mixed selection all marks done.
- [x] **Phase 4 — rich email rows.** Enrich `assembleInboxFeed` email items with thread metadata (`threadMeta`); `use-inbox-items` populates `threadSlice`; `InboxList` renders email ids via `<Thread>`. Email keyed by raw threadId (`inboxSelectionId`) so `<Thread>` shares one `bulkSelected` with channel rows. Verify email rows show labels/AOP/opens in the unibox.

### Known follow-up

The email row's **mouse checkbox** shift-click still ranges over the email `getCurrentThreadList()` (Thread's built-in handler), which the unibox doesn't populate — so in the unibox use the **keyboard** `s`/`⇧S` (unified) for email-row selection, or the channel-row checkboxes. Populating `currentThreadList` from the unified order (or overriding Thread's checkbox in the unibox) would close this; deferred to keep the email mail-list untouched.

## Verification steps

- `pnpm --filter mail types` and `pnpm --filter @zero/server types` clean after each phase.
- Feed renders all channels; scrolling/pagination still loads more.
- `s` selects the hovered row (any channel); `⇧S` range-selects across channels; `⌘S` toggles.
- A mixed selection (Slack + LinkedIn + email) marked done removes all and hides them server-side.
- Email rows in the unibox render labels/AOP and open the thread view.
- Existing tests: `feed-db.test.ts`, `EmptyStateSuggestions.test.tsx`, threadSlice/selection tests stay green.