f0742dd4f9
The form-submit handler in chat.html was setting ``textarea.disabled = true`` synchronously before the browser actually serialized the form. Disabled form fields are excluded from submission, so the request body contained ``prose=""`` even when the user had typed text — which the server (correctly) rejected with the new empty-prose 400. Net effect: typing "hello" + Send gave a "prose cannot be empty" error. Switched to ``readOnly``: same UX (user can't edit while streaming) but the field IS submitted. The unlock path now also clears the textarea and refocuses for the next turn.
160 lines
5.6 KiB
HTML
160 lines
5.6 KiB
HTML
{% extends "layout.html" %}
|
|
{% block title %}{{ host_bot.name }} - chat{% endblock %}
|
|
{% block content %}
|
|
<div class="chat-shell" data-chat-id="{{ chat.id }}"
|
|
hx-ext="sse"
|
|
sse-connect="/chats/{{ chat.id }}/events">
|
|
<header class="chat-header">
|
|
<h1>{{ host_bot.name }}</h1>
|
|
<div class="chat-meta muted">{{ chat.time }}</div>
|
|
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
|
|
</header>
|
|
|
|
<section class="timeline" id="timeline"
|
|
sse-swap="turn_html"
|
|
hx-swap="beforeend">
|
|
{% if not turns %}
|
|
<p class="muted">No turns yet. Start typing below.</p>
|
|
{% else %}
|
|
{% for turn in turns %}
|
|
<div class="turn turn-{{ turn.role }}">
|
|
<strong>{{ turn.speaker }}</strong>
|
|
{{ turn.text|render_prose|safe }}
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</section>
|
|
|
|
<form class="turn-input" method="post" action="/chats/{{ chat.id }}/turns">
|
|
<textarea name="prose" rows="3" placeholder="What do you say or do?" required></textarea>
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
|
|
<aside class="drawer" id="drawer" hidden
|
|
hx-get="/chats/{{ chat.id }}/drawer"
|
|
hx-trigger="revealed"
|
|
hx-swap="innerHTML">
|
|
<p class="muted">Loading drawer…</p>
|
|
</aside>
|
|
</div>
|
|
<script>
|
|
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
|
const drawer = document.getElementById('drawer');
|
|
const isHidden = drawer.hasAttribute('hidden');
|
|
if (isHidden) drawer.removeAttribute('hidden');
|
|
else drawer.setAttribute('hidden', '');
|
|
e.target.setAttribute('aria-expanded', String(isHidden));
|
|
});
|
|
</script>
|
|
<script>
|
|
// Streaming UX (T34): typing indicator, Stop button, send-lock,
|
|
// disconnect banner. Listens to the existing HTMX SSE channel for
|
|
// `token` (per-chunk) and `turn_html` (final swap) events. The
|
|
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
|
|
// in T19 commits truncated; this script just shows the banner when
|
|
// the SSE EventSource fires `error` after the connection drops.
|
|
(function () {
|
|
const shell = document.querySelector('.chat-shell');
|
|
if (!shell) return;
|
|
const chatId = shell.dataset.chatId;
|
|
const form = shell.querySelector('.turn-input');
|
|
if (!form) return;
|
|
const textarea = form.querySelector('textarea[name="prose"]');
|
|
const sendBtn = form.querySelector('button[type="submit"]');
|
|
const timeline = document.getElementById('timeline');
|
|
|
|
let isStreaming = false;
|
|
let typingEl = null;
|
|
|
|
function ensureTypingEl() {
|
|
if (typingEl) return typingEl;
|
|
typingEl = document.createElement('div');
|
|
typingEl.className = 'turn turn-bot streaming';
|
|
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
|
|
timeline.appendChild(typingEl);
|
|
return typingEl;
|
|
}
|
|
|
|
function unlock() {
|
|
isStreaming = false;
|
|
if (sendBtn) sendBtn.disabled = false;
|
|
if (textarea) {
|
|
textarea.readOnly = false;
|
|
textarea.value = '';
|
|
textarea.focus();
|
|
}
|
|
const stop = shell.querySelector('.stop-streaming');
|
|
if (stop) stop.remove();
|
|
}
|
|
|
|
function showBanner(msg) {
|
|
let banner = shell.querySelector('.connection-lost');
|
|
if (banner) return;
|
|
banner = document.createElement('div');
|
|
banner.className = 'connection-lost error';
|
|
banner.textContent = msg;
|
|
form.parentElement.insertBefore(banner, form);
|
|
}
|
|
|
|
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
|
|
// (event name) and detail.data (payload string).
|
|
shell.addEventListener('htmx:sseMessage', (e) => {
|
|
const evt = e.detail.type;
|
|
const data = e.detail.data;
|
|
if (evt === 'token' && isStreaming) {
|
|
let parsed;
|
|
try { parsed = JSON.parse(data); } catch (_) { return; }
|
|
const el = ensureTypingEl();
|
|
el.querySelector('.streaming-text').textContent += (parsed.text || '');
|
|
} else if (evt === 'turn_html') {
|
|
// The server already pushes the final HTML via sse-swap on the
|
|
// timeline element; we just remove the typing placeholder and
|
|
// unlock the input. (Don't replace innerHTML here — HTMX has
|
|
// already done the append by the time this fires.)
|
|
if (typingEl) {
|
|
typingEl.remove();
|
|
typingEl = null;
|
|
}
|
|
unlock();
|
|
}
|
|
});
|
|
|
|
// SSE connection lost — show a banner and unlock so the user can
|
|
// retry. The server commits the partial as truncated when its
|
|
// request.is_disconnected() poll trips (T19).
|
|
shell.addEventListener('htmx:sseError', () => {
|
|
if (isStreaming) {
|
|
showBanner('connection lost — partial response saved');
|
|
unlock();
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', () => {
|
|
isStreaming = 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;
|
|
if (!shell.querySelector('.stop-streaming')) {
|
|
const stopBtn = document.createElement('button');
|
|
stopBtn.type = 'button';
|
|
stopBtn.className = 'stop-streaming btn';
|
|
stopBtn.textContent = 'Stop';
|
|
stopBtn.addEventListener('click', async () => {
|
|
try {
|
|
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
|
|
method: 'POST',
|
|
});
|
|
} catch (_) {
|
|
// Network error on cancel is non-fatal — server will time out
|
|
// its own stream eventually and commit truncated.
|
|
}
|
|
});
|
|
form.parentElement.insertBefore(stopBtn, form);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|