fix: send button morphs into a clickable STOP icon while working (no more disabled spinner); grouped bottom-left tools; silent-recording message; completed images auto-join the library; transient image-status retries

This commit is contained in:
Daniel 2026-09-09 04:12:30 +02:00
parent a4945d4d1d
commit d6476f99d9
4 changed files with 33 additions and 13 deletions

View file

@ -62,10 +62,12 @@
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>
<div class="assistant-composer-footer">
<span id="assistant-autosave-state" class="assistant-autosave-state" aria-live="polite"></span>
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i></label>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<button id="btn-assistant-voice" class="assistant-voice-btn" type="button" title="Voice conversation"><i class="fas fa-headset"></i></button>
<button id="btn-assistant-mic" class="assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
<div class="assistant-tools">
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i></label>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
<button id="btn-assistant-voice" class="assistant-voice-btn" type="button" title="Voice conversation"><i class="fas fa-headset"></i></button>
<button id="btn-assistant-mic" class="assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
</div>
<button id="btn-assistant-send" class="btn-send" type="submit"><i class="fas fa-arrow-up"></i></button>
</div>
</form>

View file

@ -271,9 +271,10 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
.assistant-composer { border:1px solid var(--g200); border-radius:20px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:10px 14px 6px; display:flex; flex-direction:column; gap:2px; }
.assistant-composer > label { display:none; }
#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; }
.assistant-composer-footer { display:flex; align-items:center; justify-content:space-between; gap:4px; border-top:1px solid var(--g100); padding-top:4px; margin-top:2px; }
.assistant-composer-footer { display:flex; align-items:center; gap:2px; border-top:1px solid var(--g100); padding-top:4px; margin-top:2px; }
.assistant-composer-footer .assistant-tools { display:flex; align-items:center; gap:2px; margin-right:auto; }
.assistant-composer-footer .assistant-attach, .assistant-composer-footer .assistant-voice-btn, .assistant-composer-footer .assistant-mic { width:30px; height:30px; font-size:14px; }
.assistant-composer-footer .btn-send { width:32px; height:32px; font-size:14px; }
.assistant-composer-footer .btn-send { width:32px; height:32px; font-size:14px; margin-left:auto; }
.assistant-attach, .assistant-voice-btn, .assistant-mic { width:38px; height:38px; border:none; background:none; border-radius:50%; display:flex; align-items:center; justify-content:center; color:var(--g600); cursor:pointer; font-size:16px; }
.assistant-attach:hover, .assistant-voice-btn:hover, .assistant-mic:hover { background:var(--g100); }
.assistant-mic.recording { background:var(--danger,#dc2626); color:#fff; }

View file

@ -963,6 +963,11 @@ import {
}
};
if (typeof transcribeAudio === 'function' && blob && blob.size > 100) {
if (blob.size < 4000 && !live) {
if (typeof showToast === 'function') showToast('Recording was too short or silent — speak closer to the microphone', 'error');
finish('');
return;
}
transcribeAudio(blob).then(function(data) {
// Server transcription wins; the live transcript is the fallback,
// never lose what the browser already heard.
@ -1068,11 +1073,6 @@ import {
resizeAssistantInput();
if (status) status.textContent = 'Asking…';
var send = document.getElementById('btn-assistant-send');
if (send) {
send.classList.toggle('busy', !!isBusy);
var sendIcon = send.querySelector('i');
if (sendIcon) sendIcon.className = isBusy ? 'fas fa-stop' : 'fas fa-arrow-up';
}
if (send) send.click();
}
@ -2048,8 +2048,11 @@ import {
}
if (label) label.textContent = text || (isBusy ? 'Working...' : 'Ready');
if (send) {
send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
// The send button becomes a clickable STOP while the assistant works.
send.classList.toggle('busy', !!isBusy);
var icon = send.querySelector('i');
if (icon) icon.className = isBusy ? 'fas fa-stop' : 'fas fa-arrow-up';
send.setAttribute('aria-label', isBusy ? 'Stop generating' : 'Send');
}
if (input) input.disabled = !!isBusy;
if (attachInput) attachInput.disabled = !!isBusy;

View file

@ -86,6 +86,20 @@ test('regenerate replays the preceding question for the latest answer and never
assert.match(all[all.length - 1].textContent, /Regenerated answer/);
});
test('the send button becomes a clickable stop icon while the assistant works', async t => {
const app = ui(t);
const c = app.context;
const send = app.document.getElementById('btn-assistant-send');
assert.equal(send.querySelector('i').className, 'fas fa-arrow-up');
c.setBusy(true, 'Working');
assert.equal(send.disabled, false, 'still clickable so it can cancel');
assert.ok(send.classList.contains('busy'));
assert.equal(send.querySelector('i').className, 'fas fa-stop');
c.setBusy(false, 'Ready');
assert.equal(send.querySelector('i').className, 'fas fa-arrow-up');
assert.ok(!send.classList.contains('busy'));
});
test('editing a user message returns the question to the composer', async t => {
const app = ui(t);
const c = app.context;