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