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>
|
<title>{% block title %}chat{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<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.org@1.9.12" defer></script>
|
||||||
|
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
|||||||
@@ -162,13 +162,43 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', () => {
|
// Render the user's prose optimistically as a turn-you DOM node.
|
||||||
isStreaming = true;
|
// 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;
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
// readOnly (not disabled) — disabled fields are excluded from the
|
isStreaming = true;
|
||||||
// form submission, which would send prose="" and trigger the
|
|
||||||
// server's empty-prose 400.
|
|
||||||
if (textarea) textarea.readOnly = true;
|
|
||||||
if (!shell.querySelector('.stop-streaming')) {
|
if (!shell.querySelector('.stop-streaming')) {
|
||||||
const stopBtn = document.createElement('button');
|
const stopBtn = document.createElement('button');
|
||||||
stopBtn.type = 'button';
|
stopBtn.type = 'button';
|
||||||
@@ -186,6 +216,25 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
|||||||
});
|
});
|
||||||
form.parentElement.insertBefore(stopBtn, form);
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user