date-groups.test.tsfix(mail): review fixes — schedule rollback, slot cap, scheduled-index prune, nits Addresses code-review findings on this branch: - schedule: an ambiguously-failed enqueue/park (timeout after the broker accepted it) could still fire after scheduleMail returned {success:false}. rollback now CANCELS (status 'cancelled' + payload/scheduled dropped) instead of just deleting status, so a secretly-committed send can't surprise-deliver. - agent-execution-slot: transfer the permit on release instead of decrement-then-reacquire, closing a microtask race where a fresh caller and a woken waiter both claimed one freed slot and pushed active above MAX (regression test proven to fail on the old code). - listScheduled: extract the index prune into reconcileScheduledIndex, run the per-key status reconcile concurrently per page, and cover it with tests (pending/pruned/malformed/sort). Nits: buildSnippet truncates before escaping (no bisected entities); drafting Level-2 recovery retry regains additionalLabelNames parity; the missing-reply- chain warning routes through console.warn; Draft gains dateOverride / disableSelection / disableOpen so scheduled rows show sendAt, hide the dead checkbox, and no-op threadless clicks; Cmd+Shift+L opens the same send-later dialog as the button. Adds a useLocalDayStart midnight-rollover test. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 22, 2026, 11:20 PMinbox-compiled-query.test.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 PMinbox-conversation-filter.test.tsxfix(test): the filter builder's Status column is labelled Stage now Left behind by the rename in a7e49aba6: the test drives the real filter UI, so it queries the column by its visible label. The column id is still `status` — which is what the assertions below read — only the label moved. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 18, 2026, 12:01 AMinbox-landing-redirect.test.tsxfix(mail): landing on /mail picks the first inbox tab, not the `inbox` slug The redirect hard-coded /mail/inbox, but only the default layout has an inbox on that slug. Under important/other the URL resolved to no inbox at all, so the tab strip highlighted Important from its activeInbox fallback while the all-channels feed got no inboxId and ran unscoped. Forward to the first tab in row order instead, carrying the query string so ?channel= and ?threadOpen= survive. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 20, 2026, 11:04 PMinbox-section.test.tsxfeat: move section total count next to pager ("1-25 of 74") Drops the unread-count badge from beside the inbox name and folds the total into the pager on the right: "1-25 of 74 < >". When the section is empty the whole right side collapses to "0". When the count isn't known yet we still show the range without "of N". Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>May 24, 2026, 10:48 PMmail-navigation-escape.test.tsxfix(pipeline,inbox): filters that survive a refresh, and a lost deal that leaves the board Three reported bugs, two of them the same mechanism seen from different ends. A rejected viewConfig save was invisible. Every filter control fires `void saveCanvasViewConfig(id)`, so a rejection had no observer at all: no toast, no retry, and a local config the user believed was stored. `dirtyCanvasIds` kept the edit on screen — but that Set was never persisted while `canvasesById` was, so a reload came back with the change still in localStorage and nothing marking it unsaved. The server's pre-save row then won rehydration, which is why this read as "my pipeline filters don't survive a refresh" rather than as a write that failed. The save now reports failure and resolves false instead of rejecting, so all ~20 `void` call sites are covered without touching them; the dirty set persists, so the guard already in upsertCanvas holds across the reload it was being thrown away by; and CanvasHeader stops flashing "Saved" over a failure. Saves are also chained per canvas. Working through a filter popover fired several updateCanvas mutations at one row concurrently, so the last RESPONSE to land, not the last edit made, decided what the server kept. Closed Lost deals stayed in the pipeline because status is filtered SERVER-side: a page's membership is fixed when it is fetched, and marking a deal Closed Lost writes to the store, never to that page. The status/priority change now invalidates listConversations — it was the only successful-update path that never did — and the fetched rows are re-checked against the optimistic value, so the row leaves in the same frame as the click and the refetch merely agrees. That check is one shared predicate now; the kanban had its own copy, which is why two views of one pipeline disagreed about what a stage filter admits. Escape clears the inbox filters, in a second stage. Selection first, filters only once nothing is selected — a single stage would throw a filter set away on every ordinary deselect. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 29, 2026, 4:06 PMpendingComposeAction.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 PMreminder-detection.test.tsfeat: improve reminder scheduling and mail debugging reliability Expand reminder and scheduled-send coverage while adding Gmail query diagnostics and frontend debugger support to make delivery and sync issues easier to detect. Co-authored-by: Cursor <<email>>May 21, 2026, 6:20 PMrender-labels-compat.test.tsxfeat: replace /agentOperatingProcedures with Brain playbooks experience Move AOP configuration into the Brain so the standalone /agentOperatingProcedures route can be retired. - Remove the /agentOperatingProcedures route + page; extract its shared icon and field-editor exports into modules/aop (aop-icons + components) and repoint all importers - Add /brain home (recents with paths, suggestions sidepanel with a collapsed floating chat) and /brain/playbooks routing screen: per-AOP colour-coded cards with live User/Org file trees, AI-executions switch that seeds the playbook directory, editable selection criteria, and create/delete/identity management - Add a playbook Settings tab (active, multi-conversation scope, selection criteria) and a special playbook breadcrumb back to /brain/playbooks - Drop the Agent nav button, move Brain below Calendar - Add private aop.seedPlaybookFiles; remove the CRM Updater opportunity-creation UI Co-Authored-By: Claude Opus 4.8 <<email>>Jun 15, 2026, 9:06 PMsplit-inbox-templates.test.tsxfix(mail): the canonical names the page you are on, and robots keeps its Disallows Review fixes for the four commits on this branch. The SEO half of the marketing commit had two real defects. Root's new `rel=canonical` was a bare origin, and React Router does not merge meta up the tree — so every route without its own `meta` export (/pricing, /legal/*, /roadmap, /hr) told crawlers it was a duplicate of the homepage, while /pricing sat in the sitemap the same build emits. It now names the path the visitor is on, through a shared `canonicalUrl` helper that /blog, /blog/:slug and /bookdemo use too — the blog pair had been pointing at the app host, which after the cutover is a URL that redirects. robots.txt carried a blank line between `Allow: /` and the new Disallow block. A blank line ends a record, so under the original convention every one of those rules belonged to no user-agent at all — the authenticated app would have stayed crawlable, which is the whole thing the block exists to prevent. The rest is contract and cleanup: `unreadClause` had been inserted between `splitClause`'s doc and `splitClause`, leaving each function carrying the other's contract; the Unread badge dropped `role="button"` (a button inside a button in the accessibility tree, and unreachable by keyboard anyway) and moved to the `text-xxs` utility; a Next.js `'use client'` directive left a React Router route; `onSubmitted`, the exported `CALENDLY_URL` and a comment for a deleted dialog went with it; and ~30 stray blank lines came out, two of which had split JSDoc blocks mid-sentence. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 20, 2026, 11:47 PMsplit-routing.test.tsxfix: align split-routing and agenda drag tests with current UI Co-authored-by: Cursor <<email>>Jul 2, 2026, 10:57 AMstacked-inbox-parallel.test.tsxfix(mail): stop provider failures blanking threads, losing sends, and hiding mail Five defects traced from the Pirros onboarding (2026-08-03/04) and Isabelle's staging inbox. Full evidence in apps/server/src/docs/bug-blank-thread-and-missing-inbox-rows.md. The shared cause: the list is served from the Postgres mirror while opening a thread is served only from Gmail, and both sides failed silently. - Thread reads discarded the S3 snapshot they had already loaded when the provider errored, so a draft-only thread that Gmail 404s rendered as a blank pane and got tombstoned. 18% of one user's thread reads in a day; 197 of 228 tombstones in 14 days were this shape. Now any provider failure serves the snapshot, read-only, and only tombstones when nothing is stored. - markThreadDeleted matched on thread_id alone, so a 404 in one mailbox could hide a thread in another. Scoped to the connection. - A non-UUID conversationId ('failedCreatingConversation') aborted the mirror write; the caller swallows that, so the thread never reached the inbox while the Gmail cursor advanced. Coerced to null — the row is the inbox, the conversation link is not. - The send path never routed threadId through toProviderThreadId, so the composer's draftSessionId-<uuid> reached Gmail as a thread id and every new compose failed with 400 Invalid thread_id value. - Deleting a draft resolved its id only via the same manager.get that 404s, so no delete was sent and the route still reported success; listThreadsFromDb also ignored deleted_at, which summaries.ts and hash.ts both honour. Resolve from the snapshot, tombstone on an explicit delete, and filter deleted_at. - Stacked inbox containers loaded sequentially, gated on the previous one reporting ready — so a container whose query never settled left every container below it permanently unfetched, with no request, error or log. They now load in parallel. Read and delete are deliberately opposite on a 404: a read serves the snapshot and must not tombstone; an explicit delete must. Both directions are pinned by tests. Includes a dry-run-by-default repair script for the 198 already-tombstoned draft threads — run it only after deploy, or unfixed prod re-creates them. Tests written first and observed failing; two reproduce the exact prod strings. 1025 passed across mail/crm/driver, 159 across the frontend thread suite. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 4, 2026, 5:39 PMstacked-inbox-view.test.tsxfeat: 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 PMstage-terminality.test.tsfeat(mail): Active Pipeline excludes parked stages, not just closed ones "Active" is the operative word: a deal On hold or Unqualified is not closed, but it is not moving either, and mail about it is not what the tab is for. The preset now derives its excluded set from the off-ramp classification rather than the terminal one — closed/lost/won/DQ/churned PLUS hold/nurture/paused/stalled/ unqualified. Mirrors OFFRAMP_LABEL_RE from services/crm/stage-inference.ts, the same authority the server uses when reconciling a stage arc, so both halves agree on what counts as a side state. Still label-derived, so it works on any org's stage vocabulary without a hard-coded list. Verified headlessly as <email>: 64 threads, exactly matching the ground-truth SQL for the same rule. Co-Authored-By: Claude Opus 5 <<email>>Aug 4, 2026, 11:07 PMsystem-folder-splits.test.tsfeat: split inbox fix, chat thread fix, and moreMay 26, 2026, 11:11 AMthreadSlice.test.tsfix(types): close 205 typecheck errors by repairing real client/server drift apps/mail typechecked against a stale apps/server/dist d.ts (the server's package.json points "types" at dist), so a chunk of the reported drift was phantom. Rebuilt it and fixed the four script errors that blocked `tsc -b` in server, which is what regenerates it. The rest was genuine contract drift, fixed at the source rather than cast away — routes that dropped fields their callers still read (agentExecutions, getUpcomingCalendarEvents' conversationId, createCanvas' description), schema splits the UI never followed (conversationUsers vs conversationContacts), and enums that grew server-side but not client-side (TASK_TYPES, ActionStatus, DateFilterOperator). Also deletes code that was already dead: an unreferenced sort popover whose store API is gone, a panel importing a deleted module, the system-skill metadata UI whose mutation was removed deliberately, and a test asserting a store method that no longer exists. apps/server: 4 -> 0. apps/mail: 228 -> 23, all remaining errors being dependency resolution (zod v3/v4, react-router dev/runtime skew, tiptap v2 via novel) rather than code. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 7:45 PMunibox-navigation-parity.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 PMuse-page-window.test.tsxfeat: per-inbox threads + page-window hooks (design: stacked-inbox-design phase 3) useInboxThreads is the per-section data hook for the stacked layout: takes an explicit inbox config, runs the same listThreads infinite query that useThreads runs for the active inbox, and exposes pages as a 2D array so callers can render one page at a time. usePageWindow is a thin cursor over those pages — prev walks back through cached pages, next walks forward and fetches when stepping past the last cached page. Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>May 24, 2026, 9:24 PMuse-threads.test.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 PM