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:
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user