timeline.md54.0 KBView on GitHub # Conversation Timeline — Universal Composer
> Detail doc for the **Inbox** tab. Companion to [overview.md](./overview.md). Supersedes Phase 3 of the overview where the two diverge — keep this doc as the source of truth for the Inbox.
> The Inbox is the renamed "Timeline" tab. The visual component is still called `SlackTimeline` because that name describes the rendering style; the **tab key**, **tab label**, and **URL segment** are all `inbox`.
## 1) Introduction — goal, present state, future state
We want the Inbox tab to be a Slack-style activity stream with a **pinned next-step card** sitting above the events and a **universal composer** docked at the bottom that can send messages on whatever channel the conversation is wired to — any linked Slack channel, a reply on any prior email thread, or a brand-new email started from scratch with recipients picked off the conversation's own contacts. Today, [PastEventsTimeline.tsx](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx) renders rich per-event cards (`EmailEventCard`, `MeetingEventCard`, `SlackMessageEventCard`, …) with no composer at all; sending an email is a separate route through [compose-display.tsx](apps/mail/modules/drafting/components/compose-display.tsx) which mounts the full 2,253-line [email-composer.tsx](apps/mail/modules/drafting/components/email-composer.tsx). We will replace `PastEventsTimeline` with `SlackTimeline` (single-row events), extract a slimmer `CoreEmailComposer` body out of `EmailComposer`, and build a `UniversalComposer` on top that switches between Email and Slack via a channel selector, supports attachments for both, adds a contact picker fed from the conversation's own contacts/events, and persists drafts per `(conversationId, target)` across tab switches.
## 2) Present state
### 2.1 Architecture diagram
```text
Timeline tab (ConversationTabBody → 'timeline')
└── PastEventsTimeline ← cards, no composer
├── TimelineEvent ← per-row "compact" badge style
│ (avatar/icon + type badge + RelativeDate)
├── EmailEventCard ← rich expanded email body
├── MeetingEventCard
├── SlackMessageEventCard
├── CallEventCard
├── NoteEventCard
└── (other event renderers)
(no composer in the tab — sending happens elsewhere)
apps/mail/app/(routes)/mail/compose/page.tsx
└── ComposeDisplay
└── EmailComposer ← 2,253 lines
├── useEmailComposerForm() ← attachments, cc/bcc toggle, schedule, etc.
├── useComposeEditor() ← TipTap email editor
├── RecipientAutosuggest (to/cc/bcc) ← uses useContacts() = trpc.crm.listContacts(1000)
├── handleAttachment(File[]) → setValue('attachments', File[])
├── Send → serializeFiles(attachments)
│ → trpc.mail.send({ threadId, to, subject, message, attachments[base64], … })
└── Autosave → trpc.mail.upsertDraft
trpc.integrations.slack.sendMessage({ workspaceId, channelId, message }) ← text only
sendSlackMessage() in services/integrations/slack/slack-api.ts ← chat.postMessage only;
no files.upload exposed
```
### 2.2 Step-by-step walkthrough
1. **Timeline rendering.** The legacy `timeline` tab in `ConversationTabBody` (now called `inbox`) mounts [PastEventsTimeline.tsx](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx). It receives `events: ConversationEvent[]` from `conversationData.data.conversation.events`, sorts them, and dispatches to a per-type card component (`EmailEventCard`, `MeetingEventCard`, `SlackMessageEventCard`, `CallEventCard`, `NoteEventCard`). Each card is a bordered block with hover actions (`EventHoverActions` at [PastEventsTimeline.tsx:100](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx)) wired to `trpc.crm.deleteEvent` and `MoveEventPopover`.
2. **Per-event compact row (used in some surfaces).** [TimelineEvent.tsx:62](apps/mail/modules/conversations/components/timeline/TimelineEvent.tsx) renders a `[icon] [type badge · from/to · primary] [date]` row with `getBadgeLabel`, `getBadgeContact`, `getEventIcon`, `getEventColor` from `./helpers`. No avatar; no composer hook.
3. **There is no composer in the Inbox tab today.** Sending an email goes through [compose-display.tsx](apps/mail/modules/drafting/components/compose-display.tsx) (route `/mail/compose`), which wraps the full `EmailComposer`.
4. **`EmailComposer` shape.** [email-composer.tsx:125](apps/mail/modules/drafting/components/email-composer.tsx) takes `{ draftSessionId, threadId?, conversationId?, emailHeaderMessageId?, initialTo, initialCc, initialBcc, initialSubject, initialMessage, initialAttachments, replyToMessage, replyingTo, expandable, disableAutosave, disableAutofocus, … }`. Internally it owns:
- **Form** via `useForm` + zod ([email-composer.tsx:113](apps/mail/modules/drafting/components/email-composer.tsx)):
```ts
{ to: Email[]; subject: string; message: string; attachments?: File[]; cc?: Email[]; bcc?: Email[]; threadId?: string; fromEmail?: string; headers?: any }
```
- **UI state + attachments** via [use-email-composer-form.ts:109](apps/mail/modules/drafting/hooks/use-email-composer-form.ts) — `handleAttachment(File[])` compresses images and stores raw `File[]` on the form under `attachments`. `removeAttachment(idx)` mutates the list.
- **Editor** via [use-compose-editor.ts](apps/mail/modules/drafting/hooks/use-compose-editor.ts) (custom TipTap, separate from [markdown-editor.tsx](apps/mail/components/markdown-editor.tsx)).
- **Recipients** via `<RecipientAutosuggest control={form.control} name="to" … />` at [recipient-autosuggest.tsx:113](apps/mail/components/ui/recipient-autosuggest.tsx); suggestions come from [use-contacts.ts:14](apps/mail/hooks/use-contacts.ts) which calls `trpc.crm.listContacts({ limit: 1000 })`. No way to inject a custom suggestion source today.
- **Autosave + draft reconciliation** — substantial logic that we do not need in the universal composer.
5. **Send (email).** [email-composer.tsx:1246](apps/mail/modules/drafting/components/email-composer.tsx) calls `serializeFiles(values.attachments ?? [])` (base64), then `trpc.mail.send.mutate(...)` against [mail.ts:1457](apps/server/src/trpc/routes/mail.ts). Server input:
```ts
{
threadId?: string | null; // present → reply on existing thread
draftId?: string | null;
emailHeaderMessageId?: string; // becomes In-Reply-To header
to: Sender[]; cc?: Sender[]; bcc?: Sender[];
subject: string;
message: string; // HTML
attachments?: SerializedFile[]; // { name, type, size, lastModified, base64 }
headers?: Record<string, string>;
fromEmail?: string;
isForward?: boolean;
scheduleAt?: string;
}
```
`SerializedFile` is defined at [schemas.ts:3](apps/mail/modules/drafting/utils/schemas.ts). New thread (no `threadId`) is the "compose new email" path.
6. **Send (Slack).** [integrations.ts:1314](apps/server/src/trpc/routes/integrations.ts) `integrations.slack.sendMessage({ workspaceId, channelId, message })`. Underneath, [slack-api.ts:295](apps/server/src/services/integrations/slack/slack-api.ts) `sendSlackMessage(channelId, message, workspaceId, userId)` runs the `slack-post-message` superglue workflow which is `chat.postMessage` text only. **No `files.upload`, no multipart, no v2 `files.getUploadURLExternal` flow exposed today.** Slack attachments are a server-side addition.
7. **Channels available on a conversation.** Linked Slack channels live on `conversation.integrationMetadata` entries with `type === 'slack'` ([ConversationBodyContent.tsx:553](apps/mail/modules/conversations/components/ConversationBodyContent.tsx)). Email "threads" derive from `conversation.events` entries with `event.emailEvent` (`{ threadId, messageId, subject, fromEmail, toEmails, ccEmails, … }` at [crm/types/index.ts:383](apps/mail/modules/crm/types/index.ts)).
8. **Conversation contacts source.** There is no `conversationData.data.conversation.contacts` field today on `getConversation` — the conversation's people surface via `crm.getConversationMembers` (used by [ConversationParticipantsPanel.tsx:56](apps/mail/modules/conversations/components/ConversationParticipantsPanel.tsx)) plus deduped sender/recipient emails extracted from `conversation.events[*].emailEvent` (`fromEmail`, `fromName`, `toEmails`, `ccEmails`). The contact picker must union these two sources.
9. **Draft persistence today.** `EmailComposer` autosaves the email draft to the server via `trpc.mail.upsertDraft` and to the Zustand store at `state.threadData[draftSessionId]` (see [compose-display.tsx:37-63](apps/mail/modules/drafting/components/compose-display.tsx)). There is no per-conversation universal-composer draft slot.
Data flowing through a send today, illustrative:
```ts
// after handleAttachment(file)
form.values = {
to: ['<email>'],
subject: 'Re: pricing',
message: '<p>Sounds good — confirming for Tuesday.</p>',
attachments: [File('pricing.pdf', 142_133)],
};
// at submit
const payload = {
threadId: 'thr_abc',
emailHeaderMessageId: '<<email>>',
to: [{ email: '<email>', name: 'Lia' }],
subject: 'Re: pricing',
message: '<p>Sounds good — confirming for Tuesday.</p>',
attachments: [{ name: 'pricing.pdf', type: 'application/pdf', size: 142133, lastModified: 1717..., base64: 'JVBERi0xLjQK…' }],
};
trpc.mail.send.mutate(payload);
```
```ts
// slack path today
trpc.integrations.slack.sendMessage.mutate({
workspaceId: 'T01ABCDE',
channelId: 'C09XYZ',
message: 'Confirming for Tuesday.',
});
// no attachment field
```
## 3) Designed state
### 3.1 Architecture diagram
```text
Inbox tab (ConversationTabBody → 'inbox')
└── SlackTimeline flex-col h-full
├── PinnedNextStepCard (NextStepsCard) sticky-top, border-b
│ (extracted from AgentRow TASK_AGGREGATOR body; see next-steps.md)
├── events scroll region (flex-1, overflow-auto)
│ [day divider]
│ SlackTimelineEvent rows (grid-cols-[32px_1fr_auto])
│ ┌────────────────────────────────────────────────┐
│ │ [Avatar] Name ⇢ 2:04 PM [⋯ hover]│
│ │ Body content (collapsible 3 → all) │
│ └────────────────────────────────────────────────┘
└── UniversalComposer sticky bottom (border-t)
[Slack│Email│Cedar] toggle pinned top-right of the docked area
┌──────────────────────────────────────────────────┐ bg-raised single
│ #channel-name (slack) ← no divider below │ rounded-md container
│ Editor (MarkdownEditor, Cmd+Enter sends) │ for slack / cedar
│ [attachment chips when present] │
│ [📎] [Send ⌘↵] │
└──────────────────────────────────────────────────┘
When channel=email: the docked area is the full <EmailComposer /> straight
up — same To/Cc/Bcc/Subject/Toolbar/TipTap/Attachments/ScheduleSend/Send as
/mail/compose — no extra wrapper.
@ pressed in the slack editor opens the ContactPicker anchored to the editor.
UniversalComposer internals
├── resolveSlackTargets(conversation) → SlackTarget[]
├── ChannelToggle(value, onChange, disabled) ← segmented [Slack | Email | Cedar]
│ Cedar option uses the Cedar logo.
├── SlackPanel ← #channel header + MarkdownEditor
│ + paperclip + send (no @ button)
├── CedarPanel ← stub MarkdownEditor + Send
├── ContactPicker(open, onOpenChange, anchor) ← controlled @-popover, anchored to
│ an invisible span inside the slack
│ editor's relative wrapper
└── useComposerDraft(conversationId, channelKey) ← Zustand slice keyed by
`slack:<channelId>` | 'email' | 'cedar'
trpc.integrations.slack.uploadFile ← NEW (Phase 4)
├── input: { workspaceId, channelId, files: SerializedFile[], message?: string }
└── server: services/integrations/slack/slack-files.ts → files.getUploadURLExternal +
POST upload URL + files.completeUploadExternal
trpc.integrations.slack.resolveSlackPrincipals ← NEW (Phase 1.5 + 3)
├── input: { workspaceId, emails?: string[], userIds?: string[] }
├── output: { byEmail: Record<email, { userId, displayName, avatarUrl } | null>,
│ byUserId: Record<userId, { email?, displayName, avatarUrl } | null> }
└── server: services/integrations/slack/slack-users.ts
├── users.lookupByEmail per email
├── users.info per userId
└── cached per (workspaceId, email|userId) for ~1h. Requires `users:read`
(and `users:read.email` for the email direction).
SlackBodyRenderer (client) ← NEW (Phase 1.5)
parseSlackMrkdwn(text, resolveUserId) → React nodes
decodes <@U…>, <#C…|name>, <!here|channel|subteam>, <url|label>,
*bold*, _italic_, ~strike~, `code`, ```block```, >quote, &/</>
```
`ComposeTarget` shape (single source of truth, lives in [resolve-compose-targets.ts](apps/mail/modules/conversations/components/timeline/composer/resolve-compose-targets.ts)):
```ts
export type ComposeTarget =
| {
kind: 'email-reply';
id: string; // stable key=[redacted]
threadId: string;
headerMessageId?: string; // becomes In-Reply-To
defaultTo: Sender[];
defaultSubject: string; // pre-fixed with "Re: "
label: string; // e.g. "Reply · Re: pricing"
}
| {
kind: 'email-new';
id: 'email-new';
defaultTo: Sender[]; // seeded from conversation contacts
label: 'New email';
}
| {
kind: 'slack';
id: string; // `slack:${workspaceId}:${channelId}`
workspaceId: string;
channelId: string;
channelName: string;
label: string; // e.g. "#acme-cedar"
};
```
### 3.2 Step-by-step walkthrough
1. **`SlackTimeline`** at [apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx](apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx) — `flex flex-col h-full`. Scrollable list of `SlackTimelineEvent` rows, day-divider rows (`— Today —`, `— Mar 12 —`) between events on different days, sticky `UniversalComposer` at the bottom. Receives `conversationId`, derives `events` from `useConversationById(conversationId)`.
2. **`SlackTimelineEvent`** at [apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx](apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx) — single grid row, `grid-cols-[32px_1fr_auto]`: avatar (with event-type icon overlay reusing `getEventIcon` from [helpers.ts](apps/mail/modules/conversations/components/timeline/helpers.ts)), name+content, time-of-day. Long bodies collapse to 3 lines with "Show more". Hover-reveals the same actions block as [PastEventsTimeline.tsx:100](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx) (`MoveEventPopover`, delete via `trpc.crm.deleteEvent`, "Open thread" when `emailEvent.threadId`).
2a. **Slack body fidelity — mrkdwn decoding + real avatars.** Slack's `text` is mrkdwn and currently renders literally (`Hey <@U097KR4SPA6>` shows the raw token, and avatars fall back to BIMI by email domain). Two pieces fix this:
- **`SlackBodyRenderer`** at [apps/mail/modules/conversations/components/timeline/SlackBodyRenderer.tsx](apps/mail/modules/conversations/components/timeline/SlackBodyRenderer.tsx) — given `text` and an injected `resolveUserId(userId) → { displayName } | null`, parses Slack mrkdwn into React nodes: `<@U…>` → `@DisplayName` (falls back to `@unknown` when unresolved), `<#C…|name>` → `#name`, `<!here|channel>` → `@here|@channel`, `<!subteam^S…|name>` → `@name`, `<url|label>` → `<a href>`, plus inline `*bold*` / `_italic_` / `~strike~` / `` `code` `` / ```` ```block``` ```` / `>quote` and the HTML entity unescapes. Used by both `SlackTimelineEvent` and the existing `SlackMessageEventCard`.
- **`useSlackPrincipals(workspaceId, userIds)`** at [apps/mail/modules/conversations/components/timeline/use-slack-principals.ts](apps/mail/modules/conversations/components/timeline/use-slack-principals.ts) — `SlackTimeline` scans visible events, collects every `userId` referenced in `<@U…>` tokens and every speaker `slackUserId`, fires a single batched `trpc.integrations.slack.resolveSlackPrincipals` query, and threads the resolver into both `SlackBodyRenderer` and `BimiAvatar` via a context provider. `BimiAvatar` gains an optional `avatarUrl` prop that, when set, short-circuits the BIMI/domain logo lookup.
- **Ingest-side capture (forward fix).** `SlackMessageEvent` gains an optional `userImageUrl` field on its schema in [crm/types/index.ts:488](apps/mail/modules/crm/types/index.ts), and the Slack ingest in [slack-events-webhook.ts:804](apps/server/src/services/integrations/slack/slack-events-webhook.ts) calls `users.info` (cached) when persisting a message, storing the result. Legacy events without it fall back to the client-side `useSlackPrincipals` lookup.
3. **`UniversalComposer`** at [apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx](apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx) — top-level layout for the docked composer. Props `{ conversationId }`. Renders as a **single contained `rounded-md` box** matching the visual language of the standalone email composer: a recipient row at the top, the active editor in the middle, and a footer row with attachments on the left and the channel toggle + send button on the right. Responsibilities: build target list, hold selected channel, hold composer draft, render the right recipient row + editor, send.
```ts
const targets = useComposeTargets(conversationId); // memo
const [channel, setChannel] = useComposerChannelState(conversationId); // 'slack' | 'email' | 'cedar'
const target = pickTarget(targets, channel); // resolves to a concrete ComposeTarget
const draft = useComposerDraft(conversationId, channel); // {body, subject, to, cc, bcc, attachments}
```
Switching the channel toggle swaps **both** the recipient row and the editor in place — email selects the TipTap rich-text editor with To/Cc/Bcc/Subject; slack selects the `MarkdownEditor` with a `#channel` chip. Preferred default per the overview: Slack wins iff the conversation has only Slack linked; otherwise Email (`email-reply` for the most recent inbound email thread, falling back to `email-new`).
4. **`resolveComposeTargets(conversation)`** at [apps/mail/modules/conversations/components/timeline/composer/resolve-compose-targets.ts](apps/mail/modules/conversations/components/timeline/composer/resolve-compose-targets.ts) — pure helper. Returns an ordered list:
- One `slack` target per `conversation.integrationMetadata.filter(m => m.type === 'slack')` entry. Use `channelName ?? channelId` for label.
- One `email-reply` target per **distinct** `event.emailEvent.threadId` in `conversation.events`, sorted by latest event in that thread first. `defaultTo` is the other side of the most recent message (inbound → reply to `fromEmail`; outbound → reply to `toEmails`). `defaultSubject = subject.startsWith('Re:') ? subject : 'Re: ' + subject`. `headerMessageId = mostRecentMessage.messageId`.
- Always exactly one `email-new` target at the end. `defaultTo` is the deduped union of (a) `crm.getConversationMembers` (external members only) and (b) every `fromEmail`/`toEmails`/`ccEmails` from `conversation.events`.
```ts
// illustrative
targets = [
{ kind: 'slack', id: 'slack:T01:C09', workspaceId: 'T01', channelId: 'C09', channelName: 'acme-cedar', label: '#acme-cedar' },
{ kind: 'email-reply', id: 'email-reply:thr_abc', threadId: 'thr_abc', headerMessageId: '<CAH..>', defaultTo: [{ email:'<email>', name:'Lia' }], defaultSubject: 'Re: pricing', label: 'Reply · Re: pricing' },
{ kind: 'email-new', id: 'email-new', defaultTo: [...all conversation contacts], label: 'New email' },
];
```
5. **`ChannelToggle`** at [apps/mail/modules/conversations/components/timeline/composer/ChannelToggle.tsx](apps/mail/modules/conversations/components/timeline/composer/ChannelToggle.tsx) — segmented control `[ Slack │ Email │ Cedar ]` that sits in the **bottom-right of the composer container, immediately to the left of the send button**. Selecting a channel:
- swaps the editor (TipTap rich-text for email, `MarkdownEditor` for slack/cedar),
- swaps the recipient row (email → `EmailRecipientRow`; slack → `SlackChannelSelector` chip; cedar → no recipient row),
- reads the per-channel draft from `useComposerDraft(conversationId, channel)` so each channel keeps its own body / recipients / subject.
The legacy chip+dropdown `TargetSelector` is replaced by this in-footer toggle — the channel name is always visible and one click away. Channels that aren't applicable to the conversation (e.g. no linked Slack workspace) are rendered disabled with a tooltip rather than hidden, so the toggle has a stable shape.
6. **`ContactPicker`** at [apps/mail/modules/conversations/components/timeline/composer/ContactPicker.tsx](apps/mail/modules/conversations/components/timeline/composer/ContactPicker.tsx) — `Popover` with avatar + name + email rows for everyone on the conversation, sourced via `useConversationContacts(conversationId)`. Search input filters by name/email. Two click actions per row: **Add as recipient** (only meaningful in email body) and **@-mention** (inserts a mention chip in the editor). The mention chip is a TipTap node-view that renders `@Name` locally but serializes per target on send — see step 6a. For email targets only, the picker also doubles as the "add cc/bcc" UI.
6a. **`@`-mention behavior — one mental model, two serializations.** Mention chips carry `{ email, name, slackUserId? }`. Resolution and serialization:
- **Slack target.** When the picker opens with `target.kind === 'slack'`, fire `trpc.integrations.slack.resolveSlackPrincipals.useQuery({ workspaceId, emails })` (batched, server-cached) and read `byEmail` to populate `slackUserId` per row. Rows with no Slack account in that workspace show a "not in workspace" hint and fall through to plain text on insert. On send, the editor serializes chips with `slackUserId` to mrkdwn `<@U12345>` (real ping); chips without one serialize to plain text `@Name`.
- **Email target.** No mention protocol exists, so we mimic Gmail's behavior. The chip serializes to `<a href="mailto:<email>">@Lia</a>` in the HTML body. On send, `CoreEmailComposer` reconciles `cc` by adding any mentioned email that is not already on `to`/`cc`/`bcc`. This way "@ picks a person from the conversation" actually reaches them, matching the Slack mental model.
- Picker rows render a small badge — "Will ping" (Slack with `slackUserId`), "Will CC" (email and not already a recipient), or "Mention" (cosmetic only) — so the user knows what `@` will do before clicking.
7. **`useConversationContacts(conversationId)`** at [apps/mail/modules/conversations/components/timeline/composer/use-conversation-contacts.ts](apps/mail/modules/conversations/components/timeline/composer/use-conversation-contacts.ts) — unions:
- `trpc.crm.getConversationMembers({ conversationId })` → `[{ personEmail, person: { name, profilePictureUrl? }, permissions, … }]`.
- For every `event.emailEvent` in `conversation.events`, the `(fromName, fromEmail)` and each `toEmails[i]`/`ccEmails[i]`.
- Deduped on lowercased email. Returns `Array<{ email, name?, avatarUrl?, source: 'member' | 'event' }>`.
8. **`CoreEmailComposer`** at [apps/mail/modules/drafting/components/core-email-composer.tsx](apps/mail/modules/drafting/components/core-email-composer.tsx) — extracted from [email-composer.tsx](apps/mail/modules/drafting/components/email-composer.tsx). Slim controlled component, **no autosave**, **no draft reconciliation**, **no schedule-send picker**, **no snippets quick-row**. Props:
```ts
{
to: Sender[]; onToChange: (s: Sender[]) => void;
cc?: Sender[]; onCcChange?: …; showCc?: boolean;
bcc?: Sender[]; onBccChange?: …; showBcc?: boolean;
subject: string; onSubjectChange: (s: string) => void;
body: string; onBodyChange: (html: string) => void; // HTML for mail.send
attachments: File[]; onAttachmentsChange: (f: File[]) => void;
recipientSuggestions?: RecipientSuggestion[]; // injected from ContactPicker source
mentionAutoCcEmails?: string[]; // from MentionExtension; merged into cc on send
replyMode?: boolean; // hides subject
editorClassName?: string;
}
```
Internals reused: `useComposeEditor` (TipTap), `RecipientAutosuggest` (extended to accept a `suggestions` prop — see Phase 2), `Toolbar`, `LinkBubbleMenu`, attachment chip list rendered from `attachments` via `formatFileSize`. `EmailComposer` keeps existing behavior by importing `CoreEmailComposer` and layering autosave/draft/scheduling on top — no breakage to `compose-display.tsx`.
9. **`SlackComposerBody`** at [apps/mail/modules/conversations/components/timeline/composer/SlackComposerBody.tsx](apps/mail/modules/conversations/components/timeline/composer/SlackComposerBody.tsx) — wraps [markdown-editor.tsx](apps/mail/components/markdown-editor.tsx) (note: `MarkdownEditor`, not the TipTap email editor — Slack accepts mrkdwn so we send `editor.getMarkdown()`). Props `{ body, onBodyChange, attachments, onAttachmentsChange, onMention }`. Attachment chip list identical to `CoreEmailComposer`. Cmd+Enter sends; Enter inserts newline (we override via `handleKeyDown` prop on `MarkdownEditor`).
10. **`useComposerDraft(conversationId, targetId)`** at [apps/mail/modules/conversations/components/timeline/composer/useComposerDraft.ts](apps/mail/modules/conversations/components/timeline/composer/useComposerDraft.ts) — Zustand slice keyed by `` `${conversationId}::${targetId}` `` storing `{ body, subject, toEmails: string[], ccEmails: string[], bccEmails: string[] }`. Attachments stay in component-local state (binary `File`s don't survive remount cleanly; documenting this trade-off and accepting it). The store is **not** persisted to localStorage — drafts live only for the session. Justification: prevents stale base64 blobs accumulating in localStorage; matches the implicit expectation that timeline composers are quick replies.
11. **Send dispatch.** `UniversalComposer.onSend(values)` switches on `target.kind`:
- `slack` → `trpc.integrations.slack.sendMessage.mutate({ workspaceId, channelId, message: values.body })`. If `attachments.length > 0`, calls `trpc.integrations.slack.uploadFile.mutate({ workspaceId, channelId, files: SerializedFile[], message: values.body })` instead (Phase 4 only — until then the attach button is disabled with a tooltip when `target.kind === 'slack'`).
- `email-reply` → `trpc.mail.send.mutate({ threadId, emailHeaderMessageId, to, cc, bcc, subject, message: values.bodyHtml, attachments: SerializedFile[] })`.
- `email-new` → same but with `threadId: undefined`, `emailHeaderMessageId: undefined`, fresh `subject`.
12. **Optimistic event append.** On `onSuccess`, `UniversalComposer` calls a new helper `appendOutboundEvent(conversationId, optimistic)` on the Cedar store (mirrors existing `removeEventsFromConversation` in [TimelineEvent.tsx:77](apps/mail/modules/conversations/components/timeline/TimelineEvent.tsx)) so the new row appears immediately, then `queryClient.invalidateQueries({ queryKey=[redacted] id: conversationId }) })`. The optimistic event has `id: \`optimistic:${uuid()}\`` and `eventType: target.kind === 'slack' ? 'slack' : 'outbound_email'` so `SlackTimelineEvent` renders it identically to a real one.
13. **Attachment handling.**
- **Email**: identical to today — `File[]` in component state → `serializeFiles()` on submit → `attachments[]` of `SerializedFile` in the `mail.send` payload. No new server work.
- **Slack**: requires a new server-side route. Designed as `integrations.slack.uploadFile` returning `{ ok, fileIds[] }`, implemented in [apps/server/src/services/integrations/slack/slack-files.ts](apps/server/src/services/integrations/slack/slack-files.ts) using Slack's modern flow: `files.getUploadURLExternal` (one per file) → HTTP POST the bytes → `files.completeUploadExternal({ files: [{id, title}], channel_id, initial_comment: message })`. Uses the same `superglue.executeWorkflow` pattern as [slack-api.ts:295](apps/server/src/services/integrations/slack/slack-api.ts) (`assertProviderSideEffectsAllowed('slack.files.upload')`). **Until Phase 4 lands the attach button is disabled when `target.kind === 'slack'` with a tooltip "Slack attachments coming soon".**
14. **Keyboard.** Composer-scoped hotkeys (reusing the existing `react-hotkeys-hook` `'compose'` scope from [email-composer.tsx:169-172](apps/mail/modules/drafting/components/email-composer.tsx)): `mod+Enter` → send. Plain `Enter` inside the editor inserts a newline (TipTap default). `Esc` while the composer editor has focus blurs (does not close the conversation — capture-phase blocked).
15. **AOP / config impact: none.** The composer reads only conversation runtime data; nothing in `aop.displayConfig` changes.
## 4) Implementation phases
### Phase 1 — Slack-style Timeline (visual only)
**Goal:** Replace `PastEventsTimeline` with `SlackTimeline` + `SlackTimelineEvent` for the new Inbox tab, and mount the extracted `NextStepsCard` as a pinned card at the top of the scroll region. No composer yet. `PastEventsTimeline` stays on disk because other surfaces still reference it ([ConversationBodyContent.tsx](apps/mail/modules/conversations/components/ConversationBodyContent.tsx), [ConversationBodyLayout.tsx](apps/mail/modules/conversations/components/ConversationBodyLayout.tsx), [ConversationTabContent.tsx](apps/mail/modules/conversations/components/ConversationTabContent.tsx)).
- [x] Create [apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx](apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx) — `grid-cols-[32px_1fr_auto]` row with `BimiAvatar` (reuse from [ConversationOverviewCard.tsx](apps/mail/modules/conversations/components/ConversationOverviewCard.tsx) import), name + collapsible content (3-line clamp + "Show more"), time-of-day via `formatTime` from [@/lib/utils](apps/mail/lib/utils.ts), event-type icon overlay via `getEventIcon`/`getEventColor` from [helpers.ts](apps/mail/modules/conversations/components/timeline/helpers.ts).
- [x] Hover reveal: reuse the action cluster (`MoveEventPopover`, delete, "Open thread") by extracting `EventHoverActions` from [PastEventsTimeline.tsx:100](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx) into `apps/mail/modules/conversations/components/timeline/EventHoverActions.tsx` and importing it from both.
- [x] Create [apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx](apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx) — receives `conversationId`, uses `useConversationById` for events, renders day dividers between rows on different `format(date,'yyyy-MM-dd')` keys. No composer yet; bottom is empty. The top of the scroll region renders `<NextStepsCard conversationData={…} />` as a pinned card (sticky, border-b) above the day dividers — reuse the extracted card from [NextStepsCard.tsx:112](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx); hide the entire pinned block when `conversationData.data.userTasks` is empty and there's no `nextSteps` markdown on the conversation.
- [x] In `ConversationTabBody` (created in overview Phase 2), point the `inbox` case at `<SlackTimeline conversationId={…} />` instead of `<PastEventsTimeline />`.
**Tests:**
- [x] `pnpm --filter @cedar/mail typecheck`
- [x] `pnpm --filter @cedar/mail lint`
- [ ] Manual: open a conversation, verify the Inbox tab renders the pinned `NextStepsCard` at the top and Slack-style rows (avatar + name + content) underneath with hover delete still working. Other surfaces that mount `PastEventsTimeline` still render normally.
### Phase 1.5 — Slack rendering fidelity (mrkdwn + real avatars)
**Goal:** Decode Slack mrkdwn (`<@U…>`, `<#C…|name>`, `<!here>`, `<url|label>`, inline formatting) and render real Slack avatars on the Inbox tab. Stand up the shared `resolveSlackPrincipals` route used by both `SlackBodyRenderer` and (later) the @-mention feature.
- [x] Add [apps/server/src/services/integrations/slack/slack-users.ts](apps/server/src/services/integrations/slack/slack-users.ts) exporting `resolveSlackPrincipals({ workspaceId, userId, emails?, userIds? })`. For each email, runs `users.lookupByEmail`; for each userId, runs `users.info`. Returns `{ byEmail, byUserId }` where each entry is `{ userId, email?, displayName, avatarUrl } | null`. Wrap with an in-memory LRU keyed `(workspaceId, kind, key)` with ~1h TTL.
- [x] Add `resolveSlackPrincipals` tRPC query to the `integrations.slack` namespace in [apps/server/src/trpc/routes/integrations.ts](apps/server/src/trpc/routes/integrations.ts): `input z.object({ workspaceId: z.string(), emails: z.array(z.string().email()).max(50).optional(), userIds: z.array(z.string()).max(50).optional() })` → `resolveSlackPrincipals`. Verify the existing Slack OAuth scopes include `users:read` and `users:read.email`; document the re-auth requirement in a release note if not.
- [ ] _Deferred_ — `userImageUrl?: string` on `SlackMessageEvent` + ingest-side capture. Skipped for now because it requires a DB migration and the client-side `useSlackPrincipals` lookup already covers both new and legacy events through the same backend route. Revisit if the per-conversation lookup overhead becomes noticeable (server cache is in-memory, so cold paths cost one Slack `users.info` per unique speaker).
- [x] Create [apps/mail/modules/conversations/components/timeline/SlackBodyRenderer.tsx](apps/mail/modules/conversations/components/timeline/SlackBodyRenderer.tsx) implementing `parseSlackMrkdwn(text, resolveUserId)` and exporting `<SlackBodyRenderer text resolveUserId />`. Decodes the token set listed in §3.2 step 2a. Also exports `slackTextToPlain(text, resolveUserId?)` for single-line previews.
- [x] Add an `avatarUrl?: string` prop to [apps/mail/components/ui/bimi-avatar.tsx](apps/mail/components/ui/bimi-avatar.tsx). When provided, it short-circuits the BIMI/domain logo lookup and renders the supplied URL directly (still falls back to initials on image error).
- [x] Create [apps/mail/modules/conversations/components/timeline/use-slack-principals.ts](apps/mail/modules/conversations/components/timeline/use-slack-principals.ts) — collects `userIds` from a list of events (speaker IDs + `<@U…>` matches in text) and fires `trpc.integrations.slack.resolveSlackPrincipals` keyed by `workspaceId`. Returns `(workspaceId, userId) => { displayName, avatarUrl } | null`.
- [x] In [apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx](apps/mail/modules/conversations/components/timeline/SlackTimelineEvent.tsx), thread `resolveUserId` and a per-speaker `avatarUrl` (from the resolver) through to `<BimiAvatar avatarUrl … />` and replace the raw `<p>{text}</p>` with `<SlackBodyRenderer text resolveUserId />`.
- [x] In [apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx](apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx), call `useSlackPrincipals` once for the visible events and pass `resolveSlackPrincipal` to each row as a prop.
- [x] Apply the same renderer + resolver in [EventDetailDialog.tsx](apps/mail/modules/conversations/components/timeline/EventDetailDialog.tsx) (the full Slack message detail view). For inline single-line surfaces — [ConversationInbox.tsx](apps/mail/modules/conversations/components/ConversationInbox.tsx) latest-message preview and `getEventDescription` in [helpers.tsx](apps/mail/modules/conversations/components/timeline/helpers.tsx) — use the unresolved `slackTextToPlain` helper (no extra network round-trip).
**Tests:**
- [x] `pnpm --filter @zero/mail types` — no new errors in changed files (repo has pre-existing errors elsewhere).
- [x] `pnpm --filter @zero/mail lint` — no new lint errors in changed files.
- [x] `pnpm --filter @zero/server` lint clean for `slack-users.ts` + `integrations.ts`.
- [ ] Unit: `parseSlackMrkdwn` fixture tests covering each token type and nested/escaped cases.
- [ ] Manual: open a Slack-linked conversation with a message containing `<@U…>`, `<#C…|x>`, `<https://…|label>`, `*bold*`, `>quote`. Verify it renders as decoded text with `@DisplayName`/`#channel-name`/clickable link, the speaker's real Slack avatar shows in the row, and a message from a user not yet in cache resolves after one round-trip.
### Phase 2 — Harden `RecipientAutosuggest` for injected suggestions
**Goal:** Allow `RecipientAutosuggest` to accept a caller-supplied suggestion list so the universal composer's contact picker can drive recipient autocomplete from conversation contacts instead of the global CRM list. Land this with `EmailComposer` still working unchanged.
- [x] In [apps/mail/components/ui/recipient-autosuggest.tsx](apps/mail/components/ui/recipient-autosuggest.tsx), add an optional `suggestions?: RecipientSuggestion[]` prop that, when present, replaces the `useContacts()`-derived list. Defaults to the current behavior when omitted.
- [x] Add a paired `disableContactsQuery?: boolean` prop so injected callers can skip the global `trpc.crm.listContacts({ limit: 1000 })` fetch.
- [x] Update the three `EmailComposer` call sites of `<RecipientAutosuggest />` (to/cc/bcc) to keep working unchanged.
**Tests:**
- [x] `pnpm --filter @cedar/mail typecheck`
- [x] `pnpm --filter @cedar/mail lint`
- [ ] Manual: open `/mail/compose`, send a new email with cc; reply to a thread; both work exactly as before (the new props default to undefined so behavior is unchanged).
### Phase 3 — Universal composer (Slack + Email inline) with channel toggle
**Goal:** Dock a single contained composer at the bottom of `SlackTimeline` that hosts both channels inline. A segmented `[ Slack │ Email │ Cedar ]` toggle sits in the bottom-right, next to send. Selecting **Slack** shows a `#channel` chip + `MarkdownEditor`; selecting **Email** shows To/Cc/Bcc/Subject + the TipTap rich-text editor (rendered by a slim `CoreEmailComposer` extracted from `email-composer.tsx`). Recipient row, body, and footer all live inside a single `rounded-md` container that matches the standalone email composer's visual language.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/resolve-compose-targets.ts](apps/mail/modules/conversations/components/timeline/composer/resolve-compose-targets.ts) — `resolveSlackTargets(conversation): SlackTarget[]` returning one entry per `conversation.integrationMetadata.filter(m => m.type === 'slack')`. Also `resolveEmailDispatchTargets(conversation): EmailDispatchTarget[]` returning one `{ kind: 'new' }` plus one `{ kind: 'reply'; threadId; subject; latestFromName?; latestAt }` per distinct `event.emailEvent.threadId` in `conversation.events`, sorted by most recent. Unit-test on fixtures: 0 slack channels, 2 slack channels, mixed inbound/outbound emails.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/use-conversation-contacts.ts](apps/mail/modules/conversations/components/timeline/composer/use-conversation-contacts.ts) — union of `crm.getConversationMembers` and event-derived senders/recipients; deduped on lowercased email. Returns `Array<{ email, name?, avatarUrl?, source: 'member' | 'event' }>`. Implementation uses `HydratedConversation.people` directly (already on the conversation) instead of a separate `crm.getConversationMembers` round-trip.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/useComposerDraft.ts](apps/mail/modules/conversations/components/timeline/composer/useComposerDraft.ts) — Zustand store keyed `${conversationId}::${channelId}` storing `{ body: string }` (Slack-only; no subject/recipients in the timeline composer). Not persisted to localStorage. Lives in its own standalone Zustand store rather than the main CedarStore slice to keep the change surface tight.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/SlackChannelSelector.tsx](apps/mail/modules/conversations/components/timeline/composer/SlackChannelSelector.tsx) — chip `[#channel-name ▾]` + `DropdownMenu`. Only renders when there are 2+ Slack channels. When there's exactly 1, render a static `[#channel-name]` label. When there are 0, the whole Slack composer is hidden.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/EmailDispatcherButton.tsx](apps/mail/modules/conversations/components/timeline/composer/EmailDispatcherButton.tsx) — button `[✉ Email ▾]` opening a `DropdownMenu` with "New email" and "Reply to: ⟨subject⟩" rows from `resolveEmailDispatchTargets`. "New email" navigates to `/mail/compose`. "Reply to" calls `openThread(threadId)` on the Cedar store to mount the thread UI with its inline composer.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/ContactPicker.tsx](apps/mail/modules/conversations/components/timeline/composer/ContactPicker.tsx) — `Popover` with avatar/name/email rows sourced via `useConversationContacts`. Single click action per row: **Mention** (inserts a `MentionExtension` chip into the editor). Per-row badge: "Will ping" when `slackUserId` resolves for the selected channel's workspace, else "Mention" (plain text).
- [x] Use the `slack-users.ts` service + `resolveSlackPrincipals` tRPC route added in Phase 1.5 to populate `slackUserId` per contact when the picker opens. No new server work.
- [x] Create a TipTap `MentionExtension` at [apps/mail/modules/conversations/components/timeline/composer/mention-extension.ts](apps/mail/modules/conversations/components/timeline/composer/mention-extension.ts). Node attrs: `{ name, slackUserId? }`. Renders as a styled chip in the editor. `renderMarkdown` serializes to `<@{slackUserId}>` when set, else `@{name}` — Slack-only; email serialization is not needed since email is dispatched out-of-band.
- [x] Wire mention insertion in `SlackComposerBody`: when a contact row's "Mention" action fires, insert a `MentionExtension` node with attrs sourced from `useConversationContacts` + the `resolveSlackPrincipals` map (`UniversalComposer` holds the editor instance and lifts the insert helper into `ContactPicker`).
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/SlackComposerBody.tsx](apps/mail/modules/conversations/components/timeline/composer/SlackComposerBody.tsx) — wraps [markdown-editor.tsx](apps/mail/components/markdown-editor.tsx), paperclip button (disabled with tooltip until Phase 4), `Cmd+Enter` send via `handleKeyDown`, `Enter` inserts newline. Sends via `trpc.integrations.slack.sendMessage`. (Attachment chip list deferred to Phase 4 alongside the upload route.)
- [x] Create [apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx](apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx) — layout: `[SlackChannelSelector] [ContactPicker (@)]` header row above the editor, `[EmailDispatcherButton]` pinned right. Owns selected channel state (defaults to the first channel; falls back to hiding the Slack composer when no channels are linked, leaving just the Email button).
- [x] Add `appendOutboundEvent(conversationId, optimisticEvent)` to the Cedar store next to the existing `removeEventsFromConversation` reducer. Call from `SlackComposerBody.onSuccess`, then `queryClient.invalidateQueries({ queryKey=[redacted] id }) })`.
- [x] Mount `<UniversalComposer conversationId={…} />` at the bottom of `SlackTimeline` (sticky, `border-t`).
**Tests:**
- [x] `pnpm --filter @cedar/mail typecheck`
- [x] `pnpm --filter @cedar/mail lint`
- [x] Unit: `resolveSlackTargets` + `resolveEmailDispatchTargets` fixture tests.
- [ ] Manual: in a Slack-linked conversation, send a text message to a channel; verify it appears as an optimistic row and reconciles after refresh. Switch tabs and back — draft body persists. In a multi-channel conversation, switch the chip between channels and confirm draft state is per-channel.
- [ ] Manual: Email button. New email → `/mail/compose` opens prefilled with the conversation context. Reply → the linked thread opens with its inline composer. Both still work as before.
- [ ] Manual: `@`-mention. Mention a contact who is in the Slack workspace → recipient gets pinged. Mention a contact who isn't → falls through to plain `@Name`.
### Phase 4 — Slack attachment uploads
**Goal:** Add a real `integrations.slack.uploadFile` route and enable the Slack paperclip button. Independently shippable; gated behind the chip being on a slack target.
- [x] Add [apps/server/src/services/integrations/slack/slack-files.ts](apps/server/src/services/integrations/slack/slack-files.ts) exporting `uploadSlackFiles({ channelId, workspaceId, userId, files: SerializedFile[], message? })`. Implementation: for each file, call `files.getUploadURLExternal` → POST bytes to the returned `upload_url` → collect `file_id`. Then one final `files.completeUploadExternal({ files: [{id, title}], channel_id, initial_comment: message })`. Reuse `getUserAuth({ userId, serverName: 'slack' })` and `assertProviderSideEffectsAllowed('slack.files.upload')` exactly like [slack-api.ts:313](apps/server/src/services/integrations/slack/slack-api.ts).
- [x] Add `uploadFile` tRPC mutation to the `integrations.slack` namespace in [apps/server/src/trpc/routes/integrations.ts](apps/server/src/trpc/routes/integrations.ts):
```ts
uploadFile: privateProcedure
.input(z.object({
workspaceId: z.string(),
channelId: z.string(),
files: z.array(serializedFileSchema).min(1),
message: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => uploadSlackFiles({ ...input, userId: ctx.sessionUser.id }));
```
- [x] Verify the Slack OAuth scope `files:write` is part of the existing Slack scope set used at install time; if not, add it in the Slack provider config and document the re-auth requirement in a release note (no code change in `apps/mail`). Surfaced as a runtime error in `uploadSlackFiles` ("missing files:write scope. Reconnect Slack to grant it.") rather than scope-config audit — handled defensively at the call site since the install-time scope set lives outside this repo.
- [x] In `UniversalComposer`, when `target.kind === 'slack'` and `attachments.length > 0`, call `integrations.slack.uploadFile` instead of `integrations.slack.sendMessage`. Body-only sends still use `sendMessage`. Implemented inside `SlackComposerBody` (the only Slack-target component) rather than `UniversalComposer`.
- [x] Enable the Slack paperclip button (remove the disabled-with-tooltip guard from Phase 3).
**Tests:**
- [x] `pnpm --filter @zero/mail types` — no new errors in changed files.
- [x] `pnpm --filter @zero/mail lint` — no new errors in changed files.
- [x] `pnpm --filter @zero/server types` — clean for slack-files.ts + integrations.ts.
- [ ] Manual: in a Slack-linked conversation, send a message with a small image and a PDF attached; verify both appear in the Slack channel and an optimistic Slack event lands on the timeline.
### Phase 5 — Cleanup
**Goal:** Retire `PastEventsTimeline` (only safe once all consumers are migrated) and tidy up.
- [x] Audit remaining `PastEventsTimeline` consumers. Decisions:
- [ConversationBodyContent.tsx](apps/mail/modules/conversations/components/ConversationBodyContent.tsx) (2 mounts) — **keep**. This is the legacy split-pane body that renders rich expanded cards alongside the conversation thread; the redesign focuses on the tabbed Inbox view and the split view is still in use.
- [ConversationTabContent.tsx](apps/mail/modules/conversations/components/ConversationTabContent.tsx) — **keep**. Wraps the legacy non-tabbed conversation layout that some entry points still hit.
- [SlackTimeline.tsx](apps/mail/modules/conversations/components/timeline/SlackTimeline.tsx) — **keep**. The new Inbox itself reuses `ConversationUpdateCard` and `MergedUpdatesGroup` from `PastEventsTimeline`, so the file's sub-components are now load-bearing for the new design too.
- [ConversationBodyLayout.tsx](apps/mail/modules/conversations/components/ConversationBodyLayout.tsx) — not a consumer (grep returned no import); listed in the doc as a possible site, but the layout component never imported `PastEventsTimeline`.
- [x] If all consumers migrate, delete [PastEventsTimeline.tsx](apps/mail/modules/conversations/components/timeline/PastEventsTimeline.tsx) and unused per-type cards. Not applicable — consumers above are staying.
- [x] Delete `TimelineEvent.tsx` if no surface still mounts it. Still mounted by [CRMCellTimeline.tsx](apps/mail/modules/conversations/components/CRMCellTimeline.tsx) and the legacy [timeline.tsx](apps/mail/modules/conversations/components/timeline/timeline.tsx) — **keep**.
- [x] Run `pnpm deps:check` to confirm the new composer modules don't pull skills/agents into UI. Clean: 809 modules / 4314 dependencies, no violations.
**Tests:**
- [x] `pnpm --filter @zero/mail types` — no new errors in changed files.
- [x] `pnpm --filter @zero/mail lint` — no new errors in changed files.
- [x] `pnpm deps:check` — 0 violations.
- [ ] Manual: full smoke pass — open three conversations (email-only, slack-only, hybrid), verify the composer chip defaults are right, attachments work on both channels, drafts persist across tab switches but not page reloads, optimistic events appear and reconcile.
### Phase 6 — Single-container composer + channel toggle
**Goal:** Reshape `UniversalComposer` so the composer area is a single `bg-raised rounded-md` box per channel with no internal dividers between recipient row and editor. Email mode renders the full `<EmailComposer />` straight up — no slimmed shim. The channel toggle pins top-right of the docked area and applies across all modes. Bring the standalone `EmailComposer` visual treatment in line: drop to `rounded-md` and restyle the **Send** button to match the inline pill-style send.
- [x] Create [apps/mail/modules/conversations/components/timeline/composer/ChannelToggle.tsx](apps/mail/modules/conversations/components/timeline/composer/ChannelToggle.tsx) — segmented `[ Slack │ Email │ Cedar ]` control. Cedar option uses the Cedar logo (`/CedarLogoTransparent.png`). Channels with no plausible target render disabled with a tooltip; toggle shape is stable across conversations.
- [x] Rework [apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx](apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx). Top-of-area row holds the `ChannelToggle`. Body:
- `email` → full `<EmailComposer draftSessionId="conversation-${id}-email" conversationId expandable />`.
- `slack` → `SlackPanel`: `bg-raised rounded-md` container; `#channel` header (no divider below); `MarkdownEditor` body; attachment chips when present; footer with paperclip-left and Send-right.
- `cedar` → `CedarPanel`: same shape; stub send.
- [x] Remove the dedicated `@` button. Pressing `@` in the slack editor opens the `ContactPicker` controlled-popover, anchored to an invisible span pinned to the bottom-left of the editor's `relative` wrapper.
- [x] Extend `ContactPicker` with optional controlled `open` / `onOpenChange` props and an `anchor` slot. When `anchor` is set, the picker renders as a `PopoverAnchor` (no visible trigger).
- [x] Delete `EmailDispatcherButton`, `SlackComposerBody`, and `QuickEmailComposerBody` — superseded by the unified composer + full `EmailComposer` mount.
- [x] Extend `useComposerDraft` to be channel-agnostic: keyed by `slack:<channelId>` | `'email'` | `'cedar'`. Email mode does not use it (the full `EmailComposer` owns its own draft state via the existing autosave path).
- [x] **Polish on the standalone `EmailComposer`:** drop the outer container, drag-overlay rings, and inner stack from `rounded-2xl` → `rounded-md`. Restyle the Send button to a pill (`bg-primary text-primary-foreground rounded-full px-3 py-1 text-xs`) matching the inline composer, with the `⌘↵` hint on the right.
**Tests:**
- [x] `pnpm --filter @zero/mail types` — no new errors in changed files (4 pre-existing errors in `email-composer.tsx` lines 184/431/653/1020 unchanged).
- [x] `pnpm --filter @zero/mail lint` — clean on changed files.
- [ ] Manual: in a hybrid conversation, toggle Slack ↔ Email ↔ Cedar and confirm the body swaps in place; `@` in the slack editor opens the contact picker without a button; drafts persist per-channel; send works on slack; the standalone `EmailComposer` at `/mail/compose` still works and now uses `rounded-md` + the matching pill send button.