Skip to content

Stop the chat composer from showing formatting that sending removes #287

Description

@HMarzban

Problem

The chat composer shows three formats that a sent message loses: strikethrough, headings, and horizontal rules. The feed shows the text of the message without them.

On 14ab7f9c1. Paths are under apps/webapp/src/components/chatroom/, except paths that start with apps/.

  1. The composer turns off only link, code, codeBlock, and listKeymap in StarterKit (components/MessageComposer/hooks/useTiptapEditor.ts:126-139). So Strike, Heading, and HorizontalRule stay on. editor.getHTML() then holds s, h1 to h6, and hr tags.
  2. The message allowlist has none of these tags (apps/webapp/src/utils/sanitizeContent.ts:4-18). DOMPurify removes a tag that is not on the list and keeps its text. An hr has no text, so nothing is left.
  3. The send path applies the allowlist in prepareOutboundContent (utils/outboundMessagePipeline.ts:79-81). Long messages also go through sanitizeChunk (components/MessageComposer/hooks/useComposerSubmit.ts:168). The feed applies the same allowlist again in getSanitizedMessageBodyHtml (apps/webapp/src/utils/sanitizeContent.ts:51-60, called at components/MessageCard/components/MessageContent/components/HTMLBody.tsx:19-22).

Measured results (see Notes):

Composer input Composer HTML Sent and shown HTML
Strikethrough button, ⌘+⇧+S, or ~~3pm~~ <p>meet at <s>3pm</s> 4pm</p> <p>meet at 3pm 4pm</p>
Typed # Plan <h1>Plan</h1><p></p> Plan
Pasted <h2>Agenda</h2><p>First item</p> <h2>Agenda</h2><p>First item</p> Agenda<p>First item</p>
Typed ---, then below <hr><p>below</p> <p>below</p>

The two cases need different fixes.

  • Strikethrough is a chat format. The format toolbar has a Strikethrough button on desktop and on mobile (components/MessageComposer/components/Toolbar/formatToolbarLayout.ts:24). The button calls toggleStrike(), and its tooltip names ⌘+⇧+S (components/MessageComposer/components/Toolbar/ToolbarButtons/StrikethroughButton.tsx:17-20). The chatroom rules list the group as B I S </> (apps/webapp/src/components/chatroom/CLAUDE.md:64). A lost strikethrough can change the meaning: "meet at 3pm 4pm" arrives as "meet at 3pm 4pm".
  • Headings and horizontal rules are not chat formats. The toolbar has no control for either (components/MessageComposer/components/Toolbar/formatToolbarLayout.ts:23-28). They still get into the composer from a typed # or --- at the start of a line, or from a paste. In the composer, headings take the pad styles. A typed # Plan shows at 28pt bold. A typed # alone shows the pad title placeholder "Enter document name". Notes give the style chain.

Steps to reproduce

  1. On desktop, sign in, open a pad, and open a heading chat.
  2. Type meet at . Click the Strikethrough button, type 3pm, click the button again, and type 4pm. Send.
  3. The composer showed 3pm struck through. The sent message in the feed shows meet at 3pm 4pm with no strikethrough.
  4. Type # in the empty composer. See "Enter document name" in large bold text. Type Plan and send. The feed shows plain Plan.
  5. On a web page, copy a heading and the line after it. Paste them into the composer and send. The composer showed a large bold heading. The feed shows plain text.
  6. Type --- in the empty composer. See a horizontal rule. Type below and send. The feed shows only below.

Acceptance criteria

  • Text struck through with the Strikethrough button, with ⌘+⇧+S, or with ~~text~~ shows struck through in the feed after send.
  • A message edited to add strikethrough shows it in the feed after save.
  • After typing # Plan and sending, the composer and the feed both show # Plan as paragraph text, with no heading size and no bold weight.
  • Typing # in an empty composer never shows "Enter document name".
  • A pasted heading and the line after it show as two paragraphs in the composer, with no heading size. The feed shows the same two paragraphs.
  • After typing --- at the start of a line and sending, the composer and the feed both show --- as text, with no horizontal rule.
  • Bold, italic, underline, inline code, links, mentions, bullet lists, numbered lists, quotes, and code blocks send and show as they do today.
  • The attribute allowlist does not change.

Agent Brief

Category: bug
Summary: The chat composer must not show formatting that the send path removes. Keep strikethrough on send, and stop the composer from making headings and horizontal rules.

Current behavior:
The composer editor keeps StarterKit's Strike, Heading, and HorizontalRule. The MESSAGE_HTML_PURIFY allowlist has no s, h1 to h6, or hr. sanitizeMessageContent() and sanitizeChunk() remove those tags on send and keep their text. getSanitizedMessageBodyHtml() applies the same allowlist when the feed renders. Headings in the composer also take the pad heading styles and the pad title placeholder.

Desired behavior:

  • Strikethrough is on the format toolbar, so the allowlist keeps s. The feed shows it.
  • The toolbar has no heading or horizontal rule control. So the composer turns off Heading and HorizontalRule in its StarterKit options. A typed # or --- stays as text, and a pasted heading becomes a paragraph.
  • Do not add heading or horizontal rule support to chat in this issue. That change needs a maintainer decision first. It also needs styles in the composer and in the feed.
  • The attribute allowlist stays as it is.

Key interfaces:

  • MESSAGE_HTML_PURIFY — the one allowlist for send and render.
  • sanitizeMessageContent() and sanitizeChunk() — the send path.
  • getSanitizedMessageBodyHtml() — the feed render path.
  • useTiptapEditor — its StarterKit.configure() options.
  • prepareOutboundContent() — reads editor.getHTML() and sanitizes it.
  • StrikethroughButton and FORMAT_TOOLBAR_GROUPS — the formats the toolbar offers.

Out of scope

  • Messages sent before the fix. Since d1903f4b9, the send has removed the strikethrough before storage, so the fix cannot bring it back.
  • A heading or horizontal rule control on the toolbar.
  • Attributes that the allowlist removes, such as class on mentions and code blocks.
  • Chunk length for long messages, tracked in Keep a long chat edit whole, and never send an empty chunk #264.

Notes

One allowlist serves send and render, so one change fixes both. Rows sent since d1903f4b9 never held s. Older rows can hold s, and they show it after the fix. So there is nothing to migrate.

The allowlist never had s, h1 to h6, or hr. It came in with d1903f4b9 in June 2025. Since then it gained only span, in 454e01ca4. Later, 87efd8d65 moved the two copies of the list into one constant. So the gap is not a settled decision.

s carries no attributes. A second run added s to a copy of the allowlist. A sample with every other composer format gave the same output as today.

The style chain for composer headings: every Tiptap editor root has the class tiptap ProseMirror. The pad heading sizes and the pad title placeholder are scoped to .tiptap.ProseMirror (apps/webapp/src/styles/editor/_heading-node.scss:2-14, :31-46, :48-76). Every pad page loads them (apps/webapp/src/pages/_app.tsx:148-151, apps/webapp/src/styles/document-styles.scss:3, apps/webapp/src/styles/components/_document-shell.scss:4, apps/webapp/src/styles/editor/_index.scss:4). This is why the preferred fix turns Heading off instead of allowing h1 to h6.

The shared prose styles draw a 2px horizontal rule in the composer (apps/webapp/src/styles/_prose-mirror-body.scss:102-106, applied at apps/webapp/src/styles/globals.scss:651-654). A composer that holds only a horizontal rule cannot send, because composerSendGate needs text (utils/composerSendGate.ts:24-27).

Turning Heading off in the composer does not touch the chat code that names HEADING_TYPE. useReplyInThreadHandler and useCopyMessageToDocHandler work on the pad editor, settings.editor.instance.

Coordinate with #264. It measures chunk length on the sanitized HTML, and a kept s tag adds to that length.

Coordinate with #263. Its hand-off copy moves the composer text onto the landing row. Today a format that the send removes can make the two look different.

The evidence level is harness. The runs used Chrome 153, headless, on 14ab7f9c1. The runs used a StarterKit editor with the composer's StarterKit options, not the real composer. The runs used the real sanitizeMessageContent and getSanitizedMessageBodyHtml, Tiptap 3.31.3, and DOMPurify 3.4.15. Typing and ⌘+⇧+S went through the editor's text and key handlers, not DOM events. Pastes used EditorView.pasteHTML. The runs left out Mention, Hyperlink, InlineCode, Indent, and CodeBlockLowlight. None of them makes or removes s, h1 to h6, or hr. The heading sizes and the placeholder came from the real _heading-node.scss and prose styles, compiled with Sass. The 2px horizontal rule is a code trace. Nothing was reproduced in the running app.

A second run with Heading and HorizontalRule off kept # Plan, ---, and pasted heading text as paragraphs, in the composer and in the sent HTML.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresUIbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions