use-channel-artifact-item.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 PMuse-channel-hotkey-actions.tsfix(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 PMuse-channel-reactions.tsfeat: Slack reactions reach the unibox (design: slack-parity phase 6) The doc's own §6 correction was right and the phase is smaller than it looks: `reaction_added`/`reaction_removed` have been ingested since `crm_event_reactions` landed, `crm.toggleReaction` already posts to Slack as the user, and the CONVERSATION timeline has rendered chips all along. The gap was one read and one row: `services/inbox/slack-messages.ts` never selected reactions, so the same message showed them on the deal timeline and not in the unibox. Reactions now come back on both Slack reads as a correlated aggregate — a join would multiply every message by its reaction count and make the reply rollup count each reply once per reaction — reduced by `aggregateEventReactions`, the same function `crm.getConversation` uses, so the two surfaces cannot disagree. `ChannelMessageList` draws the chips and the add affordance for both the channel stream and the thread panel. `useChannelReactions` writes through the SAME `crm.toggleReaction` mutation, and differs from `useToggleReaction` in exactly one respect: the optimistic state lands in the two react-query caches rather than in the conversation store, because a Slack channel need not be linked to a deal at all — which is the whole point of Phase 1. It patches both caches, since a root message is on screen twice when its thread is open. Three cases the chip has to survive, all pinned: a webhook-written reaction has no `emojiUnicode` and resolves through Slack's shortcode table; a workspace's custom emoji has no glyph anywhere and falls back to its name rather than rendering nothing; and a live-filled thread message has no `crm_events` row, so it gets no affordance instead of a dead one. Phase 4 needed no work: the "N replies" divider shipped on 2026-08-11 in c6d0201cc, the day before the brief was written. Verified rather than assumed — `channels messages` on #cedar-chat returns 11 roots carrying replyCount / lastReplyAt / replierUserIds, and `--thread <ts>` returns root + 2 replies through `inbox.slackThreadMessages`, which is the route the button opens. Headless surface: `cedar-cli channels react --event <id> --key <shortcode>` wraps the existing `crm.toggleReaction`. Proven live as <email> on #cedar-data-monitors: added 👀 (count 1, reactedByMe), read it back through `inbox.slackChannelMessages`, toggled it off again — Slack channel left as found. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 12, 2026, 12:42 AMuse-chat-reactions.tsfix(inbox): clear the composer between chats, and provision the columns the code needs Three from the Greptile review on #2513. The composer draft was the one piece of per-chat state not reset on the row change. This view stays mounted while you move between rows — `historyLimit`, `pending`, `openThreadTs` and the scroll anchor all reset on `item.id`, and `draft` did not. It backs the textarea's `value`, so an unsent reply to A stayed on screen when you opened B, `persistDraft` would save it as B's draft on blur, and the agent context published A's words beside B's chat id, label and conversation. No saved draft is ever read back into the composer, so clearing is the whole fix. `outbound-ddl.sql` was missing every column this branch added — `sender_provider_id`, `status`, `deleted`, `reactions` and `message_provider_urn` on the WhatsApp table, and `reactions` on the LinkedIn one. A fresh bootstrap therefore provisioned tables the chat read and the ingest cannot use. Greptile caught the identity column; the other six were already missing beside it. The reaction cache patch now writes only its own channel's query key. Patching both meant a click could touch a cached message in the other channel sharing a provider id, and the rollback captures a single `previous` — so a failed mutation could write one channel's aggregate onto the other's message. The hook is constructed with its channel; there was nothing to gain by guessing. Greptile's fourth note — relative imports in ReactionChip/ChangeCompanyEnrichmentDialog — is declined: every sibling in those directories imports the same way, and an absolute path in one file would read as the odd one out. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 6:44 PMuse-inbox-channel-connect.tsfeat: inbox — connect prompt for LinkedIn/WhatsApp when the account isn't linked Selecting the LinkedIn or WhatsApp filter with no connected account now shows a circular brand-mark connect prompt (mirroring the empty-email state) that kicks off the same Unipile hosted-auth flow as settings → connections, instead of a dead empty feed. Extracts the connect+poll flow into shared hooks so the settings cards and the inbox prompt share one implementation. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 28, 2026, 11:49 PMuse-inbox-item-actions.tsfix(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 PMuse-inbox-items.tsfix(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 AMuse-linkedin-sync-on-load.tsfix(inbox): Refresh works in the unified feed The refresh button called refetchThreads (mail.listThreads / the email thread store), which the unibox doesn't render from — so it did nothing in /inbox/<channel>. In the unified feed it now invalidates the inbox.listItems feed query AND dispatches an inbox:refresh event that the LinkedIn/WhatsApp sync hooks listen for to force a Unipile re-pull, so new channel messages surface too. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 29, 2026, 4:56 PMuse-open-channel-item.tsfix(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 PMuse-slack-item-avatars.tsfix(inbox): omni-channel feed fixes — mark-done tz, Slack mentions/DM, WhatsApp name+snippet, hotkeys, refresh - feed: coerce LinkedIn/WhatsApp last_message_at to timestamptz in the done-guard so marking done actually hides recently-active chats under non-UTC sessions - inbox rows: decode Slack @-mentions in the snippet, bold Slack channel name, show the person's name for Slack DMs, and slide/collapse rows out on action - hotkeys: e/w/! now act on the hovered unified-feed row (channel → mark done, email → real Gmail action + drop from feed); clear stale hover target after - LinkedIn/WhatsApp sync: re-fire on window focus (throttled) instead of once - WhatsApp: channel-dependent conversation filter (all chats on the dedicated tab, conversation-linked only in the unibox); persist display_name so rows show a real name instead of "WhatsApp contact"; un-gate snippet for linked chats - LinkedIn/WhatsApp: structured logging for the message-load / sync resolution so empty-message / unresolved-name cases are diagnosable in Axiom Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 29, 2026, 1:02 PMuse-slack-sync-on-load.tsfeat(inbox): retire the server-merged feed, and make one email read serve both lists (design: inbox-triage phases 8 + 0) Finishes Phase 8 and lands Phase 0. RETIRED — `inbox.listItems` and `assembleInboxFeed`, with `fetchEmailItems`, `inboxCompiledQuery`, the folder→query fallbacks and `resolveEmailLabelTerms`. feed.ts drops 875→500 lines and no longer reads mail at all. Its 4 test files are RETARGETED, not deleted: scope onto `participatingChannels` and the three sources' SQL, and the real-DB files onto the live `inbox.listChannelItems` route. TWO PAGING BUGS the server-merged feed was hiding, both found by retargeting those tests onto a single source — three others had been covering for each one: * `mergeAndPage` truncated a single-source feed at page two: the sources resume at `<= cursorSortedAt`, so every page re-read its boundary row and spent the `limit + 1` that proves "more remains". It now takes a REQUIRED `sourcesSaturated` — required so a new caller cannot silently reintroduce it. * The sources ordered by `last_message_at` alone while the pager tiebreaks by id, so a LIMIT landing mid-tie-band took an arbitrary subset and the skipped rows sorted ABOVE the minted cursor — gone for good. All six source queries now order `(last_message_at DESC, <itemId> COLLATE "C" DESC)`; the collation is load-bearing, since the default one folds case and ignores punctuation and would disagree with `compareItemsDesc`. Measured: three rows vanished from a paged walk of a real feed. PHASE 0 — one email read, one route resolver: * The `headChanged` latch is per-surface. Phase 8 gave `mail.listThreads` a second caller, so a single per-connection flag meant whichever surface read first consumed the signal. `markPendingHeadChange` fans out to every surface, because the reconcile dedup key carries none and the second reader routinely joins the first's in-flight run. * `use-route-inbox.ts` resolves the active inbox from the URL for BOTH lists; the persisted `settings.activeInboxId` fallback is gone. Closes the important/other and system-folder divergences as well. * `drafts.create` awaits `surfaceDraftInInbox`, and the label join row is born with its ordering key instead of being backfilled by a second statement. compose-feed becomes self-verifying now that its diff target is gone: ordered, unique, stable-across-paging, and watermark-respecting, checked over real paging rounds and each probed against a deliberately broken history. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:56 PMuse-whatsapp-sync-on-load.tsfix(inbox): Refresh works in the unified feed The refresh button called refetchThreads (mail.listThreads / the email thread store), which the unibox doesn't render from — so it did nothing in /inbox/<channel>. In the unified feed it now invalidates the inbox.listItems feed query AND dispatches an inbox:refresh event that the LinkedIn/WhatsApp sync hooks listen for to force a Unipile re-pull, so new channel messages surface too. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 29, 2026, 4:56 PM