componentsperf(crm): stop shipping list rows what they cannot render Follows the event projection with the other two thirds of the payload. On the same 50-row page: 2,809 KB -> 1,173 KB. userTasks was 58% of it. The list had no status filter at all, so every task ever attached to a conversation went over the wire — 650 deleted, 329 agent_deleted and 268 done against 59 todo on one page; 341 renderable out of 6,935 org-wide. Every list surface filters to todo before drawing (the tasks cell, the current/future action columns, the kanban card), so the rest was never rendered. Filtering to todo + recommended takes it 1,621 KB -> 175 KB. The blobs on a task are small (~350 bytes of task_output on a todo task), so this is deliberately a row filter and not a narrower field projection: the execution ids and chat_thread_id / task_group_id drive execution mode when a task is opened from a card, and trimming them would risk that for a few KB. customFields was 522 KB. Two things, neither of them the values themselves: 54% of entries carry no value at all (4,028 of 7,506), and an absent entry renders exactly what an empty one does — every reader is `field?.value || null` — so they are skipped unless they still have a signal dot to draw. And 31% of the bytes were envelope (id, conversationId, agentExecutionId, editedBy, lastEdited, createdAt) that no list surface reads; every lookup goes through the field name. updatedAt stays, because getCustomFieldsRevision keys the store's change detection on it. 522 KB -> 332 KB, now 84% real user content. Also deletes `siblingTasks`, which existed to carry the full task list to TimelineTaskItem — where it was declared as a prop and never destructured. Five call sites were threading a conversation's entire task history into a component that ignored it. Left alone deliberately: primaryCompany (75 KB across ~50 enrichment columns — trimming it needs a store guard so a list refetch cannot blank the panel's company card, which is a poor trade for 6%), and the event cap, which is a product dial rather than waste. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:42 PM3 defectsconfigfix(mail): stop a list load selecting a deal, and rename the Status label to Stage A fresh /agent chat came up bound to whatever deal sorted first. setCurrentConversationList auto-selected conversations[0] into activeConversationId, which on the chat surfaces is not a row highlight but the CHAT'S CONTEXT — useRouteChatThread writes it onto the thread's selectedArtifact and the attach effect commits it as primaryConversation. So the Top Deals tile finishing its load put a deal nobody opened into a brand-new chat's chip row and into what the agent was told. Loading a list now selects nothing; j/k is unaffected (focusedIndex starts null, so the first press still resolves to row 0 — and no longer skips it). CardListCanvasView also claimed the global currentConversationList and read it back to render. useCanvasConversations gains syncCurrentList and returns the ordered list, so a tile that merely displays deals no longer repoints the app's shared selection state. That leaves nothing writing that list on /agent. Rename the deal status field's LABEL to "Stage" everywhere it shows (the filter builder, table header, group-by, kanban, profile, onboarding preview). Reported on Vooma's onboarding: "these are stages, not statuses — status is like open and closed." Column id stays `status`; statusOverview keeps its name, being a prose summary rather than the stage enum. Add mod+N for a new chat, sharing one implementation with the chat header's button so the two cannot drift, plus a row in the hotkey bar. The desktop File menu had CmdOrCtrl+N on Compose, which the main process consumes before the page sees it — moved to CmdOrCtrl+Shift+N. A plain browser tab reserves the key and will never dispatch it. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:06 PMhooksperf(crm): give conversation rows their own event projection A row draws a dot timeline and three "last <type> at" columns, and nothing else — but crm.listConversations was building up to seven nested sub-objects per event through eight LEFT JOINs to feed it. Rows now get five scalars and a flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw three times the history for no more bytes: measured on a 50-row page, 547KB over 641 events before, 538KB over 1,919 now. That cap was also quietly wrong. "Last meeting" was derived by scanning whatever events the list happened to ship, so a deal whose last meeting fell outside its 15 most recent rendered blank — 53 of the 304 conversations with a meeting, in the org this was measured on — while SORTING by the same field used an unbounded MAX(occurred_at) and placed it somewhere the column contradicted. The three columns now read scalars computed over the whole history, from the same expressions the ORDER BY builder already used. Splitting the projections needed guards the single shared type had been hiding: - setConversations takes an explicit `projection`. Both queries write one store key and replace wholesale, so a list refetch would otherwise empty the timeline of whatever deal was open — the same bug that previously just shrank it to 15 (client-data-architecture.md §1.2). - getEventType/getEventTitle read `eventType` rather than sniffing which sub-object came back from a join, which equated "this join returned nothing" with "this is a note" and would have greyed out every dot. - a dot click opens EventDetailDialog on an id and fetches the full event, since the row no longer carries one. Also carries the in-flight pending-conversation-field-writes mask and its tests: it edits the same setConversations body, so it cannot be split out. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PM2 defectslibperf(crm): give conversation rows their own event projection A row draws a dot timeline and three "last <type> at" columns, and nothing else — but crm.listConversations was building up to seven nested sub-objects per event through eight LEFT JOINs to feed it. Rows now get five scalars and a flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw three times the history for no more bytes: measured on a 50-row page, 547KB over 641 events before, 538KB over 1,919 now. That cap was also quietly wrong. "Last meeting" was derived by scanning whatever events the list happened to ship, so a deal whose last meeting fell outside its 15 most recent rendered blank — 53 of the 304 conversations with a meeting, in the org this was measured on — while SORTING by the same field used an unbounded MAX(occurred_at) and placed it somewhere the column contradicted. The three columns now read scalars computed over the whole history, from the same expressions the ORDER BY builder already used. Splitting the projections needed guards the single shared type had been hiding: - setConversations takes an explicit `projection`. Both queries write one store key and replace wholesale, so a list refetch would otherwise empty the timeline of whatever deal was open — the same bug that previously just shrank it to 15 (client-data-architecture.md §1.2). - getEventType/getEventTitle read `eventType` rather than sniffing which sub-object came back from a join, which equated "this join returned nothing" with "this is a note" and would have greyed out every dot. - a dot click opens EventDetailDialog on an id and fetches the full event, since the row no longer carries one. Also carries the in-flight pending-conversation-field-writes mask and its tests: it edits the same setConversations body, so it cannot be split out. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PMstoreperf(crm): stop shipping list rows what they cannot render Follows the event projection with the other two thirds of the payload. On the same 50-row page: 2,809 KB -> 1,173 KB. userTasks was 58% of it. The list had no status filter at all, so every task ever attached to a conversation went over the wire — 650 deleted, 329 agent_deleted and 268 done against 59 todo on one page; 341 renderable out of 6,935 org-wide. Every list surface filters to todo before drawing (the tasks cell, the current/future action columns, the kanban card), so the rest was never rendered. Filtering to todo + recommended takes it 1,621 KB -> 175 KB. The blobs on a task are small (~350 bytes of task_output on a todo task), so this is deliberately a row filter and not a narrower field projection: the execution ids and chat_thread_id / task_group_id drive execution mode when a task is opened from a card, and trimming them would risk that for a few KB. customFields was 522 KB. Two things, neither of them the values themselves: 54% of entries carry no value at all (4,028 of 7,506), and an absent entry renders exactly what an empty one does — every reader is `field?.value || null` — so they are skipped unless they still have a signal dot to draw. And 31% of the bytes were envelope (id, conversationId, agentExecutionId, editedBy, lastEdited, createdAt) that no list surface reads; every lookup goes through the field name. updatedAt stays, because getCustomFieldsRevision keys the store's change detection on it. 522 KB -> 332 KB, now 84% real user content. Also deletes `siblingTasks`, which existed to carry the full task list to TimelineTaskItem — where it was declared as a prop and never destructured. Five call sites were threading a conversation's entire task history into a component that ignored it. Left alone deliberately: primaryCompany (75 KB across ~50 enrichment columns — trimming it needs a store guard so a list refetch cannot blank the panel's company card, which is a poor trade for 6%), and the event cap, which is a product dial rather than waste. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:42 PMtypesMerge remote-tracking branch 'origin/staging' into feat/crm-custom-object-linkage # Conflicts: # apps/mail/modules/conversations/components/ConversationOverviewCard.tsx # apps/mail/modules/conversations/components/conversationFieldRenderers.tsx # apps/mail/modules/crm/types/index.ts # apps/server/src/services/crm/__tests__/external-crm-events.test.ts # apps/server/src/services/integrations/crm/driver.tsSep 19, 2026, 8:32 PMutilsfix(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 PM1 defectfield-enums.tsfix: hasFutureCalendar column header, sort routing, and sort application - Add hasFutureCalendar to EnumFieldKey/ENUM_FIELD_KEYS so it routes to UnifiedColumnPopover instead of DateColumnPopover ("Next Step Date" label) - Remove hasFutureCalendar from DATE_FIELDS in ColumnConfigurationItem and sortable-column-header; add to ENUM_FIELDS in both - Exclude hasFutureCalendar from backend sort in both use-crm-conversations and compute-canvas-filters; add client-side comparator that sorts by actual meeting startTime (no meeting sorts last) - Fix use-canvas-conversations signature guard to include activeSorts so client-side sort re-applies when sort config changes without data changes Co-Authored-By: Claude Sonnet 4.6 (1M context) <<email>>Jun 22, 2026, 11:44 PMindex.tsfeat(onboarding): make /onboarding the whole setup, and retire the modal The card-and-dots wizard at /onboarding only connected accounts. Everything a user actually has to decide — inbox layout, sub-inboxes, task groups, pipeline views — was discoverable only by finding the right dialog afterwards. The other thing called onboarding, a dev-only modal behind two profile menus, ran on mock data and configured nothing at all. So /onboarding is now the full-page rail-driven flow, and the integration steps survive inside it as the Connect section, spliced in from whatever the org has installed rather than hard-coded — a team with no CRM never sees a CRM step, and the rail does not count one toward its progress. Provider selection moved inside each step, so Back means one thing instead of two. The modal is deleted along with the CRMTablePreview/SimpleCRM subtree only it used. Its templates screen is kept, unreferenced, at modules/onboarding/ templates: the AI-variable notation and source-email provenance are the design a real templates surface should be built on, and re-deriving them would lose that. Nothing imports it and it is not routed, but it stays inside the project so it keeps type-checking instead of rotting into a snippet that no longer builds. Also drops /onboarding-setup, added one commit ago — one flow, one URL. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 11:52 AMSTATE_OVERVIEW_REMOVAL_DESIGN.mdfeat: remove agent state-overview (score/notification) subsystem Removes concept #1 — the per-(agent, conversation) score/notification widget: the agent_conversation_states table, aop_agents.state_overview_config, updateAgentStateOverviewTool, AgentState.statusOverview / data.agents hydration, the ScoreCircle/notification badge in AgentRow, the dynamic ags_* canvas columns, the timeline agent-state cards, AgentStateCellRenderer/ColumnPopover, and the AgentStateDebuggerTab. Preserves concept #2 — the unrelated plain-text conversation.statusOverview field (crm_conversations.status_overview) written by the dashboard tool / CRM updater / external-CRM sync and read by export, SSE, the overview card, and CRM columns. Only the name was shared. DB migration authored (drop_agent_state_overview.sql, idempotent) but not auto-applied; orphaned table/column are harmless until applied manually. Type-clean (no new errors vs baseline); server tests 25 passing. Design: apps/mail/modules/crm/STATE_OVERVIEW_REMOVAL_DESIGN.md Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 17, 2026, 8:39 PM