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