channelAttachments.test.tsxfix(inbox): show Slack file attachments in the unibox, not just on the deal
The slack-images work called the render half done, and for the conversation
timeline it was. But the timeline is not where people read Slack — the unibox
channel view is, and it drew no attachment at all. A customer's screenshot
appeared on the deal and vanished on the screen you actually open.
Slack's own vocabulary is what hid it. crm_slack_messages carries TWO columns a
reader could reasonably call attachments: `attachments`, the link-unfurl array,
which the unibox has selected since slack-parity Phase 2, and `attachment_refs`,
the real uploads. The channel read selected the first and not the second, so
files looked covered. They were not.
The presign moves out of attachSlackAttachmentUrls into slack-attachment-urls.ts
for the reason the gap existed at all: the loop lived inside conversations.ts, so
the second read had nothing to reach for and shipped without one. One
implementation, so the next surface cannot inherit a read that silently drops
files — the same argument getProxiedUrl made on the frontend.
A live-filled thread returns an empty ref list rather than forwarding Slack's own
url_private. That URL needs our token, so handing it to a browser renders a
BROKEN image where "not ingested yet" is the honest answer.
This renders nothing on its own, and that is worth saying plainly: there are
still ZERO stored refs (472 flagged messages on jesse's org, measured today),
because Phase 2's reconnect is gated on declaring files:read in the production
app's manifest. Both halves are required, and each one alone looks exactly like
the other being broken — which is how a year of missing screenshots read as one
problem instead of two.
Tests: 6 on the presign (jsonb that is not an array reads as no attachments — the
double-encoded write Phase 2 caught, from the reader's side; a failed presign
degrades to a chip while its neighbours still sign) and 4 on the row, confirmed
to fail with the render reverted, which is the only way to know a test of "it
shows up" is testing anything.
design: apps/server/docs/slack-images.md phase 5
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 3:35 PMchannelFilterAvailability.test.tsxfeat(inbox,two-factor,marketing): channel filter on query tabs, 2FA policy, host routing
Three in-flight threads plus supporting docs, committed together.
- inbox: the channel badge no longer goes empty on a Gmail-query tab —
participatingChannels stops dropping chat channels when the tab rule is
mailOnly, so an explicit single-channel badge asks the sources it names.
- two-factor: policy module with its own tests, wired through session
verification and auth, with the env and runtime-contract keys it needs.
- marketing: host routing for the signed-out root, with a redirect test.
- docs: inbox channel-filter bug writeup, daily agenda templates,
open-tracking v2 notes, and the 2026-08-30 customer feedback audit.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 11:53 AMchannelKeyCollision.test.tsfeat(inbox): compose the unified feed in the client (design: inbox-triage phase 8)
The unibox is now assembled in the browser from one query per channel instead of
one server-merged page, so a `mail.listThreads` refetch updates its email rows
without re-fetching Slack, LinkedIn and WhatsApp alongside them.
- inboxSlice holds per-channel `ChannelFeedSlice`s; `selectMergedFeed` runs the
shared `mergeChannelFeeds` watermark. `setChannelFeeds` writes the whole map at
ONE commit point: `setInboxFeed` reconciled `focusedIndex`, and four queries
settling independently would have jittered the keyboard cursor once per settle.
Lookup selectors keep every loaded row, so a deep link below the watermark still
resolves.
- useInboxItems issues four gated infinite queries and keeps its public shape.
`fetchNextPage` pages ONLY the gating channels — fetching any other loads rows
that stay held. Participation comes from `inbox.getFeedScope`, already resolved
server-side; the client re-derives nothing.
- `linkedOnly` reaches `listChannelItems` so the dedicated Slack/WhatsApp tabs keep
showing unlinked containers. Hard-coding the unified feed's semantic had emptied
them.
Verified headlessly as the operator against this checkout (:8790) with
`mail-admin compose-feed`, over the plain unibox, a mail-only split and a
CRM-filtered inbox: no server row lost, correct ordering, and the CRM rule
narrowing every channel without ever reaching the client.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 4:02 PMchannelReactions.test.tsxfeat(inbox): reactions on LinkedIn and WhatsApp, same UI as Slack
Slack had the floating toolbar, the chips and the ingest; LinkedIn and WhatsApp
had none of it — their messages rendered with no reaction row at all, and the
thread view gated the handlers on `ref.kind === 'slack'`.
Both providers do support reactions. Verified against the live Unipile API
rather than the docs, which describe only the write route: a message carries
`reactions: [{ value, is_sender, sender_id, sender_attendee_id }]` on LinkedIn
and WhatsApp alike, and `POST /messages/{id}/reaction { reaction }` writes one.
Two things differ from Slack, and both are the provider's model:
- the key is the NATIVE EMOJI, not a shortcode — there are no custom emoji to
name — so `key` and `emojiUnicode` are both the glyph;
- ONE reaction per person per message. Reacting again replaces rather than
accumulates, so the toggle sets or clears, and the optimistic write moves a
reaction between chips instead of adding a second.
What does NOT differ is anything the user sees: `aggregateChannelReactions`
returns the same `EventReaction[]` Slack's read produces, so the same
ChannelMessageList, the same floating toolbar and the same ReactionChip render
all three channels.
Storage follows each channel's read, since a mirror can only be staler than the
source it is read from:
- LinkedIn's chat read is `crm_linkedin_messages`, so reactions are stored on
the row (new `reactions` jsonb, additive migration applied to the shared DB)
and refreshed by the chat sync — the catch-up that already runs on open.
They cannot ride on the insert alone: reactions change after a message is
stored, which no other column here does, and the upsert is deliberately DO
NOTHING, so the replay path refreshes them with its own statement.
- WhatsApp's chat read goes straight to Unipile, which returns the reactor
list inline, so its DTO carries the aggregate and needs no column.
Also fixes a WhatsApp bug this surfaced: the thread view read `direction` /
`occurredAt`, which are LinkedIn's spellings — WhatsApp's live DTO says
`fromSelf` / `at`, so every WhatsApp message rendered as inbound with no
timestamp, and with no date to divide on, no day pills either.Aug 15, 2026, 11:24 PMchannelThreadRemind.test.tsxMerge origin/staging into fix/dm-container-kind
22 conflicted files. Most were unions — two sides adding different imports,
exports, wiki entries or allowed-import patterns. Four were competing designs,
resolved on evidence rather than preference:
* New chat (LeftSidebarContent) — staging. `fbca03d0d` (Aug 30) makes a new chat
the DRAFT chat, with no thread id to mint; it retires this branch's Aug 26
`goHomeToNewChat` + `findOpenEmptyChat` reuse by name, solving the same
pile-up more fundamentally.
* `permissions:` -> allowedSkills (playbook-execution-triggers) — HEAD. Staging
threads `subagent.permissions` into `allowedSkills`; its OWN comments say that
value is never enforced ("nothing sets this key today", automation-agent.ts:
"a scoping feature that has never been enforced"). `77ecfb397` audited the
data before deleting it: of 768 subagent docs, 21 declare the key, 19 are
no-ops, and the 2 with real values name four skills that do not exist — wiring
it on would have broken both agents and changed nothing for the rest. Staging's
parsedByRefId restructure is kept; only the dead feed is dropped.
* Before-meeting preps (calendar-events) — composed. This branch's
`selectBeforeMeetingConfigs` (shared with the prep-status read model so the
"wrong stage" chip cannot contradict the code that skipped it) plus staging's
meeting-moved re-arm. Staging's inline stage filter is dropped: identical
semantics, and the loop below already reads `schedulableConfigs`.
* Settings/general — composed. This branch's form-kit row layout plus staging's
2FA card, ported onto SettingsSection. Staging's Cache card is the pre-kit copy
of a section this page already carries.
Agent tool scoping was two independently-built narrowings of one permission
surface (`narrowSkillToolsToAgentScope` here, `withoutTools(deniedToolIds)` on
staging); both are restrictions, so both are applied. MCP connections was the
same shape: staging's extracted `createMcpConnection` owns the write, extended
to carry this branch's `requiresAgentGrant` and setup-time tool policy.
Fallout the tests caught, all cross-cutting rather than in any conflict:
`optionColor` moved to components/ui/option-picker (board still imported the old
path); `encryptCredential` dropped from an import line still used by
updateMcpConnection; the drafter rename left a stale slug in EXPECTED_FOLDER;
and staging's new remind test matched two options once this branch's "at 8am"
suggestion existed.
pnpm run autofix:local green: oxlint, deps:check, 3741 Jest, 9671 Vitest.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 1:56 AMchatMessageOrdering.test.tsfix(inbox): four defects in the LinkedIn thread, one per report
1. OUR OWN MESSAGE RENDERED UNDER THE PROSPECT'S NAME. Unipile reports
`is_sender: 0` for a message Cedar had just sent, with a numeric `sender_id`
(77358347) rather than the seat's member urn — so the chat sync stored the
reply as INBOUND. Verified against the live API, not inferred. `stampOutbound`
now upserts DO UPDATE instead of DO NOTHING: whatever the provider says, a row
we are stamping is one we sent, so if the sync got there first it is corrected.
The one row already in this state was repaired.
2. THE DAY PILL SAT ABOVE THE WRONG MESSAGE. The thread sorted on
`String(occurredAt)`, and superjson revives drizzle timestamps as `Date`, so
`String()` produced "Sun Aug 16 2026 13:31:21 GMT-0700" — the thread was
ordered BY WEEKDAY NAME (Fri, Mon, Sat, Sun, Thu, Tue, Wed). Invisible in the
types, which declared `string`, and plausible-looking most days. Now compared
as epoch ms, with the type admitting both shapes.
3. Padding under the day badge: `py-3` → `pt-3 pb-1.5`. The pill belongs to the
messages below it and should sit closer to them than to the run it ended.
4. Sending waited on a Unipile round-trip plus our own ingest before the message
appeared — about a second, long enough to read as dropped. It now renders
immediately as a `LooseMessage`, so it flows through the same sort, day
divider and authorship mapping as a real one, and drops out when the server
hands the same text back. Slack is excluded deliberately: its rows are keyed
by `crm_events.id`, and an invented one would be a message whose reaction
toggle addresses nothing.Aug 16, 2026, 1:55 PMchatReactions.test.tsfix(whatsapp): one id namespace for messages, and reactions that survive the read
`crm_whatsapp_messages.message_provider_id` was filled from two id namespaces — Unipile's
`message_id` over the webhook, `m.provider_id ?? m.id` over the REST sync — so one physical
message arriving by both routes became two rows. The identical defect Phase 5 fixed for
LinkedIn (53e1afa01); it was simply never carried across. Measured before the flip: 203 of 216
rows keyed by WhatsApp's native id, only 13 by Unipile's, with 13 duplicate groups already
formed and 2 chats carrying both namespaces at once.
Unipile's id wins for the reason it won on LinkedIn: `message_received` carries no
WhatsApp-native message id at all, so keying on the native id would reject every webhook
message. An id present on both paths beats a better id present on one. The native id survives
as `message_provider_urn`, learned late and guarded on IS NULL.
NO DISCRIMINATOR EXISTS, so the re-key does not use one. `3EB054F21AE14AF46B8B32` is native and
22 characters, exactly as long as Unipile's `Np5fFtqPU_qPoX-ydwdLRA` — LinkedIn could pre-filter
on `2-%`, and any equivalent regex here would be a guess that re-keys a correct row onto
nothing. `rekey-whatsapp-ids` maps via the REST object carrying both ids: a row is natively
keyed iff its key appears as a `provider_id` in its own chat's history.
RE-KEY RAN BEFORE THIS LANDS, as a precondition rather than cleanup — the corrected sync meeting
a natively-keyed row inserts a SECOND row under the new key. Live: 190 re-keyed, 13 merged into
their twin, 0 unmapped, 0 unreachable; a second run finds nothing. After: 203 rows, one
namespace, 203 distinct keys, 0 orphaned events.
This is also what settles the reaction write. `POST /messages/{id}/reaction` takes Unipile's id,
which LinkedIn already sent and WhatsApp did not — the two callers now agree.
Three defects the mirror-first read (70e6518ee) introduced by landing on top of the reactions
commit (373e16636), which had assumed the WhatsApp read still went straight to Unipile:
- Reactions never survived a refetch. `reactToMessage` wrote nothing locally on the grounds that
the next read came from the provider; it now patches the mirror and returns the aggregate, as
LinkedIn's toggle does.
- The optimistic cache write never matched a mirrored LinkedIn message: it keyed on `id` (a
Cedar uuid there) while the write path addresses messages by `providerMessageId`. Extracted as
`matchesMessage` with the three read shapes under test.
- Both mirrored reads returned the OLDEST N messages — `orderBy(asc).limit()` — so a chat with
history opened on its beginning and never reached today. Same correction `hydrateSlackThread`
already carries.
Also: `parseWebhookEvents` raises on a missing `message_id` instead of `continue`-ing, so the
batch settles transient and Unipile redelivers; silence is what let two namespaces coexist
unnoticed.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 3:33 PMfeedPartialFailure.test.tsxfix(inbox): stop offering a next page no channel can serve
The second half of Greptile's "failed slices retain stale data".
A channel that fails on a LATER fetch keeps its loaded rows — react-query
retains the last good data — so it stays in the merge and can still be the one
setting the watermark. Paging it is then the only thing that would release the
rows held beneath it, and paging it fails. The feed went on advertising
`hasNextPage`, so "Load more" was a button that silently did nothing on every
press.
When every channel the next page would come from has failed, say there is no
more. The notice above the list explains why, and Retry is the control that can
actually change the answer.
Keeping the rows and letting them bound the merge is deliberate, and stays:
dropping rows we successfully fetched would lose the rep real data over a
transient blip, and releasing the held rows early would let a successful retry
interleave rows above ones already scrolled past — the exact reordering the
watermark exists to prevent.
The page-target list is now computed once and shared with `fetchNextPage`
instead of being derived twice from the same inputs.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 1:52 AMfeedScopeGate.test.tsxfix(tests): types:test reaches zero in both packages
The leftovers, and three of them were assertions or props that could not work.
DEAD SHAPES. `model` is retired from `AgentSummary` (797 of 896 production docs
said `sonnet` and the hardcoded per-tool constants were what ran), yet two
fixtures still set it. `summary` is not an `AgentSharePanel` prop — it takes
`{ agentId, className }`. `onAddTaskToGroup` is no longer on
`AgendaTaskCallbacks`, and the mocks type is a mapped type OVER that interface,
so the extra key was the one thing it could not carry.
GROWN TYPES. `AgentSummary` gained `namespace` — where the agent's FOLDER lives,
which is a different question from `scope`, where its document lives — plus
`defaultFile` and `chatEnabled`.
NARROWING, NOT CASTING. `closest()` answers `Element | null` and `within()` wants
an `HTMLElement`: the non-null `!` covered the null and not the widening, so it
is an `instanceof` check now and a card whose text escaped its container fails
loudly. Same for `PAGE[0].createdAt` and the reference-lookup input.
MOCKS FROM THE REAL THING. `applyUpdate` carries `ApplyUpdateClient`'s signature,
so `mock.calls[1][0].update` — the bytes those cases exist to prove were re-sent
— is a real read rather than an out-of-range one on an empty tuple. Likewise
`logToolCall` (six arguments, five destructured), `createPlaybookExtensions` and
`useSubagentCreation`, whose option types come off the hook rather than being
exported for a test's benefit. Four `as unknown as` casts go with them.
`feedScopeGate`'s `ALL` lost its `as const`: nothing read it in a type position,
and the readonly tuple could not be the mutable `string[]` the resolved
participation set arrives as.
Also `shareDocumentTool.test.ts`, new this morning, through the same two helpers
the rest of the server already uses — `toolOutput()` for the `ValidationError`
arm and one named boundary for Mastra's request context.
apps/mail 19 -> 0, apps/server 11 -> 0. Both `types` stay clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 9:39 PMhotkeyTargets.test.tsfix(unibox): a chat id the feed forgot still isn't a Gmail thread
`splitHotkeyTargets` decided what a selection id was by asking the loaded feed, and read a
lookup MISS as "email". But selection and hover state outlive a feed replacement, so a miss
is an ordinary event — and on one, a `li:`/`wa:`/`slack:` id went to the Gmail driver, which
is precisely the modify-a-thread-that-does-not-exist this module exists to prevent.
The prefix is structural (`services/inbox/feed-core.ts` mints it) and an email row's id is a
bare Gmail thread id, which is hex and cannot hold a colon — so the prefix answers the
question without the feed. An id that is a chat but resolves to nothing is now dropped from
both halves: there is no row to act on, and no driver that could act on it.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 4:40 PMmergeWatermark.test.tsfeat(inbox): compose the unified feed in the client (design: inbox-triage phase 8)
The unibox is now assembled in the browser from one query per channel instead of
one server-merged page, so a `mail.listThreads` refetch updates its email rows
without re-fetching Slack, LinkedIn and WhatsApp alongside them.
- inboxSlice holds per-channel `ChannelFeedSlice`s; `selectMergedFeed` runs the
shared `mergeChannelFeeds` watermark. `setChannelFeeds` writes the whole map at
ONE commit point: `setInboxFeed` reconciled `focusedIndex`, and four queries
settling independently would have jittered the keyboard cursor once per settle.
Lookup selectors keep every loaded row, so a deep link below the watermark still
resolves.
- useInboxItems issues four gated infinite queries and keeps its public shape.
`fetchNextPage` pages ONLY the gating channels — fetching any other loads rows
that stay held. Participation comes from `inbox.getFeedScope`, already resolved
server-side; the client re-derives nothing.
- `linkedOnly` reaches `listChannelItems` so the dedicated Slack/WhatsApp tabs keep
showing unlinked containers. Hard-coding the unified feed's semantic had emptied
them.
Verified headlessly as the operator against this checkout (:8790) with
`mail-admin compose-feed`, over the plain unibox, a mail-only split and a
CRM-filtered inbox: no server row lost, correct ordering, and the CRM rule
narrowing every channel without ever reaching the client.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 4:02 PMopenChannelItem.test.tsxfix(inbox): give every channel chat an artifact, and an address
Clicking a LinkedIn row left the URL untouched and the panel beside it empty. One
cause: openChannelChat set a display artifact only for Slack. The artifact is not
just the address a URL is projected from — it is what artifactToContext reads to
pick the surface for the chat column — so LinkedIn and WhatsApp opened with the
mail surface beside them and the counterpart card had no slot to render into.
All three channels now set one, and LayoutUrlSync's Slack block is extracted into
a hook called three times: ?slack=, ?linkedin=, ?whatsapp=. One copy of the ref
handshake instead of three.
This makes a LinkedIn chat take the single display slot and close on the back
button, exactly as Slack always has. Three tests pinned the old asymmetry and now
pin the new symmetry.
Presentation, from looking at real profiles:
- the unlinked "No conversation linked" block is suppressed here; for a DM that
will never be a deal it is a permanent no above the only thing this surface has
to say
- the role shown as current no longer repeats as the first past role
- Past is a timeline, with consecutive roles at one company grouped, so a
promotion reads as one job rather than three
- posts are a carousel, fitting all five in one row's height
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 4:26 PMscrollAnchor.test.tsfix: address review findings on the Slack parity branch
Five findings from reviewing this branch, plus two the fixes uncovered.
1) SCROLL ANCHORING (blocking). The scroll-load shipped without it. Older
messages are PREPENDED, so the content above the viewport grew while scrollTop
stayed put: the message being read slid down the page, the reader was left at the
top again, and the next scroll event re-triggered the load — chaining to the
1000-row ceiling and then calling Slack once per scroll tick. The layout effect
now adds the height delta back when not pinned to the bottom, and resets its
baseline when the opened row changes. `useLayoutEffect` so the correction lands
before paint. Pinned by tests on the arithmetic (jsdom reports every height as 0,
so the component itself cannot exercise it).
2) CROSS-TENANT CURSOR WRITE. The Slack history cursor matched on
`(channel, external_id)` with an `OR account_ref IS NULL` fallback and no org.
Two orgs in one Slack Connect channel hold two containers with the same
external_id, so org A's backfill could stamp `history_complete` on org B's — the
health metric telling B its history had been walked when nobody had. Now keyed on
the container id the caller resolved from its own org. The LinkedIn equivalent
already scoped by org; the inconsistency was the tell.
3) LINKING LEFT A TEAMMATE WITH AN EMPTY TIMELINE. While a channel is unlinked
`projectMessageToLinkedMembers` gives events to exactly one member — it projects
only to members whose link row already carries a conversation, and an unlinked
container has none. So the UPDATE that claims unattributed events found nothing
for anyone else: a teammate linked the channel and got a blank deal timeline
beside a unibox full of messages. Linking now CREATES the events that member
never had, which is also what keeps `projectMessageToLinkedMembers`' invariant
intact — an event still means "this is on my deal", never "I can see this
channel", so a mere syncer still gets nothing.
4) BACKFILL DIDN'T REQUIRE MEMBERSHIP. `getContainerReport` resolves any
container in the caller's org, which is right for a report and wrong for an
action. `catchUp` has always required a link row; `backfill` now does too, via a
new `isMember` on the report.
5) A dead widen-branch for LinkedIn in `loadOlder`, and query objects in a dep
array that rebuilt the scroll handler every render.
Two the fixes surfaced:
THE ATTACH COULD FAIL THE LINK. It shared the outer catch, so any failure —
a missing table in a test harness, a lock timeout in production — returned
`updated: false`, which the route turns into "No link row for this container" and
a rep reads as "my channel did not link". It did link. The attach now has its own
try: degrade to "linked, history not attached", loudly. This is what made
`linking-create.test.ts` fail mysteriously rather than obviously, and it would
have done the same to a rep.
A TEST THAT PINNED AN UNREACHABLE STATE. `leaves a teammate's events alone`
manufactured a teammate event that production never creates for an unlinked
container, so it passed while describing a state that cannot arise. Replaced with
three that cover the real shape, including idempotency — the link sweep re-runs
the ladder, so a second pass must not double every timeline. The harness gained
the partial unique index and `occurred_at` column production has; without them
the ON CONFLICT and the INSERT fail at the statement, not the assertion.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 12, 2026, 3:34 PMslackChannelHint.test.tsfeat(inbox): compose the unified feed in the client (design: inbox-triage phase 8)
The unibox is now assembled in the browser from one query per channel instead of
one server-merged page, so a `mail.listThreads` refetch updates its email rows
without re-fetching Slack, LinkedIn and WhatsApp alongside them.
- inboxSlice holds per-channel `ChannelFeedSlice`s; `selectMergedFeed` runs the
shared `mergeChannelFeeds` watermark. `setChannelFeeds` writes the whole map at
ONE commit point: `setInboxFeed` reconciled `focusedIndex`, and four queries
settling independently would have jittered the keyboard cursor once per settle.
Lookup selectors keep every loaded row, so a deep link below the watermark still
resolves.
- useInboxItems issues four gated infinite queries and keeps its public shape.
`fetchNextPage` pages ONLY the gating channels — fetching any other loads rows
that stay held. Participation comes from `inbox.getFeedScope`, already resolved
server-side; the client re-derives nothing.
- `linkedOnly` reaches `listChannelItems` so the dedicated Slack/WhatsApp tabs keep
showing unlinked containers. Hard-coding the unified feed's semantic had emptied
them.
Verified headlessly as the operator against this checkout (:8790) with
`mail-admin compose-feed`, over the plain unibox, a mail-only split and a
CRM-filtered inbox: no server row lost, correct ordering, and the CRM rule
narrowing every channel without ever reaching the client.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 4:02 PMuniboxHotkeys.test.tsxfix(unibox): a chat row that answers the keyboard, and an R that actually drafts
R reported "No emails to select" on a LinkedIn or Slack row, and on an email row
it opened the thread and left no composer. Two bugs behind one key.
The list handlers resolved their target from a ref that only ever holds an EMAIL
id, so a hovered chat row was invisible to every one of them — and anything
reached through the bulk selection instead handed `li:<chatId>` to the Gmail
driver, which issued modifies for thread ids Gmail has never heard of (x, u, i,
h, #). So the handlers now resolve ONE target set and split it: Gmail ids down
the optimistic email path, chat rows to the channel path, both halves every
time. That deletes actOnSingleInboxId / handleHoveredInboxItem /
bulkActOnSelectedInbox along with their dropFromFeed calls for email, which
stopped matching anything when the feed moved to the client.
r/⇧R open the chat, which is where its composer takes focus. The opener moved
to module state because MailListHotkeys is mounted at the root and can never be
handed openChannel as a prop; the row click goes through the same function.
Spam/trash/archive collapse into Mark done, i stars (a chat row has one flag), h
snoozes, and f says it cannot rather than doing something adjacent.
The email half: createDraftInThread needs the thread's messages, and a row you
press R on has not fetched its body yet — so the first pass fails by design and
the whole thing rested on a retry that did not exist. The effect depended on
threadId and a callback built from stable store actions, so nothing changed when
mail.get landed. It now retries on the messages arriving, attempts once, and
clears either way so a stuck action cannot fire in the next thread opened.
Also: markChannelRead clears the `u` override as well as marking the source
read, or a row marked unread by hand re-bolds on the refetch that call itself
schedules. And the seven `as 'linkedin' | 'whatsapp' | 'slack'` casts became a
real narrowing.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 2:12 PMunifiedInboxOrder.test.tsfeat(inbox): compose the unified feed in the client (design: inbox-triage phase 8)
The unibox is now assembled in the browser from one query per channel instead of
one server-merged page, so a `mail.listThreads` refetch updates its email rows
without re-fetching Slack, LinkedIn and WhatsApp alongside them.
- inboxSlice holds per-channel `ChannelFeedSlice`s; `selectMergedFeed` runs the
shared `mergeChannelFeeds` watermark. `setChannelFeeds` writes the whole map at
ONE commit point: `setInboxFeed` reconciled `focusedIndex`, and four queries
settling independently would have jittered the keyboard cursor once per settle.
Lookup selectors keep every loaded row, so a deep link below the watermark still
resolves.
- useInboxItems issues four gated infinite queries and keeps its public shape.
`fetchNextPage` pages ONLY the gating channels — fetching any other loads rows
that stay held. Participation comes from `inbox.getFeedScope`, already resolved
server-side; the client re-derives nothing.
- `linkedOnly` reaches `listChannelItems` so the dedicated Slack/WhatsApp tabs keep
showing unlinked containers. Hard-coding the unified feed's semantic had emptied
them.
Verified headlessly as the operator against this checkout (:8790) with
`mail-admin compose-feed`, over the plain unibox, a mail-only split and a
CRM-filtered inbox: no server row lost, correct ordering, and the CRM rule
narrowing every channel without ever reaching the client.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 4:02 PM