Skip to content

Chat messages

A conversation is just a transcript the server renders and a form that appends to it. One POST returns three fragments in one body: the user’s message, the assistant’s aria-busy placeholder — the handle the streaming-response recipe fills over SSE — and a fresh composer swapped out of band. No custom JavaScript; installChatScroll() (auto-init) keeps the reader at the bottom.

Type a message and send it — the POST answers with the three-fragment body: your (escaped) user <li>, the aria-busy assistant placeholder, and the out-of-band composer reset. The placeholder then streams its reply — that half is the streaming-response recipe. An empty send answers 422 into the composer only; a prompt containing “fail” demonstrates the error path, and Stop truncates the reply mid-stream. The endpoint implements the server response contract under api/recipes/chat-messages/.

  1. Hi! Send a message and I will stream back a canned reply. Include the word “fail” to watch the error path instead.

The composer nests as the chat root’s last child, so the whole exchange is one composition:

<div class="hc-chat" role="log" aria-label="Conversation">
<ol class="hc-chat__list" tabindex="0" id="chat-list">
<!-- server-rendered history -->
</ol>
<button class="hc-chat__jump hc-button" data-size="sm" type="button">↓ Latest</button>
<form class="hc-field" id="composer" method="post" action="/chat/messages"
data-hx-post="/chat/messages"
data-hx-target="#chat-list" data-hx-swap="beforeend">
<label class="hc-field__label" for="prompt">Message</label>
<textarea class="hc-input" id="prompt" name="prompt" rows="2"
data-autosize data-hc-submit-on-enter></textarea>
<button class="hc-button" data-variant="primary" type="submit">Send</button>
</form>
</div>

Why each attribute is there:

Attribute Purpose
role="log" (root) Appended complete messages are announced; the role is not allowed on <ol>.
tabindex="0" (list) The scrollable region is keyboard-reachable.
data-hx-target="#chat-list" + data-hx-swap="beforeend" The POST appends to the transcript — nothing is re-rendered wholesale.
method="post" + action The no-JS path: a plain form post, answered with 303.
data-autosize (textarea) The composer grows with the message (CSS field-sizing, no script) from two rows up to --hc-input-autosize-max — see input → Auto-growing textarea.
data-hc-submit-on-enter (textarea) Enter sends, Shift+Enter breaks the line, and an Enter that confirms an IME candidate (Japanese, Chinese, Korean input) is left alone — installSubmitOnEnter() calls form.requestSubmit(), so the request takes the same path as the Send button. Without JavaScript, Enter just breaks the line and Send posts.
Case Status Body
Message accepted 200 The user <li data-role="user"> + the assistant placeholder <li data-role="assistant" data-state="streaming" aria-busy="true" id="reply-…"> (both land via beforeend), plus the fresh composer with data-hx-swap-oob="outerHTML" — the textarea clears without touching the transcript.
Empty / invalid prompt 422 Only the out-of-band composer re-render: data-invalid, aria-invalid + aria-describedby, and the .hc-field__message error. Nothing targets the transcript, so no bogus entry appears. Needs the one-time htmx:beforeSwap 422 allowance.
No JS 303 Redirect back to the conversation page (branch on the HX-Request header); the full page includes the new exchange.

The server owns rendering: escape the echoed prompt (the transcript is an HTML sink), and never ship client-side markdown — assistant replies arrive as server-rendered HTML fragments, hc-code blocks included.

  • The placeholder’s aria-busy="true" defers the log region’s announcement: assistive tech reads the reply once, when the final swap removes the attribute (streaming-response’s done event, or your non-streaming completion).
  • The 422 composer follows the field-errors pattern (aria-invalid, aria-describedby).
  • Pinned by a real-htmx browser test (test-browser/chat-messages.spec.mjs): append + placeholder, OOB reset, 422 isolation, stick-to-bottom across six exchanges, axe.

The composer composes with the upload patterns end to end — no new contract:

  1. Make the composer enctype="multipart/form-data" and put a dropzone (or a plain file input) next to the textarea.
  2. While the POST uploads, installUploadProgress() (the file-upload recipe) drives the progress row on each pending attachment card.
  3. The server echoes the files inside the user <li> as an hc-attachments list — attachment cards are message markup, so the transcript stays one server-rendered document.
  4. The reply side is unchanged: the placeholder and the streaming contract are the same with or without attachments.

Try it live — the same demo with a multipart composer: attach a file (1 MB or smaller, any type) and send, and the server echoes the attachment card inside your user <li> before streaming the reply.

  1. Hi! Attach a file (1 MB or smaller), add a message if you like, and send — the attachment card is echoed inside your message.