feat: streaming UX with Stop, disconnect handling, send-lock
This commit is contained in:
@@ -46,4 +46,107 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
|
||||
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.disabled = false;
|
||||
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;
|
||||
if (textarea) textarea.disabled = 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 %}
|
||||
|
||||
Reference in New Issue
Block a user