Add stop button
This commit is contained in:
@@ -91,6 +91,10 @@
|
|||||||
<path d="m9 9 6 6"/>
|
<path d="m9 9 6 6"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="stop" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.0 KiB |
@@ -108,6 +108,26 @@ body {
|
|||||||
margin-top: var(--s0);
|
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 */
|
/* Collapsible */
|
||||||
/* ==================== */
|
/* ==================== */
|
||||||
@@ -259,21 +279,34 @@ body {
|
|||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose__action-btn--send.loading {
|
.compose__action-btn--send .compose__icon--stop {
|
||||||
position: relative;
|
display: none;
|
||||||
color: transparent;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose__action-btn--send.loading::after {
|
.compose__action-btn--send.loading {
|
||||||
content: "";
|
pointer-events: auto;
|
||||||
position: absolute;
|
cursor: pointer;
|
||||||
width: 0.75rem;
|
}
|
||||||
height: 0.75rem;
|
|
||||||
border: 2px solid var(--color-gray2);
|
/* Hide the send icon, show the stop icon during loading */
|
||||||
border-top-color: var(--color-primary);
|
.compose__action-btn--send.loading .compose__icon--send {
|
||||||
border-radius: 50%;
|
display: none;
|
||||||
animation: spin 0.8s linear infinite;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.compose__actions {
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ class Odidere {
|
|||||||
this.isRecording = false;
|
this.isRecording = false;
|
||||||
this.isMuted = false;
|
this.isMuted = false;
|
||||||
this.isResetPending = false;
|
this.isResetPending = false;
|
||||||
|
this.isStopPending = false;
|
||||||
this.mediaRecorder = null;
|
this.mediaRecorder = null;
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
this.$attach = document.getElementById('attach');
|
this.$attach = document.getElementById('attach');
|
||||||
this.$attachments = document.getElementById('attachments');
|
this.$attachments = document.getElementById('attachments');
|
||||||
this.$chat = document.getElementById('chat');
|
this.$chat = document.getElementById('chat');
|
||||||
|
this.$chatLoading = document.getElementById('chat-loading');
|
||||||
this.$fileInput = document.getElementById('file-input');
|
this.$fileInput = document.getElementById('file-input');
|
||||||
this.$model = document.getElementById('model');
|
this.$model = document.getElementById('model');
|
||||||
this.$ptt = document.getElementById('ptt');
|
this.$ptt = document.getElementById('ptt');
|
||||||
@@ -141,8 +143,9 @@ class Odidere {
|
|||||||
this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown);
|
this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown);
|
||||||
this.$textInput.addEventListener('input', this.#handleTextareaInput);
|
this.$textInput.addEventListener('input', this.#handleTextareaInput);
|
||||||
|
|
||||||
// Send button
|
// Send button: when loading, acts as stop button with two-press safety
|
||||||
this.$send.addEventListener('click', () => this.#submitText());
|
this.$send.addEventListener('click', () => this.#handleSendClick());
|
||||||
|
this.document.addEventListener('click', (e) => this.#handleStopCancel(e));
|
||||||
|
|
||||||
// Reset button: two-press safety
|
// Reset button: two-press safety
|
||||||
this.$reset.addEventListener('click', () => this.#handleResetClick());
|
this.$reset.addEventListener('click', () => this.#handleResetClick());
|
||||||
@@ -356,6 +359,58 @@ class Odidere {
|
|||||||
this.$reset.classList.remove('footer__toolbar-btn--pending');
|
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
|
// AUDIO RECORDING
|
||||||
// ====================
|
// ====================
|
||||||
@@ -887,8 +942,17 @@ class Odidere {
|
|||||||
#setLoadingState(loading) {
|
#setLoadingState(loading) {
|
||||||
this.isProcessing = loading;
|
this.isProcessing = loading;
|
||||||
this.$send.classList.toggle('loading', 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;
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
id="send"
|
id="send"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
<svg class="icon"><use href="/static/icons.svg#send"></use></svg>
|
<svg class="icon compose__icon--send"><use href="/static/icons.svg#send"></use></svg>
|
||||||
|
<svg class="icon compose__icon--stop"><use href="/static/icons.svg#stop"></use></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<section class="chat" id="chat" aria-live="polite">
|
<section class="chat" id="chat" aria-live="polite">
|
||||||
|
<div class="chat-loading" id="chat-loading" hidden>
|
||||||
|
<div class="chat-loading__spinner"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{ template "footer" . }}
|
{{ template "footer" . }}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user