fix: chat UI — load htmx-ext-sse, render user prose optimistically, AJAX submit

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.
This commit is contained in:
Joseph Doherty
2026-04-27 14:26:29 -04:00
parent 3b83786b8b
commit f7eec707a9
2 changed files with 56 additions and 6 deletions
+1
View File
@@ -6,6 +6,7 @@
<title>{% block title %}chat{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js" defer></script>
</head>
<body>
{% block body %}{% endblock %}
+55 -6
View File
@@ -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();
}
});
})();
</script>