bulk-actions-bar.tsxfeat: [thread] bulk actionsDec 18, 2025, 10:27 AMemail-tracking-indicator.tsxfix(mail): stop a tracked-email open from white-screening the whole app
EmailTrackingIndicator called useState + useTRPC, returned null early when
openCount/clickCount were 0, and only then called useQuery and three useMemos.
Those counts come off the thread query, so the moment a recipient opened a
tracked email the same mounted instance re-rendered past the guards and ran six
hooks where React had recorded two — React #310, caught by the route error
boundary, which replaced the app with the error screen.
Hit <email> on staging (2026-08-17/18) and, with the same
fingerprint, <email> and isabelle on production (2026-08-11); one
2026-08-05 session looped the crash 59 times.
All six hooks move above the guards; the two early returns collapse into one
`if (!isVisible) return null` after the last hook. Behaviour is unchanged — the
query was already gated by `enabled: isOpen`, and a disabled TanStack v5 query
still reports isLoading === false.
ESLint had flagged all four lines the whole time; CI runs oxlint, where
rules-of-hooks was off. Turned it on, scoped off for apps/server and packages
(server helpers named use* are not hooks), and renamed the CompanyGTMPanel click
handler `useTemplate` to `applyTemplate` so the repo lints clean. Verified
against a copy of the pre-fix file: the gate reproduces the four errors and
would have blocked this.
Report: apps/mail/docs/bug-email-tracking-indicator-conditional-hooks.md
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:46 PMinbox-section.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 PMInboxLinkedDealsSection.tsxfix(mail): stop the edit-inboxes dialog overflowing the viewport, and constrain the inbox form
The dialog width regressed to `sm:max-w-6xl`. DialogContent's base cap is
`max-w-[calc(100%-2rem)]` with NO breakpoint prefix, so a prefixed rule overrides
it from 640px up: on a 1024px laptop the dialog rendered 1152px wide, off the edge
of the screen, taking every popover anchored near its right edge with it. Capped
with min() so the template gallery stays wide on large screens without ever
exceeding the window.
The add/edit form is now a centred max-w-2xl column rather than fields stretched
across the full gallery width, and the Linked deals filter list is fixed-width:
each row is its own popover trigger and the leaf popovers are a fixed 384px
anchored to the row's left edge, so a full-width row put those anchors at the far
right.
Renames the Pipeline template to "Active Pipeline".
Co-Authored-By: Claude Opus 5 <<email>>Aug 4, 2026, 7:57 PMSplitInboxTabs.tsxfeat(calendar,tasks): mint real Zoom meetings, and two fixes for surfaces that lied
Zoom reaches a Google Calendar event through the vendor's Workspace Add-on running
INSIDE Google, never through an external API client. Probed live against two accounts:
all 17 calendars report `allowedConferenceSolutionTypes: ["hangoutsMeet"]`, so the
`addOn` option added in ead6b26a4 was unreachable code — and had it rendered, Google
would have answered "Invalid conference type value". Cedar now holds a Zoom OAuth token
per user, mints the meeting through Zoom's own API, and writes the join details onto the
event. That is what Superhuman does, and there is no Calendar-API shortcut they know
that we do not.
The driver implements the existing OAuth interface, which buys the sealed AES-GCM state,
`initiateOAuth`'s URL builder, and `refreshMcpConnection`. The last is load-bearing
rather than convenient: Zoom rotates the refresh token on every refresh and kills the old
one, so two concurrent refreshes presenting the same stored token leave it dead — exactly
the failure the single-flight coalescing there already exists for. The row is written
`direct_oauth`, never `external_mcp`, because every MCP surface selects on that column and
an agent enumerating tools from api.zoom.us should not be possible.
Attachment goes to `location` (the only field that renders on every Google client),
`description` (a fenced, idempotent dial-in block — recreating the meeting REPLACES it
rather than appending beside it, which is what a "does it already contain this URL?"
check gets wrong), and `conferenceData` best-effort. `addOn` is gone from both
`createRequest` enums, the agent tool, the chat route, and `CalendarEvent.conferenceType`;
describing an existing conference is still allowed, because that is what a Zoom meeting is
by the time it reaches the event body. Inert until ZOOM_CLIENT_ID is set.
Two unrelated fixes, both reported by the same customer:
- "Search a deal…" searched every conversation the user owned. Cedar mints one per
unrecognised correspondent, so on the reporting account 117 conversations held 6 deals
and typing "gmail" returned a column of recruiters' addresses. `dealsOnly` narrows to
the AOPs the user actually works — `isNoOp = false` being their own declaration that
Cedar does work there — and falls back to today's behaviour for the 18 of 109 active
accounts that have no workable AOP.
- Every task surface splits due-now from upcoming at `endOfToday()`, read inside memos
whose dependencies are all data. Nothing in them moves when the day does, so a tab left
open past midnight files everything due TODAY under Upcoming, out of its group column,
which then collapses into the hidden-columns rail for being empty. `useDayKey` is the
missing dependency.
Staged whole-tree, not session-scoped: this branch is shared with a concurrent session
whose in-flight work (the board's Done lane, the calendar drag-settle animation, the
onboarding setup flow, the task-groups CLI) is entangled with these changes both within
files and across them — CalendarView passes props DayColumn only accepts in its
uncommitted form — so a scoped commit would not have compiled. That session likewise
swept this change's crm.ts and NewTaskDialog edits into a5fc7b295.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:35 PMSplitSettingsCreator.tsxrefactor(mail): drop the icon from split inboxes
An inbox is identified by its name; the icon next to it carried no signal
and cost a picker in the create/edit form, an icon per template, and a
nullable field threaded through the client, the tRPC schema, and the CLI.
Removes it end to end. Stored icons are stripped harmlessly on read — the
record schema is non-strict, so existing user_settings rows still parse.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 5, 2026, 9:06 AMSplitTemplateCard.tsxfeat(calendar,tasks): mint real Zoom meetings, and two fixes for surfaces that lied
Zoom reaches a Google Calendar event through the vendor's Workspace Add-on running
INSIDE Google, never through an external API client. Probed live against two accounts:
all 17 calendars report `allowedConferenceSolutionTypes: ["hangoutsMeet"]`, so the
`addOn` option added in ead6b26a4 was unreachable code — and had it rendered, Google
would have answered "Invalid conference type value". Cedar now holds a Zoom OAuth token
per user, mints the meeting through Zoom's own API, and writes the join details onto the
event. That is what Superhuman does, and there is no Calendar-API shortcut they know
that we do not.
The driver implements the existing OAuth interface, which buys the sealed AES-GCM state,
`initiateOAuth`'s URL builder, and `refreshMcpConnection`. The last is load-bearing
rather than convenient: Zoom rotates the refresh token on every refresh and kills the old
one, so two concurrent refreshes presenting the same stored token leave it dead — exactly
the failure the single-flight coalescing there already exists for. The row is written
`direct_oauth`, never `external_mcp`, because every MCP surface selects on that column and
an agent enumerating tools from api.zoom.us should not be possible.
Attachment goes to `location` (the only field that renders on every Google client),
`description` (a fenced, idempotent dial-in block — recreating the meeting REPLACES it
rather than appending beside it, which is what a "does it already contain this URL?"
check gets wrong), and `conferenceData` best-effort. `addOn` is gone from both
`createRequest` enums, the agent tool, the chat route, and `CalendarEvent.conferenceType`;
describing an existing conference is still allowed, because that is what a Zoom meeting is
by the time it reaches the event body. Inert until ZOOM_CLIENT_ID is set.
Two unrelated fixes, both reported by the same customer:
- "Search a deal…" searched every conversation the user owned. Cedar mints one per
unrecognised correspondent, so on the reporting account 117 conversations held 6 deals
and typing "gmail" returned a column of recruiters' addresses. `dealsOnly` narrows to
the AOPs the user actually works — `isNoOp = false` being their own declaration that
Cedar does work there — and falls back to today's behaviour for the 18 of 109 active
accounts that have no workable AOP.
- Every task surface splits due-now from upcoming at `endOfToday()`, read inside memos
whose dependencies are all data. Nothing in them moves when the day does, so a tab left
open past midnight files everything due TODAY under Upcoming, out of its group column,
which then collapses into the hidden-columns rail for being empty. `useDayKey` is the
missing dependency.
Staged whole-tree, not session-scoped: this branch is shared with a concurrent session
whose in-flight work (the board's Done lane, the calendar drag-settle animation, the
onboarding setup flow, the task-groups CLI) is entangled with these changes both within
files and across them — CalendarView passes props DayColumn only accepts in its
uncommitted form — so a scoped commit would not have compiled. That session likewise
swept this change's crm.ts and NewTaskDialog edits into a5fc7b295.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:35 PMstacked-inbox-view.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 PM