fix: keep busy-input send available on mobile

This commit is contained in:
starship-s authored and Hermes Agent committed 2026-04-29 04:31:54 +00:00
1 parent 59abbd1300
commit 96182e5f51
6 files changed
+134 -21

No files matched your search

+1
View File
@@ -177,6 +177,7 @@ function mobileSwitchPanel(name){
}
$('btnSend').onclick=()=>{
if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
if(window._micActive){
window._micPendingSend=true;
_stopMic();
+6 -1
View File
@@ -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;}
+73 -8
View File
@@ -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:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
queue:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/><circle cx="18" cy="18" r="3" stroke-width="2"/></svg>',
interrupt:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h11"/><polyline points="11 7 16 12 11 17"/><path d="M19 5v14"/></svg>',
steer:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12h10"/><path d="M10 6l6 6-6 6"/><path d="M18 5v5h-5"/><path d="M18 19v-5h-5"/></svg>',
stop:'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>',
disabled:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>'
};
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();
+40
View File
@@ -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."""
+10 -8
View File
@@ -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():
+4 -4
View File
@@ -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