searchSlice.tsrefactor: delete bridge & merge mailStore into cedarStoreFeb 26, 2026, 9:45 PMuse-inbox-compiled-queries.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-inboxes.tsfeat(onboarding): a setup flow that shows you the product while you configure it
Adds `/onboarding-setup` — a sidebar-driven flow over the four surfaces a new
user actually has to configure (Inbox, sub-inboxes, Tasks, Pipeline), each step
paired with a live preview of the real product rather than a screenshot of it.
Reachable from the profile menu as "Set up Cedar".
Two inbox-creation races surfaced by clicking through the template gallery are
fixed on the way, since the flow leans on that path hard:
- Per-mutation `invalidateQueries` lost rows. Create #1's refetch was issued
after #2 had written its optimistic row, so a server list predating #2 landed
last and overwrote it — the row vanished and the template card flipped back to
unselected. Now the last create to settle does the single refetch.
- Two adds fired back to back both read the cache before either had written to
it and claimed the same position, so a run of template clicks piled up at one
index and the tab strip reordered itself on refetch. Positions are now issued
from a carried-forward high-water mark.
A failed create drops only its own row instead of restoring a whole snapshot,
and `removeInbox` no longer swallows the rejection — callers holding their own
selected-state need to hear about it, or the row returns while the card stays
deselected.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 10:48 PMuse-prefetch-thread.tsfix: revert viraat mail changesApr 14, 2026, 2:00 PMuse-route-inbox.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-search-query-sync.tsfix(mail): a search that leaves the screen stops filtering the list
Isabelle's Email tab showed a full page of older mail with almost nothing
from the past week, while the same screen's All-channels badge showed her
whole inbox. It was not missing mail: the list was still filtered by a
`thiago` search she had run three hours earlier, and mail.listThreads
re-ran it 52 times on the 5-minute refetch interval with no search box on
screen to say so.
SearchInput held the only writer that resets searchState.value, and
mail.tsx mounts it only while `isSearchOpen || !!searchQuery`.
isSearchOpen resets to false on remount and ?search= is dropped by
navigation, so the input unmounts in the same commit as the param change
and its effect never runs for the new value — while useCedarStore, a
module singleton, keeps the compiled query. The mirror moves into
useSearchQuerySync(), called unconditionally next to the list it filters:
the URL owns the filter, the input only edits the URL.
Second defect from the same logs: her Starred split returned 0 rows on
every read (over-fetch-cap truncation) and stayed there. The server hands
back a short page plus a usable cursor for exactly that case, but MailList
renders the inbox-zero state when the list is empty, so the VList never
mounts and the auto-load effect bailed on its null-ref guard before it
could page. An empty list with hasNextPage now pages ahead of that guard,
and spins rather than announcing an inbox zero it cannot yet claim.
Report: apps/mail/docs/bug-email-tab-shows-few-threads.md
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 12:47 PMuse-search-value.tsrefactor: delete bridge & merge mailStore into cedarStoreFeb 26, 2026, 9:45 PMuse-unread-filter.tsfeat(mail): ⇧U filters the inbox you are on to unread, on every channel
Superhuman's split-inbox filter, with the difference that matters here: Cedar's
inbox is omni-channel, so the filter has to hold for email, LinkedIn, WhatsApp
and Slack at once, or the one merged list would say "Unread" while carrying read
chats. Email narrows through an `is:unread` clause on the compiled query; the
chat channels through their own read state, with the `cedar_inbox_item_state`
override included — the SQL twin of the overlay applied after the read, so a chat
the rep marked unread by hand survives the filter instead of vanishing while its
badge says unread.
The filtered list is a new cache key, so it is seeded with the unread subset of
what is already cached: the filter lands on the next frame rather than a round
trip, and the server's page — which reaches further back than the loaded window —
only adds to it. That placeholder may only under-report (a row whose read state
cannot be determined is dropped, never kept), and an empty one spins rather than
claiming an inbox zero the server is about to contradict.
A filtered inbox looks exactly like an empty one, so the tab wears a clickable
"Unread" badge, the counts follow the filtered query under their own hash, and
both empty states name the filter instead of congratulating the user on mail they
have merely hidden.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 20, 2026, 11:25 PM