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