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