DRAFT_SESSION_ARCHITECTURE.md36.8 KBView on GitHub
# Draft Session Architecture

## Overview

This document describes the architecture for stable draft editing that prevents content loss during save operations and thread data synchronization.

### Core Problem

When a user creates a new draft and the first `draft.create()` completes, the server response updates the message ID in thread data. This causes React to remount the editor component (due to key change), which resets the editor to the content from when the save was initiated—losing any content the user typed while the request was in flight.

### Solution Summary

1. **New `draftSessionId` field**: A stable identifier for the editing session ✅ IMPLEMENTED
2. **Stable component keys**: Editor components key off `draftSessionId` (which never changes) ✅ IMPLEMENTED
3. **Initial-only content loading**: `useComposeEditor` ignores `initialValue` changes after first render ✅ IMPLEMENTED
4. **Server echoes session ID**: Server returns `draftSessionId` in response for client matching ✅ IMPLEMENTED

### Key Implementation Detail: Initial-Only Content Loading

The critical fix that prevents content loss is in `useComposeEditor`. The hook uses refs to capture `initialValue` only on the first render:

```typescript
// In use-compose-editor.ts
const initialValueRef = React.useRef(initialValue);
const hasInitialized = React.useRef(false);

// Only capture the initial value once per mount
if (!hasInitialized.current) {
  initialValueRef.current = initialValue;
  hasInitialized.current = true;
}

// Content memo uses the stable ref, with empty deps (runs once per mount)
content: React.useMemo(() => {
  const stableInitialValue = initialValueRef.current;
  // ... parse and return content
}, []), // Empty deps - only runs once
```

This approach is simpler than storing state externally because:

- Editor content stays local to the component (no global state sync issues)
- When component key changes (remount), refs reset and new `initialValue` is used correctly
- When component re-renders (key stable), refs persist and `initialValue` changes are ignored
- No need for complex merge logic - the editor owns its content once mounted

### Key Insight: Separate Field vs Overloaded ID

Instead of using a special format for the message `id` (like "session-draft-xxx"), we add a **dedicated `draftSessionId` field**. This is cleaner because:

- Message `id` can naturally update from temp to real server ID
- `draftSessionId` remains stable throughout the editing session
- For existing drafts, `draftSessionId` equals `draftId` (already stable)
- No special string parsing needed

---

## Architecture Diagram

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                           THREAD DISPLAY                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                     ThreadDraftSection                              │    │
│  │                                                                     │    │
│  │  ┌─────────────────────────────────────────────────────────────┐    │    │
│  │  │ DraftTabs (only if multiple drafts)                         │    │    │
│  │  │ [Draft 1] [Draft 2] [Draft 3]                                │    │    │
│  │  └─────────────────────────────────────────────────────────────┘    │    │
│  │                           │                                         │    │
│  │                           │ selectedSessionId                       │    │
│  │                           ▼                                         │    │
│  │  ┌─────────────────────────────────────────────────────────────┐    │    │
│  │  │ DraftComposer                                               │    │    │
│  │  │ key=[redacted]  ← ALWAYS STABLE                 │    │    │
│  │  │                                                             │    │    │
│  │  │  ┌───────────────────────────────────────────────────────┐  │    │    │
│  │  │  │ EmailComposer                                         │  │    │    │
│  │  │  │ - Owns editor state                                   │  │    │    │
│  │  │  │ - Receives draftId updates via props                  │  │    │    │
│  │  │  │ - initialMessage only used on mount                   │  │    │    │
│  │  │  └───────────────────────────────────────────────────────┘  │    │    │
│  │  └─────────────────────────────────────────────────────────────┘    │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

---

## Data Model

### Message with Draft Session ID

```typescript
interface ParsedMessage {
  id: string; // Message ID (temp initially, real after save)
  draftId?: string; // Gmail draft ID (null until first save)
  draftSessionId?: string; // NEW: Stable session identifier for editing
  isDraft: boolean;
  processedHtml: string;
  // ... other fields
}
```

### Draft Session ID Rules

```typescript
// When creating a NEW draft (reply, forward, compose):
const newDraft = {
  id: `temp-draft-${Date.now()}`, // Temporary, will change
  draftId: undefined, // No Gmail draft yet
  draftSessionId: crypto.randomUUID(), // NEW: Stable session ID
  isDraft: true,
  // ...
};

// When loading an EXISTING draft (from server):
const existingDraft = {
  id: 'real-msg-456', // Real Gmail message ID
  draftId: 'gmail-draft-xyz', // Real Gmail draft ID
  draftSessionId: 'gmail-draft-xyz', // Use draftId as session ID (already stable)
  isDraft: true,
  // ...
};
```

### Why This Works

| Scenario                | `id`       | `draftId`   | `draftSessionId` | Component Key       |
| ----------------------- | ---------- | ----------- | ---------------- | ------------------- |
| New draft (before save) | `temp-123` | `null`      | `uuid-abc`       | `uuid-abc`          |
| New draft (after save)  | `real-456` | `gmail-xyz` | `uuid-abc`       | `uuid-abc` ✅ Same! |
| Existing draft          | `real-456` | `gmail-xyz` | `gmail-xyz`      | `gmail-xyz`         |

The key insight: `draftSessionId` is assigned once and **never changes** during an editing session.

---

## Implementation Status

### All Steps Completed ✅

The full architecture is now implemented with isolated draft editing:

1. ✅ **ParsedMessage type updated** - Added `draftSessionId?: string` field
2. ✅ **Draft creation updated** - New drafts get `draftSessionId = crypto.randomUUID()`
3. ✅ **EmailComposer passes session ID** - Sends `draftSessionId` in draft.create() calls
4. ✅ **Server echoes session ID** - Returns `draftSessionId` in response
5. ✅ **Initial-only content loading** - `useComposeEditor` ignores `initialValue` changes after mount
6. ✅ **ThreadDisplay restructured** - Drafts now render separately from message list
7. ✅ **ThreadDraftSection created** - Container managing draft selection and rendering
8. ✅ **DraftComposer created** - Wrapper with stable key based on `draftSessionId`
9. ✅ **DraftTabs created** - Tab UI for switching between multiple drafts

¬### Architecture Overview (Inline Rendering)

Drafts render **inline at their position** in the message list, not at the end.

```
ThreadDisplay
└── MessageList
    ├── MailDisplay (message 1)
    ├── MailDisplay (message 2)
    ├── ThreadDraftSection (drafts after message 2) ← INLINE, stable key
    │   ├── DraftTabs (only if multiple drafts)
    │   └── DraftComposer key=[redacted]
    │       └── EmailComposer
    │           └── useComposeEditor (initialValue captured once via ref)
    └── MailDisplay (message 3)
```

The key insight: consecutive drafts are grouped together, but they render **at their position** in the thread, preserving the conversation flow.

### Key Files

| File                       | Purpose                                              |
| -------------------------- | ---------------------------------------------------- |
| `thread-display.tsx`       | Renders MessageList + ThreadDraftSection             |
| `thread-draft-section.tsx` | Manages draft selection, renders tabs + composer     |
| `draft-composer.tsx`       | Wrapper for EmailComposer with send/delete handlers  |
| `draft-tabs.tsx`           | Tab UI for multiple drafts                           |
| `email-composer.tsx`       | The actual editor component                          |
| `use-compose-editor.ts`    | TipTap editor hook with initial-only content loading |
| `reply-composer.tsx`       | **DEPRECATED** - replaced by DraftComposer           |

---

## Original Implementation Steps (Reference)

### Step 1: Update ParsedMessage Type

**File**: `apps/mail/modules/threads/threadList/store/threadSlice.ts`

```typescript
export interface ParsedMessage {
  id: string;
  draftId?: string;
  draftSessionId?: string; // NEW: Stable identifier for draft editing sessions
  isDraft: boolean;
  // ... rest of fields
}
```

### Step 2: Update Draft Creation

**File**: `apps/mail/modules/threads/thread/components/thread-display.tsx`

**Current behavior**: Creates draft with `id: "temp-draft-{timestamp}"`

**New behavior**: Also add `draftSessionId`

```typescript
// When user clicks reply/forward/compose
const createNewDraft = () => {
  const sessionId = crypto.randomUUID();

  const newDraft: ParsedMessage = {
    id: `temp-draft-${Date.now()}`, // Temporary ID (can change)
    draftId: undefined, // Will be set after first save
    draftSessionId: sessionId, // NEW: Stable session ID (never changes)
    isDraft: true,
    processedHtml: '',
    // ... other fields
  };

  // Add to thread messages
  const updatedMessages = [...currentThread.messages, newDraft];
  setThreadData(threadId, { ...currentThread, messages: updatedMessages });
};
```

### Step 3: Update EmailComposer to Pass Session ID to Server

**File**: `apps/mail/modules/drafting/components/email-composer.tsx`

**Change**: Include `draftSessionId` in the `draft.create()` call

```typescript
interface EmailComposerProps {
  // ... existing props
  draftSessionId?: string; // NEW: Stable session identifier
}

export function EmailComposer({
  draftId,
  draftSessionId,
  messageId,
  threadId,
  // ...
}: EmailComposerProps) {
  const saveDraft = useCallback(
    async () => {
      // ... existing validation ...

      const draftData = {
        to: values.to.join(', '),
        cc: values.cc?.join(', '),
        subject: values.subject,
        message: editor.getHTML(),
        draftId: draftId, // null for first save
        threadId: threadId,
        draftSessionId: draftSessionId, // NEW: Pass session ID for echo
        // ... other fields
      };

      const response = await createDraft(draftData);

      // Response: { id: "gmail-draft-xyz", draftSessionId: "uuid-abc", message: {...} }

      // Update the draft with the real draftId
      // Match by draftSessionId (stable), not by message id (changes)
      if (response?.id && draftSessionId) {
        const existingThread = getThreadData(threadId);
        if (existingThread) {
          const updatedMessages = existingThread.messages.map((msg) => {
            if (msg.draftSessionId === draftSessionId) {
              return {
                ...msg,
                id: response.message?.id ?? msg.id, // Update message ID
                draftId: response.id, // Add Gmail draft ID
                // draftSessionId stays the same!
              };
            }
            return msg;
          });

          setThreadData(threadId, { ...existingThread, messages: updatedMessages });
        }
      }
    },
    [
      /* deps */
    ],
  );
}
```

### Step 4: Update Server to Echo Session ID

**File**: `apps/server/src/trpc/routes/drafts.ts`

**Change**: Accept and return `draftSessionId` in the response

```typescript
// Input schema
const createDraftInput = z.object({
  // ... existing fields ...
  draftSessionId: z.string().optional(), // NEW: Client's session identifier
});

// Handler
async function createDraft(input) {
  // ... create draft in Gmail ...

  return {
    id: gmailDraftId,
    draftSessionId: input.draftSessionId, // Echo back for client matching
    message: {
      id: gmailMessageId,
      threadId: gmailThreadId,
    },
  };
}
```

### Step 5: Update ThreadDataSync to Smart Merge

**File**: `apps/mail/modules/threads/thread/components/thread-data-sync.tsx`

**Current behavior**: Full replace of thread data

**New behavior**: Smart merge that preserves drafts with active sessions

```typescript
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { useMailStore } from '@/modules/store';
import { useEffect } from 'react';

export function ThreadDataSync({ threadId }: { threadId: string }) {
  const { data, isLoading } = useThreadQuery(threadId);
  const setThreadData = useMailStore((state) => state.setThreadData);
  const getThreadData = useMailStore((state) => state.getThreadData);

  useEffect(() => {
    if (!data || !threadId) return;

    const existingThread = getThreadData(threadId);

    if (!existingThread) {
      // No existing data - assign draftSessionId to any drafts from server
      const messagesWithSessions = data.messages.map((msg) => {
        if (msg.isDraft && msg.draftId && !msg.draftSessionId) {
          return { ...msg, draftSessionId: msg.draftId };
        }
        return msg;
      });
      setThreadData(threadId, { ...data, messages: messagesWithSessions });
      return;
    }

    // Smart merge: preserve drafts with draftSessionId
    const mergedMessages = smartMergeMessages(existingThread.messages, data.messages);

    setThreadData(threadId, {
      ...data,
      messages: mergedMessages,
    });
  }, [data, threadId, setThreadData, getThreadData]);

  return null;
}

function smartMergeMessages(local: ParsedMessage[], server: ParsedMessage[]): ParsedMessage[] {
  const result: ParsedMessage[] = [];

  // Build lookup maps
  const localBySessionId = new Map<string, ParsedMessage>();
  const localByDraftId = new Map<string, ParsedMessage>();

  for (const msg of local) {
    if (msg.draftSessionId) {
      localBySessionId.set(msg.draftSessionId, msg);
    }
    if (msg.draftId) {
      localByDraftId.set(msg.draftId, msg);
    }
  }

  const consumedDraftIds = new Set<string>();

  // 1. Preserve all local drafts with draftSessionId (active editing sessions)
  for (const localMsg of local) {
    if (localMsg.isDraft && localMsg.draftSessionId) {
      // This is an active draft session - KEEP IT
      result.push(localMsg);

      // Mark the corresponding server draft as consumed
      if (localMsg.draftId) {
        consumedDraftIds.add(localMsg.draftId);
      }
    }
  }

  // 2. Add all non-draft messages from server
  for (const serverMsg of server) {
    if (!serverMsg.isDraft) {
      result.push(serverMsg);
    }
  }

  // 3. Add server drafts that don't match any local session
  for (const serverMsg of server) {
    if (serverMsg.isDraft && serverMsg.draftId) {
      if (!consumedDraftIds.has(serverMsg.draftId)) {
        // No local session for this draft - add from server
        // Assign draftSessionId = draftId for stability
        result.push({
          ...serverMsg,
          draftSessionId: serverMsg.draftId,
        });
      }
    }
  }

  // 4. Sort by receivedOn to maintain order
  result.sort((a, b) => new Date(a.receivedOn).getTime() - new Date(b.receivedOn).getTime());

  return result;
}
```

### Step 6: Restructure Draft UI (Remove Grouping)

**File**: `apps/mail/modules/threads/thread/components/thread-display.tsx`

**Current behavior**: Groups consecutive drafts, passes array to ReplyCompose

**New behavior**: Separate DraftTabs + single DraftComposer

```typescript
// In ThreadMessages component

function ThreadMessages({ messages, threadId }: { messages: ParsedMessage[], threadId: string }) {
  // Separate drafts from regular messages
  const drafts = messages.filter(m => m.isDraft);
  const nonDraftMessages = messages.filter(m => !m.isDraft);

  // State for selected draft (by draftSessionId)
  const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
    drafts[0]?.draftSessionId ?? null
  );

  // Update selection if current draft is removed
  useEffect(() => {
    if (selectedSessionId && !drafts.find(d => d.draftSessionId === selectedSessionId)) {
      setSelectedSessionId(drafts[0]?.draftSessionId ?? null);
    }
  }, [drafts, selectedSessionId]);

  const selectedDraft = drafts.find(d => d.draftSessionId === selectedSessionId);

  return (
    <>
      {/* Render non-draft messages */}
      {nonDraftMessages.map((message) => (
        <MailDisplay key=[redacted] message={message} />
      ))}

      {/* Render draft section at the end */}
      {drafts.length > 0 && (
        <ThreadDraftSection
          drafts={drafts}
          selectedSessionId={selectedSessionId}
          onSelectDraft={setSelectedSessionId}
          selectedDraft={selectedDraft}
          threadId={threadId}
        />
      )}
    </>
  );
}
```

### Step 7: Create ThreadDraftSection Component

**File**: `apps/mail/modules/threads/thread/components/thread-draft-section.tsx` (NEW)

```typescript
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { DraftTabs } from './draft-tabs';
import { DraftComposer } from './draft-composer';

interface ThreadDraftSectionProps {
  drafts: ParsedMessage[];
  selectedSessionId: string | null;
  onSelectDraft: (sessionId: string) => void;
  selectedDraft: ParsedMessage | undefined;
  threadId: string;
}

export function ThreadDraftSection({
  drafts,
  selectedSessionId,
  onSelectDraft,
  selectedDraft,
  threadId,
}: ThreadDraftSectionProps) {
  if (!selectedDraft) return null;

  return (
    <div className="mx-4 my-2">
      {/* Draft tabs - OUTSIDE the composer */}
      {drafts.length > 1 && (
        <DraftTabs
          drafts={drafts}
          selectedSessionId={selectedSessionId}
          onSelectDraft={onSelectDraft}
        />
      )}

      {/* Single draft composer with stable key */}
      <DraftComposer
        key=[redacted]  // STABLE KEY!
        draft={selectedDraft}
        threadId={threadId}
      />
    </div>
  );
}
```

### Step 8: Create DraftComposer Component

**File**: `apps/mail/modules/threads/thread/components/draft-composer.tsx` (NEW)

```typescript
import { EmailComposer } from '@/modules/drafting/components/email-composer';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';

interface DraftComposerProps {
  draft: ParsedMessage;
  threadId: string;
}

export function DraftComposer({ draft, threadId }: DraftComposerProps) {
  // This component's key is draft.draftSessionId (set by parent)
  // So it won't remount when draft.id or draft.draftId updates

  return (
    <div className="w-full overflow-visible rounded-2xl border">
      <EmailComposer
        editorClassName="min-h-[50px]"
        className="max-w-none! w-full overflow-visible"
        onSendEmail={handleSendEmail}
        onDelete={handleDeleteDraft}
        initialMessage={draft.processedHtml}
        initialTo={draft.to?.map(t => t.email) ?? []}
        initialCc={draft.cc?.map(c => c.email) ?? []}
        initialBcc={draft.bcc?.map(b => b.email) ?? []}
        initialSubject={draft.subject ?? ''}
        draftId={draft.draftId}              // Can be null initially
        draftSessionId={draft.draftSessionId} // NEW: Stable session ID
        messageId={draft.id}
        threadId={threadId}
        // ... other props
      />
    </div>
  );
}
```

### Step 9: Create DraftTabs Component

**File**: `apps/mail/modules/threads/thread/components/draft-tabs.tsx` (NEW)

```typescript
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';

interface DraftTabsProps {
  drafts: ParsedMessage[];
  selectedSessionId: string | null;
  onSelectDraft: (sessionId: string) => void;
}

export function DraftTabs({
  drafts,
  selectedSessionId,
  onSelectDraft,
}: DraftTabsProps) {
  if (drafts.length <= 1) return null;

  return (
    <div className="mb-2 flex items-center gap-2">
      <Tabs
        value={selectedSessionId || drafts[0]?.draftSessionId}
        onValueChange={onSelectDraft}
      >
        <TabsList className="bg-muted/50 h-8">
          {drafts.map((draft, index) => (
            <TabsTrigger
              key=[redacted]
              value={draft.draftSessionId!}
              className="text-xs data-[state=active]:bg-white dark:data-[state=active]:bg-[#404040]"
            >
              Draft {index + 1}
            </TabsTrigger>
          ))}
        </TabsList>
      </Tabs>
    </div>
  );
}
```

---

## Edge Cases & Behavior

### Case 1: New Draft → First Save → Keep Typing

```
Timeline:
T=0: User clicks Reply
     → Message created: { id: "temp-123", draftId: null, draftSessionId: "uuid-abc" }
     → Component key=[redacted]

T=1: User types "Hello World"
     → Editor state: "Hello World"

T=2: Auto-save triggers
     → draft.create({ draftSessionId: "uuid-abc", message: "Hello World" })

T=3: User types " How are you?"
     → Editor state: "Hello World How are you?"
     → (Request still in flight)

T=4: Server responds
     → { id: "gmail-draft-xyz", draftSessionId: "uuid-abc", message: { id: "real-456" } }
     → Update message by draftSessionId:
       { id: "real-456", draftId: "gmail-draft-xyz", draftSessionId: "uuid-abc" }
     → Component key unchanged: "uuid-abc"
     → NO REMOUNT
     → Editor still has: "Hello World How are you?" ✅
```

### Case 2: mail.get Returns During Editing

```
Timeline:
T=0: User is editing
     → Local: { id: "real-456", draftId: "gmail-xyz", draftSessionId: "uuid-abc", content: "New" }

T=1: Something triggers mail.get
     → Server returns: { id: "real-456", draftId: "gmail-xyz", content: "Old" }
     → Note: Server doesn't know about draftSessionId

T=2: smartMergeMessages runs
     → Local has draft with draftSessionId "uuid-abc" and draftId "gmail-xyz"
     → Server has draft with draftId "gmail-xyz"
     → KEEPS local (has draftSessionId = active session)
     → Marks server "gmail-xyz" as consumed
     → Editor unchanged ✅
```

### Case 3: Opening Existing Draft (Page Load)

```
Timeline:
T=0: User loads thread page
     → mail.get returns: { id: "real-456", draftId: "gmail-xyz" }
     → No draftSessionId from server

T=1: smartMergeMessages runs (no local data)
     → Assigns: draftSessionId = draftId = "gmail-xyz"
     → Draft: { id: "real-456", draftId: "gmail-xyz", draftSessionId: "gmail-xyz" }

T=2: User clicks into draft to edit
     → Component key=[redacted] (stable)
     → Editor initializes with server content

T=3: User edits, auto-save happens
     → draft.create({ draftId: "gmail-xyz", draftSessionId: "gmail-xyz", ... })
     → Key unchanged, editor stable ✅
```

### Case 4: Sending the Draft

```
Timeline:
T=0: User clicks Send
     → Local: { id: "real-456", draftId: "gmail-xyz", draftSessionId: "uuid-abc" }

T=1: mail.send({ draftId: "gmail-xyz", ... })
     → Server sends email

T=2: Server responds success
     → Remove from local by draftSessionId OR draftId
     → Can use either since we have both

T=3: mail.get triggered (or optimistic update)
     → Thread now has sent message instead of draft ✅
```

### Case 5: Deleting the Draft

```
Timeline:
T=0: User clicks Delete
     → Local: { id: "real-456", draftId: "gmail-xyz", draftSessionId: "uuid-abc" }

T=1: Client immediately removes by draftSessionId
     → Optimistic UI update

T=2: drafts.delete({ draftId: "gmail-xyz" })
     → Server deletes

T=3: Success - done
     → On failure - could restore ✅
```

### Case 6: Draft Deleted on Another Device

```
Timeline:
T=0: User is editing locally
     → Local: { draftId: "gmail-xyz", draftSessionId: "uuid-abc", content: "My work" }

T=1: Another device deletes the draft

T=2: mail.get triggers
     → Server response has NO draft with draftId "gmail-xyz"

T=3: smartMergeMessages runs
     → Local has draft with draftSessionId "uuid-abc"
     → draftSessionId means it's an active session
     → KEEP IT (even though server doesn't have it)
     → User can keep working
     → Next save: draftId is invalid, server creates NEW draft ✅

Alternative: Show warning UI
```

### Case 7: Multiple Drafts, User Switches Tabs

```
Timeline:
T=0: Thread has 2 drafts
     → Draft A: { draftSessionId: "uuid-aaa", draftId: "gmail-A" } - selected
     → Draft B: { draftSessionId: "uuid-bbb", draftId: "gmail-B" }

T=1: User types in Draft A
     → Draft A editor has content "Hello"

T=2: User clicks Tab B
     → selectedSessionId changes to "uuid-bbb"
     → DraftComposer re-renders with key=[redacted]
     → Draft A composer UNMOUNTS

T=3: QUESTION: What happens to Draft A's content?

OPTION A: Single composer (current design)
     → Draft A unmounts, content lost if not saved
     → Auto-save should trigger on unmount
     → On switch back, reloads from saved content

OPTION B: Keep both mounted, hide inactive
     → Both composers stay mounted
     → CSS hides inactive one
     → Content preserved in both ✅

RECOMMENDATION: Option A with reliable auto-save on tab switch
```

### Case 8: Close Thread, Reopen Later

```
Timeline:
T=0: User is editing
     → Local: { draftId: "gmail-xyz", draftSessionId: "uuid-abc", content: "My work" }

T=1: User navigates away (closes thread)
     → Auto-save triggers on unmount → saves "My work"
     → Thread data cleared from Zustand (thread not selected)

T=2: User reopens thread
     → mail.get fetches fresh data
     → Server returns: { id: "real-456", draftId: "gmail-xyz", content: "My work" }
     → No draftSessionId (server doesn't store it)

T=3: ThreadDataSync runs
     → No local session for this draft
     → Assigns draftSessionId = draftId = "gmail-xyz"
     → Loads normally ✅
```

### Case 9: Network Failure During First Save

```
Timeline:
T=0: User creates new draft
     → { id: "temp-123", draftId: null, draftSessionId: "uuid-abc" }

T=1: User types "Hello"
     → Auto-save triggers

T=2: draft.create() fails (network error)
     → draftId still null
     → draftSessionId unchanged
     → User can keep typing

T=3: Retry (auto or manual)
     → draft.create() succeeds
     → { draftId: "gmail-xyz" } assigned
     → Key unchanged: "uuid-abc" ✅
```

### Case 10: mail.get Before First Save Completes

```
Timeline:
T=0: User creates draft
     → { id: "temp-123", draftId: null, draftSessionId: "uuid-abc" }

T=1: draft.create() in flight

T=2: mail.get triggers (from something else)
     → Server doesn't know about our draft yet

T=3: smartMergeMessages
     → Local has draft with draftSessionId "uuid-abc" (no draftId)
     → It has a draftSessionId = active session
     → KEEP IT
     → Editor continues working ✅

T=4: draft.create() completes
     → draftId assigned
     → Normal flow continues
```

---

## Data Flow Summary

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         DATA FLOW DIAGRAM                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. CREATE NEW DRAFT                                                        │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │ User clicks Reply                                               │     │
│     │      ↓                                                          │     │
│     │ Generate: draftSessionId = uuid()                               │     │
│     │      ↓                                                          │     │
│     │ Create message: { id: temp, draftId: null, draftSessionId: X }  │     │
│     │      ↓                                                          │     │
│     │ Add to Zustand threadData                                       │     │
│     │      ↓                                                          │     │
│     │ Render DraftComposer key=[redacted]                       │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  2. FIRST SAVE                                                              │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │ Auto-save triggers                                              │     │
│     │      ↓                                                          │     │
│     │ draft.create({ draftSessionId: X, ... })                        │     │
│     │      ↓                                                          │     │
│     │ Server creates draft, returns: { id: gmail-Y, draftSessionId: X }│    │
│     │      ↓                                                          │     │
│     │ Find message by draftSessionId, update: { draftId: gmail-Y }    │     │
│     │      ↓                                                          │     │
│     │ Component key unchanged (draftSessionId) → NO REMOUNT           │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
│  3. MAIL.GET DURING EDITING                                                 │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │ mail.get returns server data                                    │     │
│     │      ↓                                                          │     │
│     │ smartMergeMessages()                                            │     │
│     │      ↓                                                          │     │
│     │ For each local draft with draftSessionId:                       │     │
│     │   → KEEP local (active session)                                 │     │
│     │   → Skip matching server draft                                  │     │
│     │      ↓                                                          │     │
│     │ Add server non-drafts + unmatched server drafts                 │     │
│     │      ↓                                                          │     │
│     │ Editor content preserved                                        │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

---

## File Changes Summary

| File                        | Change Type | Description                                                |
| --------------------------- | ----------- | ---------------------------------------------------------- |
| `threadSlice.ts`            | MODIFY      | Add `draftSessionId` to `ParsedMessage` interface          |
| `thread-display.tsx`        | MODIFY      | Remove draft grouping, create drafts with `draftSessionId` |
| `thread-data-sync.tsx`      | MODIFY      | Implement smart merge logic using `draftSessionId`         |
| `thread-draft-section.tsx`  | NEW         | Container for tabs + composer                              |
| `draft-tabs.tsx`            | NEW         | Tab UI for multiple drafts                                 |
| `draft-composer.tsx`        | NEW         | Wrapper that passes `draftSessionId`                       |
| `email-composer.tsx`        | MODIFY      | Accept `draftSessionId` prop, match by it after save       |
| `reply-composer.tsx`        | DELETE      | Replaced by `draft-composer`                               |
| `apps/server/.../drafts.ts` | MODIFY      | Echo `draftSessionId` in response                          |

---

## Testing Checklist

### Basic Flows

- [ ] Create new reply draft, type, save completes, keep typing - no content loss
- [ ] Create new draft, close thread, reopen - content persisted
- [ ] Edit existing draft (from previous session) - stable editing
- [ ] Send draft - removed from thread correctly
- [ ] Delete draft - removed from thread correctly

### Edge Cases

- [ ] Type fast during first save - content preserved
- [ ] mail.get during editing - local content preserved
- [ ] Network failure on save - can retry, no content loss
- [ ] Multiple drafts - switching tabs works correctly
- [ ] Draft deleted on another device - local content preserved

### Merge Logic

- [ ] New unsaved draft + mail.get → draft preserved
- [ ] Saved draft + mail.get with same draftId → local wins
- [ ] No local draft + mail.get with draft → server draft loads with draftSessionId

### Performance

- [ ] Large threads with multiple drafts - no lag
- [ ] Rapid saves - no duplicate messages

---

## Migration Notes

### Breaking Changes

- `ReplyCompose` component removed
- Draft grouping logic removed from `ThreadDisplay`
- `setThreadData` behavior changes (merge vs replace for drafts)

### Backward Compatibility

- Existing drafts (without `draftSessionId`) get one assigned on load
- Assignment rule: `draftSessionId = draftId` for existing drafts
- No database changes required
- Server change is additive (new optional field echoed back)

---

## Future Improvements

1. **Conflict Resolution UI**: When draft is deleted elsewhere, show dialog
2. **Multi-tab Detection**: Detect if same draft is open in multiple browser tabs
3. **Offline Support**: Queue saves when offline, sync when reconnected
4. **Tab Switch Optimization**: Keep both composers mounted, hide inactive