format-inbox-count.tsfeat: stacked inbox view (design: stacked-inbox-design phase 4)
When inboxLayout === 'stacked' and the user is on /mail/inbox, replace
the per-inbox tab strip with a single synthetic [Agenda][Inbox] header
and render every configured inbox (Important, customs..., Other) as a
vertically stacked, collapsible section. Each section runs its own
listThreads infinite query via useInboxThreads(inbox, { pageSize: 25 })
and exposes its own prev/next paginator via usePageWindow — paging one
section doesn't disturb the others.
SYSTEM_INBOXES_BY_LAYOUT.stacked now mirrors important_other so both
system inboxes live in `inboxes` for the section iterator; routing for
the synthetic Inbox tab uses the existing /mail/inbox standard slug.
formatInboxCount moved to lib/format-inbox-count.ts so SplitInboxTabs
and InboxSection share one formatter.
No user-facing toggle yet — that lands in phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>May 24, 2026, 9:32 PMinbox-compiled-query.tsrefactor: unify inbox storage and retire split-definitions
Co-Authored-By: Codex Sonnet 4.6 (1M context) <<email>>
Co-authored-by: Cursor <<email>>Jun 1, 2026, 11:51 PMinbox-query-params.tsfeat(mail): CRM-filtered inboxes — AOP + conversation-field rules (design: pipeline-inbox-crm-stage-filter phases 1-4)
An inbox can now carry a `conversationFilter`: restrict `label:INBOX` to threads
whose linked CRM conversation matches an AOP + field rule. "In pipeline" becomes
a property of the deal rather than something an AI label re-derives per email.
The rule is structured, not a query token. It rides beside `compiledQuery` and
resolves through buildConversationFilterConditions — the SAME builder the
/conversations surface uses — so the two can never disagree about what a filter
means. The filter schema itself moved to lib/conversation-filter-schema.ts and is
now shared with crm.listConversations.
Routing safety is the load-bearing part: Gmail cannot answer these queries, but
the query text is an ordinary `label:INBOX`, so nothing downstream could tell.
`mirrorOnly` (derived from the filter's presence, not the parsed query) closes
all four places that would otherwise hand it to Gmail and silently return the
user's entire inbox — a wrong answer that looks right.
Two guards stop such an inbox from partitioning Inbox: alsoShowInImportant
defaults on, and buildImportantQueryExclusions unconditionally skips filtered
inboxes — without which the default inbox compiles to `label:INBOX -(label:INBOX)`
and renders empty.
Headless: `cedar-cli mail create-inbox --conversation-filter`, `inbox-count`, and
`load-inbox` (now prints linked conversation + stage). Verified end to end as
<email>: 148 threads, an exact thread-id set match against raw SQL,
zero overlap with the inverse filter.
Co-Authored-By: Claude Opus 5 <<email>>Aug 4, 2026, 7:04 PMroute-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 PMsplit-query-normalization.tsfix: stabilize split inbox routing and reminder state
Normalize split-inbox query behavior across client/server and apply optimistic reminder updates so thread state stays consistent while async reminder workflows complete.
Co-authored-by: Cursor <<email>>May 31, 2026, 8:22 PMsystem-folder-splits.tsfix: add conversation_id to structured logs for agenda access issues
When loading or saving conversation documents (agendas), permission errors
now include the conversation_id, user_id, org_id, and document path to help
debug access denied errors like the one Alessandro encountered.
- [getDoc] Add conversation_id to both by-id and by-lookup permission logs
- [updateHtmlContent] Add conversation_id to document save permission logs
- [applyUpdate] Extract and log conversation_ids from agenda task changes
This makes it much easier to diagnose permission issues when users get
"Conversation not found or access denied" errors on agenda documents.
Co-Authored-By: Claude Haiku 4.5 <<email>>May 26, 2026, 11:26 AMunread-filter.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 PM