From 96182e5f5125e2ac6dd1e285c4ec2b3d9a2a2019 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:15:50 -0600 Subject: [PATCH] fix: keep busy-input send available on mobile --- static/boot.js | 1 + static/style.css | 7 ++- static/ui.js | 81 ++++++++++++++++++++++++++--- tests/test_1062_busy_input_modes.py | 40 ++++++++++++++ tests/test_sprint20.py | 18 ++++--- tests/test_sprint20b.py | 8 +-- 6 files changed, 134 insertions(+), 21 deletions(-) diff --git a/static/boot.js b/static/boot.js index fdbb8afe..60e7fdb7 100644 --- a/static/boot.js +++ b/static/boot.js @@ -177,6 +177,7 @@ function mobileSwitchPanel(name){ } $('btnSend').onclick=()=>{ + if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction(); if(window._micActive){ window._micPendingSend=true; _stopMic(); diff --git a/static/style.css b/static/style.css index d4d9d771..62370fa2 100644 --- a/static/style.css +++ b/static/style.css @@ -724,7 +724,7 @@ .ctx-indicator-wrap:hover .ctx-tooltip,.ctx-indicator-wrap:focus-within .ctx-tooltip{opacity:1;transform:translateY(0);} .ctx-tooltip-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:5px;} .ctx-tooltip-line+.ctx-tooltip-line{margin-top:3px;} - .cancel-btn{width:34px;height:34px;border-radius:50%;background:var(--error);border:none;color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 10px rgba(0,0,0,.18);} + .cancel-btn{display:none!important;width:34px;height:34px;border-radius:50%;background:var(--error);border:none;color:#fff;cursor:pointer;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 10px rgba(0,0,0,.18);} .cancel-btn:hover{background:var(--error);transform:scale(1.06);box-shadow:0 4px 14px rgba(0,0,0,.25);filter:brightness(1.1);} .cancel-btn:active{transform:scale(.96);} .icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;} @@ -737,7 +737,12 @@ .mic-dot{width:6px;height:6px;border-radius:50%;background:var(--error);animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;} .status-text{font-size:11px;color:var(--muted);padding-left:4px;} .send-btn{width:34px;height:34px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px var(--accent-bg-strong);} + .send-btn.stop,.send-btn.interrupt{background:var(--error);box-shadow:0 2px 10px rgba(0,0,0,.18);} + .send-btn.steer{background:var(--purple,#8b5cf6);box-shadow:0 2px 10px rgba(139,92,246,.25);} + .send-btn.queue{background:var(--accent);} .send-btn:hover{background:var(--accent-hover);transform:scale(1.08);box-shadow:0 4px 14px var(--accent-bg-strong);} + .send-btn.stop:hover,.send-btn.interrupt:hover{background:var(--error);box-shadow:0 4px 14px rgba(0,0,0,.25);filter:brightness(1.1);} + .send-btn.steer:hover{background:var(--purple,#8b5cf6);box-shadow:0 4px 14px rgba(139,92,246,.3);filter:brightness(1.08);} .send-btn:active{transform:scale(0.95);box-shadow:0 1px 4px var(--accent-bg);} .send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;} .send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;} diff --git a/static/ui.js b/static/ui.js index 2c6fefe2..80c4fbc2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1189,22 +1189,87 @@ function unlockComposerForClarify(){ updateSendBtn(); } +function _composerHasContent(){ + const msg=$('msg'); + return !!((msg&&msg.value.trim().length>0)||S.pendingFiles.length>0); +} + +function getComposerPrimaryAction(){ + const msg=$('msg'); + const hasContent=_composerHasContent(); + const locked=!!(msg&&msg.disabled); + if(locked) return 'disabled'; + const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning(); + const isBusy=!!S.busy||compressionRunning; + if(!isBusy) return hasContent?'send':'disabled'; + if(!hasContent){ + if(S.activeStreamId&&typeof cancelStream==='function') return 'stop'; + return 'disabled'; + } + const busyMode=window._busyInputMode||'queue'; + if(busyMode==='steer'){ + if(S.activeStreamId&&typeof _trySteer==='function') return 'steer'; + return 'queue'; + } + if(busyMode==='interrupt'){ + if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt'; + return 'queue'; + } + return 'queue'; +} + +function _setComposerPrimaryButtonIcon(btn,action){ + const icons={ + send:'', + queue:'', + interrupt:'', + steer:'', + stop:'', + disabled:'' + }; + const next=icons[action]||icons.send; + if(btn.innerHTML!==next) btn.innerHTML=next; +} + function updateSendBtn(){ const btn=$('btnSend'); if(!btn) return; - const msg=$('msg'); - const hasContent=msg&&msg.value.trim().length>0||S.pendingFiles.length>0; - const canSend=hasContent&&!S.busy&&!(msg&&msg.disabled); - // Hide while busy (cancel button takes its place); show otherwise - btn.style.display=S.busy?'none':''; - btn.disabled=!canSend; - if(canSend&&!btn.classList.contains('visible')){ + const action=getComposerPrimaryAction(); + btn.dataset.action=action; + btn.classList.toggle('stop',action==='stop'); + btn.classList.toggle('queue',action==='queue'); + btn.classList.toggle('interrupt',action==='interrupt'); + btn.classList.toggle('steer',action==='steer'); + btn.title={send:'Send message',queue:'Queue message',interrupt:'Interrupt and send',steer:'Steer current response',stop:'Stop generation',disabled:'Send message'}[action]||'Send message'; + btn.setAttribute('aria-label',btn.title); + _setComposerPrimaryButtonIcon(btn,action); + // Single primary action button: while busy/no-draft it becomes the red Stop + // action; while busy with a draft it reflects queue/interrupt/steer. + btn.style.display=''; + btn.disabled=action==='disabled'; + if(action!=='disabled'&&!btn.classList.contains('visible')){ btn.classList.remove('visible'); requestAnimationFrame(()=>btn.classList.add('visible')); - } else if(!canSend){ + } else if(action==='disabled'){ btn.classList.remove('visible'); } } + +async function handleComposerPrimaryAction(){ + if(window._micActive){ + window._micPendingSend=true; + _stopMic(); + return; + } + const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send'; + if(action==='disabled') return; + if(action==='stop'){ + if(typeof cancelStream==='function') await cancelStream(); + return; + } + await send(); +} + function setBusy(v){ S.busy=v; updateSendBtn(); diff --git a/tests/test_1062_busy_input_modes.py b/tests/test_1062_busy_input_modes.py index f6e73401..e2d3c21c 100644 --- a/tests/test_1062_busy_input_modes.py +++ b/tests/test_1062_busy_input_modes.py @@ -15,6 +15,7 @@ ROOT = Path(__file__).parent.parent CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8") MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") @@ -148,6 +149,45 @@ class TestSlashCommandHandlers: ) +class TestBusySendButton: + """The composer send button must remain usable for busy-input actions.""" + + def test_update_send_btn_uses_single_primary_action_button(self): + idx = UI_JS.find("function updateSendBtn()") + assert idx >= 0, "updateSendBtn() not found" + body = UI_JS[idx:UI_JS.find("function setBusy", idx)] + assert "getComposerPrimaryAction()" in body, ( + "updateSendBtn must derive icon/color/enabled state from one composer-primary action helper" + ) + assert "btn.dataset.action=action" in body, ( + "btnSend should expose its current action for CSS, tests, and accessibility" + ) + assert "btn.classList.toggle('stop',action==='stop')" in body, ( + "busy/no-draft state should turn the single primary button into the red stop action" + ) + assert "btn.style.display=''" in body, ( + "the single primary button should remain visible while busy; it becomes Stop when there is no draft" + ) + + def test_composer_primary_action_accounts_for_all_busy_input_modes(self): + idx = UI_JS.find("function getComposerPrimaryAction()") + assert idx >= 0, "getComposerPrimaryAction() not found" + body = UI_JS[idx:UI_JS.find("function _setComposerPrimaryButtonIcon", idx)] + assert "return 'stop'" in body, "busy/no-draft + active stream must map to stop" + assert "return 'queue'" in body, "queue mode and unavailable steer/interrupt fallbacks must map to queue" + assert "return 'interrupt'" in body, "interrupt mode with an active stream must map to interrupt" + assert "return 'steer'" in body, "steer mode with active stream support must map to steer" + assert "window._busyInputMode||'queue'" in body, "helper must respect the Busy input mode setting" + + def test_send_button_click_uses_primary_action_handler(self): + assert "function handleComposerPrimaryAction()" in UI_JS, ( + "btnSend click should route through a primary action handler so Stop can cancel instead of sending" + ) + assert "handleComposerPrimaryAction" in BOOT_JS, ( + "boot.js should wire btnSend to handleComposerPrimaryAction(), not directly to send()" + ) + + class TestSendBusyBranchDispatch: """send()'s busy block must read window._busyInputMode and branch accordingly.""" diff --git a/tests/test_sprint20.py b/tests/test_sprint20.py index e97eefd9..51be99d1 100644 --- a/tests/test_sprint20.py +++ b/tests/test_sprint20.py @@ -280,15 +280,17 @@ def test_boot_js_mic_status_toggle(): def test_boot_js_send_stops_mic(): - """btnSend onclick must stop mic before sending (send guard).""" - js, _ = get_text("/static/boot.js") - # The send button onclick should check _micActive and stop recording - send_onclick_idx = js.find("$('btnSend').onclick") + """btnSend primary action path must stop mic before sending.""" + boot_js, _ = get_text("/static/boot.js") + ui_js, _ = get_text("/static/ui.js") + send_onclick_idx = boot_js.find("$('btnSend').onclick") assert send_onclick_idx != -1 - # Find the handler code — check that _micActive check appears near send assignment - handler_end = js.find(';', send_onclick_idx) - handler = js[send_onclick_idx:handler_end + 1] - assert '_micActive' in handler or 'stopMic' in handler.lower() + assert 'handleComposerPrimaryAction' in boot_js[send_onclick_idx:send_onclick_idx + 200] + handler_idx = ui_js.find('function handleComposerPrimaryAction') + assert handler_idx != -1 + handler = ui_js[handler_idx:handler_idx + 500] + assert '_micActive' in handler + assert '_stopMic()' in handler def test_boot_js_btn_mic_onclick(): diff --git a/tests/test_sprint20b.py b/tests/test_sprint20b.py index c4bdc54c..daf93ab1 100644 --- a/tests/test_sprint20b.py +++ b/tests/test_sprint20b.py @@ -236,9 +236,9 @@ def test_ui_js_update_send_btn_function(): def test_update_send_btn_checks_content(): - """updateSendBtn must check textarea value length.""" + """Composer primary action helper must check textarea value length.""" js, _ = get_text("/static/ui.js") - fn_idx = js.find('function updateSendBtn') + fn_idx = js.find('function _composerHasContent') fn_end = js.find('\n}', fn_idx) + 2 fn_body = js[fn_idx:fn_end] assert 'msg' in fn_body @@ -247,9 +247,9 @@ def test_update_send_btn_checks_content(): def test_update_send_btn_checks_pending_files(): - """updateSendBtn must also show send button when files are attached.""" + """Composer primary action helper must also count attached files as content.""" js, _ = get_text("/static/ui.js") - fn_idx = js.find('function updateSendBtn') + fn_idx = js.find('function _composerHasContent') fn_end = js.find('\n}', fn_idx) + 2 fn_body = js[fn_idx:fn_end] assert 'pendingFiles' in fn_body