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" . }}