fix: keep busy-input send available on mobile
This commit is contained in:
1 parent
59abbd1300
commit
96182e5f51
6 files changed
+134
-21
No files matched your search
@@ -177,6 +177,7 @@ function mobileSwitchPanel(name){
|
||||
}
|
||||
|
||||
$('btnSend').onclick=()=>{
|
||||
if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
|
||||
if(window._micActive){
|
||||
window._micPendingSend=true;
|
||||
_stopMic();
|
||||
|
||||
+6
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user