conversation-agenda-next-step-node.md19.0 KBView on GitHub
# Pinned "Next Step" node in the conversation agenda document

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

We want the conversation agenda document (the Next Steps Agent surface) to always show, as its top row, a pinned "Next step" section: a header with an editable date badge, the next-step text directly below it, and the upcoming scheduled meetings — mirroring the standalone `NextStepsCard`. Today the agenda document only contains server-derived `dateHeading` + `agendaTask` nodes, while the next-step date/text live on `conversation.nextStepDate` / `conversation.nextSteps` and are edited exclusively through `NextStepsCard` (rendered in CRM/timeline surfaces); the agenda document has no awareness of them. The change introduces two new TipTap nodes (`nextStepNode`, `scheduledMeetingsNode`), teaches the server-side conversation-agenda reconciler to inject them at the top of the document on every fetch, and adds a server `DocumentSaveHook` that writes edits to the `nextStepNode` back onto the conversation row — leaving `NextStepsCard` untouched for its existing surfaces.

## 2) Present state

### 2.1 Architecture diagram

```text
                    ┌──────────────────────────────┐
   CRM / timeline   │   NextStepsCard (standalone) │  edits conversation.nextSteps
   surfaces  ─────► │   date badge + md editor +   │  + nextStepDate via
                    │   meetings list              │  onConversationUpdate()
                    └──────────────┬───────────────┘
                                   │ trpc crm.updateConversation
                                   ▼
                         ┌───────────────────┐
                         │ crm_conversations │  nextSteps / nextStepDate /
                         │       row         │  scheduled_calendar_events
                         └───────────────────┘

   Next Steps Agent ────► ConversationAgendaDocument (TipTap + Y.Doc)
                             │  documents.getDoc({ conversation_agenda })
                             ▼
                    ┌────────────────────────────────────┐
                    │ getDocImpl → conversationAgendaDef  │
                    │  seed: buildConversationAgendaJson  │
                    │  reconcile: reconcileConversation-  │
                    │             AgendaYDoc (tasks only) │
                    └────────────────────────────────────┘
   doc = [ dateHeading, agendaTask*, …, trailing paragraph ]
   (no next-step / meetings content)
```

### 2.2 Step-by-step walkthrough

1. **Card render** — `NextStepsCard` at [NextStepsCard.tsx:112](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx) renders a date badge ([NextStepsCard.tsx:252-283](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx)), an upcoming-meetings list ([NextStepsCard.tsx:288-338](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx)), and a `MarkdownEditor`. Its parents (`ConversationTabContent.tsx`, `AgentRow.tsx`) feed `nextSteps`, `nextStepDate`, `scheduledCalendarEvents` and wire `onConversationUpdate('nextSteps' | 'nextStepDate', …)`.
   - Data in: `{ nextSteps: string, nextStepDate: Date | null, scheduledCalendarEvents: ScheduledCalendarEvent[] }`
2. **Persist** — edits call `crm.updateConversation` ([apps/server/src/trpc/routes/crm.ts](apps/server/src/trpc/routes/crm.ts)) which writes `crm_conversations.nextSteps` (text) and `crm_conversations.nextStepDate` (timestamp). `scheduled_calendar_events` is a jsonb column on the same row, hydrated server-side ([apps/server/src/services/crm/conversations.ts:3120](apps/server/src/services/crm/conversations.ts)).
3. **Agenda fetch** — `ConversationAgendaDocument` ([ConversationAgendaDocument.tsx:75](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx)) calls `documents.getDoc({ documentType: 'conversation_agenda', path })`.
4. **getDoc route** — `getDoc` at [apps/server/src/trpc/routes/documents.ts:223](apps/server/src/trpc/routes/documents.ts) calls `getDocImpl` with `fetchers.fetchConversationTasks` ([documents.ts:257](apps/server/src/trpc/routes/documents.ts)).
5. **Doc-type dispatch** — `conversationAgendaDef` at [doc-type-registry.ts:157-192](apps/server/src/services/document-store/doc-type-registry.ts): `seedFn` builds a fresh doc, `reconcile` merges new task rows; `broadcastDeltaOnReconcile: true`.
6. **Seed** — `buildConversationAgendaJson` at [conversation-agenda-reconciler.ts:105-134](apps/server/src/services/agenda/conversation-agenda-reconciler.ts) emits a `dateHeading` per day, an `agendaTask` per task, and a trailing empty paragraph.
   - Data after this step:
     ```json
     { "type": "doc", "content": [
       { "type": "dateHeading", "attrs": { "date": "2026-05-19" } },
       { "type": "agendaTask", "attrs": { "taskId": "…", "dueDate": "2026-05-19" } },
       { "type": "paragraph", "content": [] }
     ] }
     ```
7. **Reconcile** — `reconcileConversationAgendaYDoc` at [conversation-agenda-reconciler.ts:337-444](apps/server/src/services/agenda/conversation-agenda-reconciler.ts) dedups, then inserts only tasks whose `taskId` is absent. It never touches non-task structure.
8. **Client editor** — `ConversationAgendaDocument` mounts `MarkdownEditor` with `extraExtensions` ([ConversationAgendaDocument.tsx:232-250](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx)): `DateHeadingNode`, `AgendaTaskNode`, `ConversationAgendaInvariants`, `Collaboration`, `EnsureNodeIds`.
9. **Invariants** — `createConversationAgendaInvariantsExtension` at [ConversationAgendaInvariants.ts:137-192](apps/mail/modules/agentCanvas/extensions/ConversationAgendaInvariants.ts) walks top-level children and heals `agendaTask.dueDate` / `conversationId`. It assumes every non-`dateHeading` top-level node is an `agendaTask`.
10. **Save → hooks** — Y.Doc flushes run `DOCUMENT_SAVE_HOOKS` ([registry.ts:13-20](apps/server/src/services/document-saving/registry.ts)). `agendaTaskSyncHook` ([agenda-task-sync.ts:25-178](apps/server/src/services/document-saving/hooks/agenda-task-sync.ts)) reads `changeSet.byType.get('agendaTask')` and emits `user_tasks` INSERT/UPDATE/DELETE, pushing `attrPatches` for backfilled `taskId`s.

## 3) Designed state

### 3.1 Architecture diagram

```text
   crm_conversations row  ──nextSteps / nextStepDate / scheduled_calendar_events──┐
            ▲                                                                     │
            │ (3) nextStepSyncHook writes node edits back                         │ (1) fetcher reads
            │                                                                     ▼
   ┌────────┴─────────┐                                  ┌──────────────────────────────────┐
   │ DOCUMENT_SAVE_   │                                  │ conversationAgendaDef.reconcile/  │
   │ HOOKS            │◄─── Y.Doc flush ────┐            │ seedFn                            │
   │  + nextStepSync  │                     │            │  ensureNextStepNode(ydoc, ctx)    │
   └──────────────────┘                     │            │  ensureScheduledMeetingsNode(…)   │
                                            │            └──────────────────┬───────────────┘
   ConversationAgendaDocument (TipTap)──────┘                               │
     extraExtensions += NextStepNode, ScheduledMeetingsNode                 ▼
                                                  doc = [ nextStepNode,            ◄─ pinned
                                                          scheduledMeetingsNode,   ◄─ pinned
                                                          dateHeading, agendaTask*, … ]
```

### 3.2 Step-by-step walkthrough

1. **New fetcher** — add `fetchConversationNextStepContext(db, userId, convId)` (new, in [apps/server/src/services/document-store/get-doc.ts](apps/server/src/services/document-store/get-doc.ts) alongside `fetchActiveConversationTasks`, or a sibling module). Selects from `crm_conversations` with org/user authorization.
   - Returns:
     ```ts
     { nextSteps: string | null; nextStepDate: Date | null;
       scheduledCalendarEvents: ScheduledCalendarEvent[] }
     ```
   - Add `fetchConversationNextStep` to `SeedFetchers` ([doc-type-registry.ts:53-59](apps/server/src/services/document-store/doc-type-registry.ts)) and wire it in the `getDoc` route fetchers block ([documents.ts:255-259](apps/server/src/trpc/routes/documents.ts)).
2. **Seed injects nodes** — `buildConversationAgendaJson` ([conversation-agenda-reconciler.ts:105](apps/server/src/services/agenda/conversation-agenda-reconciler.ts)) gains the next-step context as a param and prepends two nodes before the first `dateHeading`.
   - Data after this step:
     ```json
     { "type": "doc", "content": [
       { "type": "nextStepNode", "attrs": { "date": "2026-05-22" },
         "content": [{ "type": "text", "text": "Send post-meeting follow-up" }] },
       { "type": "scheduledMeetingsNode", "attrs": { "meetings": [ … ] } },
       { "type": "dateHeading", "attrs": { "date": "2026-05-19" } }
     ] }
     ```
3. **Reconcile injects/refreshes nodes** — in `reconcileConversationAgendaYDoc` ([conversation-agenda-reconciler.ts:337](apps/server/src/services/agenda/conversation-agenda-reconciler.ts)), before the task pass, run two new helpers in a `ydoc.transact(…, 'system')`:
   - `ensureNextStepNode(fragment, ctx)` — if no `nextStepNode` exists, insert a `Y.XmlElement('nextStepNode')` at index 0 with `nodeId`, `date` attr, and a `Y.XmlText` of `nextSteps`. If it exists, overwrite its `date` attr + text to match the row (row is source of truth; mirrors task behavior where the server owns structure).
   - `ensureScheduledMeetingsNode(fragment, ctx)` — same pattern at index 1; an atom node carrying `meetings` as a JSON-serialized attr (read-only, always overwritten from the row).
   - Both count toward `ReconcileResult.mutated` so the existing `broadcastDeltaOnReconcile` path pushes the delta to open editors.
4. **New client nodes** — add `NextStepNode.tsx` and `ScheduledMeetingsNode.tsx` under [apps/mail/modules/agentCanvas/components/](apps/mail/modules/agentCanvas/components/), modeled on `DateHeadingNode.tsx` (node def lines ~306-333) and `AgendaTaskNode.tsx`:
   - `nextStepNode`: `group: 'block'`, `content: 'inline*'`, `draggable: false`, `selectable: false`, attrs `{ date: string | null, nodeId }`. NodeView renders a "Next step" label, a date badge (reuse `RelativeDateBadge` + `DatePickerWithNaturalInput` from `NextStepsCard`), and `NodeViewContent` for the editable text.
   - `scheduledMeetingsNode`: `group: 'block'`, `atom: true`, `selectable: false`, attrs `{ meetings: unknown[], nodeId }`. Read-only NodeView reusing the meetings markup from [NextStepsCard.tsx:288-338](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx).
5. **Register nodes** — add both to `extraExtensions` in [ConversationAgendaDocument.tsx:232-250](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx).
6. **Invariants skip pinned nodes** — in `ConversationAgendaInvariants` `appendTransaction` ([ConversationAgendaInvariants.ts:156-184](apps/mail/modules/agentCanvas/extensions/ConversationAgendaInvariants.ts)), the `doc.forEach` walk must `return` early for `nextStepNode` / `scheduledMeetingsNode` so they are not treated as `agendaTask`s. The `Backspace`/`Delete` heading-splice shortcuts ([ConversationAgendaInvariants.ts:86-128](apps/mail/modules/agentCanvas/extensions/ConversationAgendaInvariants.ts)) already only target `dateHeading`, so no change there; `selectable: false` + the server re-inserting the node on next fetch make accidental deletion self-healing.
7. **New sync hook** — add `next-step-sync.ts` under [apps/server/src/services/document-saving/hooks/](apps/server/src/services/document-saving/hooks/), `name: 'agenda.nextStepSync'`, `appliesTo: ['conversation_agenda']`. In `run`, read `changeSet.byType.get('nextStepNode')` ([types.ts:51-62](apps/server/src/services/document-saving/types.ts)); on a `create`/`update` change, write `crm_conversations.nextSteps` from `change.next.text` and `nextStepDate` from the `date` attr, scoped to `scope.id` with the same org-authorization check pattern as `agendaTaskSyncHook` ([agenda-task-sync.ts:47-66](apps/server/src/services/document-saving/hooks/agenda-task-sync.ts)).
   - Data written:
     ```json
     { "nextSteps": "Send post-meeting follow-up", "nextStepDate": "2026-05-22T00:00:00.000Z" }
     ```
8. **Register hook** — append `nextStepSyncHook` to `DOCUMENT_SAVE_HOOKS` ([registry.ts:13-20](apps/server/src/services/document-saving/registry.ts)) after `agendaTaskSyncHook`.

## 4) Implementation phases

### Phase 1 — Client nodes + invariants

**Goal:** Define `nextStepNode` and `scheduledMeetingsNode`, register them in the agenda editor, and make invariants ignore them.

- [x] Create `apps/mail/modules/agentCanvas/components/NextStepNode.tsx` — TipTap node `nextStepNode` (`group: 'block'`, `content: 'inline*'`, `draggable/selectable: false`, attrs `date`, `nodeId`) with a React NodeView: "Next step" label, date badge (reuse `RelativeDateBadge` + `DatePickerWithNaturalInput`), and `NodeViewContent` for the text.
- [x] Create `apps/mail/modules/agentCanvas/components/ScheduledMeetingsNode.tsx` — TipTap atom node `scheduledMeetingsNode` (attrs `meetings`, `nodeId`), read-only NodeView reusing the meetings list markup from [NextStepsCard.tsx:288-338](apps/mail/modules/conversations/components/timeline/NextStepsCard.tsx).
- [x] Register both nodes in `extraExtensions` in [ConversationAgendaDocument.tsx:232-250](apps/mail/modules/agentCanvas/components/ConversationAgendaDocument.tsx).
- [x] `ConversationAgendaInvariants` — no change needed: the `doc.forEach` walk at [ConversationAgendaInvariants.ts:156-184](apps/mail/modules/agentCanvas/extensions/ConversationAgendaInvariants.ts) only mutates `agendaTask` nodes and ignores every other top-level type, so `nextStepNode` / `scheduledMeetingsNode` pass through inertly. The `Backspace`/`Delete` shortcuts only target `dateHeading`.
- [x] Add `nextStepNode` + `scheduledMeetingsNode` to `ID_NODE_TYPES` in [ensureNodeIdsPlugin.ts:33-57](apps/mail/modules/files/yjs/ensureNodeIdsPlugin.ts) so the `nodeId` attr is declared on the schema (required for server hook dispatch).

**Tests:**

- [x] Add `apps/mail/tests/modules/agentCanvas/next-step-node.test.ts` — schema-level test (repo convention: `getSchema`, no DOM/NodeViews) asserting both nodes register and round-trip their attrs/content from JSON.
- [x] `pnpm --filter @zero/mail test tests/modules/agentCanvas/next-step-node.test.ts` — 6 tests pass.

### Phase 2 — Server reconciler injects the pinned nodes

**Goal:** `documents.getDoc` for `conversation_agenda` always returns `nextStepNode` + `scheduledMeetingsNode` at the top, sourced from the conversation row.

- [x] Add `fetchConversationNextStepContext(db, userId, convId)` returning `{ nextSteps, nextStepDate, meetings }` (next to `fetchActiveConversationTasks` in [documents.ts](apps/server/src/trpc/routes/documents.ts)). Meetings come from `calendarEvents` (`endTime > now`, recurring series collapsed to earliest instance).
- [x] Add `fetchConversationNextStep` to `SeedFetchers` ([doc-type-registry.ts:53-59](apps/server/src/services/document-store/doc-type-registry.ts)) and wire it into the `getDoc` route fetchers ([documents.ts:255-259](apps/server/src/trpc/routes/documents.ts)).
- [x] Extend `buildConversationAgendaJson` ([conversation-agenda-reconciler.ts:105-134](apps/server/src/services/agenda/conversation-agenda-reconciler.ts)) to accept the next-step context and prepend the two nodes.
- [x] Add `ensureNextStepNode` + `ensureScheduledMeetingsNode` helpers and call them inside `reconcileConversationAgendaYDoc` ([conversation-agenda-reconciler.ts:337-444](apps/server/src/services/agenda/conversation-agenda-reconciler.ts)) before the task pass; fold their mutation into `ReconcileResult.mutated`.
- [x] Update `conversationAgendaDef.seedFn` / `reconcile` ([doc-type-registry.ts:167-185](apps/server/src/services/document-store/doc-type-registry.ts)) to fetch and pass the next-step context.

**Tests:**

- [x] Add `apps/server/src/services/agenda/__tests__/conversation-agenda-reconciler.test.ts` — asserts `buildConversationAgendaJson` emits the pinned nodes, and `reconcileConversationAgendaYDoc` injects them into a doc that lacks them, is idempotent, and refreshes them on change.
- [x] `pnpm --filter @zero/server test src/services/agenda` — 5 tests pass.

### Phase 3 — Server sync hook writes node edits back to the conversation

**Goal:** Editing the date badge or text in the agenda document persists to `conversation.nextStepDate` / `nextSteps`.

- [x] Create `apps/server/src/services/document-saving/hooks/next-step-sync.ts` — `DocumentSaveHook` named `agenda.nextStepSync`, `appliesTo: ['conversation_agenda']`, reading `changeSet.byType.get('nextStepNode')` and writing `crm_conversations.nextSteps` + `nextStepDate` for `scope.id` (the conversation, already route-authorized).
- [x] Register `nextStepSyncHook` in `DOCUMENT_SAVE_HOOKS` ([registry.ts:13-20](apps/server/src/services/document-saving/registry.ts)).

**Tests:**

- [x] Add `apps/server/src/services/document-saving/hooks/__tests__/next-step-sync.test.ts` — asserts create/update changes write `nextSteps` + `nextStepDate`, partial changes write only the changed field, an emptied date clears `nextStepDate`, and a non-conversation scope is skipped.
- [x] `pnpm --filter @zero/server test src/services/document-saving/hooks` — 6 tests pass.
- [ ] Manual end-to-end: open a conversation's Next Steps Agent, confirm the pinned "Next step" row renders with date + text + meetings; edit the date and text, reload, and confirm the change shows in `NextStepsCard` on the CRM surface (and vice-versa). _(Not run — requires the running app; left for the user.)_

## Verification

- Unit/integration: the three `pnpm --filter … test` commands above.
- Type + lint: `pnpm --filter @zero/mail typecheck` and `pnpm --filter @zero/server typecheck`; `pnpm deps:check` (server hook touches the document-saving layer — verify no Mastra/skill imports leak in).
- End-to-end in the running app (frontend `5173`, API `8787`): expand the Next Steps Agent on a conversation that has `nextStepDate`/`nextSteps` set — the pinned node must appear at the top with the correct badge, text, and meetings. Edit each, confirm persistence by reloading and by checking `NextStepsCard` in the CRM/timeline view reflects the same values.