Skip to content

Chat Composer Upgrade (TipTap + Virtuoso)

Status: shipped 2026-08-01 (PR #197) · falsification-hardened 2026-08-03 (PR #209) · retrospected 2026-08-03

What was decided

The table chat's composer and feed are built on the primitives professional chat products use — while the WIRE FORMAT stays plain text.

  • Composer = TipTap, trimmed to plain-text chat: every mark and rich block disabled ("what the wire can't carry can't be composed"). Enter sends; Shift+Enter inserts a hard break. Mentions are ATOMIC chip nodes (one backspace removes the whole mention) that serialize to today's @NAME token via the library's default renderText — no custom serializer to drift from upstream.
  • Mention picking stays the shipped docked-list contract (trailing @token or the toolbar @ button; identity square + plain name; click supplies the @) — NOT TipTap's floating-popup suggestion machinery.
  • Feed = react-virtuoso (followOutput stick-to-bottom, startReached + firstItemIndex for scrollback with no scroll jump) — the same feed that table-chat-history's durable backfill pages into.
  • Failure keeps the draft: a coded send failure shows the shared strip and leaves the composed text intact.

The wire contract is a PATH property (falsification G1–G3)

The plan's claims ("newlines as \n", "the 200-char cap is enforced in-editor") were each half-true — the falsification fixed all three red-first:

  1. G1 — newlines died server-side. TipTap's HardBreak renderText() correctly emits \n and the feed renders whitespace-pre-wrap, but sanitizeTalk's /\s+/g → " " (written before multiline existed) flattened every newline. Hygiene now collapses HORIZONTAL whitespace only, trims line edges, caps newline runs at one blank line — still the ONE shared definition (WS + HTTP).
  2. G2 — two counters of "the same" quantity diverged. CharacterCount counts a mention ATOM as one character; the wire expands @LONGNAME — so an editor-accepted draft could exceed the cap and the server's slice(0, 200) silently truncated the tail, possibly mid-mention (unaddressing the agent). submit() now refuses over-cap SERIALIZED text (talk_too_long, draft kept).
  3. G3 — Enter fired during IME composition. A CJK user confirming a kana→kanji conversion would send the half-composed draft. Enter defers to the IME (view.composing) at both the shortcut and submit().

Key trade-offs accepted

  • Keyboard-driven mention selection (arrow keys in the picker) deferred — parity with the shipped click-to-pick UX came first.
  • jsdom can't type into contenteditable, so composer tests drive TipTap commands through an editorRef seam and assert the SERIALIZED wire text.
  • VirtuosoMockContext renders zero rows when initialTopMostItemIndex is set — the feed reads the mock context and applies the start-at-newest index only outside it.

Full impl detail: .indusk/planning/archive/chat-composer-upgrade/ (workbench planning archive).