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