unified-document-loading.md28.8 KBView on GitHub
# Unified Document Loading

## 1) Introduction — goal, present state, future state

We want every Y.js-backed editor in the app to use the same IDB-first warm/cold loading strategy — detect whether IndexedDB already has content and skip the server round-trip if so, only fetching the Y.js blob on a genuine cold open — and we want all that logic to live in a single shared `<Document />` primitive. Today, `<Document />` passes `contentYjsBase64` directly to the provider and re-applies it on every query refetch, AgendaDocument has an equivalent but buggy variant (stale Y.Doc ref causes empty editors), and ConversationAgendaDocument has the correct IDB-first approach but it lives only in that component. The change extracts ConversationAgendaDocument's warm/cold pattern into `<Document />`, removes the `contentYjsBase64` prop, adds a `refreshFromServer()` handle method, strips the hardcoded rich-text extensions into a `useRichTextExtensions()` hook, and ports both agenda surfaces to `<Document />`, deleting their inline provider wiring.

## 2) Present state

### 2.1 Architecture diagram

```text
 ┌─────────────────────────────────────────────────────────────┐
 │ OverviewDocTab / FileEditor                                 │
 │  trpc.documents.getDoc (full, ~243 KB contentYjs blob)      │
 │  → documentId + contentYjsBase64                           │
 │                                                             │
 │  <Document documentId contentYjsBase64 extraExtensions>     │
 │    acquireProvider({ contentYjsBase64 })                    │
 │      └─ buildProvider → idbReady.then(applyInitialState)   │
 │    bytes-adoption effect [dep: contentYjsBase64]            │
 │    <MarkdownEditor extraExtensions=[                        │
 │      Collaboration(ydoc), EnsureNodeIds,                   │
 │      FileLinkNode, ConversationNode, …richText,             │
 │      …caller-extensions]>                                   │
 └─────────────────────────────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────┐
 │ AgendaDocument  (bypasses <Document />)                     │
 │  trpc.documents.getDoc (full, includes contentYjs)          │
 │  → agendaDoc + contentYjsBase64                            │
 │                                                             │
 │  BUGGY: render-time Y.Doc recreation on documentId change   │
 │    ydocRef.current = new Y.Doc()           lines 280-282   │
 │  acquireProvider({ contentYjsBase64 })     line 722-728     │
 │  extraExtensions useMemo deps=[dayKey, dragBus]             │
 │    → Collaboration captures STALE ydocRef → empty editor   │
 │  <MarkdownEditor extraExtensions={extraExtensions}>         │
 │    no key prop → editor never remounts on docId change      │
 └─────────────────────────────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────┐
 │ ConversationAgendaDocument  (bypasses <Document />)         │
 │  trpc.documents.getDoc (omitContent: true) → documentId    │
 │                                                             │
 │  Y.Doc: ??= (created once, never recreated)  line 119      │
 │  acquireProvider (NO bytes)                  line 124       │
 │  idbReady.then:                                             │
 │    fragment.length > 0 → warm, skip          line 139      │
 │    else → trpcClient.getDoc({documentId})    line 152      │
 │            → applyInitialState(bytes)        line 164      │
 │  <MarkdownEditor extraExtensions={…agenda}>                 │
 └─────────────────────────────────────────────────────────────┘
```

### 2.2 Step-by-step walkthrough

#### `<Document />` cold-open path (OverviewDocTab)

1. **OverviewDocTab fetches full doc** — `useQuery(trpc.documents.getDoc)` at [OverviewDocTab.tsx:297](apps/mail/modules/conversations/components/OverviewDocTab.tsx). No `omitContent`, so `contentYjs` (~243 KB) is included on every revalidation.
   ```json
   { "id": "f622ce16-…", "contentYjs": "<base64 243 KB>", "content": "…md…" }
   ```

2. **`<Document />` render-time Y.Doc recreation** — [document.tsx:156](apps/mail/modules/documents/document.tsx). Creates a new `Y.Doc` whenever `documentId` changes, including null → id transitions.
   ```ts
   if (!ydocRef.current || prevDocIdRef.current !== documentId) {
     ydocRef.current = new Y.Doc();
   }
   ```

3. **Provider acquired with bytes** — `acquireProvider` at [document.tsx:168](apps/mail/modules/documents/document.tsx). Passes `contentYjsBase64` through to `buildProvider`.

4. **`buildProvider` schedules bytes seeding** — [providerRegistry.ts:199](apps/mail/modules/documents/yjs/providerRegistry.ts). On `idbReady`, calls `provider.applyInitialState(bytes)`. If IDB already has content, the merge is a no-op because state vectors match; if cold, it seeds the Y.Doc.

5. **Bytes-adoption effect** — [document.tsx:183](apps/mail/modules/documents/document.tsx). Runs whenever `contentYjsBase64` changes (every query refetch). Calls `applyInitialState` again if no pending local changes. This is how "Refresh from server" propagates.

6. **`composedExtensions` includes hardcoded rich-text extensions** — [document.tsx:343](apps/mail/modules/documents/document.tsx). `FileLinkNode`, `ConversationNode`, `createConversationMention`, `EventNode`, `createEventMention` are always included regardless of caller, along with search hooks and warm-up queries that run even for surfaces that don't use them.

7. **`<MarkdownEditor key=[redacted] ?? 'loading'}>` mounts** — [document.tsx:395](apps/mail/modules/documents/document.tsx). The `key` prop ensures a full remount when `documentId` arrives, so the `Collaboration` extension binds to the (just-recreated) fresh Y.Doc.

#### AgendaDocument cold-open path (BUGGY)

1. **Fetch full doc** — `useQuery(trpc.documents.getDoc)` at [AgendaDocument.tsx:194](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx). Includes `contentYjs`.

2. **Render-time Y.Doc recreation guard** — [AgendaDocument.tsx:280](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx). Creates new `Y.Doc` when `editorDocumentId` changes.
   - Render 1 (`agendaDoc=null`): creates Y.Doc A, `prevAdoptedDocIdRef = null`
   - Render 2 (`agendaDoc` loaded): creates Y.Doc B, `prevAdoptedDocIdRef = documentId`

3. **`extraExtensions` useMemo — stale closure** — [AgendaDocument.tsx:291](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx), deps `[dayKey, dragBus]`. Since neither dep changed between renders 1 and 2, React returns the cached extensions list — which includes `Collaboration.configure({ document: Y.Doc A })` from render 1.

4. **Provider acquired with Y.Doc B** — [AgendaDocument.tsx:722](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx). Provider seeds Y.Doc B with server bytes.

5. **Editor mounts with Y.Doc A** — `<MarkdownEditor>` at [AgendaDocument.tsx:1413](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx). No `key` prop, so editor never remounts. Collaboration extension is bound to the empty Y.Doc A. Provider seeds Y.Doc B. Result: **empty editor**.

#### ConversationAgendaDocument (correct approach)

1. **Fetch metadata only** — `useQuery` with `omitContent: true` at [ConversationAgendaDocument.tsx:82](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx). No `contentYjs` in response; saves ~243 KB per revalidation.

2. **Y.Doc created once** — `ydocRef.current ??= new Y.Doc()` at [ConversationAgendaDocument.tsx:119](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx). Never recreated.

3. **Provider acquired without bytes** — `acquireProvider` at [ConversationAgendaDocument.tsx:124](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx). `buildProvider` skips the `idbReady.then(applyInitialState)` path since no bytes are provided.

4. **IDB warm/cold check** — [ConversationAgendaDocument.tsx:134](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx).
   - Warm: `fragment.length > 0` → IDB replayed content → skip
   - Cold: `trpcClient.documents.getDoc.query({ documentId })` → apply `seed.contentYjs`

5. **`extraExtensions` deps include `ydoc`** — [ConversationAgendaDocument.tsx:310](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx). Since Y.Doc never changes, `Collaboration` extension is always bound to the correct ref.

## 3) Designed state

### 3.1 Architecture diagram

```text
 ┌─────────────────────────────────────────────────────────────┐
 │ OverviewDocTab / FileEditor                                 │
 │  trpc.documents.getDoc (omitContent: true) → documentId    │
 │  <Document documentId={docId} extraExtensions={             │
 │    useRichTextExtensions(), …surface-specific}>             │
 └───────────────────┬─────────────────────────────────────────┘
                     │
 ┌─────────────────────────────────────────────────────────────┐
 │ AgendaDocument / ConversationAgendaDocument                 │
 │  trpc.documents.getDoc (omitContent: true) → documentId    │
 │  <Document documentId={docId} extraExtensions={             │
 │    …agenda-specific (no Collaboration/EnsureNodeIds)}>      │
 └───────────────────┬─────────────────────────────────────────┘
                     │
 ┌─────────────────────────────────────────────────────────────┐
 │ <Document />  (shared primitive)                            │
 │  Y.Doc: ??= per documentId (reset on id change via ref)    │
 │  acquireProvider({ documentId, ydoc })  [NO bytes prop]     │
 │  idbReady.then:                                             │
 │    fragment.length > 0 → warm, skip                         │
 │    else → trpcClient.getDoc({ documentId })                 │
 │            → provider.applyInitialState(bytes)              │
 │  useDocEvents(documentId)                                   │
 │  DocumentHandle.refreshFromServer()                         │
 │    → forceFlush + trpcClient.getDoc + applyInitialState    │
 │  <MarkdownEditor key=[redacted] ?? 'loading'}             │
 │    extraExtensions=[Collaboration(ydoc), EnsureNodeIds,     │
 │    ...extraExtensions]>                                     │
 └─────────────────────────────────────────────────────────────┘

 ┌─────────────────────────────────────────────────────────────┐
 │ useRichTextExtensions()  (hook, extracted from Document)    │
 │  FileLinkNode + createFileLinkSuggestionExtension           │
 │  ConversationNode + createConversationMention               │
 │  EventNode + createEventMention                             │
 └─────────────────────────────────────────────────────────────┘
```

### 3.2 Step-by-step walkthrough

#### `<Document />` new cold-open path

1. **Parent fetches metadata only** — parent calls `trpc.documents.getDoc` with `omitContent: true`. Response is small (no binary blob). `documentId` extracted from result.
   ```json
   { "id": "f622ce16-…", "content": "…md…", "contentYjs": null }
   ```

2. **`<Document />` Y.Doc stable init** — [document.tsx](apps/mail/modules/documents/document.tsx) (new). `ydocRef.current ??= new Y.Doc()` on first render. If `documentId` changes (same component reused across docs), Y.Doc is recreated by the render-time guard keyed on `documentId`, not on `null → id` transitions.

3. **Provider acquired without bytes** — `acquireProvider({ documentId, ydoc })` with no `contentYjsBase64`. `buildProvider` skips the bytes-seeding branch.

4. **IDB warm/cold inside `<Document />`** — in the provider effect's `idbReady.then(...)` callback (new logic in [document.tsx](apps/mail/modules/documents/document.tsx)):
   - Warm: `fragment.length > 0` → IDB has content, skip server fetch
   - Cold: `const seed = await trpcClient.documents.getDoc.query({ documentId })` → `provider.applyInitialState(base64ToUint8Array(seed.contentYjs))`

5. **`composedExtensions` contains only core + caller extensions** — [document.tsx](apps/mail/modules/documents/document.tsx) (new). `[Collaboration(ydoc), EnsureNodeIds, ...extraExtensions]`. No hardcoded rich-text extensions.

6. **`<MarkdownEditor key=[redacted] ?? 'loading'}>` mounts** — unchanged from current [document.tsx:395](apps/mail/modules/documents/document.tsx). Key forces remount when `documentId` arrives, binding `Collaboration` to the correct Y.Doc on every mount.

7. **`DocumentHandle.refreshFromServer()`** — new method. Calls `forceFlush()`, then `trpcClient.documents.getDoc.query({ documentId })`, then `provider.applyInitialState(bytes)` unconditionally (overrides warm check). OverviewDocTab's "Refresh from server" menu item calls this instead of invalidating the query for bytes.

#### OverviewDocTab with `useRichTextExtensions()`

1. **`useRichTextExtensions()` hook** — [use-rich-text-extensions.ts](apps/mail/modules/documents/use-rich-text-extensions.ts) (new). Calls `useTRPC` + `useQueryClient`, runs the same warm-up queries and `searchFileLinks`/`searchConversations`/`searchEvents` callbacks currently in `<Document />`. Returns `AnyExtension[]`.

2. **OverviewDocTab composes extensions** — `const richTextExtensions = useRichTextExtensions()`. `extraExtensions` passed to `<Document />` = `[...richTextExtensions, ScoreNode, OverviewConversationNode, Mention, CommentMark, PendingCommentMark, MeetingNode]`. The OverviewTab's `ConversationNode` (from `./tiptap-extensions/ConversationNode`) is a different impl from the one in `useRichTextExtensions()` — pass `{ includeConversationMention: false }` to `useRichTextExtensions()` to exclude the conflicting agentCanvas version.

#### AgendaDocument ported to `<Document />`

1. **AgendaDocument fetches metadata only** — `useQuery(trpc.documents.getDoc, { omitContent: true })`. Gets `documentId`.

2. **Passes `documentId` to `<Document />`** — no `contentYjsBase64`. `<Document />` handles seeding.

3. **`extraExtensions` contains agenda-specific set only** — `[DateHeadingNode, AgendaTaskNode, ConversationNode, ConversationGroupHeader, ConversationGroupNode, AgendaSubtreeDrag, CrossEditorDragSource, CrossEditorDropTarget, GlobalDragHandle, DateTriggerExtension, CrossDayDropExtension, ConversationMention]`. No `Collaboration`, no `EnsureNodeIds` — `<Document />` adds those.

4. **All task callbacks, dialogs, cross-day drag remain** — unchanged around the `<Document />` render. `AgendaEditorErrorBoundary` wraps `<Document />` instead of `<MarkdownEditor>`. `forceFlush` from `useAgendaActions(dayKey)` still works because it calls `getProvider(docId)?.forceFlush()`, which finds the provider registered by `<Document />`.

5. **Editor ref** — `<Document ref={documentRef}>` exposes `DocumentHandle`. `editorRef` (previously `MarkdownEditorHandle`) is replaced by `documentRef.current?.editor` wherever direct editor access is needed.

## 4) Implementation phases

### Phase 1 — Fix the AgendaDocument stale-ref regression

**Goal:** Stop agenda documents from appearing empty by making the Y.Doc ref stable and forcing the editor to remount when the document ID arrives, matching `<Document />`'s existing `key` pattern.

- [x] In [AgendaDocument.tsx:277](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx), replace the render-time recreation guard (lines 277–283) with `ydocRef.current ??= new Y.Doc()`. Remove `prevAdoptedDocIdRef` declaration and all usages.
- [x] Add `key=[redacted] ?? 'loading'}` to the `<MarkdownEditor>` at [AgendaDocument.tsx:1413](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx).
- [x] Confirm the `* as Y` import at line 55 is still needed (it is, for `new Y.Doc()`); no import changes needed for this phase.

**Tests:**

- [x] Manual: Open the daily agenda with existing tasks — tasks should appear (not empty editor).
- [x] Manual: Toggle the "Past tasks" panel to expand it — past dates should load their content correctly.
- [x] `pnpm --filter @cedar/mail types 2>&1 | grep AgendaDocument` — no new type errors.

### Phase 2 — Move IDB warm/cold into `<Document />`; remove `contentYjsBase64` prop

**Goal:** `<Document />` internally detects warm vs. cold IDB state and does its own cold-open server fetch, making the loading strategy self-contained and consistent with ConversationAgendaDocument.

- [x] In [document.tsx](apps/mail/modules/documents/document.tsx), remove the `contentYjsBase64` prop from `DocumentProps` and from the component body.
- [x] Remove the `initialBytesRef` and the bytes-adoption `useEffect` (lines 164–191 of current file).
- [x] Change `acquireProvider` call to omit `contentYjsBase64`.
- [x] After the `acquireProvider` call in the provider `useEffect`, add warm/cold IDB detection: after `provider.idbReady`, check `provider.ydoc.getXmlFragment('prosemirror').length > 0`; on cold, call `trpcClient.documents.getDoc.query({ documentId })` and `provider.applyInitialState(base64ToUint8Array(seed.contentYjs))`. Include a `cancelled` guard identical to ConversationAgendaDocument's pattern at [ConversationAgendaDocument.tsx:133](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx).
- [x] Add `refreshFromServer(): Promise<void>` to `DocumentHandle` interface and `useImperativeHandle` implementation. It should: call `getProvider(documentId)?.forceFlush()`, then fetch `trpcClient.documents.getDoc.query({ documentId })`, then `provider.applyInitialState(bytes)`.
- [x] Import `trpcClient` from `@/providers/query-provider` in [document.tsx](apps/mail/modules/documents/document.tsx).
- [x] In [OverviewDocTab.tsx:297](apps/mail/modules/conversations/components/OverviewDocTab.tsx), add `omitContent: true` to the `trpc.documents.getDoc` query options. Remove `contentYjsBase64={docData?.contentYjs ?? null}` from the `<Document>` render.
- [x] Replace OverviewDocTab's "Refresh from server" dropdown item handler (currently invalidates query) with `await documentRef.current?.refreshFromServer()`. Keep the `queryClient.invalidateQueries` call for metadata refresh (title, emoji) — just no longer rely on it for content re-adoption.
- [x] In [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx), remove `contentYjsBase64={contentYjsBase64}` from the `<Document>` render in `FileEditor`. Add `omitContent: true` to the `fileQuery` options if `contentYjsBase64` is no longer used for anything else; if it is used elsewhere, keep the query unchanged and just remove the prop from `<Document>`.

**Tests:**

- [x] Manual: Open OverviewDocTab cold (clear IDB via DevTools) — editor loads from server.
- [x] Manual: Open OverviewDocTab warm (navigate away and back) — editor loads from IDB, no extra network request for contentYjs.
- [x] Manual: Use "Refresh from server" dropdown — editor content updates from latest server state.
- [x] Manual: Open FileEditor in `/brain` — document loads correctly.
- [x] `pnpm --filter @cedar/mail types 2>&1 | grep -E "document\.tsx|OverviewDocTab|CompanyExplorer"` — no type errors.

### Phase 3 — Extract `useRichTextExtensions()` hook; update `<Document />` callers

**Goal:** `<Document />` contains only Y.js lifecycle + `Collaboration` + `EnsureNodeIds`. Rich-text extensions are opt-in via a hook, removing the hardcoded set that would conflict with agenda-specific extensions.

- [x] Create [apps/mail/modules/documents/use-rich-text-extensions.ts](apps/mail/modules/documents/use-rich-text-extensions.ts). Move `searchFileLinks`, `searchConversations`, `searchEvents` useCallbacks and the two warm-up `useQuery` calls from [document.tsx](apps/mail/modules/documents/document.tsx) into this hook. Export `useRichTextExtensions({ includeConversationMention?: boolean })` returning `AnyExtension[]`.
- [x] The two `onSelect` callbacks for ConversationMention and EventMention (currently inline in `<Document />`) become module-level functions in the new file since they close over nothing component-specific.
- [x] In [document.tsx](apps/mail/modules/documents/document.tsx), remove all rich-text extension imports (`FileLinkNode`, `createFileLinkSuggestionExtension`, `ConversationNode`, `createConversationMention`, `EventNode`, `createEventMention`) and their associated hooks. `composedExtensions` becomes `[Collaboration.configure({...}), EnsureNodeIds, ...extraExtensions]`.
- [x] In [OverviewDocTab.tsx](apps/mail/modules/conversations/components/OverviewDocTab.tsx), add `const richTextExtensions = useRichTextExtensions({ includeConversationMention: false })` (OverviewDocTab manages its own ConversationNode + Mention via `./tiptap-extensions`). Spread into `extraExtensions`: `[...richTextExtensions, ScoreNode, ConversationNode, Mention, CommentMark, PendingCommentMark, MeetingNode]`.
- [x] In [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx) `FileEditor`, add `const richTextExtensions = useRichTextExtensions()` and pass `extraExtensions={richTextExtensions}` to `<Document>`.
- [x] Export `useRichTextExtensions` from [apps/mail/modules/documents/index.ts](apps/mail/modules/documents/index.ts) if one exists, or leave as a direct import.

**Tests:**

- [x] Manual: In OverviewDocTab, type `[[` — file link suggestions appear.
- [x] Manual: In OverviewDocTab, type `@` — conversation mention popup appears.
- [x] Manual: In FileEditor (`/brain`), `[[` file links and `@` mentions both work.
- [x] `pnpm --filter @cedar/mail types` — clean.

### Phase 4 — Port ConversationAgendaDocument to `<Document />`

**Goal:** Remove ConversationAgendaDocument's inline Y.js provider wiring; it becomes a thin wrapper that handles fetch, task callbacks, dialogs, and chrome around `<Document />`.

- [x] In [ConversationAgendaDocument.tsx](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx), add `omitContent: true` to the `getDoc` query (already present — confirm it stays).
- [x] Remove `ydocRef`, `ydoc`, `Y.Doc` creation (lines 118–120). Remove `* as Y` import if it becomes unused.
- [x] Remove the `acquireProvider`/`releaseProvider` `useEffect` (lines 122–190). Remove `acquireProvider`, `releaseProvider`, `createTrpcApplyUpdateClient`, `base64ToUint8Array` imports.
- [x] Remove the standalone `useDocEvents(documentId)` call (line 315) — `<Document />` handles it.
- [x] Remove `trpcClient` import if used only for the cold-open fetch.
- [x] Add `import { Document, type DocumentHandle } from '@/modules/documents/document'`.
- [x] Add `const documentRef = useRef<DocumentHandle>(null)`.
- [x] Replace `<MarkdownEditor>` (lines 333–359) with `<Document ref={documentRef} documentId={storedDoc?.id ?? null} contentYjsBase64` removed, `extraExtensions={extraExtensions}` `extraSlashCommands={[]}` `placeholder="Add a follow-up — type ! to insert a date"` `className={cn(...)}` `onReady={({ editor }) => { (editor.storage as { agendaMode?: ... }).agendaMode = 'conversation'; push({...}); }}`.
- [x] Replace `MarkdownEditor` import with `MarkdownEditorHandle` removal; `editorRef` (`useRef<MarkdownEditorHandle>`) is no longer needed — remove it. Anywhere that called `editorRef.current?.editor` now uses `documentRef.current?.editor`.
- [x] The visibilitychange + unmount `forceFlush` effect calls `void forceFlush()` from `useConversationAgendaActions` — this still works because it calls `getProvider(docId)?.forceFlush()` and `<Document />` registers the provider under the same `docId`.
- [x] `SaveStatusBadge`, `isForbiddenError`, `AccessDeniedState`, dialogs all remain in the outer JSX — no change.
- [x] Remove `documentId` const derived from `storedDoc?.id ?? null` only if it was used solely for `useDocEvents` — check usages; if also used for `SaveStatusBadge`, keep it.

**Tests:**

- [x] Manual: Open a conversation's agent overview tab — ConversationAgendaDocument loads tasks correctly.
- [x] Manual: Type `!` in the editor — date picker opens and inserts a `dateHeading`.
- [x] Manual: Click snooze on a task — snooze date picker opens.
- [x] `pnpm --filter @cedar/mail types 2>&1 | grep ConversationAgendaDocument` — clean (pre-existing lastEditedBy type mismatch at line 113 is unrelated to this refactor).

### Phase 5 — Port AgendaDocument to `<Document />`

**Goal:** AgendaDocument drops its own Y.js provider wiring and the stale-ref Y.Doc pattern (from Phase 1) and uses `<Document />` for all loading, while retaining all task-specific callbacks, cross-day drag, dialogs, and keyboard listeners.

- [x] In [AgendaDocument.tsx](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx), add `omitContent: true` to the `trpc.documents.getDoc` query options at line 194.
- [x] Remove `ydocRef` and `Y.Doc` creation (Phase 1 already changed this to `??=`; now remove entirely). Remove `* as Y` import.
- [x] Remove `acquireProvider`/`releaseProvider` `useEffect` (lines 719–732). Remove `acquireProvider`, `releaseProvider`, `createTrpcApplyUpdateClient` imports.
- [x] Remove `useDocEvents(documentId)` call (line 288). Remove the `documentId` store selector (line 285) if it was used only for `useDocEvents`; if also used elsewhere (e.g., `useAgendaActions`), keep it.
- [x] Remove `Collaboration.configure({...})` and `EnsureNodeIds` from the `extraExtensions` useMemo — `<Document />` adds them. Remove `Collaboration` import from `@tiptap/extension-collaboration` and `EnsureNodeIds` import from `@/modules/documents/yjs` if no longer used.
- [x] Add `import { Document, type DocumentHandle } from '@/modules/documents/document'`.
- [x] Add `const documentRef = useRef<DocumentHandle>(null)`.
- [x] Replace `<MarkdownEditor>` at [AgendaDocument.tsx:1413](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx) with `<Document ref={documentRef} documentId={agendaDoc?.id ?? null} extraExtensions={extraExtensions} extraSlashCommands={isReadOnly ? [] : extraSlashCommands} className={cn(...)} onReady={({ editor }) => { if (isReadOnly) editor.setEditable(false); editorRegistry?.register(dayKey, editor); editorMountedRef.current = true; }} placeholder="Start writing — type / for commands…">`. `AgendaEditorErrorBoundary` wraps `<Document>` instead of `<MarkdownEditor>`.
- [x] Replace `editorRef` (`useRef<MarkdownEditorHandle>`) with access via `documentRef.current?.editor` at all call sites: `editorRef.current?.editor` → `documentRef.current?.editor`; `editorRef.current?.getMarkdown()` → `documentRef.current?.getMarkdown()`. Update `MarkdownEditorHandle` import removal.
- [x] Remove `MarkdownEditor` and `MarkdownEditorHandle` imports.
- [x] `forceFlush` from `useAgendaActions(dayKey)` is unaffected — it calls `getProvider(docId)?.forceFlush()` which finds the provider `<Document />` registered.
- [x] Remove `agendaDocRef` (used only for `contentYjsBase64` in the old `acquireProvider` call).

**Tests:**

- [x] Manual: Open daily agenda with existing tasks — tasks appear, not empty.
- [x] Manual: Type an `@` mention — conversation search popup opens.
- [x] Manual: Drag a task to a different day in the multi-day stack — cross-day drop works.
- [x] Manual: Open past panel — past dates load correctly.
- [x] Manual: Snooze a task (clock icon) — date picker + reason dialog flow works.
- [x] `pnpm --filter @cedar/mail types` — clean across all changed files (pre-existing lastEditedBy type mismatch in buildDailyAgendaFsNode/buildConversationAgendaFsNode unrelated to this refactor).