diff --git a/static/app.js b/static/app.js index 927ee8b28..c4a733217 100644 --- a/static/app.js +++ b/static/app.js @@ -53,6 +53,69 @@ window.uiModule = uiModule; window.adminModule = adminModule; window.cookbookModule = cookbookModule; +function _isMobileChatInput() { + return window.innerWidth <= 768 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches); +} + +function _isForegroundChatBusy() { + const sendBtn = document.querySelector('.send-btn'); + return !!window.__odysseusChatBusy + || Date.now() < (window.__odysseusChatBusyUntil || 0) + || !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending') + || (sendBtn && (sendBtn.title || '').toLowerCase().includes('stop')); +} + +function _shouldQueueFromMobileEnter(e, input) { + return e.key === 'Enter' + && !e.shiftKey + && !e.ctrlKey + && !e.metaKey + && !e.altKey + && !e.isComposing + && _isMobileChatInput() + && _isForegroundChatBusy() + && !!(input && input.value && input.value.trim()); +} + +function _shouldQueueFromMobileLineBreak(input) { + return _isMobileChatInput() + && _isForegroundChatBusy() + && !!(input && input.value && input.value.trim()); +} + +function _isLineBreakInputEvent(e) { + return e + && (e.inputType === 'insertLineBreak' + || e.inputType === 'insertParagraph' + || e.data === '\n'); +} + +function _submitMobileQueuedInput(input) { + if (!input || !_shouldQueueFromMobileLineBreak(input)) return false; + const now = Date.now(); + const last = Number(input.dataset.mobileQueueSubmitAt || 0); + if (now - last < 300) return true; + input.dataset.mobileQueueSubmitAt = String(now); + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return true; + } + window.__odysseusQueueStreamingSubmit = now; + const form = document.getElementById('chat-form'); + const submitBtn = form && form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.click(); + else if (form) form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + return true; +} + +function _syncMobileEnterKeyHint(input) { + if (!input) return; + input.setAttribute('enterkeyhint', (_isMobileChatInput() && _isForegroundChatBusy()) ? 'send' : 'enter'); +} + +function _countLineBreaks(s) { + return ((s || '').match(/\n/g) || []).length; +} + function initForegroundActivityHeartbeat() { let lastSent = 0; const minGapMs = 12000; @@ -3226,17 +3289,38 @@ function initializeEventListeners() { // Textarea auto-resize const textarea = el('message'); if (textarea) { + _syncMobileEnterKeyHint(textarea); + window.addEventListener('odysseus:chat-busy-change', () => _syncMobileEnterKeyHint(textarea)); uiModule.autoResize(textarea); - textarea.addEventListener('input', () => { + let previousTextareaValue = textarea.value || ''; + textarea.addEventListener('beforeinput', (e) => { + if (_isLineBreakInputEvent(e) && _shouldQueueFromMobileLineBreak(textarea)) { + e.preventDefault(); + e.stopPropagation(); + _submitMobileQueuedInput(textarea); + } + }); + textarea.addEventListener('input', (e) => { + const currentValue = textarea.value || ''; + const insertedLineBreak = _isLineBreakInputEvent(e) + || _countLineBreaks(currentValue) > _countLineBreaks(previousTextareaValue); + if (insertedLineBreak && _shouldQueueFromMobileLineBreak(textarea)) { + textarea.value = currentValue.replace(/\n+$/g, ''); + previousTextareaValue = textarea.value || ''; + _submitMobileQueuedInput(textarea); + return; + } + previousTextareaValue = currentValue; uiModule.autoResize(textarea); + _syncMobileEnterKeyHint(textarea); }); textarea.addEventListener('paste', () => { setTimeout(() => uiModule.autoResize(textarea), 1); }); textarea.addEventListener('keydown', (e) => { - const isMobile = window.innerWidth <= 768 + const isMobile = _isMobileChatInput(); - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) { + if (_shouldQueueFromMobileEnter(e, textarea) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) { // If ghost autocomplete is active, accept the suggestion instead of submitting if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) { e.preventDefault(); @@ -3249,8 +3333,14 @@ function initializeEventListeners() { // Check if already submitting before triggering form submission const form = el('chat-form'); if (form) { - const submitBtn = form.querySelector('button[type="submit"]'); - if (submitBtn) submitBtn.click(); + if (_isForegroundChatBusy() && textarea.value && textarea.value.trim()) { + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return; + } + window.__odysseusQueueStreamingSubmit = Date.now(); + } + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.click(); } } }); @@ -3685,10 +3775,31 @@ function startOdysseusApp() { return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0; } + function _updateStreamingSubmitButton() { + if (!sendBtn || sendBtn.dataset.mode !== 'streaming') return false; + const hasText = messageInput && messageInput.value.trim().length > 0; + const nextPhase = hasText ? 'queue' : 'processing'; + if (sendBtn.dataset.phase === nextPhase) return true; + sendBtn.dataset.phase = nextPhase; + sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land'); + if (hasText) { + sendBtn.innerHTML = _sendIcon; + sendBtn.title = 'Queue message'; + } else { + sendBtn.innerHTML = _stopIcon; + sendBtn.title = 'Stop generation'; + } + return true; + } + function _updateSendBtnIcon() { if (!sendBtn) return; - // Don't override if streaming (stop button) or recording - if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return; + if (sendBtn.dataset.mode === 'streaming') { + _updateStreamingSubmitButton(); + return; + } + // Don't override if recording + if (sendBtn.dataset.mode === 'recording') return; const prevMode = sendBtn.dataset.mode || ''; const hasText = messageInput && messageInput.value.trim().length > 0; const hasFiles = _hasAttachments(); @@ -3784,6 +3895,12 @@ function startOdysseusApp() { const hasText = messageInput && messageInput.value.trim().length > 0; const hasFiles = _hasAttachments(); + if (sendBtn.dataset.mode === 'streaming') { + if (hasText) window.__odysseusQueueStreamingSubmit = Date.now(); + handleSubmit(e); + return; + } + // New chat mode — empty input, no attachments, no STT if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') { if (sessionModule) { @@ -3823,9 +3940,10 @@ function startOdysseusApp() { // Enter to send (shift+enter for newline), or new chat when empty if (messageInput) { messageInput.addEventListener('keydown', (e) => { - const isMobile = window.innerWidth <= 768 + if (e.defaultPrevented) return; + const isMobile = _isMobileChatInput(); - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) { + if (_shouldQueueFromMobileEnter(e, messageInput) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) { e.preventDefault(); // Flush the debounced icon update so dataset.mode reflects the current // text state. Without this, a fast type-and-Enter would still see the @@ -3836,6 +3954,12 @@ function startOdysseusApp() { if (railNew) railNew.click(); return; } + if (_isForegroundChatBusy() && messageInput.value && messageInput.value.trim()) { + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return; + } + window.__odysseusQueueStreamingSubmit = Date.now(); + } handleSubmit(e); } }); @@ -3855,7 +3979,11 @@ function startOdysseusApp() { _syncModelPickerAutohide(); messageInput.addEventListener('input', () => { _syncModelPickerAutohide(); - _debouncedUpdateIcon(); + if (sendBtn && sendBtn.dataset.mode === 'streaming') { + _updateSendBtnIcon(); + } else { + _debouncedUpdateIcon(); + } }, { passive: true }); } diff --git a/static/js/chat.js b/static/js/chat.js index 6bca955b7..df68d7e33 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -48,6 +48,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { window.__odysseusChatBusy = !!active; window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200; + window.dispatchEvent(new CustomEvent('odysseus:chat-busy-change', { detail: { active: !!active } })); } catch (_) {} } let _pendingContinue = null; // Stores the stopped AI element to merge with new response @@ -326,7 +327,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // arrow out for the stop icon — otherwise the swap happens mid-flight // and the user sees nothing fly out. setTimeout(() => { - submitBtn.innerHTML = _stopSvg; + if (submitBtn.dataset.mode !== 'streaming') return; + const msgInput = uiModule.el('message'); + const hasQueuedText = !!(msgInput && msgInput.value && msgInput.value.trim()); + submitBtn.innerHTML = hasQueuedText && icons ? icons.send : _stopSvg; + submitBtn.dataset.phase = hasQueuedText ? 'queue' : 'processing'; + submitBtn.title = hasQueuedText ? 'Queue message' : 'Stop generation'; submitBtn.classList.remove('anim-launch'); void submitBtn.offsetWidth; submitBtn.classList.add('anim-land'); @@ -471,6 +477,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return true; } + export function queueStreamingComposerRequest() { + if (!isStreaming) return false; + const queuedInput = uiModule.el('message'); + const queuedText = (queuedInput && queuedInput.value || '').trim(); + if (!queuedText) return false; + if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { + try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} + return true; + } + if (_queueAgentRequest(queuedText)) { + queuedInput.value = ''; + queuedInput.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(queuedInput); + try { window._updateSendBtnIcon && window._updateSendBtnIcon(); } catch (_) {} + } + return true; + } + function _drainQueuedAgentRequests() { if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; if (_queuedDrainTimer) return; @@ -507,21 +531,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } - // If currently streaming, a non-empty composer means "queue this next". - // Empty composer keeps the existing Stop behavior. + // If currently streaming, keyboard Enter can queue a non-empty composer. + // Clicking the stop icon should still stop normally, even if text exists. if (isStreaming) { - const queuedInput = uiModule.el('message'); - const queuedText = (queuedInput && queuedInput.value || '').trim(); - if (queuedText) { - if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { - try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} - return; - } - if (_queueAgentRequest(queuedText)) { - queuedInput.value = ''; - queuedInput.dispatchEvent(new Event('input', { bubbles: true })); - if (uiModule.autoResize) uiModule.autoResize(queuedInput); - } + const queueRequestedAt = Number(window.__odysseusQueueStreamingSubmit || 0); + const shouldQueueStreamingSubmit = queueRequestedAt && Date.now() - queueRequestedAt < 1200; + window.__odysseusQueueStreamingSubmit = 0; + if (shouldQueueStreamingSubmit && queueStreamingComposerRequest()) { return; } if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) {