From f7eec707a93753fc637cf3608974276a16cb3afd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 14:26:29 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20chat=20UI=20=E2=80=94=20load=20htmx-ext-?= =?UTF-8?q?sse,=20render=20user=20prose=20optimistically,=20AJAX=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled bugs reported as: 'click send doesn't clear textarea, my message isn't displayed, bot response only after refresh'. Root causes: 1) **htmx-ext-sse was never loaded**. base.html only included htmx.org; the hx-ext="sse", sse-connect, and sse-swap attributes on the chat shell were no-ops. The browser never opened the SSE EventSource, so bot streaming never arrived live. Added the extension script tag. 2) **Form was a vanilla POST**. The submit handler in chat.html locked the input but didn't preventDefault, so the browser POSTed to /chats/X/turns and waited for the 204. The textarea only cleared inside unlock(), which fires on the SSE turn_html event — which never fired (see #1). Even with SSE working, the vanilla POST left the page in a half-loaded state. Switched to fetch + e.preventDefault() so the page stays fully responsive while the bot streams. 3) **User's prose was never rendered client-side**. The server persists user_turn events but doesn't publish a turn_html for them — the SSE channel is bot-output-only by design. Added appendUserTurn(prose) that creates a turn-you div in the timeline immediately on submit, before the fetch fires, so the user can see what they sent. Net flow now: click Send → user prose appears instantly → textarea clears → fetch POSTs → bot streams in via SSE → turn_html lands → unlock() re-enables the form. No reload needed for any of it. --- chat/templates/base.html | 1 + chat/templates/chat.html | 61 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/chat/templates/base.html b/chat/templates/base.html index 01d0abd..916d779 100644 --- a/chat/templates/base.html +++ b/chat/templates/base.html @@ -6,6 +6,7 @@ {% block title %}chat{% endblock %} + {% block body %}{% endblock %} diff --git a/chat/templates/chat.html b/chat/templates/chat.html index 7c27470..7f16c65 100644 --- a/chat/templates/chat.html +++ b/chat/templates/chat.html @@ -162,13 +162,43 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => { } }); - form.addEventListener('submit', () => { - isStreaming = true; + // Render the user's prose optimistically as a turn-you DOM node. + // Without this the user can't see what they just sent until the page + // reloads — the server persists ``user_turn`` events but doesn't + // publish a turn_html for them (the SSE channel is bot-output-only). + function appendUserTurn(prose) { + const div = document.createElement('div'); + div.className = 'turn turn-you'; + const strong = document.createElement('strong'); + strong.textContent = 'you'; + const p = document.createElement('p'); + p.textContent = prose; + div.appendChild(strong); + div.appendChild(p); + timeline.appendChild(div); + div.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + + // Intercept the form submit and POST via fetch so we can: + // 1. Render the user's prose immediately (optimistic). + // 2. Clear the textarea immediately. + // 3. Keep the page state intact while the bot streams its + // response over SSE — vanilla form POST + 204 leaves the + // browser in a half-loaded state with the textarea unflushed. + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (isStreaming) return; + const prose = textarea ? (textarea.value || '').trim() : ''; + if (!prose) return; + + appendUserTurn(prose); + if (textarea) { + textarea.value = ''; + textarea.readOnly = true; + } if (sendBtn) sendBtn.disabled = true; - // readOnly (not disabled) — disabled fields are excluded from the - // form submission, which would send prose="" and trigger the - // server's empty-prose 400. - if (textarea) textarea.readOnly = true; + isStreaming = true; + if (!shell.querySelector('.stop-streaming')) { const stopBtn = document.createElement('button'); stopBtn.type = 'button'; @@ -186,6 +216,25 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => { }); form.parentElement.insertBefore(stopBtn, form); } + + // Fire the actual POST. The bot's response arrives via SSE + // (``turn_html`` event swaps into the timeline; ``unlock()`` runs + // on receipt to clear streaming state and re-enable the form). + try { + const body = new URLSearchParams({ prose }).toString(); + const resp = await fetch(form.action, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok && resp.status !== 204) { + showBanner('send failed (HTTP ' + resp.status + ') — try again'); + unlock(); + } + } catch (err) { + showBanner('send failed — check your connection'); + unlock(); + } }); })();