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();
+ }
});
})();