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.
Live demo
Section titled “Live demo”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/.
The blessed markup
Section titled “The blessed markup”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:
| 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. |
Server response contract
Section titled “Server response contract”| 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.
Accessibility
Section titled “Accessibility”- 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’sdoneevent, 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.
Attachments
Section titled “Attachments”The composer composes with the upload patterns end to end — no new contract:
- Make the composer
enctype="multipart/form-data"and put a dropzone (or a plain file input) next to the textarea. - While the POST uploads,
installUploadProgress()(the file-upload recipe) drives the progress row on each pending attachment card. - The server echoes the files inside the user
<li>as anhc-attachmentslist — attachment cards are message markup, so the transcript stays one server-rendered document. - 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.
Related
Section titled “Related”- Chat — the transcript component and its tokens.
- Streaming response — fills the placeholder over SSE (sse-updates documents the general push channel).
- field-errors — the 422 convention this composer reuses.
- file-upload — the OOB fresh-form reset pattern.