From 6c8c04c804bfaa2e3e90693f18bc90f3d3fcc281 Mon Sep 17 00:00:00 2001 From: dwrz Date: Tue, 9 Jun 2026 01:32:49 +0000 Subject: [PATCH] Add stop button --- internal/service/static/icons.svg | 4 + internal/service/static/main.css | 59 +++++++++++---- internal/service/static/main.js | 75 ++++++++++++++++++- .../templates/static/footer/compose.gohtml | 3 +- internal/service/templates/static/main.gohtml | 3 + 5 files changed, 127 insertions(+), 17 deletions(-) diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 14845f1..7bab416 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -91,6 +91,10 @@ + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index c3cc978..04408ea 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -108,6 +108,26 @@ body { margin-top: var(--s0); } +.chat-loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--s1) 0; +} + +.chat-loading[hidden] { + display: none; +} + +.chat-loading__spinner { + width: 1rem; + height: 1rem; + border: 2px solid var(--color-gray2); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + /* ==================== */ /* Collapsible */ /* ==================== */ @@ -259,21 +279,34 @@ body { animation: pulse 1s ease-in-out infinite; } -.compose__action-btn--send.loading { - position: relative; - color: transparent; - pointer-events: none; +.compose__action-btn--send .compose__icon--stop { + display: none; } -.compose__action-btn--send.loading::after { - content: ""; - position: absolute; - width: 0.75rem; - height: 0.75rem; - border: 2px solid var(--color-gray2); - border-top-color: var(--color-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; +.compose__action-btn--send.loading { + pointer-events: auto; + cursor: pointer; +} + +/* Hide the send icon, show the stop icon during loading */ +.compose__action-btn--send.loading .compose__icon--send { + display: none; +} + +.compose__action-btn--send.loading .compose__icon--stop { + display: block; +} + +/* Pending stop: yellow background, click again to confirm */ +.compose__action-btn--send.loading.pending { + background: var(--color-yellow); + color: var(--color-black); +} + +@media (hover: hover) { + .compose__action-btn--send.loading.pending:hover { + background: var(--color-yellow); + } } .compose__actions { diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 262f33c..559cf8e 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -29,12 +29,14 @@ class Odidere { this.isRecording = false; this.isMuted = false; this.isResetPending = false; + this.isStopPending = false; this.mediaRecorder = null; // DOM Elements this.$attach = document.getElementById('attach'); this.$attachments = document.getElementById('attachments'); this.$chat = document.getElementById('chat'); + this.$chatLoading = document.getElementById('chat-loading'); this.$fileInput = document.getElementById('file-input'); this.$model = document.getElementById('model'); this.$ptt = document.getElementById('ptt'); @@ -141,8 +143,9 @@ class Odidere { this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown); this.$textInput.addEventListener('input', this.#handleTextareaInput); - // Send button - this.$send.addEventListener('click', () => this.#submitText()); + // Send button: when loading, acts as stop button with two-press safety + this.$send.addEventListener('click', () => this.#handleSendClick()); + this.document.addEventListener('click', (e) => this.#handleStopCancel(e)); // Reset button: two-press safety this.$reset.addEventListener('click', () => this.#handleResetClick()); @@ -356,6 +359,58 @@ class Odidere { this.$reset.classList.remove('footer__toolbar-btn--pending'); } + /** + * #handleSendClick routes to either send or stop based on loading state. + */ + #handleSendClick() { + if (this.$send.classList.contains('loading')) { + this.#handleStopClick(); + } else { + this.#submitText(); + } + } + + /** + * #handleStopClick toggles the pending stop state on first press, + * aborts the current request on second press. + */ + #handleStopClick() { + if (this.isStopPending) { + this.isStopPending = false; + this.$send.classList.remove('pending'); + this.#stopConversation(); + } else { + this.isStopPending = true; + this.$send.classList.add('pending'); + } + } + + /** + * #handleStopCancel clears the pending stop state when the user + * clicks anywhere outside the send button. + * @param {MouseEvent} event + */ + #handleStopCancel(event) { + if (!this.isStopPending) return; + if (this.$send.contains(event.target)) return; + this.isStopPending = false; + this.$send.classList.remove('pending'); + } + + /** + * #stopConversation aborts the current streaming request and audio. + */ + #stopConversation() { + this.#stopCurrentAudio(); + + if (this.currentController) { + this.currentController.abort(); + this.currentController = null; + } + + this.#setLoadingState(false); + } + // ==================== // AUDIO RECORDING // ==================== @@ -887,8 +942,17 @@ class Odidere { #setLoadingState(loading) { this.isProcessing = loading; this.$send.classList.toggle('loading', loading); - this.$send.disabled = loading; + this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message'); + // Show/hide the spinner in the chat area. + // Positioning is handled by #renderMessages to ensure it's always at the end. + this.$chatLoading.hidden = !loading; + // Keep the send button enabled during loading so it can act as a stop button. this.$ptt.disabled = loading; + // Clear any pending stop state when loading ends. + if (!loading && this.isStopPending) { + this.isStopPending = false; + this.$send.classList.remove('pending'); + } } /** @@ -1182,6 +1246,11 @@ class Odidere { this.#renderAssistantMessage(msg, msg.meta ?? meta, toolResults); } + + // Ensure the loading spinner is always at the end of the chat. + if (!this.$chatLoading.hidden) { + this.$chat.appendChild(this.$chatLoading); + } } /** diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml index 7243093..8ff1e93 100644 --- a/internal/service/templates/static/footer/compose.gohtml +++ b/internal/service/templates/static/footer/compose.gohtml @@ -33,7 +33,8 @@ id="send" aria-label="Send message" > - + + diff --git a/internal/service/templates/static/main.gohtml b/internal/service/templates/static/main.gohtml index 8f740e0..1ce340f 100644 --- a/internal/service/templates/static/main.gohtml +++ b/internal/service/templates/static/main.gohtml @@ -1,6 +1,9 @@ {{ define "main" }}
+
{{ template "footer" . }}