Appearance
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
@NAMEtoken via the library's defaultrenderText— no custom serializer to drift from upstream. - Mention picking stays the shipped docked-list contract (trailing
@tokenor the toolbar@button; identity square + plain name; click supplies the@) — NOT TipTap's floating-popup suggestion machinery. - Feed = react-virtuoso (
followOutputstick-to-bottom,startReached+firstItemIndexfor 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:
- G1 — newlines died server-side. TipTap's HardBreak
renderText()correctly emits\nand the feed renderswhitespace-pre-wrap, butsanitizeTalk'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). - 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'sslice(0, 200)silently truncated the tail, possibly mid-mention (unaddressing the agent).submit()now refuses over-cap SERIALIZED text (talk_too_long, draft kept). - 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 andsubmit().
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
editorRefseam and assert the SERIALIZED wire text. VirtuosoMockContextrenders zero rows wheninitialTopMostItemIndexis 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).