aop-columns.tsfeat(mail): build an inbox's CRM rule with the real conversation filter UI (design: pipeline-inbox-crm-stage-filter phase 5)
Adds a "Linked deals" section to the inbox editor: pick AOP(s), then filter on
that AOP's own stages and custom fields. The column set is derived from the
selection, so Customer Success offers its stages and Deals offers discovery →
closed won, with no hard-coded enum anywhere.
This reuses FilterSortPopoverContent rather than growing a second filter UI. The
new `{ kind: 'inbox' }` scope is draft-local — the inbox does not exist yet while
the form is open — so it carries its own config + setter instead of a store slice.
Sharp edge found while wiring it: FilterSortPopoverContent only uses the adapter
for add/remove/clear. The six leaf column popovers resolve their write target from
a `canvasId` prop, falling back to the GLOBAL CRM slice when absent — so every
click in the inbox builder would have silently re-filtered the user's /crm page
and never touched the draft. They now consult a ColumnFilterOverride context
first; a test asserts the value lands on the draft AND that the global slice stays
untouched. The pre-existing 'sidebar' scope has the same latent bug (noted, not
changed here).
Also seeds docs/wiki entries for the mail read path and this subsystem, and
comments the deleted_at sharp edge at all three SQL builders.
Co-Authored-By: Claude Opus 5 <<email>>Aug 4, 2026, 7:28 PMbackground-fields.tsfix(mail): keep background fields hidden in working-memory fallback
The WorkingMemoryCard filtered background (sub-event taxonomy) values via the
conversation's AOP definitions, but the fallback used when those defs are
unavailable — `currentAop === null` because the conversation has no aopId, or
because an other-org AOP's minimal fetch failed — returned every custom field
unfiltered, so background taxonomy names/values rendered as editable inputs.
WorkingMemoryEntry values carry no background flag, so they can't be filtered
without a definition.
Fix: build an org-wide set of background field IDs from all known AOPs
(`aopsById`) — background taxonomy IDs are canonical and shared across an org's
AOPs — and exclude those names in the fallback. Legitimate fields still show
during load / on AOP-fetch failure (flicker-avoidance preserved); background
values never leak. Adds the pure `collectBackgroundFieldIds` helper alongside
`getVisibleFieldDefinitions`.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Aug 2, 2026, 2:05 PMcanvas-columns.tsfeat: custom column resizing + preset editing improvements + instant expanded viewMar 24, 2026, 5:12 PMcompute-canvas-filters.tsfix(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 PMconversation-filter-params.tsfeat(mail): build an inbox's CRM rule with the real conversation filter UI (design: pipeline-inbox-crm-stage-filter phase 5)
Adds a "Linked deals" section to the inbox editor: pick AOP(s), then filter on
that AOP's own stages and custom fields. The column set is derived from the
selection, so Customer Success offers its stages and Deals offers discovery →
closed won, with no hard-coded enum anywhere.
This reuses FilterSortPopoverContent rather than growing a second filter UI. The
new `{ kind: 'inbox' }` scope is draft-local — the inbox does not exist yet while
the form is open — so it carries its own config + setter instead of a store slice.
Sharp edge found while wiring it: FilterSortPopoverContent only uses the adapter
for add/remove/clear. The six leaf column popovers resolve their write target from
a `canvasId` prop, falling back to the GLOBAL CRM slice when absent — so every
click in the inbox builder would have silently re-filtered the user's /crm page
and never touched the draft. They now consult a ColumnFilterOverride context
first; a test asserts the value lands on the draft AND that the global slice stays
untouched. The pre-existing 'sidebar' scope has the same latent bug (noted, not
changed here).
Also seeds docs/wiki entries for the mail read path and this subsystem, and
comments the deleted_at sharp edge at all three SQL builders.
Co-Authored-By: Claude Opus 5 <<email>>Aug 4, 2026, 7:28 PMcustom-fields-revision.tsfeat: document renderer & fixesApr 29, 2026, 11:30 AMdate-format.tsperf(mail): cut 4 heavy libs off the eager store graph; revert the FCP-gaming skeleton
Measured authenticated for the first time (local build + local API, real session) and the
earlier conclusions do not survive it. /inbox takes ~44s to fully settle and issues 79 API
calls to 23 distinct endpoints -- 56 of them redundant repeats, with the first request not
leaving until 6.9s. The bundle was never the dominant cost on a real page load. Full
numbers in docs/design/frontend-load-performance.md section 7.
Two things in this commit.
1. Revert the skeleton text (was F12).
Adding static text to HydrateFallback moved First Contentful Paint from ~2656ms to ~596ms
without making the page usable one millisecond sooner -- optimising the metric, not the
experience. It was also mail-shaped (search bar, Inbox/Drafts/Sent tabs, thread rows) while
rendering on EVERY route, so /agent showed mail furniture that then reshuffled into
something else. HydrateFallback is now the left icon rail (genuinely global chrome) plus a
neutral spinner.
2. Finish what F5 started.
F5 made client-providers lazy-load the chat renderers, but the store still imported them
directly, so the heavy libraries never actually left the critical path. Four edges cut, all
found by walking the runtime import graph from modules/store/index.ts:
agentConnectionSlice -> toolMappings directly, not via messageRenderers
(was pulling react-markdown, motion, dompurify, posthog-js)
crm/utils barrel -> new date-format.ts, not time.ts
(was pulling chrono-node + fuse.js for one pure date helper)
email-utils -> cleanHtml split into html-sanitize.ts
(was pulling DOMPurify; deliberately NOT re-exported, which
would have recreated the same barrel trap)
modules reachable from the eager store registry: 107 -> 70
heavy npm packages on that graph: 9 -> 2
Remaining on that graph: email-addresses (legitimately needed by the address parsing the
slice uses) and @dagrejs/dagre via outboundFlowSlice -> stagesToFlow -> autoLayout, which
is the same shape as the @xyflow/react fix and is the next one to cut.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 9:39 PMfilter-people-by-company.tsfeat: conversation view polish + slack principals plumbing
Bundles the in-flight conversation UI tweaks (badges, contacts/files
tabs, overview card, inbox padding) with the Phase 1.5 backing pieces
for the timeline redesign: slack-users service, resolveSlackPrincipals
tRPC route, BimiAvatar avatarUrl short-circuit, SlackBodyRenderer,
useSlackPrincipals, and is-noise-email helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>Jun 3, 2026, 5:04 PMfilter-sort-helpers.tsfeat(pipeline): filter the table down to deals that actually need an action
The Suggested Action column could only be sorted, so a rep scanning for work
still had to read past every row with nothing due. Its popover now offers the
same Has/No binary the Tasks column has, mapped to the currentActionHasTasks
filter the backend already supported.
Two fixes fall out of wiring it:
- The built-in "Deal Actions" tab stored its filter as a `before` operator with
a runtime date, a shape the compute path never read — so the tab that says
"show me what I have to do" only sorted. It filters now.
- The due cutoff was end of tomorrow while the cell shows tasks due through end
of today, so a task-type filter could surface rows whose Suggested Action cell
showed no action. Both sides now share currentActionDueCutoff().
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 11:11 PMindex.tsperf(mail): cut 4 heavy libs off the eager store graph; revert the FCP-gaming skeleton
Measured authenticated for the first time (local build + local API, real session) and the
earlier conclusions do not survive it. /inbox takes ~44s to fully settle and issues 79 API
calls to 23 distinct endpoints -- 56 of them redundant repeats, with the first request not
leaving until 6.9s. The bundle was never the dominant cost on a real page load. Full
numbers in docs/design/frontend-load-performance.md section 7.
Two things in this commit.
1. Revert the skeleton text (was F12).
Adding static text to HydrateFallback moved First Contentful Paint from ~2656ms to ~596ms
without making the page usable one millisecond sooner -- optimising the metric, not the
experience. It was also mail-shaped (search bar, Inbox/Drafts/Sent tabs, thread rows) while
rendering on EVERY route, so /agent showed mail furniture that then reshuffled into
something else. HydrateFallback is now the left icon rail (genuinely global chrome) plus a
neutral spinner.
2. Finish what F5 started.
F5 made client-providers lazy-load the chat renderers, but the store still imported them
directly, so the heavy libraries never actually left the critical path. Four edges cut, all
found by walking the runtime import graph from modules/store/index.ts:
agentConnectionSlice -> toolMappings directly, not via messageRenderers
(was pulling react-markdown, motion, dompurify, posthog-js)
crm/utils barrel -> new date-format.ts, not time.ts
(was pulling chrono-node + fuse.js for one pure date helper)
email-utils -> cleanHtml split into html-sanitize.ts
(was pulling DOMPurify; deliberately NOT re-exported, which
would have recreated the same barrel trap)
modules reachable from the eager store registry: 107 -> 70
heavy npm packages on that graph: 9 -> 2
Remaining on that graph: email-addresses (legitimately needed by the address parsing the
slice uses) and @dagrejs/dagre via outboundFlowSlice -> stagesToFlow -> autoLayout, which
is the same shape as the @xyflow/react fix and is the next one to cut.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 9:39 PMis-noise-email.tsfeat: conversation view polish + slack principals plumbing
Bundles the in-flight conversation UI tweaks (badges, contacts/files
tabs, overview card, inbox padding) with the Phase 1.5 backing pieces
for the timeline redesign: slack-users service, resolveSlackPrincipals
tRPC route, BimiAvatar avatarUrl short-circuit, SlackBodyRenderer,
useSlackPrincipals, and is-noise-email helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <<email>>Jun 3, 2026, 5:04 PMmeeting-display.tsfix(meetings): a Circleback recording that plays, on open, with a link that goes to Circleback
Three things stood between a Circleback meeting and its recording.
The recording could not be fetched at all. credentials-encryption derives its
AES key from ORG_CREDENTIALS_ENCRYPTION_KEY || BETTER_AUTH_SECRET, so which
secret a credential was written under is a property of the environment at WRITE
time. Every Circleback MCP token set was written under BETTER_AUTH_SECRET; once
the org key was set, every decrypt died with "Cipher job failed", the call sites
swallowed it into "no credentials", and the player said "No playable recording
for this meeting" with the real cause visible nowhere. Decryption now tries each
configured secret; encryption still writes with the first.
The meeting dialog waited for a press before fetching. That poster exists so a
coaching document with ten moments does not fire ten mints on open against a
Fathom budget of 30/min — an argument that does not apply to one recording the
reader opened to watch. `autoLoad` skips the press without autoplaying.
"Open in Circleback" went to storage.googleapis.com. What ingest stored is a
signed GCS object whose signature dies 24h later; unsigned it answers 403
forever. The page is built from the meeting id instead, and no surface will link
to a raw media file any more.
Webhook-era rows (numeric external_id, all before 2026-07-29) still cannot mint
or link — neither ReadMeetings nor the page route takes a numeric id.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 5, 2026, 12:21 PMmerge-enum-options.tsfeat: aopSlice to remove hook complexityFeb 27, 2026, 1:11 AMrelative-dates.tsfeat(pipeline): filter the table down to deals that actually need an action
The Suggested Action column could only be sorted, so a rep scanning for work
still had to read past every row with nothing due. Its popover now offers the
same Has/No binary the Tasks column has, mapped to the currentActionHasTasks
filter the backend already supported.
Two fixes fall out of wiring it:
- The built-in "Deal Actions" tab stored its filter as a `before` operator with
a runtime date, a shape the compute path never read — so the tab that says
"show me what I have to do" only sorted. It filters now.
- The due cutoff was end of tomorrow while the cell shows tasks due through end
of today, so a task-type filter could surface rows whose Suggested Action cell
showed no action. Both sides now share currentActionDueCutoff().
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 11:11 PMsort-priority.tsfeat(crm): split filters and sorting into separate popovers, sort from a header wins
Filtering and sorting were one popover with one horizontal row of badges, where
badge order silently encoded sort priority. Order now only means something in the
sort list, which is the only list you can drag.
Sorting a column from its header takes priority 0 and pushes the rest down, rather
than landing last where it has no visible effect. Shared by all seven column
popovers plus toggleColumnSort, so every entry point behaves the same.
Column popovers opened from a list are scoped to that list's concern and anchor
beside it like a nested menu; a "Set filter" / "Set sort" button hands the column
to the other list. Enum fields keep one list for both — drag is the sort order,
click is the filter — so their affordances split by mode instead.
Drops ~750 lines of dead code (FilterSortBadgeRow, the deprecated inline row,
OwnerUserFilter). The drag-reorder test now runs against the real function instead
of a hand-copied duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 4:29 PMstage-terminality.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 PMstrategic-overview-layout.tsfix(overview): the layout editor names the field the deal view actually shows
The editor and the Overview tab each kept their own copy of the top-row field
list, and only the tab rewrote the legacy `next_step_quality` cell to the native
`status` field. So the editor labelled a cell "Next Step Quality" that every deal
renders as "Stage". Both now read one shared definition, which also carries the
rewrite, the `risk` label the tab was missing, and the five-slot cap.
The default row is Stage, Next step, Last touch, Forecast, ACV. The seed's sixth
cell overflowed a five-slot grid; `on_track` and the native `risk` stay extracted
and stay selectable, they are just no longer in the default row.
migrate-strategic-top-row.ts brings the 130 stored rows onto that shape. It
resolves the Forecast cell per-AOP: three AOPs never had the seeded `forecast`
field, only their own CRM's `forecast_category` with real options and ~200 real
values, so they point the cell at the field they actually have rather than at a
blank one.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 11:02 PMtime.tsfix(crm): "3h ago", not "Today", for everyone west of UTC
`formatRelativeDate` read two different calendars. The day branch built its comparison from
UTC components — deliberately, so a date-only value stored at midnight UTC does not slide
back a day for US viewers — and said so in a comment ending "BOTH sides must read the same
clock". The sub-24h branch three lines above it asked date-fns `isToday`, which reads the
LOCAL calendar. The one branch nobody re-checked was the one the comment was about.
West of UTC the two disagree for most of the evening. At 04:37Z on the 31st — 00:37 in New
York — a touch from 3h41m earlier is 20:55 on the 30th locally but still the 31st in UTC. So
`isToday` was false, the hour branch was skipped, and the day math, which had both on the
31st, answered "Today". A rep opening a deal after 8pm saw a meeting from that afternoon
labelled as though the hour did not matter.
The UTC day values are now computed once, above both branches, and the hour branch tests
those instead of the ambient zone. `isToday` is gone with it.
── Why the suite could not catch this ──
Every CI runner is UTC, and in UTC the two calendars cannot disagree, so all five existing
tests passed on the broken code. The suite even documented the hole — "keys off the viewer's
LOCAL calendar day by design, so it is not timezone-stable" — right under a test asserting
"3h ago", which is exactly the assertion that is not stable. UTC is not a neutral default for
date code; it is the blind spot.
So there is now a second pass, west of UTC: `tests/timezone/`, run by
`pnpm --filter @zero/mail test:tz` and by its own CI step, excluded from the main run so it
cannot pass vacuously there. TZ has to be set before the process starts — V8 caches the zone
on first use, so neither assigning `process.env.TZ` in a test nor a custom testEnvironment can
move it (I tried both; a guard test in the file now fails if the zone ever stops applying).
Verified the way a regression test has to be: the new suite fails on the old implementation
with exactly "Expected 3h ago, Received Today", and passes on the fix.
Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 1:51 PM