Skip to content

feat(drafts): conversation rail (Rail-1) — draft-level un-anchored chat surface - #227

Merged
prellr merged 3 commits into
mainfrom
feat/drafts-conversation-rail
Jun 30, 2026
Merged

prellr merged 3 commits into
mainfrom
feat/drafts-conversation-rail

Conversation

@prellr

@prellr prellr commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

Builds Rail-1 of the Drafts "global germination rail": a draft-level, un-anchored conversation surface (chat-style), distinct from the anchored annotation threads. Humans only this slice — author_type/author_user_id are modeled now so Rail-2 (Aye via multica draft say) is a drop-in with no migration.

Approach: a near-mechanical clone of the shipped draft_annotation_message stack, minus the anchor — a flat per-draft message log. Each layer mirrors the annotation surface 1:1.

What's in it

Backend (migration 126)

  • draft_message table: id, draft_id (FK→draft CASCADE), workspace_id (denormalized FK→workspace), open-enum author_type (no CHECK), nullable author_user_id (FK→user SET NULL), body, created_at; (draft_id, created_at) index.
  • Queries ListDraftMessages / CreateDraftMessage (sqlc, no drift).
  • Handler draft_message.go: list + create, resolving the parent draft via loadDraftForUser then using draft.ID (UUID convention); empty body → 400; publishes draft_message:created with the resolveActor actor.
  • Extracted resolveDraftAnnotationAuthor → shared resolveDraftAuthor (it was annotation-agnostic) — reused by both surfaces.
  • Routes GET/POST under /api/drafts/{id}/messages (inherits the workspace-member middleware).
  • WS event EventDraftMessageCreated.

Core TS — DraftMessage types + index export; DraftMessageSchema/ListDraftMessagesResponseSchema (.loose(), parseWithFallback, EMPTY fallbacks) + a malformed-response drift test; listDraftMessages/addDraftMessage client methods; draftMessageKeys (keyed [wsId, draftId]) + list options; useAddDraftMessage (optimistic append + temp→real swap); onDraftMessageCreated WS updater (de-dupe + temp-match); wired draft_message:created into use-realtime-sync + WSEventType.

Frontend (packages/views/drafts/)

  • Extracted shared row: the annotation thread's inline ThreadMessage → common/message-row.tsx (avatar + sender + timestamp + Markdown). Both message shapes satisfy its props; the annotation panel imports it (No-Duplication Rule).
  • conversation/conversation-rail.tsx: scrollable list (shared row) over a persistent bottom composer (Cmd/Ctrl+Enter to send), empty state.
  • conversation/draft-side-panel.tsx: two-tab wrapper — Conversation (default) | Annotations (+ open-count badge). Local useState for the active tab. Owns the w-96 rail chrome; each tab renders embedded.
  • Swapped AnnotationThreadPanel → DraftSidePanel in the editor (annotation props threaded to the Annotations tab + wsId/draftId to the rail; w-96 footprint + drag regions unchanged).
  • i18n drafts.conversation.* (en + zh-Hans).

Annotation panel — unchanged behaviorally

AnnotationThreadPanel gained an opt-in embedded prop (drops the standalone width/border/title header when inside the tab wrapper). The default — and everything its own tests render — is identical to before. Its 11 tests pass unchanged after the MessageRow extraction.

Verification

  • make sqlc clean (no drift); go build ./... + go vet ./... clean; full go test ./internal/handler/ green (DraftMessage create+list / empty-body 400 / bad-UUID guard / cross-owner denial, plus all existing Draft + annotation tests after the resolveDraftAuthor rename). Migrations 123–126 applied locally.
  • pnpm typecheck (all 6 tasks incl. web + desktop), pnpm test (all packages, 866 views + 389 core), pnpm lint (0 errors), i18n parity test — all green.

🤖 Generated with Claude Code

prellr and others added 3 commits June 29, 2026 23:39
Add the draft-level, un-anchored conversation surface — a flat per-draft
message log distinct from the anchored annotation threads. Near-mechanical
clone of the draft_annotation_message stack, minus the anchor.

- Migration 126: draft_message table (id, draft_id, workspace_id,
  open-enum author_type, nullable author_user_id, body, created_at) +
  (draft_id, created_at) index. Human-only for now; author_type/
  author_user_id exist so Rail-2 (Aye) is a drop-in with no migration.
- Queries: ListDraftMessages / CreateDraftMessage (sqlc-generated).
- Handler draft_message.go: list + create, resolving the parent draft via
  loadDraftForUser then using draft.ID; empty body -> 400; publishes
  draft_message:created with the resolveActor actor.
- Extract resolveDraftAnnotationAuthor -> resolveDraftAuthor (it was
  annotation-agnostic); reused by both surfaces.
- Routes GET/POST under /api/drafts/{id}/messages (workspace-member group).
- WS event EventDraftMessageCreated.
- Tests: create+list, empty-body 400, bad-draft-UUID guard, cross-owner
  denial; annotation tests stay green after the rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the annotation-message TS stack for the flat draft_message rail.

- Types: DraftMessage / ListDraftMessagesResponse / AddDraftMessageRequest;
  reuses the open author-type union for enum-drift safety.
- Schemas: DraftMessageSchema + ListDraftMessagesResponseSchema (.loose,
  parseWithFallback) + EMPTY fallbacks + a malformed-response drift test
  (unknown author_type downgrades; wrong-typed/missing fields fail closed).
- Client: listDraftMessages / addDraftMessage through parseWithFallback.
- message-queries: draftMessageKeys keyed [wsId, draftId] + list options.
- message-mutations: useAddDraftMessage with optimistic append + temp->real
  swap on success (mirrors the fixed annotation-message hook; flat shape).
- message-ws-updaters: onDraftMessageCreated with de-dupe-by-id +
  temp-match replacement; unit-tested.
- Wire draft_message:created into use-realtime-sync (specificEvents + ws.on
  + cleanup) and add it to WSEventType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Extract the annotation thread's inline ThreadMessage into a shared
  common/message-row.tsx (avatar + sender + timestamp + Markdown). Both
  DraftAnnotationMessage and DraftMessage satisfy its props; the annotation
  panel imports it (No-Duplication Rule). Panel behavior unchanged — its
  tests stay green.
- conversation/conversation-rail.tsx: scrollable message list (shared row)
  over a persistent bottom composer (Textarea + Send, Cmd/Ctrl+Enter),
  empty state; reads draftMessageListOptions, posts via useAddDraftMessage.
- conversation/draft-side-panel.tsx: two-tab wrapper — Conversation
  (default) | Annotations (+ open-count badge). Local useState for the
  active tab (ephemeral). Owns the w-96 rail chrome; each tab renders
  embedded (no double chrome — AnnotationThreadPanel gains an `embedded`
  prop that drops its standalone width/border/title header).
- Swap AnnotationThreadPanel -> DraftSidePanel in the editor, threading the
  annotation props to the Annotations tab + wsId/draftId to the rail.
- i18n drafts.conversation.* (en + zh-Hans).
- Tests: conversation-rail (renders rows, composer posts + clears, Cmd+Enter
  sends, empty guard) + draft-side-panel tab switch + badge wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@prellr
prellr merged commit 1a030be into main Jun 30, 2026
2 checks passed
@prellr
prellr deleted the feat/drafts-conversation-rail branch June 30, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant