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"></textarea>
<button class="hc-button" data-variant="primary" type="submit">Send</button>
</form>
</div>

Why each attribute is there:

AttributePurpose
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" + actionThe no-JS path: a plain form post, answered with 303.
CaseStatusBody
Message accepted200The 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 prompt422Only 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 JS303Redirect 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.