You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/.
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.
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.
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
On desktop, sign in, open a pad, and open a heading chat.
Type meet at . Click the Strikethrough button, type 3pm, click the button again, and type 4pm. Send.
The composer showed 3pm struck through. The sent message in the feed shows meet at 3pm 4pm with no strikethrough.
Type # in the empty composer. See "Enter document name" in large bold text. Type Plan and send. The feed shows plain Plan.
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.
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.
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.
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 underapps/webapp/src/components/chatroom/, except paths that start withapps/.link,code,codeBlock, andlistKeymapin StarterKit (components/MessageComposer/hooks/useTiptapEditor.ts:126-139). So Strike, Heading, and HorizontalRule stay on.editor.getHTML()then holdss,h1toh6, andhrtags.apps/webapp/src/utils/sanitizeContent.ts:4-18). DOMPurify removes a tag that is not on the list and keeps its text. Anhrhas no text, so nothing is left.prepareOutboundContent(utils/outboundMessagePipeline.ts:79-81). Long messages also go throughsanitizeChunk(components/MessageComposer/hooks/useComposerSubmit.ts:168). The feed applies the same allowlist again ingetSanitizedMessageBodyHtml(apps/webapp/src/utils/sanitizeContent.ts:51-60, called atcomponents/MessageCard/components/MessageContent/components/HTMLBody.tsx:19-22).Measured results (see Notes):
~~3pm~~<p>meet at <s>3pm</s> 4pm</p><p>meet at 3pm 4pm</p># Plan<h1>Plan</h1><p></p>Plan<h2>Agenda</h2><p>First item</p><h2>Agenda</h2><p>First item</p>Agenda<p>First item</p>---, thenbelow<hr><p>below</p><p>below</p>The two cases need different fixes.
components/MessageComposer/components/Toolbar/formatToolbarLayout.ts:24). The button callstoggleStrike(), and its tooltip names ⌘+⇧+S (components/MessageComposer/components/Toolbar/ToolbarButtons/StrikethroughButton.tsx:17-20). The chatroom rules list the group asB I S </>(apps/webapp/src/components/chatroom/CLAUDE.md:64). A lost strikethrough can change the meaning: "meet at3pm4pm" arrives as "meet at 3pm 4pm".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# Planshows at 28pt bold. A typed#alone shows the pad title placeholder "Enter document name". Notes give the style chain.Steps to reproduce
meet at. Click the Strikethrough button, type3pm, click the button again, and type4pm. Send.3pmstruck through. The sent message in the feed showsmeet at 3pm 4pmwith no strikethrough.#in the empty composer. See "Enter document name" in large bold text. TypePlanand send. The feed shows plainPlan.---in the empty composer. See a horizontal rule. Typebelowand send. The feed shows onlybelow.Acceptance criteria
~~text~~shows struck through in the feed after send.# Planand sending, the composer and the feed both show# Planas paragraph text, with no heading size and no bold weight.#in an empty composer never shows "Enter document name".---at the start of a line and sending, the composer and the feed both show---as text, with no horizontal rule.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_PURIFYallowlist has nos,h1toh6, orhr.sanitizeMessageContent()andsanitizeChunk()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:
s. The feed shows it.#or---stays as text, and a pasted heading becomes a paragraph.Key interfaces:
MESSAGE_HTML_PURIFY— the one allowlist for send and render.sanitizeMessageContent()andsanitizeChunk()— the send path.getSanitizedMessageBodyHtml()— the feed render path.useTiptapEditor— itsStarterKit.configure()options.prepareOutboundContent()— readseditor.getHTML()and sanitizes it.StrikethroughButtonandFORMAT_TOOLBAR_GROUPS— the formats the toolbar offers.Out of scope
d1903f4b9, the send has removed the strikethrough before storage, so the fix cannot bring it back.classon mentions and code blocks.Notes
One allowlist serves send and render, so one change fixes both. Rows sent since
d1903f4b9never helds. Older rows can holds, and they show it after the fix. So there is nothing to migrate.The allowlist never had
s,h1toh6, orhr. It came in withd1903f4b9in June 2025. Since then it gained onlyspan, in454e01ca4. Later,87efd8d65moved the two copies of the list into one constant. So the gap is not a settled decision.scarries no attributes. A second run addedsto 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 allowingh1toh6.The shared prose styles draw a 2px horizontal rule in the composer (
apps/webapp/src/styles/_prose-mirror-body.scss:102-106, applied atapps/webapp/src/styles/globals.scss:651-654). A composer that holds only a horizontal rule cannot send, becausecomposerSendGateneeds text (utils/composerSendGate.ts:24-27).Turning Heading off in the composer does not touch the chat code that names
HEADING_TYPE.useReplyInThreadHandleranduseCopyMessageToDocHandlerwork on the pad editor,settings.editor.instance.Coordinate with #264. It measures chunk length on the sanitized HTML, and a kept
stag 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 realsanitizeMessageContentandgetSanitizedMessageBodyHtml, 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 usedEditorView.pasteHTML. The runs left out Mention, Hyperlink, InlineCode, Indent, and CodeBlockLowlight. None of them makes or removess,h1toh6, orhr. The heading sizes and the placeholder came from the real_heading-node.scssand 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.