conversations-page.md60.5 KBView on GitHub # Conversations Page — Slack-style Sidebar + Conversation Workspace
> Detail doc for the new `/conversations` route. Companion to [overview.md](./overview.md), [timeline.md](./timeline.md), [next-steps.md](./next-steps.md), and [sidebar-filter-sort-group.md](./sidebar-filter-sort-group.md) — sidebar filter, sort & group rebuild (post-phase-4 follow-up).
>
> **The legacy `/home` canvas dashboard is untouched.** It still mounts `HomeView` ([HomeView.tsx:111](apps/mail/modules/home/components/HomeView.tsx)) at [routes.ts:42](apps/mail/app/routes.ts). Our new split-pane sidebar + conversation workspace lives at a brand-new `/conversations` route; the existing `/home` files (`HomeView`, `HomeCanvas`, `NewCanvasScreen`, `SaveConfigButton`) are not modified or deleted by this redesign.
## 1) Introduction — goal, present state, future state
We want `/conversations` to be the default landing for a Cedar Mail user: a Slack-style left sidepanel listing every conversation, grouped and sorted however the user prefers, with an always-visible **Agenda** entry pinned at the top of the sidepanel that opens a global, **cross-conversation** date-grouped task list, a **search bar** just under it for quick lookup, default groups for **Starred** and **Response** (awaiting reply), and per-row affordances — an **unread message count badge** (rendered **blue when the conversation has an open task**, neutral otherwise) and a **Remind** button that creates a one-click user task on the conversation. Selecting a conversation in the sidepanel renders the redesigned [ConversationView](apps/mail/modules/crm/components/ConversationView.tsx) (six tabs per [overview.md](./overview.md): **Inbox · Next Steps · Agents · Files · CRM · Contacts**) in the right pane — same component, no outer page chrome. Per-conversation tasks continue to live in the Next Steps tab inside ConversationView; the Conversations Agenda is purely additive — a cross-conversation roll-up of every task assigned to me across every deal, rendered with the same `TaskBlockList` + `groupTasksByDate` components from [next-steps.md](./next-steps.md). Today the only standalone conversation-list surfaces are `/crm` (canvas), `/pipeline` (grouped canvas), and `/mail/conversation-inbox`; conversation viewing happens inside the global `ActiveViewDisplay` overlay driven by the [conversationsSlice](apps/mail/modules/conversations/slice/conversationsSlice.ts) `viewStack`. We add a new `/conversations` route with a split layout — `ConversationsSidebar` on the left (header row · pinned Agenda · search bar · grouped conversation rows with unread + remind affordances) and a `ConversationsMainPane` on the right that mounts `ConversationView` for `/conversations/c/:conversationId`, `AgendaView` for `/conversations/agenda`, and an empty state for `/conversations`. A new dedicated **Conversations** icon is added to [LeftSidebarContent](apps/mail/modules/conversations/components/LeftSidebarContent.tsx) so the page is reachable from the global icon rail.
## 2) Present state
### 2.1 Architecture diagram
```text
/home → (routes)/home/layout.tsx → Sidepanel(children=Outlet)
└── (routes)/home/page.tsx
├── ThreadConversationUrlSync(listScope='conversation-list')
├── ConversationDataSync
└── HomeView
├── LargeTabsList (Home + per-canvas + "+")
│ └── SortableTabTrigger × n (drag-to-reorder canvases)
├── HomeCanvas (default "Home" tab body)
├── CanvasRenderer (per-canvas tab body)
└── ActiveViewDisplay ← global overlay; mounts ConversationView when viewStack top === 'conversation'
(routes)/layout.tsx
└── LeftSidebarContent (global app nav — small icon column with /home, /mail, /crm, …)
conversation-list surfaces today live in:
/crm → ConversationCanvas (virtua VList) [ConversationCanvas.tsx:1]
/pipeline → GroupedConversationCanvas [GroupedConversationCanvas.tsx:47]
/mail/conversation-inbox → ConversationInbox [ConversationInbox.tsx:1]
```
### 2.2 Step-by-step walkthrough
1. **Route registration** — `/home` is registered at [routes.ts:42](apps/mail/app/routes.ts):
```ts
layout('(routes)/home/layout.tsx', [route('/home', '(routes)/home/page.tsx')])
```
The outer app shell ([(routes)/layout.tsx](apps/mail/app/(routes)/layout.tsx)) wraps every page with `<LeftSidebarContent />` (icon-rail nav) and `<Outlet />`.
2. **Existing `HomeLayout`** at [layout.tsx](apps/mail/app/(routes)/home/layout.tsx) wraps the legacy `/home` page in a [Sidepanel](apps/mail/components/ui/sidepanel.tsx) and an `Outlet`. The `Sidepanel` is a right-side companion strip — *not* what we mean by "Slack-style sidebar"; it's a collapsible chat/widgets column. Today no `defaultContent` is set, so it shows whatever the `Sidepanel`'s default renderer chooses. This file is left unchanged; our new `ConversationsLayout` is a separate file at `(routes)/conversations/layout.tsx`.
3. **`HomePage`** at [page.tsx](apps/mail/app/(routes)/home/page.tsx):
```tsx
<>
<ThreadConversationUrlSync listScope="conversation-list" />
<ConversationDataSync />
<HomeView />
</>
```
- `ThreadConversationUrlSync` at [thread-conversation-url-sync.tsx:36](apps/mail/modules/ux/components/thread-conversation-url-sync.tsx) owns `?threadOpen=<id>` and `?conversationId=<id>` ⇄ store; conversation `section` suffix is encoded as `conversationId=<id>/<section>`.
- `ConversationDataSync` hydrates `state.conversations[id]` whenever the active id changes.
4. **`HomeView`** at [HomeView.tsx:111](apps/mail/modules/home/components/HomeView.tsx) renders a draggable tab strip backed by `canvasSlice` (`homeViewCanvasIds`, `canvasesById`, `activeCanvasId`). Empty Home tab body shows the [HomeCanvas](apps/mail/modules/home/components/HomeCanvas.tsx) widget (an `UpcomingMeetingsWidget` + canvas template picker). The whole tab strip is *hidden* when `viewStack.at(-1) !== null` or `newEmail` is set, and `<ActiveViewDisplay />` ([active-view-display.tsx:20](apps/mail/components/ui/active-view-display.tsx)) takes over the pane.
5. **Conversation viewing today** — `ConversationView` is *not* mounted by `/home` directly. It is mounted by [active-view-display.tsx:38](apps/mail/components/ui/active-view-display.tsx) which lives at the global app shell and reads `viewStack` (set by [conversationsSlice.ts:634](apps/mail/modules/conversations/slice/conversationsSlice.ts) `setIsConversationOpen`). The `?conversationId=` param drives `activeConversationId` + `isConversationOpen` + `conversationSection`.
6. **Conversation list precedent** — closest two-pane list-with-detail pattern is [ConversationCanvas.tsx:1](apps/mail/modules/crm/components/conversation-canvas/ConversationCanvas.tsx):
- Data via `useCanvasConversations(canvasId)` → `trpc.crm.listConversations` ([crm.ts:470](apps/server/src/trpc/routes/crm.ts)). Input schema accepts `status`, `priority`, `aopIds`, `ownerUserIds`, `important`, `hasTodoTasks`, `latestEventFilter`, `taskDueDate`, `sortBy[]`, `limit`, `cursor`.
- Output `{ conversations: CrmConversation[], total, nextCursor, hasMore }` from [listConversationsSingleQuery](apps/server/src/services/crm/conversations.ts:3498).
- Rows render via [ConversationItem.tsx:92](apps/mail/modules/crm/components/conversation-canvas/ConversationItem.tsx). Uses `virtua`'s `VList` for virtualization (used elsewhere too — confirmed in [crm-table.tsx](apps/mail/modules/crm/components/crm-table.tsx)).
- Grouped variant at [GroupedConversationCanvas.tsx:47](apps/mail/modules/crm/components/conversation-canvas/GroupedConversationCanvas.tsx) uses [useGroupedConversations.ts:46](apps/mail/modules/crm/hooks/use-grouped-conversations.ts) which buckets by upcoming-meeting / meeting-followups / tasks.
7. **Starred / pinned today** — there is **no separate `starred`/`pinnedAt` concept**. `crm_conversations.important boolean` ([crm-schema.ts:440](apps/server/src/db/crm-schema.ts)) exists and is exposed:
- Toggled in row UI via `handleToggleImportant` at [ConversationItem.tsx:532-539](apps/mail/modules/crm/components/conversation-canvas/ConversationItem.tsx) (calls `optimisticUpdateConversation(id, { important: !current })`).
- `important` is a valid filter on `listConversations` ([crm.ts:456](apps/server/src/trpc/routes/crm.ts)).
- `crm_email_threads.is_starred boolean` ([crm-schema.ts:1798](apps/server/src/db/crm-schema.ts)) is a separate, **thread-level** flag mirroring Gmail's STAR label — unrelated to conversations.
8. **Awaiting response today** — no field, no derived helper. Direction can only be inferred from `conversation.events[*]` by walking the latest event and checking `event.eventType` (e.g. `email_inbound` vs `email_outbound`). Not part of `listConversations`' filter set.
9. **Tasks per-user query for Agenda** — already exists: `userTasks.listUserTasks` at [user-tasks.ts:40](apps/server/src/trpc/routes/user-tasks.ts) defaults to "all my tasks across all conversations" (filters by `userId = sessionUser.id`, excludes `deleted`/`agent_deleted`). Accepts `status?: 'todo' | 'done'`, `dueDateAfter/Before`, `conversationId?` (optional — when omitted, returns cross-conversation), `sortDueDate`, `limit`. Output `{ tasks: UserTask[] }`. **Suitable as-is for the global Agenda.**
10. **Filter / sort persistence today** — three coexisting precedents:
- **localStorage direct** — [ConversationBodyContent.tsx:523,529](apps/mail/modules/conversations/components/ConversationBodyContent.tsx) reads/writes `cedar:conv-body:tab`.
- **localStorage in `HomeView` itself** — [HomeView.tsx:41,146,157](apps/mail/modules/home/components/HomeView.tsx) uses `cedar-last-home-tab` directly.
- **Zustand `persist` middleware** — wired in [createCedarStore.ts:54-79](apps/mail/modules/cedar-os/src/store/createCedarStore.ts) (the cedar-os example) and consumed by the app's main store at [store/index.ts:61](apps/mail/modules/store/index.ts) (`create<CedarStore>()` — verify whether `persist` is wrapped in the actual app entry; if not, the inline `localStorage.getItem/setItem` pattern is the right precedent for a slice this small).
- **DB-backed user settings** — `userSettingsSlice` exists, but it's reserved for cross-device account prefs; sidebar groupings are device-local UX state.
11. **Filter popover precedent** — [FilterSortConfigurationRow.tsx:648](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx) `FilterSortPopoverContent` is the existing filter/sort popover used by `ConversationCanvas`. It is canvas-scoped (writes to `canvas.viewConfig.filterSortConfig`) so we cannot reuse it directly for a non-canvas surface, but the popover trigger/popover styling and the per-column popovers (`OwnerFilterPopoverContent`, status/priority pickers) are reusable.
12. **`Sidepanel` chrome conflict** — `ConversationsLayout` wraps the page in `<Sidepanel>`. That `Sidepanel` is the *right-side* companion strip (chat / widgets / calendar). It is unrelated to the Slack-style left sidebar we are building. We keep it; the Slack sidebar lives inside the `Outlet` and is independent.
13. **`AOP statusOptions` / `priorityOptions`** — Surfaced through `useAop()` and exposed to renderers via the `aop.displayConfig`. Status/priority option sets come from `statusOptionsData` (per-AOP) — they are what `ConversationOverviewCard`'s status/priority pickers already read. Group-by `status` / `priority` must enumerate values from the active AOP, not a hard-coded list.
14. **Unread state today** — `crm_email_threads.hasUnread boolean` ([crm-schema.ts:1797](apps/server/src/db/crm-schema.ts)) is denormalized per thread (Gmail-mirrored). There is **no per-conversation unread count** on `listConversations` rows today; the field would need a `SUM`/`COUNT(*) FILTER (WHERE hasUnread)` rollup added to [listConversationsSingleQuery](apps/server/src/services/crm/conversations.ts:3498). This is the only server change required by the new sidebar.
15. **Open-task signal today** — `listConversations` already accepts `hasTodoTasks` as a filter ([crm.ts:507](apps/server/src/trpc/routes/crm.ts), [conversations.ts:418](apps/server/src/services/crm/conversations.ts)), but the same rollup is not returned on each row. We need a sibling `openTaskCount` (or boolean `hasOpenTasks`) returned per conversation so the sidebar row can decide whether to paint the unread badge blue without an extra round trip.
16. **Remind action today** — there is no one-click "remind me later on this conversation" affordance anywhere in the app. The only way to set a reminder today is to open the conversation, navigate to Next Steps, and create a `UserTask` manually. The backend primitive exists: `trpc.userTasks.createTask` at [user-tasks.ts:286](apps/server/src/trpc/routes/user-tasks.ts) accepts `{ conversationId, description, dueDate, taskChannel, taskType?, agentExecutionEnabled }` and creates a `todo` task scoped to the active user — exactly what a sidebar Remind button needs.
17. **Search today** — Conversation search lives only inside `/crm` and `/mail/conversation-inbox` surfaces; there is no sidebar-scoped text filter. `trpc.crm.listConversations` does not currently take a `search` input; the existing precedent for free-text filtering is client-side `String.includes` over the loaded result set ([ConversationCanvas.tsx](apps/mail/modules/crm/components/conversation-canvas/ConversationCanvas.tsx) filters in memory after fetching). We use the same precedent in v1 (client-side filter over `conversation.name + company.name + last event subject`) so we don't block on a server-side search index.
A representative `listConversations` row (relevant fields only):
```json
{
"id": "conv_abc",
"name": "Acme — Q2 rollout",
"important": true,
"status": "active",
"priority": "high",
"primaryUser": "user_jl",
"aopId": "aop_sales",
"lastContactedAt": "2026-05-30T14:02:00Z",
"nextStepDate": null,
"dealValue": 25000,
"company": { "id": "co_acme", "name": "Acme", "logoUrl": "…" },
"integrationMetadata": [{ "type": "slack", "channelId": "C123", "channelName": "acme-deals" }],
"events": [{ "id": "evt_1", "eventType": "email_inbound", "occurredAt": "2026-05-30T14:02:00Z" }]
}
```
## 3) Designed state
### 3.1 Architecture diagram
```text
/conversations → ConversationsLayout → ConversationsWorkspace (empty right pane)
/conversations/c/:conversationId → ConversationsLayout → ConversationsWorkspace (right pane = ConversationView)
/conversations/agenda → ConversationsLayout → ConversationsWorkspace (right pane = AgendaView)
ConversationsLayout ← NEW file at apps/mail/app/(routes)/conversations/layout.tsx
├── ThreadConversationUrlSync(listScope='conversation-list')
├── ConversationDataSync
└── ConversationsWorkspace
├── ConversationsSidebar (left, ~280–320 px, resizable)
│ ├── ConversationsSidebarHeader [ "Conversations" ⏷ group & filter ]
│ ├── ConversationsSidebarAgendaRow [ 📅 Agenda <due-today> ]
│ ├── ConversationsSidebarSearchBar [ ⌕ Search conversations… ]
│ ├── divider
│ └── ConversationsSidebarBody (virtua VList — flat row stream with sticky group headers)
│ ├── ConversationsSidebarGroupHeader "Starred" (chevron · count)
│ ├── ConversationsSidebarRow × n
│ ├── ConversationsSidebarGroupHeader "Response" (awaiting reply)
│ ├── ConversationsSidebarRow × n
│ └── ConversationsSidebarGroupHeader "Other" (collapsed by default)
│ ConversationsSidebarRow layout:
│ [avatar] Name ……………………… [Remind ⏰] [● n] 2d
│ ↑ ↑
│ │ └── right-aligned relative time
│ └── unread badge — neutral by default,
│ BLUE when conversation.hasOpenTasks
│
└── ConversationsMainPane (right, flex-1)
├── /conversations → ConversationsEmptyState ("Select a conversation or Agenda…")
├── /conversations/c/:id → ConversationView (no outer chrome)
└── /conversations/agenda → AgendaView
└── TaskBlockList (from next-steps.md)
tasks = trpc.userTasks.listUserTasks({ status:'todo' }) ∪ done (toggle)
Sidebar grouping, sort, search state
conversationsSidebarSlice
├── groupBy: 'starredAndResponse' (default) | 'none' | 'status' | 'priority' | 'owner' | 'starred' | 'awaitingResponse' | 'aop'
├── sortBy: 'lastActivity' | 'dueDateAsc' | 'name' | 'dealValueDesc'
├── filters: { status?, priority?, ownerUserIds?, aopIds?, hasOpenTasks?, hasSlack? }
├── searchQuery: string ← live text from ConversationsSidebarSearchBar (NOT persisted)
├── collapsedGroupKeys: string[]
└── persisted (except searchQuery) via inline localStorage (key 'cedar:conversations-sidebar:v1') — same precedent as HomeView's cedar-last-home-tab
LeftSidebarContent icon rail (NEW Conversations button)
primaryNavButtons: [
{ id: 'conversations', icon: MessagesSquareIcon, href: '/conversations' }, ← NEW, inserted as the first item
{ id: 'mail', ... },
{ id: 'pipeline', ... }, (existing /home + /pipeline coupling is unchanged)
...
]
```
### 3.2 Step-by-step walkthrough
1. **Route additions** — add a new `layout('(routes)/conversations/layout.tsx', …)` block to [routes.ts](apps/mail/app/routes.ts), alongside the existing `home/layout.tsx` block (untouched):
```ts
layout('(routes)/conversations/layout.tsx', [
route('/conversations', '(routes)/conversations/page.tsx'),
route('/conversations/c/:conversationId', '(routes)/conversations/c.$conversationId.page.tsx'),
route('/conversations/agenda', '(routes)/conversations/agenda.page.tsx'),
]),
```
All three share the same `ConversationsLayout` (the split-pane shell + URL syncs).
2. **`ConversationsLayout`** at [apps/mail/app/(routes)/conversations/layout.tsx](apps/mail/app/(routes)/conversations/layout.tsx) — new file:
```tsx
<>
<ThreadConversationUrlSync listScope="conversation-list" />
<ConversationDataSync />
<ConversationsWorkspace>
<Outlet />
</ConversationsWorkspace>
</>
```
Unlike the legacy `home/layout.tsx`, this route is **not** wrapped in `<Sidepanel>` (the right-side widget strip). The Slack-style left sidebar is the only side surface.
3. **`ConversationsWorkspace`** at [apps/mail/modules/conversationsPage/components/ConversationsWorkspace.tsx](apps/mail/modules/conversationsPage/components/ConversationsWorkspace.tsx) — split-pane shell:
```tsx
<div className="flex h-full w-full">
<ConversationsSidebar className="w-[300px] shrink-0 border-r" />
<main className="min-w-0 flex-1">{children}</main>
</div>
```
No resizing in v1 (fixed 300 px); a `ResizablePanelGroup` is a follow-up.
4. **Route pages**:
- `/conversations` → [apps/mail/app/(routes)/conversations/page.tsx](apps/mail/app/(routes)/conversations/page.tsx) returns `<ConversationsEmptyState />`.
- `/conversations/c/:conversationId` → [apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx](apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx) reads `useParams().conversationId`, pushes it into the store via `setActiveConversationId` + `setIsConversationOpen(true)`, then renders `<ConversationView />` directly (not via `<ActiveViewDisplay />`).
- `/conversations/agenda` → [apps/mail/app/(routes)/conversations/agenda.page.tsx](apps/mail/app/(routes)/conversations/agenda.page.tsx) renders `<AgendaView />`.
**Why a route param and not the global `?conversationId=` query param?** The query param is still the canonical store of the open conversation across the rest of the app; the route param mirrors it locally so the URL is shareable as `/conversations/c/<id>` and so the right pane re-mounts cleanly on selection change. A small `ConversationsUrlParamSync` effect inside the conversation page reconciles the two: `useParams().conversationId` is mirrored into `activeConversationId` + `isConversationOpen`; navigating via the sidebar `<Link to={'/conversations/c/'+id}>` keeps the param authoritative.
5. **`ConversationsSidebar`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebar.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebar.tsx):
```tsx
<aside className="flex h-full flex-col bg-sidebar">
<ConversationsSidebarHeader />
<ConversationsSidebarAgendaRow />
<ConversationsSidebarSearchBar />
<div className="my-1 border-t" />
<ConversationsSidebarBody />
</aside>
```
6. **`ConversationsSidebarHeader`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarHeader.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarHeader.tsx) — one row, two slots, matching the mock `Conversations [group & filter button]`:
- Left: `<h2>Conversations</h2>` (matches existing sidebar header type scale).
- Right: `<ConversationsFilterPopover />` trigger — single button labelled "Group & filter" with a `SlidersHorizontal` icon. Both grouping and filtering live inside one popover so the header stays to two visual elements. Shows a small count badge on the trigger when any non-default filter is active.
6a. **`ConversationsSidebarSearchBar`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarSearchBar.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarSearchBar.tsx) — single text input bound to `conversationsSidebarSlice.searchQuery`:
- `<Input placeholder="Search conversations…" />` with a `Search` lucide icon prefix and an `X` clear button when non-empty.
- Debounced to 120 ms via the existing `useDebouncedValue` hook (if absent, an inline `setTimeout`).
- **Filter is client-side** in v1: `ConversationsSidebarBody` calls `filterConversationsByQuery(conversations, query)` (pure helper at [apps/mail/modules/conversationsPage/utils/filterConversationsByQuery.ts](apps/mail/modules/conversationsPage/utils/filterConversationsByQuery.ts)) which matches against `conversation.name`, `conversation.company?.name`, and the latest event's subject/preview. Case-insensitive, whitespace-tokenized, all tokens must match.
- When the query is non-empty, group headers stay rendered but auto-expand (`collapsedGroupKeys` is bypassed) so users see all matches; groups with zero post-filter rows collapse out entirely.
- `searchQuery` is intentionally **not persisted** — a stale query across reloads is more confusing than helpful.
- Hotkey: `/` focuses the search input when the right pane is not in an editable surface. Reuse the existing `useGlobalHotkey` pattern from [conversation-overview-hotkeys.ts](apps/mail/modules/conversations/utils/conversation-overview-hotkeys.ts).
7. **`ConversationsSidebarAgendaRow`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarAgendaRow.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarAgendaRow.tsx) — a single fixed row that mirrors a sidebar conversation row but routes to `/conversations/agenda`. Active styling applied when `useLocation().pathname.startsWith('/conversations/agenda')`. Right side shows a small badge with the count of `todo` tasks due today (read from the same `listUserTasks` query the AgendaView uses, cached).
8. **`ConversationsSidebarBody`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarBody.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarBody.tsx) — virtualized stream:
- Fetch via `useConversationsSidebarConversations()` → `trpc.crm.listConversations` with `{ limit: 200, ownerUserIds: null, sortBy: [{kind:'builtin', field: <sortBy from slice>, direction: 'desc'}], status: filterStatus, priority: filterPriority, important: filterStarred }`.
- Group via the **pure** `groupSidebarConversations(conversations, groupBy, aop, currentUserId)` helper at [apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts](apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts) returning `Array<{ key, label, conversations }>`.
- Render with `virtua`'s `VList` (precedent: [ConversationCanvas.tsx:32](apps/mail/modules/crm/components/conversation-canvas/ConversationCanvas.tsx)): flat array of `{type:'header'|'row', …}` items, sticky headers via CSS (`position: sticky; top: 0`).
9. **`ConversationsSidebarGroupHeader`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarGroupHeader.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarGroupHeader.tsx) — `[chevron] LABEL (count)`. Click toggles `collapsedGroupKeys[key]` in `conversationsSidebarSlice`.
10. **`ConversationsSidebarRow`** at [apps/mail/modules/conversationsPage/components/ConversationsSidebarRow.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarRow.tsx) — one Slack-style line per conversation. Compact, fixed height ~32 px. Two new affordances over the previous design: **Remind** (hover-revealed) and an **unread badge** whose color encodes task state.
```tsx
const unreadCount = conv.unreadEmailCount ?? 0;
const hasOpenTask = (conv.openTaskCount ?? 0) > 0;
return (
<Link
to={`/conversations/c/${conv.id}`}
className={cn(
'group flex items-center gap-2 rounded px-2 py-1.5',
isActive && 'bg-accent text-accent-foreground',
)}
>
<CompanyAvatar size="xs" company={conv.company} />
<span className={cn('flex-1 truncate', unreadCount > 0 && 'font-semibold')}>
{conv.name || conv.company?.name}
</span>
{/* Remind — hover-revealed, suppresses Link navigation on click */}
<RemindButton conversationId={conv.id} className="opacity-0 group-hover:opacity-100" />
{/* Unread badge — blue when an open task exists, neutral otherwise */}
{unreadCount > 0 && (
<Badge
size="xs"
className={cn(
hasOpenTask
? 'bg-blue-500 text-white dark:bg-blue-500'
: 'bg-muted text-muted-foreground',
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
<RelativeDate value={conv.lastContactedAt} className="text-xs text-muted-foreground" />
</Link>
);
```
Active row treatment matches `LeftSidebarContent`'s nav item active state (bg + text contrast). Reuse `CompanyAvatar` from [ConversationItem.tsx](apps/mail/modules/crm/components/conversation-canvas/ConversationItem.tsx) (extract the avatar block into a shared `CompanyAvatar.tsx` if not already standalone).
**Badge color contract:** the badge is **always blue when `hasOpenTask` is true and there is at least one unread message**, regardless of count. When there is no open task, the badge keeps the neutral muted styling. When `unreadCount === 0` no badge renders even if there is an open task (the row's font weight already de-emphasizes a fully-read row; an empty badge would be visual noise). Open-task-but-fully-read state is communicated by the Agenda count and by the conversation's Next Steps tab — not by a badge here.
**`RemindButton`** at [apps/mail/modules/conversationsPage/components/RemindButton.tsx](apps/mail/modules/conversationsPage/components/RemindButton.tsx):
```tsx
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<button aria-label="Remind me" className="rounded p-1 hover:bg-accent">
<BellIcon className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => createReminder({ in: '3h' })}>Later today (3h)</DropdownMenuItem>
<DropdownMenuItem onClick={() => createReminder({ in: '24h' })}>Tomorrow</DropdownMenuItem>
<DropdownMenuItem onClick={() => createReminder({ in: '7d' })}>Next week</DropdownMenuItem>
<DropdownMenuItem onClick={() => openCustomPicker()}>Pick date & time…</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
`createReminder` calls `trpc.userTasks.createTask` at [user-tasks.ts:286](apps/server/src/trpc/routes/user-tasks.ts) with `{ conversationId, description: 'Follow up on <conversation name>', dueDate, taskChannel: 'email', agentExecutionEnabled: false }`. On success: toast `"Reminder set for <relative time>"` with an Undo action that calls `trpc.userTasks.delete` against the returned `task.id`. Invalidate `trpc.userTasks.listUserTasks.queryKey()` (refreshes Agenda badge + the row's `openTaskCount`/badge color via `trpc.crm.listConversations` re-roll-up). `e.preventDefault()` on the trigger prevents the parent `<Link>` from navigating when the user clicks the bell.
11. **`ConversationsFilterPopover`** at [apps/mail/modules/conversationsPage/components/ConversationsFilterPopover.tsx](apps/mail/modules/conversationsPage/components/ConversationsFilterPopover.tsx) — Popover keyed to `conversationsSidebarSlice`. Sections:
- **Group by** — RadioGroup of `none | status | priority | owner | starred | awaitingResponse | aop`.
- **Sort by** — RadioGroup of `lastActivity (desc) | dueDateAsc | name | dealValueDesc`.
- **Filters** — small set of toggles + multi-selects:
- Status (multi-select from AOP `statusOptions`)
- Priority (multi-select from AOP `priorityOptions`)
- Owner (multi-select; default = me; "All org" toggle)
- AOP (multi-select)
- "Only starred" toggle (sets `important = true` filter)
- "Has open tasks" toggle (sets `hasTodoTasks = true`)
- "Has Slack channel" toggle (post-filter on `integrationMetadata`)
- **Reset** button at the bottom.
Trigger styling and DropdownMenu/Popover shells reused from [FilterSortConfigurationRow.tsx:648](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx).
12. **`conversationsSidebarSlice`** at [apps/mail/modules/conversationsPage/slice/conversationsSidebarSlice.ts](apps/mail/modules/conversationsPage/slice/conversationsSidebarSlice.ts) — Zustand slice composed into the main store at [store/index.ts](apps/mail/modules/store/index.ts):
```ts
export type SidebarGroupBy =
| 'starredAndResponse' // default — Starred + Response + Other (matches the conversations-page.md mock)
| 'none' | 'status' | 'priority' | 'owner' | 'starred' | 'awaitingResponse' | 'aop';
export type SidebarSortBy = 'lastActivity' | 'dueDateAsc' | 'name' | 'dealValueDesc';
export interface SidebarFilters {
status?: string[];
priority?: string[];
ownerUserIds?: string[] | 'all_org';
aopIds?: string[];
onlyStarred?: boolean;
hasOpenTasks?: boolean;
hasSlackChannel?: boolean;
}
export interface ConversationsSidebarState {
groupBy: SidebarGroupBy; // default 'starredAndResponse'
sortBy: SidebarSortBy; // default 'lastActivity'
filters: SidebarFilters; // default {}
searchQuery: string; // default '' — NOT persisted
collapsedGroupKeys: Record<string, boolean>;
setGroupBy: (g: SidebarGroupBy) => void;
setSortBy: (s: SidebarSortBy) => void;
setFilters: (next: Partial<SidebarFilters>) => void;
setSearchQuery: (q: string) => void;
toggleGroupCollapsed: (key=[redacted] => void;
resetFilters: () => void;
}
```
**Persistence:** inline `localStorage.setItem('cedar:conversations-sidebar:v1', JSON.stringify({...}))` inside each setter except `setSearchQuery`, and `localStorage.getItem(...)` in the slice initializer. This matches the precedent used by `HomeView` itself ([HomeView.tsx:41,146,157](apps/mail/modules/home/components/HomeView.tsx)) and `ConversationBodyContent` ([ConversationBodyContent.tsx:523,529](apps/mail/modules/conversations/components/ConversationBodyContent.tsx)) — same app, same key pattern, no new persistence layer. Scope is per-device, per-browser; that is the right default for sidebar UX (each device shows what it last showed).
13. **`groupSidebarConversations`** at [apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts](apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts) — pure helper:
```ts
interface SidebarGroup { key=[redacted]; label: string; conversations: HydratedConversation[]; defaultCollapsed?: boolean; }
function groupSidebarConversations(
convs: HydratedConversation[],
groupBy: SidebarGroupBy,
ctx: { aop: Aop | null; currentUserId: string; now: Date },
): SidebarGroup[];
```
Rules per `groupBy`:
- `starredAndResponse` (**default**) → three groups, in this order:
1. `Starred` — `conversation.important === true`.
2. `Response` — `isAwaitingResponse(conv)` (see below) **and not already in `Starred`**.
3. `Other` — everything else (collapsed by default).
This is what the conversations-page.md mockup shows: `Starred` block above `Response` block, with the rest reachable via the collapsed third group. A starred conversation that is also awaiting a reply stays in `Starred` only — the row's blue task badge already communicates the "needs me" signal, so showing it in both groups would be noise.
- `none` → one group `{ key: 'all', label: 'All', conversations }`.
- `status` → one group per non-empty `aop.statusOptions` entry; conversations whose `status` is falsy → `Unset`. Order: `aop.statusOptions` ordering, with `Unset` last.
- `priority` → mirrors `status` but from `aop.priorityOptions`.
- `owner` → group by `primaryUser`; current user's group is labelled `Me` and pinned first.
- `starred` → two groups: `Starred` (where `conversation.important === true`) and `Other`. `Starred` first.
- `awaitingResponse` → **client-derived**:
```ts
function isAwaitingResponse(conv: HydratedConversation): boolean {
const last = [...conv.events].sort((a,b) => +new Date(b.occurredAt) - +new Date(a.occurredAt))[0];
if (!last) return false;
return last.eventType === 'email_inbound' || last.eventType === 'slack_message_inbound';
}
```
Two groups: `Awaiting response` and `Up to date`.
- `aop` → one group per `aopId` (label = AOP name); unassigned → `No AOP` last.
All groupings respect `collapsedGroupKeys` for the chevron toggle.
14. **`AgendaView`** at [apps/mail/modules/conversationsPage/components/AgendaView.tsx](apps/mail/modules/conversationsPage/components/AgendaView.tsx):
```tsx
const { data, isLoading } = useQuery(trpc.userTasks.listUserTasks.queryOptions({ status: 'todo', limit: 1000 }));
const tasks = data?.tasks ?? [];
return (
<div className="flex h-full flex-col">
<AgendaHeader /> {/* "Agenda" title + a small "Show completed" toggle */}
<TaskBlockList tasks={tasks} mode="cross-conversation" />
</div>
);
```
- Reuses `TaskBlockList`, `TaskBlock`, `groupTasksByDate` from [next-steps.md](./next-steps.md). The components already accept a flat `tasks` array; nothing in their grouping logic is single-conversation-specific.
- **Cross-conversation badge** — `TaskBlock` needs a small new prop `showConversation?: boolean`. When true, each block renders a `[CompanyAvatar] Conversation name` chip above the title, and clicking the block (outside the action buttons) navigates to `/conversations/c/${task.conversationId}`. The block already reads `task.conversationId` from `ConversationUserTask`. Add this as a small additive prop on `TaskBlock`; default `false` so the single-conversation surface (pinned card in Inbox) is unaffected.
- The "Open draft" / "Create draft" actions inside each block continue to route to the conversation's Inbox composer as designed in [next-steps.md §3.2.5](./next-steps.md) — selecting an Open action navigates the right pane to `/conversations/c/${conversationId}` and dispatches the same `openComposerForDraft` event.
15. **`ConversationsEmptyState`** at [apps/mail/modules/conversationsPage/components/ConversationsEmptyState.tsx](apps/mail/modules/conversationsPage/components/ConversationsEmptyState.tsx) — centred placeholder: `"Select a conversation or Agenda from the sidebar."` plus a `[ Open Agenda ]` shortcut button.
16. **Conversation pane mount path** — `/conversations/c/:conversationId/page.tsx`:
```tsx
const { conversationId } = useParams();
useEffect(() => {
const s = useCedarStore.getState();
if (s.activeConversationId !== conversationId) s.setActiveConversationId(conversationId);
if (!s.isConversationOpen) s.setIsConversationOpen(true);
}, [conversationId]);
return <ConversationView onClose={() => navigate('/conversations')} />;
```
`<ConversationView />` ([ConversationView.tsx:97](apps/mail/modules/crm/components/ConversationView.tsx)) is mounted directly — **not via `<ActiveViewDisplay />`** — because the right pane already constrains the layout (no fullscreen overlay). The `onClose` handler navigates back to `/conversations` so the empty state is restored.
17. **Real-time updates** — sidebar list refreshes via the existing `trpc.crm.listConversations` cache. The current invalidation pattern from [use-optimistic-conversation-actions.ts:147,294,330,420](apps/mail/modules/crm/hooks/use-optimistic-conversation-actions.ts) already invalidates `trpc.crm.listConversations.queryKey()` after every conversation mutation. The Agenda's `listUserTasks` query is invalidated by `useOptimisticTaskActions` ([use-optimistic-task-actions.ts:36](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)). No new websocket subscription needed in this phase; if `staleTime` is too long, drop `refetchOnWindowFocus: true` and a short `refetchInterval` (e.g. 60 s) for the sidebar query.
18. **Loading / empty / pagination**:
- Sidebar loading: render 8 skeleton rows (compact, matching row height) inside the `VList` shell.
- Sidebar empty (no conversations at all): `"No conversations yet."` centred in the body.
- Sidebar empty (filter killed all results): `"No conversations match these filters. [ Reset ]"`.
- Pagination: `limit: 200` covers the vast majority of users; load more via `cursor` if `hasMore`. A small `[ Load more ]` row at the bottom — no auto-scroll-load in v1 to keep the sticky group headers stable.
19. **Real-time updates for Agenda count badge** — `ConversationsSidebarAgendaRow` reads the same `listUserTasks({ status:'todo' })` cache and filters client-side for `dueDate <= endOfToday`. Single query shared with `AgendaView`.
20. **New Conversations icon active-state** — the new `id: 'conversations'` entry added to `primaryNavButtons` in `LeftSidebarContent` (`apps/mail/modules/conversations/components/LeftSidebarContent.tsx`) needs its `isActive` predicate to match `currentPath.startsWith('/conversations')` so the icon lights up across `/conversations`, `/conversations/c/...`, and `/conversations/agenda`. The pre-existing Pipeline icon's coupling to `/home` ([LeftSidebarContent.tsx:281](apps/mail/modules/conversations/components/LeftSidebarContent.tsx)) is unrelated to this redesign and stays untouched.
## 4) Implementation phases
### Phase 0 — Backend rollups for unread + open tasks on listConversations
**Goal:** Surface `unreadEmailCount` and `openTaskCount` (or `hasOpenTasks: boolean`) on every row returned by `trpc.crm.listConversations` so the sidebar row badge can render its blue-vs-neutral state without a per-row round trip. No frontend changes in this phase.
- [x] In [listConversationsSingleQuery](apps/server/src/services/crm/conversations.ts:3498), add two subqueries: `COUNT(*) FILTER (WHERE crm_email_threads.has_unread)` joined on `conversationId`, returned as `unreadEmailCount`; `COUNT(*) FILTER (WHERE user_tasks.status = 'todo' AND user_tasks.user_id = $sessionUserId)` joined on `conversationId`, returned as `openTaskCount`.
- [x] Extend the `CrmConversation` row type at [apps/server/src/services/crm/conversations.ts](apps/server/src/services/crm/conversations.ts) with `unreadEmailCount: number` and `openTaskCount: number`.
- [x] Propagate the two new fields through the tRPC output schema at [apps/server/src/trpc/routes/crm.ts:470](apps/server/src/trpc/routes/crm.ts) so the client receives them typed.
- [x] Verify the new joins respect the existing per-user scoping on `user_tasks` (the count is **my** open tasks per conversation, not the org's).
**Tests:**
- [x] `pnpm --filter @zero/server types` (pre-existing errors only; no new errors in `conversations.ts` or `crm.ts` from this phase's edits)
- [ ] Unit/integration test against a seeded conversation with `n` unread threads and `m` open tasks for the session user — assert the row returns `{ unreadEmailCount: n, openTaskCount: m }`. **Deferred:** the existing CRM test suite ([apps/server/src/services/crm/__tests__/list-conversations-filters.test.ts](apps/server/src/services/crm/__tests__/list-conversations-filters.test.ts)) is SQL-builder-mode only (no live-DB harness); validating the rollup needs a seeded Postgres, which is environment-dependent. Verify manually against a staging DB before merging Phase 1.
### Phase 1 — Route scaffolding + split-pane shell + empty states + Conversations nav icon
**Goal:** New `/conversations`, `/conversations/c/:conversationId`, `/conversations/agenda` routes mount with a split-pane layout: static "Conversations" header + Agenda row on the left (no list yet), placeholder empty state on the right. The legacy `/home` canvas dashboard is left fully intact.
- [x] Add a new `layout('(routes)/conversations/layout.tsx', […])` block to [routes.ts](apps/mail/app/routes.ts) registering `/conversations`, `/conversations/c/:conversationId`, and `/conversations/agenda`. Do not modify the existing `home/layout.tsx` block.
- [x] Create [apps/mail/app/(routes)/conversations/layout.tsx](apps/mail/app/(routes)/conversations/layout.tsx) rendering `<><ThreadConversationUrlSync listScope="conversation-list" /><ConversationDataSync /><ConversationsWorkspace><Outlet /></ConversationsWorkspace></>`. No `<Sidepanel>` wrapper.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsWorkspace.tsx](apps/mail/modules/conversationsPage/components/ConversationsWorkspace.tsx) — split-pane shell (`flex` · 300 px sidebar · 1fr main).
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebar.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebar.tsx) skeleton (header row + Agenda row + divider + empty body).
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarHeader.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarHeader.tsx) — title + disabled filter button placeholder.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarAgendaRow.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarAgendaRow.tsx) — `<Link to="/conversations/agenda">` with active styling using `useLocation()`.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsEmptyState.tsx](apps/mail/modules/conversationsPage/components/ConversationsEmptyState.tsx).
- [x] Create [apps/mail/app/(routes)/conversations/page.tsx](apps/mail/app/(routes)/conversations/page.tsx) returning `<ConversationsEmptyState />`.
- [x] Create [apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx](apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx) returning `<ConversationsEmptyState />` for now (placeholder until Phase 3).
- [x] Create [apps/mail/app/(routes)/conversations/agenda.page.tsx](apps/mail/app/(routes)/conversations/agenda.page.tsx) returning `<div>Agenda placeholder</div>`.
- [x] Add a new **Conversations** entry to `primaryNavButtons` at [LeftSidebarContent.tsx:125](apps/mail/modules/conversations/components/LeftSidebarContent.tsx) (inserted as the first item, before `mail`) with `{ id: 'conversations', title: 'Conversations', icon: MessagesSquareIcon, href: '/conversations' }`. Update the `isActive` helper at [LeftSidebarContent.tsx:274](apps/mail/modules/conversations/components/LeftSidebarContent.tsx) so the new button matches `currentPath.startsWith('/conversations')` (covering all three sub-routes). Do not touch the existing Pipeline `/home` coupling. Reuse the `MessagesSquare` icon from `lucide-react`.
**Tests:**
- [x] `pnpm --filter @zero/mail types` (pre-existing errors only; no new errors in the new files or in `LeftSidebarContent.tsx`)
- [x] `pnpm --filter @zero/mail lint` (clean for the new files)
- [ ] Manual: visit `/conversations`, `/conversations/agenda`, `/conversations/c/<id>` and confirm the split-pane renders with the sidebar header, the Agenda row, and the placeholder on the right. Clicking the Agenda row highlights it and routes to `/conversations/agenda`. The new Conversations icon in the global rail highlights for all three URLs. The legacy `/home` route is unaffected — visit it and confirm the existing canvas dashboard still renders.
### Phase 2 — Conversation list, search bar, Remind button, unread + task badge
**Goal:** Sidebar body renders real conversations from `trpc.crm.listConversations`, grouped by `conversationsSidebarSlice.groupBy` (default `starredAndResponse`), sorted by `sortBy`, filtered by `filters`, and free-text filtered by `searchQuery`. Each row shows the unread badge (blue if open task) and a hover-revealed Remind button.
- [x] Create [apps/mail/modules/conversationsPage/slice/conversationsSidebarSlice.ts](apps/mail/modules/conversationsPage/slice/conversationsSidebarSlice.ts) per §3.2.12 with inline `localStorage` read/write under key `cedar:conversations-sidebar:v1`. Initialize from localStorage in the slice constructor. `searchQuery` is in-memory only.
- [x] Compose the new slice into the main store at [apps/mail/modules/store/index.ts](apps/mail/modules/store/index.ts) alongside the existing `createCRMSlice`, `createConversationsSlice`, etc.
- [x] Create [apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts](apps/mail/modules/conversationsPage/utils/groupSidebarConversations.ts) with the pure grouping helper per §3.2.13 — including the new default `starredAndResponse` mode.
- [x] Create [apps/mail/modules/conversationsPage/utils/filterConversationsByQuery.ts](apps/mail/modules/conversationsPage/utils/filterConversationsByQuery.ts) — pure helper matching `conversation.name`, `conversation.company?.name`, and `latestEvent.subject/preview` against a case-insensitive whitespace-tokenized query (all tokens must match).
- [x] Create [apps/mail/modules/conversationsPage/hooks/use-conversations-sidebar-conversations.ts](apps/mail/modules/conversationsPage/hooks/use-conversations-sidebar-conversations.ts) — wraps `trpc.crm.listConversations` with input derived from `conversationsSidebarSlice.{sortBy,filters}` and returns `{ conversations, isLoading, hasMore, loadMore }`. **Does not** pass `searchQuery` to the server in v1. (Note: `important` and `hasSlackChannel` aren't server filters either; they're applied client-side after fetch.)
- [x] Create [apps/mail/modules/conversationsPage/components/CompanyAvatar.tsx](apps/mail/modules/conversationsPage/components/CompanyAvatar.tsx) — small standalone Avatar wrapper with company logo + initial fallback for the row.
- [x] Create [apps/mail/modules/conversationsPage/components/RemindButton.tsx](apps/mail/modules/conversationsPage/components/RemindButton.tsx) per §3.2.10 — dropdown with three presets (Later today / Tomorrow / Next week) calling `trpc.userTasks.createTask`; toast + Undo. `e.preventDefault()` on the trigger so the parent `<Link>` doesn't navigate. (Custom date picker deferred to follow-up.)
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarRow.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarRow.tsx) per §3.2.10 — `<Link to={'/conversations/c/'+id}>`, compact one-line layout, hover-revealed `RemindButton`, unread badge that paints **blue when `openTaskCount > 0` and `unreadEmailCount > 0`**, neutral otherwise, hidden when `unreadEmailCount === 0`.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarGroupHeader.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarGroupHeader.tsx) — collapsible header with chevron and count, calls `toggleGroupCollapsed`. When `searchQuery` is non-empty, force `collapsed = false`.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarBody.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarBody.tsx) — pipeline is `listConversations` → `filterConversationsByQuery(query)` → `groupSidebarConversations(groupBy)` → flat `(header | row)[]` for `virtua` `VList` with sticky headers. Skeletons during loading, empty/filter-killed/no-search-match states.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsSidebarSearchBar.tsx](apps/mail/modules/conversationsPage/components/ConversationsSidebarSearchBar.tsx) per §3.2.6a — debounced input bound to `conversationsSidebarSlice.searchQuery`, `/` global hotkey, `X` clear button.
- [x] Create [apps/mail/modules/conversationsPage/components/ConversationsFilterPopover.tsx](apps/mail/modules/conversationsPage/components/ConversationsFilterPopover.tsx) per §3.2.11 — single combined "Group & filter" popover. Wire to `conversationsSidebarSlice`. Show a count badge on the trigger when any non-default filter is active.
- [x] Wire the `ConversationsSidebarHeader` right slot to mount `ConversationsFilterPopover` and the search-bar row to mount `ConversationsSidebarSearchBar`.
- [ ] Star-toggle integration: ensure clicking the existing star button (still on the row, in the long-press / overflow surface) calls `optimisticUpdateConversation(id, { important })` from [use-optimistic-conversation-actions.ts](apps/mail/modules/crm/hooks/use-optimistic-conversation-actions.ts); the row re-buckets automatically on the next `listConversations` invalidation. **Deferred:** no explicit star button on the sidebar row in v1 (rows are nav items). Starring still flips bucket on the next `listConversations` cache invalidation when toggled from `/crm` or the conversation view's header — the wiring already exists. A dedicated long-press / overflow surface for the sidebar row is a follow-up.
**Tests:**
- [x] `pnpm --filter @zero/mail types` (pre-existing errors only — no new errors in Phase 2 files; the long-standing server/client `HydratedConversation` divergence is unchanged)
- [x] `pnpm --filter @zero/mail lint` (clean for the new files)
- [x] Unit test [tests/modules/conversationsPage/utils/groupSidebarConversations.test.ts](apps/mail/tests/modules/conversationsPage/utils/groupSidebarConversations.test.ts) covering each `groupBy` mode (including the new `starredAndResponse` default and the `awaitingResponse` derivation across `email_inbound`/`email_outbound`/`slack_message_inbound`). 10 tests, all passing.
- [x] Unit test [tests/modules/conversationsPage/utils/filterConversationsByQuery.test.ts](apps/mail/tests/modules/conversationsPage/utils/filterConversationsByQuery.test.ts) covering multi-token AND match, case-insensitivity, and matches against name/company/event subject. 8 tests, all passing.
- [ ] Manual: open `/conversations`; sidebar populates with **Starred** + **Response** + **Other** groups by default. Change Group-by to `status`, `priority`, `awaitingResponse`, `aop` — verify groups re-form. Change Sort-by — verify ordering inside groups updates. Toggle "Only starred" / "Has open tasks" — verify list shrinks. Reload — verify all selections except `searchQuery` persist.
- [ ] Manual: type in the search bar — list narrows to matches, groups stay headed even if some are empty after filtering. Press `/` from outside — focus jumps to the search input. Clear the search — list restores.
- [ ] Manual: on a conversation row, verify the unread badge renders **blue** when the conversation has an open task and at least one unread message, **neutral** when it has unread but no open task, and **hidden** when fully read regardless of task state.
- [ ] Manual: click the Remind bell on a row, pick "Tomorrow" — toast shows "Reminder set for tomorrow", Agenda badge count increments, the row's unread badge flips to blue if it had any unread messages, and Undo removes the task.
### Phase 3 — Right pane mounts ConversationView and AgendaView
**Goal:** Selecting a row navigates to `/conversations/c/:conversationId` and mounts the redesigned `ConversationView` in the right pane. Selecting Agenda mounts `AgendaView` with a cross-conversation `TaskBlockList`. Agenda count badge wired.
- [x] Update [apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx](apps/mail/app/(routes)/conversations/c.$conversationId.page.tsx) per §3.2.16 — reconcile `useParams().conversationId` ↔ store, render `<ConversationView onClose={() => navigate('/conversations')} />`. (Note: the global `<ActiveViewDisplay />` is mounted at the outer `(routes)/layout.tsx`; verify in Phase 4 that it doesn't also push a second `ConversationView` on top of this route.)
- [x] Create [apps/mail/modules/conversationsPage/components/AgendaView.tsx](apps/mail/modules/conversationsPage/components/AgendaView.tsx) per §3.2.14 — query `trpc.userTasks.listUserTasks({ status: 'todo', limit: 1000 })`, render `<TaskBlockList tasks={…} showConversation />`.
- [x] Add `showConversation?: boolean` prop to `TaskBlock` ([apps/mail/modules/conversations/components/nextSteps/TaskBlock.tsx](apps/mail/modules/conversations/components/nextSteps/TaskBlock.tsx)) — when true, render a small `[CompanyAvatar] <conversation name>` chip above the title; clicking the block (outside action buttons) routes to `/conversations/c/${task.conversationId}`. Hydrates conversation name/company from `useCedarStore` (already populated for visible conversations).
- [x] Pass `showConversation` through `TaskBlockList` ([apps/mail/modules/conversations/components/nextSteps/TaskBlockList.tsx](apps/mail/modules/conversations/components/nextSteps/TaskBlockList.tsx)) so `AgendaView` can enable it without affecting the Inbox-pinned card surface. Also added an optional `tasks` prop so the cross-conversation flat list bypasses the store-derived per-conversation tasks.
- [x] Update [apps/mail/app/(routes)/conversations/agenda.page.tsx](apps/mail/app/(routes)/conversations/agenda.page.tsx) to render `<AgendaView />`.
- [x] Wire the Agenda count badge on `ConversationsSidebarAgendaRow` — share the same `listUserTasks` cache, count tasks where `dueDate <= endOfToday`.
- [x] Ensure `Open draft` / `Create draft` actions inside `TaskBlock` (cross-conversation mode) navigate to `/conversations/c/${task.conversationId}` first, then dispatch the existing `openComposerForDraft` event — the conversation's Inbox tab + `SlackComposer` (from [timeline.md](./timeline.md)) handle the rest.
**Tests:**
- [x] `pnpm --filter @zero/mail types` (pre-existing test-typing noise only; no new errors in Phase 3 production files)
- [x] `pnpm --filter @zero/mail lint` (clean for the new + edited files)
- [ ] Manual: click a row in the sidebar — right pane swaps to the conversation; URL becomes `/conversations/c/<id>`. Hit browser back — empty state restored, sidebar selection cleared.
- [ ] Manual: click Agenda — right pane shows date-grouped tasks across multiple conversations; clicking a task block navigates to that conversation; clicking `Open draft` on an email task navigates and opens the composer.
- [ ] Manual: complete a task in AgendaView — Agenda badge count decrements within a beat (shared cache invalidation).
### Phase 4 — Polish + end-to-end verification
**Goal:** Tighten the seams and verify nothing regressed in adjacent surfaces. The legacy `/home` canvas dashboard is **not** touched by this phase — retiring `HomeView`/`HomeCanvas`/`NewCanvasScreen`/`SaveConfigButton`/`UpcomingMeetingsWidget` is a separate cleanup tracked outside this redesign.
- [x] Grep `apps/mail` for stray references to the design's intermediate names (e.g. `HomeWorkspace`, `HomeSidebar`, `homeSidebarSlice`, `useHomeSidebarConversations`, `cedar:home-sidebar:v1`) introduced in scratch branches; replace with the final `Conversations*` / `conversationsSidebar*` / `cedar:conversations-sidebar:v1` equivalents. (No stray references found — only the doc itself describes the audit.)
- [x] Confirm the new Conversations icon's `isActive` predicate (`startsWith('/conversations')`) does not unintentionally match unrelated routes (none today, but verify). Confirmed: the only `/conversations*` routes are the three we just added.
- [x] Confirm `<ActiveViewDisplay />` does not mount a second `<ConversationView />` on top of `/conversations/c/:id` — check both global overlay state and the page mount path. The outer `(routes)/layout.tsx` does **not** mount `<ActiveViewDisplay />`; only `/tasks`, `/mail/agenda`, and `/brain` mount it explicitly. The `/conversations/*` routes mount `<ConversationView />` directly with no overlay competing.
- [x] Verify `trpc.crm.listConversations` cache is shared between the `/crm` canvas and the new sidebar so a star toggle on either surface re-buckets the sidebar without a manual refetch. Fixed: the sidebar's `useInfiniteQuery` key now nests under the tRPC-canonical `[['crm', 'listConversations']]` prefix so existing `invalidateQueries({ queryKey=[redacted] })` calls in `use-optimistic-conversation-actions.ts` and `ConversationView.tsx` reach it.
- [x] Run `pnpm deps:check` to confirm no circular imports introduced by the new `apps/mail/modules/conversationsPage/` module. Passes: 806 modules, 4303 dependencies, no violations.
**Tests:**
- [x] `pnpm --filter @zero/mail types` (pre-existing test-typing noise only; no new errors in any conversationsPage file)
- [x] `pnpm --filter @zero/mail lint` (clean for all touched files)
- [x] `pnpm deps:check` (no violations)
- [ ] Manual: full clickthrough — `/conversations` shows the empty state, sidebar lists conversations with chosen grouping, Agenda row badge reflects today's task count, selecting any conversation opens it in the right pane, the close button returns to `/conversations` with no console errors. Legacy `/home` still mounts `HomeView`.
## Open questions
- **Resizable sidebar?** v1 is fixed 300 px. A `ResizablePanelGroup` + persisted width is straightforward in a follow-up if users push for it.
- **Bulk-select on sidebar rows?** Today's `ConversationCanvas` supports shift-click bulk select via `useBulkSelection`. The Slack-style sidebar treats rows as nav items; bulk-select is out of scope unless requested.
- **Drag-to-reorder of groups?** Not in v1 — group order is determined by `groupBy` (e.g. `aop.statusOptions` ordering). User customization of group order would require a per-user override map, which we can layer on later.
- **`awaitingResponse` server-side?** The client-side derivation in §3.2.13 walks `conv.events`. This is fine while `listConversations` already returns enough event metadata; if the events array is ever trimmed, promote `awaitingResponse` to a derived column on `listConversationsSingleQuery`.
- **Server-side search upgrade?** v1 uses an in-memory `filterConversationsByQuery` over the loaded set (200 rows). If users hit the `limit: 200` ceiling and want to find earlier conversations by name, upgrade `trpc.crm.listConversations` to accept a `search` input and switch the hook to pass `searchQuery` through to the server. The component contract (`ConversationsSidebarSearchBar` → `conversationsSidebarSlice.searchQuery`) does not change.
- **Remind custom date UI?** v1 uses three quick presets plus an "Pick date & time…" option that opens a small popover with the existing date+time picker used by [TaskBlock](apps/mail/modules/conversations/components/nextSteps/TaskBlock.tsx). A richer recurring-reminder UI is out of scope.