- index.html: btnSend hidden by default (display:none), icon-only (upward arrow SVG, no text label), title attribute for accessibility - style.css: new send-btn design — 34px circle, blue fill (#7cb9ff), subtle glow box-shadow, scale() hover/active for tactile feel, .send-btn.visible with @keyframes send-pop-in (scale+opacity spring using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile override updated to preserve circle dimensions. - ui.js: updateSendBtn() — shows button with pop-in animation when textarea has content OR files are attached and agent is not busy; hides instantly when content is cleared. Hooked into setBusy() and renderTray() so button state tracks all content sources correctly. - boot.js: input event listener calls updateSendBtn() on every keystroke. - messages.js: autoResize() calls updateSendBtn() so button disappears immediately after send clears the textarea. - tests/test_sprint21.py: 33 tests covering HTML structure, CSS design (circle shape, colors, animations, keyframes), JS logic (updateSendBtn, setBusy, renderTray, autoResize integration), and regressions (363 total, all pass).
298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
async function send(){
|
|
const text=$('msg').value.trim();
|
|
if(!text&&!S.pendingFiles.length)return;
|
|
// Slash command intercept -- local commands handled without agent round-trip
|
|
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
|
|
$('msg').value='';autoResize();hideCmdDropdown();return;
|
|
}
|
|
// Don't send while an inline message edit is active
|
|
if(document.querySelector('.msg-edit-area'))return;
|
|
// If busy, queue the message instead of dropping it
|
|
if(S.busy){
|
|
if(text){
|
|
MSG_QUEUE.push(text);
|
|
$('msg').value='';autoResize();
|
|
updateQueueBadge();
|
|
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
|
|
}
|
|
return;
|
|
}
|
|
if(!S.session){await newSession();await renderSessionList();}
|
|
|
|
const activeSid=S.session.session_id;
|
|
|
|
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…');
|
|
let uploaded=[];
|
|
try{uploaded=await uploadPendingFiles();}
|
|
catch(e){if(!text){setStatus(`❌ ${e.message}`);return;}}
|
|
|
|
let msgText=text;
|
|
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
|
|
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`;
|
|
if(!msgText){setStatus('Nothing to send');return;}
|
|
|
|
$('msg').value='';autoResize();
|
|
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
|
|
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000};
|
|
S.toolCalls=[]; // clear tool calls from previous turn
|
|
clearLiveToolCards(); // clear any leftover live cards from last turn
|
|
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy
|
|
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
|
|
startApprovalPolling(activeSid);
|
|
S.activeStreamId = null; // will be set after stream starts
|
|
|
|
// Set provisional title from user message immediately so session appears
|
|
// in the sidebar right away with a meaningful name (server may refine later)
|
|
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
|
|
const provisionalTitle=displayText.slice(0,64);
|
|
S.session.title=provisionalTitle;
|
|
syncTopbar();
|
|
// Persist it and refresh the sidebar now -- don't wait for done
|
|
api('/api/session/rename',{method:'POST',body:JSON.stringify({
|
|
session_id:activeSid, title:provisionalTitle
|
|
})}).catch(()=>{}); // fire-and-forget, server refines on done
|
|
renderSessionList(); // session appears in sidebar immediately
|
|
} else {
|
|
renderSessionList(); // ensure it's visible even if already titled
|
|
}
|
|
|
|
// Start the agent via POST, get a stream_id back
|
|
let streamId;
|
|
try{
|
|
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
|
session_id:activeSid,message:msgText,
|
|
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
|
|
attachments:uploaded.length?uploaded:undefined
|
|
})});
|
|
streamId=startData.stream_id;
|
|
S.activeStreamId = streamId;
|
|
markInflight(activeSid, streamId);
|
|
// Show Cancel button
|
|
const cancelBtn=$('btnCancel');
|
|
if(cancelBtn) cancelBtn.style.display='';
|
|
}catch(e){
|
|
delete INFLIGHT[activeSid];
|
|
stopApprovalPolling();
|
|
// Only hide approval card if it belongs to the session that just finished
|
|
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();removeThinking();
|
|
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
|
|
renderMessages();setBusy(false);setStatus('Error: '+e.message);
|
|
return;
|
|
}
|
|
|
|
// Open SSE stream and render tokens live
|
|
let assistantText='';
|
|
let assistantRow=null;
|
|
let assistantBody=null;
|
|
|
|
function ensureAssistantRow(){
|
|
if(assistantRow)return;
|
|
removeThinking();
|
|
const tr=$('toolRunningRow');if(tr)tr.remove();
|
|
$('emptyState').style.display='none';
|
|
assistantRow=document.createElement('div');assistantRow.className='msg-row';
|
|
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
|
const role=document.createElement('div');role.className='msg-role assistant';
|
|
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H';
|
|
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes';
|
|
role.appendChild(icon);role.appendChild(lbl);
|
|
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
|
|
$('msgInner').appendChild(assistantRow);
|
|
}
|
|
|
|
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
|
|
let _reconnectAttempted=false;
|
|
|
|
function _wireSSE(source){
|
|
source.addEventListener('token',e=>{
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
const d=JSON.parse(e.data);
|
|
assistantText+=d.text;
|
|
ensureAssistantRow();
|
|
assistantBody.innerHTML=renderMd(assistantText);
|
|
scrollIfPinned();
|
|
});
|
|
|
|
source.addEventListener('tool',e=>{
|
|
const d=JSON.parse(e.data);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
|
|
}
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
removeThinking();
|
|
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
|
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
|
|
S.toolCalls.push(tc);
|
|
appendLiveToolCard(tc);
|
|
scrollIfPinned();
|
|
});
|
|
|
|
source.addEventListener('approval',e=>{
|
|
const d=JSON.parse(e.data);
|
|
d._session_id=activeSid;
|
|
showApprovalCard(d);
|
|
});
|
|
|
|
source.addEventListener('done',e=>{
|
|
source.close();
|
|
const d=JSON.parse(e.data);
|
|
delete INFLIGHT[activeSid];
|
|
clearInflight();
|
|
stopApprovalPolling();
|
|
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;
|
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
|
}
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.session=d.session;S.messages=d.session.messages||[];
|
|
if(d.session.tool_calls&&d.session.tool_calls.length){
|
|
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
|
} else {
|
|
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
|
|
}
|
|
if(uploaded.length){
|
|
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
|
|
if(lastUser)lastUser.attachments=uploaded;
|
|
}
|
|
clearLiveToolCards();
|
|
S.busy=false;
|
|
syncTopbar();renderMessages();loadDir('.');
|
|
}
|
|
renderSessionList();setBusy(false);setStatus('');
|
|
});
|
|
|
|
source.addEventListener('error',e=>{
|
|
source.close();
|
|
// Attempt one reconnect if the stream is still active server-side
|
|
if(!_reconnectAttempted && streamId){
|
|
_reconnectAttempted=true;
|
|
setStatus('Connection lost \u2014 reconnecting\u2026');
|
|
setTimeout(async()=>{
|
|
try{
|
|
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
|
if(st.active){
|
|
setStatus('Reconnected');
|
|
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
|
|
return;
|
|
}
|
|
}catch(_){}
|
|
_handleStreamError();
|
|
},1500);
|
|
return;
|
|
}
|
|
_handleStreamError();
|
|
});
|
|
|
|
source.addEventListener('cancel',e=>{
|
|
source.close();
|
|
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
|
|
}
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
|
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
|
|
}
|
|
renderSessionList();
|
|
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
|
|
});
|
|
}
|
|
|
|
function _handleStreamError(){
|
|
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
|
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
|
|
}else{
|
|
// User switched away — show background error banner
|
|
if(typeof trackBackgroundError==='function'){
|
|
// Look up session title from the session list cache so the banner names it correctly
|
|
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
|
trackBackgroundError(activeSid,_errTitle,'Connection lost');
|
|
}
|
|
}
|
|
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('Error: Connection lost');}
|
|
}
|
|
|
|
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
|
|
|
|
}
|
|
|
|
function transcript(){
|
|
const lines=[`# Hermes session ${S.session?.session_id||''}`,``,
|
|
`Workspace: ${S.session?.workspace||''}`,`Model: ${S.session?.model||''}`,``];
|
|
for(const m of S.messages){
|
|
if(!m||m.role==='tool')continue;
|
|
let c=m.content||'';
|
|
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('\n');
|
|
const ct=String(c).trim();
|
|
if(!ct&&!m.attachments?.length)continue;
|
|
const attach=m.attachments?.length?`\n\n_Files: ${m.attachments.join(', ')}_`:'';
|
|
lines.push(`## ${m.role}`,'',ct+attach,'');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
|
|
|
|
|
|
// ── Approval polling ──
|
|
let _approvalPollTimer = null;
|
|
|
|
// showApprovalCard moved above respondApproval
|
|
|
|
function hideApprovalCard() {
|
|
$("approvalCard").classList.remove("visible");
|
|
$("approvalCmd").textContent = "";
|
|
$("approvalDesc").textContent = "";
|
|
}
|
|
|
|
// Track session_id of the active approval so respond goes to the right session
|
|
let _approvalSessionId = null;
|
|
|
|
function showApprovalCard(pending) {
|
|
$("approvalDesc").textContent = pending.description || "";
|
|
$("approvalCmd").textContent = pending.command || "";
|
|
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
|
$("approvalDesc").textContent = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
|
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
|
|
$("approvalCard").classList.add("visible");
|
|
}
|
|
|
|
async function respondApproval(choice) {
|
|
const sid = _approvalSessionId || (S.session && S.session.session_id);
|
|
if (!sid) return;
|
|
hideApprovalCard();
|
|
_approvalSessionId = null;
|
|
try {
|
|
await api("/api/approval/respond", {
|
|
method: "POST",
|
|
body: JSON.stringify({ session_id: sid, choice })
|
|
});
|
|
} catch(e) { setStatus("Approval error: " + e.message); }
|
|
}
|
|
|
|
function startApprovalPolling(sid) {
|
|
stopApprovalPolling();
|
|
_approvalPollTimer = setInterval(async () => {
|
|
if (!S.busy || !S.session || S.session.session_id !== sid) {
|
|
stopApprovalPolling(); hideApprovalCard(); return;
|
|
}
|
|
try {
|
|
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
|
|
if (data.pending) { data.pending._session_id=sid; showApprovalCard(data.pending); }
|
|
else { hideApprovalCard(); }
|
|
} catch(e) { /* ignore poll errors */ }
|
|
}, 1500);
|
|
}
|
|
|
|
function stopApprovalPolling() {
|
|
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
|
|
}
|
|
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
|
|