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.