feat(queue): Codex-style message queue flyout above composer (#1040)

* chore: apply pending #965 queue flyout patches on local master

Queue flyout implementation (PR #965 — pending merge) applied on top of
upstream v0.50.205. Features:
- Queue card slides up from behind composer (approval-card pattern)
- Lucide icons via li(), CSS class system, no inline SVG dumps
- Drag-to-reorder by _queued_at timestamp (survives re-renders)
- Inline contenteditable edit with focus guard and blur-commit
- Combine preserves first item files, merge immediate (no 200ms race)
- Files/model compact badges per item
- Hide/expand via header chevron + composer pill + titlebar chip
- All 3 expand paths sync correctly
- border-bottom CSS order fixed, fingerprint improved, _dragTs guards

CF CSP domains also applied (deployment-specific, not in upstream PR).

* fix(queue): harden merge closure, toggleQueue sid, and drain flash

- mergeBtn _doMerge now reads live queue (_getSessionQueue) instead of stale closure q
- toggleQueue reads activeSid from S.session at call time, not captured param
- updateQueueBadge defers chips.innerHTML='' by 360ms so slide-out transition completes before content clears

* style(queue): contain:paint on inner, pill fade-in animation

* feat(queue): pill outside composer, compact collapsed state matching card width

- Move #queuePill out of .composer-box to between .composer-flyout and .composer-box
- Pill styled as compact queue-card-inner (same border, radius:14px 14px 0 0, no border-bottom)
- Pill width matches card inner: max-width:calc(var(--msg-max)-40px), centered
- Pill stays visible until user re-expands or queue drains (updateQueueBadge no longer
  hides pill when card is manually collapsed)
- Remove all queue-active/queue-pill-active composer modifications — composer untouched
- Fix: mergeBtn reads live queue not stale closure
- Fix: toggleQueue uses S.session.session_id at call time not captured param
- Fix: chips.innerHTML deferred 360ms on drain to avoid empty-card flash

* fix(queue): collapsed state persists + cross-session DOM isolation

- Add _queueCollapsed[sid] flag: set by hideBtn, cleared by pill expand / queue drain
- _renderQueueChips respects flag — no longer reopens card when new message queued while collapsed
- updateQueueBadge else-branch: DOM mutations now gated on sid===active session
- _syncQueueTitlebar only fires for active session in else-branch
- Fixes Opus/Codex-identified bugs: pill auto-reopen and cross-session DOM corruption

* fix(queue): proper pill wrapper matching queue-card structure

- Add .queue-pill-outer div wrapper (max-width:var(--msg-max); padding:0 20px)
  identical to .queue-card outer — positions pill button at exact card-inner width
- .queue-pill button fills slot with width:100%
- Removes hardcoded 740px — width is derived correctly from the same CSS variables
  the card uses, scales with --msg-max across all viewports
- JS toggles .show on pillOuter (parentElement), not on pill button directly

---------

Co-authored-by: Basit Mustafa <basit.mustafa@gmail.com>
This commit is contained in:
nesquena-hermes 2026-04-25 14:21:50 -07:00 committed by GitHub
parent ad8e10304c
commit 3ce7844a7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 372 additions and 18 deletions

View file

@ -42,11 +42,11 @@ def _security_headers(handler):
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"default-src 'self' https://*.cloudflareaccess.com; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; "
"manifest-src 'self'; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
handler.send_header(

View file

@ -52,6 +52,9 @@ const LOCALES = {
clarify_responding: 'Responding\u2026',
untitled: 'Untitled',
n_messages: (n) => `${n} messages`,
queued_label: 'Sends after response',
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
queued_cancel: 'Cancel queued message',
model_unavailable: ' (unavailable)',
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
@ -650,6 +653,9 @@ const LOCALES = {
approval_responding: 'Отвечаю…',
untitled: 'Без названия',
n_messages: (n) => `${n} сообщений`,
queued_label: 'Отправить после ответа',
queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`,
queued_cancel: 'Отменить сообщение',
model_unavailable: ' (недоступна)',
model_unavailable_title: 'Эта модель больше не входит в ваш текущий список провайдеров',
provider_mismatch_warning: (m, p) =>
@ -1238,6 +1244,9 @@ const LOCALES = {
clarify_responding: 'Respondiendo…',
untitled: 'Sin título',
n_messages: (n) => `${n} mensajes`,
queued_label: 'Enviar después de la respuesta',
queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`,
queued_cancel: 'Cancelar mensaje en cola',
model_unavailable: ' (no disponible)',
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
@ -1784,6 +1793,9 @@ const LOCALES = {
clarify_responding: 'Antwortet\u2026',
untitled: 'Unbenannt',
n_messages: (n) => `${n} Nachrichten`,
queued_label: 'Wird nach Antwort gesendet',
queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`,
queued_cancel: 'Nachricht abbrechen',
model_unavailable: ' (nicht verfügbar)',
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
@ -2113,6 +2125,9 @@ const LOCALES = {
clarify_responding: '处理中…',
untitled: '\u672a\u547d\u540d',
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
queued_label: '响应后发送',
queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`,
queued_cancel: '取消排队消息',
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`,

View file

@ -252,6 +252,10 @@
</div>
<div class="composer-wrap" id="composerWrap">
<div class="composer-flyout">
<!-- Queue flyout: slides up from behind composer, same pattern as approval-card -->
<div id="queueCard" class="queue-card" role="region" aria-label="Queued messages" aria-live="polite">
<div id="queueChips" class="queue-card-inner"></div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
@ -298,6 +302,10 @@
</div>
</div>
</div>
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
<div class="queue-pill-outer">
<button id="queuePill" class="queue-pill" aria-label="Show queued messages" type="button"></button>
</div>
<div class="composer-box" id="composerBox">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="drop-hint" id="dropHint">

View file

@ -14,7 +14,7 @@ async function send(){
if(S.busy||compressionRunning){
if(text){
if(!S.session){await newSession();await renderSessionList();}
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
$('msg').value='';autoResize();
S.pendingFiles=[];renderTray();
updateQueueBadge(S.session.session_id);
@ -137,7 +137,7 @@ async function send(){
stopApprovalPolling();
stopClarifyPolling();
// Keep the user's attempted turn by queueing it for after the current run.
queueSessionMessage(activeSid,{text:msgText,files:[]});
queueSessionMessage(activeSid,{text:msgText,files:[],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(activeSid);
showToast('Current session is still running. Reconnected and queued your message.',2600);
try{

View file

@ -112,10 +112,14 @@ async function loadSession(sid){
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
} catch(e) {
const _msgInner = $('msgInner');
if (_msgInner) {
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load session. Try switching sessions or refreshing.</div>';
if(_msgInner){
if(e.status===404){
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Session not available in web UI.</div>';
} else {
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load session. Try switching sessions or refreshing.</div>';
if(typeof showToast==='function') showToast('Failed to load session',3000,'error');
}
}
if (typeof showToast === 'function') showToast('Failed to load session', 3000, 'error');
return;
}
S.session=data.session;

View file

@ -412,6 +412,27 @@
.approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);}
.approval-btn:active{transform:translateY(0);box-shadow:none;}
.approval-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;}
/* ── Queue flyout (same slide-up pattern as approval-card) ── */
.queue-card{position:absolute;left:0;right:0;bottom:0;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;z-index:2;}
.queue-card.visible{pointer-events:auto;}
/* When queue is visible, add bottom padding to messages so last bubble isn't covered */
.messages.queue-open{padding-bottom:var(--queue-card-height,280px);}
.queue-card-inner{background:var(--surface);border:1px solid var(--border);border-bottom:none;border-radius:14px 14px 0 0;contain:paint;transform:translateY(100%);opacity:0;transition:transform .35s cubic-bezier(.32,.72,.16,1),opacity .2s ease;overflow:hidden;max-height:240px;overflow-y:auto;padding-bottom:4px;}
.queue-card.visible .queue-card-inner{transform:translateY(0);opacity:1;}
.queue-card-header{display:flex;align-items:center;gap:8px;padding:9px 14px 8px;border-bottom:1px solid var(--border);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
.queue-card-header-actions{display:flex;align-items:center;gap:4px;margin-left:auto;}
.queue-card-btn{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:6px;border:1px solid var(--border);background:none;color:var(--muted);font-size:11px;font-weight:500;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}
.queue-card-btn:hover{background:var(--hover-bg);color:var(--text);}
.queue-card-icon-btn{display:inline-flex;align-items:center;padding:3px;border-radius:5px;border:none;background:none;color:var(--muted);cursor:pointer;opacity:.5;transition:opacity .12s,background .12s;}
.queue-card-icon-btn:hover{opacity:1;background:var(--hover-bg);}
.queue-card-row{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--border);cursor:grab;}
.queue-card-row:last-child{border-bottom:none;}
.queue-card-row:hover{background:color-mix(in srgb,var(--hover-bg) 50%,transparent);}
.queue-card-drag{color:var(--muted);opacity:.3;flex-shrink:0;line-height:0;}
.queue-card-text{flex:1;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;outline:none;cursor:text;min-width:0;}
.queue-card-text:focus{white-space:pre-wrap;overflow:auto;text-overflow:clip;}
.queue-card-badges{display:flex;align-items:center;gap:5px;flex-shrink:0;font-size:10px;color:var(--muted);opacity:.6;}
.queue-card-file-badge{display:flex;align-items:center;gap:2px;}
.approval-btn-icon{font-size:13px;line-height:1;}
.approval-btn-label{line-height:1;}
.approval-kbd{display:inline-flex;align-items:center;justify-content:center;padding:1px 5px;border-radius:4px;font-size:10px;font-family:inherit;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:var(--muted);line-height:1.4;margin-left:2px;}
@ -2261,3 +2282,13 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
font-size: 10px;
font-weight: 600;
}
/* Queue pill outer: identical wrapper to .queue-card — max-width+padding positions the pill at card-inner width */
.queue-pill-outer{display:none;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;}
.queue-pill-outer.show{display:block;animation:queuePillIn .18s ease;}
@keyframes queuePillIn{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:translateY(0);}}
/* Queue pill button: fills the outer slot, styled like queue-card-inner */
.queue-pill{display:flex;width:100%;box-sizing:border-box;align-items:center;gap:8px;padding:9px 14px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);cursor:pointer;background:var(--surface);border:1px solid var(--border);border-bottom:none;border-radius:14px 14px 0 0;text-align:left;}
.queue-pill:hover{background:color-mix(in srgb,var(--hover-bg) 60%,var(--surface));}
.queue-pill-count{color:var(--text);}
.queue-pill-chevron{margin-left:auto;opacity:.5;}

View file

@ -964,6 +964,13 @@ function setBusy(v){
setTimeout(()=>{
$('msg').value=next.text||'';
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
// Restore model from queued item (sent in /api/chat/start payload)
// Note: profile is NOT restored — full profile switch requires server interaction
if(next.model&&S.session&&next.model!==S.session.model){
S.session.model=next.model;
if(typeof _applyModelToDropdown==='function'&&$('modelSelect')) _applyModelToDropdown(next.model,$('modelSelect'));
if(typeof syncModelChip==='function') syncModelChip();
}
autoResize();
renderTray();
send();
@ -972,21 +979,309 @@ function setBusy(v){
}
}
// ── Queue chip display (Codex Desktop pattern) ─────────────────────────────
// Queued messages appear as chips inside #queueChips (above the textarea)
// while pending. When the session fires the queued message it becomes a
// normal user bubble in the chat — the chip is removed at drain time.
const _queueRenderKeys={}; // per-session fingerprint to avoid redundant rebuilds
const _queueCollapsed={}; // per-session: true when user explicitly collapsed the card
function _renderQueueChips(sid){
const card=document.getElementById('queueCard');
const inner=document.getElementById('queueChips');
if(!card||!inner) return;
const q=_getSessionQueue(sid,false);
const key=q.map(e=>{const t=e&&(e.text||e.message||e.content||'');return(e&&e._queued_at||0)+':'+t.length+':'+t.slice(0,20);}).join('|');
if(key===(_queueRenderKeys[sid]||'')&&key!='') return;
// Skip re-render if user is actively editing inside the queue panel
if(inner.contains(document.activeElement)&&document.activeElement!==inner) return;
_queueRenderKeys[sid]=key;
inner.innerHTML='';
if(!q.length){
card.classList.remove('visible');
const _msgs=document.getElementById('messages');
if(_msgs) _msgs.classList.remove('queue-open');
return;
}
// Respect user-collapsed state — don't reopen if user explicitly hid the card
if(_queueCollapsed[sid]){
// Update chips content without showing card (so data is fresh if user re-expands)
inner.innerHTML='';
// fall through to render rows into inner but skip making card visible
} else {
card.classList.add('visible');
}
// Push messages area up so content isn't hidden behind the flyout
const _msgs=document.getElementById('messages');
if(_msgs&&!_queueCollapsed[sid]){
_msgs.classList.add('queue-open');
// Measure after 350ms transition completes (not mid-animation — height would be wrong)
setTimeout(()=>{
if(!card.classList.contains('visible')) return;
const h=card.getBoundingClientRect().height;
if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px');
if(typeof scrollToBottom==='function') scrollToBottom();
}, 360);
}
function _saveAndRefresh(){
const liveQ=_getSessionQueue(sid,false);
if(!liveQ.length){delete SESSION_QUEUES[sid];try{sessionStorage.removeItem('hermes-queue-'+sid);}catch(_){}}
else{SESSION_QUEUES[sid]=[...liveQ];try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}}
delete _queueRenderKeys[sid];
updateQueueBadge(sid);
}
// Header (2+ items)
if(q.length>1){
const header=document.createElement('div');
header.className='queue-card-header';
const lbl=document.createElement('span');
lbl.textContent=typeof t==='function'?t('queued_count',q.length):(q.length===1?'1 queued':`${q.length} queued`);
lbl.title='Sends automatically after the current response completes';
const actions=document.createElement('span');
actions.className='queue-card-header-actions';
const hasFiles=q.some(e=>e&&Array.isArray(e.files)&&e.files.length>0);
const mergeBtn=document.createElement('button');
mergeBtn.className='queue-card-btn';
mergeBtn.title='Combine all into one message'+(hasFiles?' — attachments will be removed':'');
mergeBtn.innerHTML=li('layers',12)+'Combine';
mergeBtn.onclick=()=>{
const _doMerge=(snapshot)=>{
const combined=snapshot.map(e=>e&&(e.text||e.message||e.content||'')).filter(Boolean).join('\n\n');
const liveQ=_getSessionQueue(sid,false);
const firstFiles=(snapshot.find(e=>e&&Array.isArray(e.files)&&e.files.length)||{files:[]}).files;
liveQ.length=0;liveQ.push({text:combined,files:firstFiles,_queued_at:Date.now()});
SESSION_QUEUES[sid]=liveQ;
try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}
delete _queueRenderKeys[sid];
updateQueueBadge(sid);
};
if(hasFiles){
if(typeof showToast==='function') showToast('Attachments on queued items will be removed',2600,'warning');
}
// Merge from current live queue (no delay — snapshot + defer caused data-loss races)
_doMerge([..._getSessionQueue(sid,false)]);
};
const clearBtn=document.createElement('button');
clearBtn.className='queue-card-icon-btn';
clearBtn.title='Clear all queued messages';
clearBtn.setAttribute('aria-label','Clear all queued messages');
clearBtn.innerHTML=li('x',13);
clearBtn.onclick=()=>{q.length=0;_saveAndRefresh();};
actions.appendChild(mergeBtn);
actions.appendChild(clearBtn);
// Hide button — collapses flyout entirely; titlebar "N queued" re-shows it
const hideBtn=document.createElement('button');
hideBtn.className='queue-card-icon-btn';
hideBtn.title='Hide queue (click the titlebar badge to show again)';
hideBtn.setAttribute('aria-label','Hide queue panel');
hideBtn.innerHTML=li('chevron-down',14);
hideBtn.onclick=()=>{
_queueCollapsed[sid]=true;
card.classList.remove('visible');
// Read live count at click time (not stale closure q)
_updateQueuePill(sid,_getSessionQueue(sid,false).length);
};
actions.appendChild(hideBtn);
header.appendChild(lbl);
header.appendChild(actions);
inner.appendChild(header);
}
let _dragTs=null; // use _queued_at timestamp — survives re-renders, not an index
q.forEach((entry,i)=>{
const _entryTs=entry&&entry._queued_at;
const entryText=entry&&(entry.text||entry.message||entry.content||'');
const _files=entry&&Array.isArray(entry.files)?entry.files.filter(Boolean):[];
const row=document.createElement('div');
row.className='queue-card-row';
row.setAttribute('role','listitem');
row.setAttribute('draggable','true');
row.ondragstart=(e)=>{if(_entryTs==null) return;_dragTs=_entryTs;row.style.opacity='.4';e.dataTransfer.effectAllowed='move';};
row.ondragend=()=>{row.style.opacity='';};
row.ondragover=(e)=>{e.preventDefault();row.style.background='var(--hover-bg)';};
row.ondragleave=()=>{row.style.background='';};
row.ondrop=(e)=>{
e.preventDefault();row.style.background='';
if(_dragTs!=null&&_dragTs!==_entryTs){
const fromIdx=q.findIndex(e=>e&&e._queued_at===_dragTs);
if(fromIdx!==-1&&fromIdx!==i){const moved=q.splice(fromIdx,1)[0];q.splice(i,0,moved);}
_dragTs=null;_saveAndRefresh();
}
};
// Drag handle
const drag=document.createElement('span');
drag.className='queue-card-drag';
drag.setAttribute('aria-hidden','true');
drag.innerHTML=typeof li==='function'?li('list-todo',13):'≡';
// Inline-editable text
const msgSpan=document.createElement('span');
msgSpan.className='queue-card-text';
msgSpan.setAttribute('contenteditable','true');
msgSpan.setAttribute('role','textbox');
msgSpan.setAttribute('aria-label','Queued message — edit in place');
msgSpan.textContent=entryText||(_files.length?'':'—');
msgSpan.setAttribute('draggable','false');
msgSpan.onfocus=()=>{msgSpan.style.overflow='auto';msgSpan.style.whiteSpace='pre-wrap';msgSpan.style.textOverflow='clip';};
msgSpan.onblur=()=>{
msgSpan.style.overflow='';msgSpan.style.whiteSpace='';msgSpan.style.textOverflow='';
const newText=msgSpan.textContent.trim();
if(newText===''&&!_files.length){ msgSpan.textContent=entryText||'—'; return; }
if(newText!==entryText){
const liveQ=_getSessionQueue(sid,false);
const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i;
if(idx!==-1){
liveQ[idx]={...liveQ[idx],text:newText};
try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}
delete _queueRenderKeys[sid];
updateQueueBadge(sid);
}
}
};
msgSpan.onkeydown=(e)=>{if(e.key==='Enter'){e.preventDefault();msgSpan.blur();}if(e.key==='Escape'){msgSpan.textContent=entryText||'—';msgSpan.blur();}};
// Compact badges (files, model, profile)
const badges=document.createElement('span');
badges.className='queue-card-badges';
if(_files.length>0){
const fb=document.createElement('span');
fb.className='queue-card-file-badge';
fb.title=_files.map(f=>f&&f.name||'file').join(', ');
fb.innerHTML=li('paperclip',11)+_files.length;
badges.appendChild(fb);
}
const _model=entry&&entry.model;
if(_model){
const mb=document.createElement('span');
mb.title='Model: '+_model;
// Use the app's friendly label system if available
const _modelLabel=(typeof _dynamicModelLabels!=='undefined'&&_dynamicModelLabels[_model])
||_model.split('/').pop().replace(/^(gpt-|claude-3\.?5?-|claude-|gemini-)/,'').replace(/-\d{4}-\d{2}-\d{2}$/,'').slice(0,12);
mb.textContent=_modelLabel;
badges.appendChild(mb);
}
// Profile badge removed — drain cannot server-switch profiles so badge was misleading
// Delete button
const delBtn=document.createElement('button');
delBtn.className='queue-card-icon-btn';
delBtn.setAttribute('aria-label',typeof t==='function'?t('queued_cancel'):'Remove queued message');
delBtn.setAttribute('draggable','false');
delBtn.title='Remove from queue';
delBtn.innerHTML=li('x',13);
delBtn.onclick=()=>{
const liveQ=_getSessionQueue(sid,false);
const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i;
if(idx!==-1) liveQ.splice(idx,1);
if(!liveQ.length){delete SESSION_QUEUES[sid];try{sessionStorage.removeItem('hermes-queue-'+sid);}catch(_){}}
else{SESSION_QUEUES[sid]=[...liveQ];try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}}
delete _queueRenderKeys[sid];
updateQueueBadge(sid);
};
row.appendChild(drag);
row.appendChild(msgSpan);
if(badges.childNodes.length) row.appendChild(badges);
row.appendChild(delBtn);
inner.appendChild(row);
});
}
function _updateQueuePill(sid,count){
const pill=document.getElementById('queuePill');
if(!pill) return;
const pillOuter=pill.parentElement; // .queue-pill-outer — same wrapper as .queue-card
const card=document.getElementById('queueCard');
const flyoutVisible=card&&card.classList.contains('visible');
if(count>0&&!flyoutVisible){
const label=typeof t==='function'?t('queued_count',count):(count===1?'1 queued':`${count} queued`);
pill.innerHTML=(typeof li==='function'?li('list-todo',12):'')+
`<span class="queue-pill-count">${label}</span>`+
`<span class="queue-pill-chevron">`+(typeof li==='function'?li('chevron-up',12):'▲')+`</span>`;
pill.title='Show queued messages';
if(pillOuter) pillOuter.classList.add('show');
pill.onclick=()=>{
delete _queueCollapsed[sid];
const c=document.getElementById('queueCard');
if(c){
c.classList.add('visible');
setTimeout(()=>{
const firstFocusable=c.querySelector('.queue-card-text, .queue-card-icon-btn');
if(firstFocusable) firstFocusable.focus();
}, 360);
}
if(pillOuter) pillOuter.classList.remove('show');
if(typeof scrollToBottom==='function') scrollToBottom();
};
} else {
if(pillOuter) pillOuter.classList.remove('show');
pill.onclick=null;
}
}
function _syncQueueTitlebar(sid,count){
const sub=document.getElementById('appTitlebarSub');
if(!sub) return;
if(count>0){
const label=typeof t==='function'?t('queued_count',count):(count===1?'1 queued':`${count} queued`);
sub.textContent=label;
sub.hidden=false;
sub.setAttribute('role','button');
sub.setAttribute('tabindex','0');
sub.setAttribute('aria-label',label);
sub.style.cursor='pointer';sub.style.color='var(--muted)';sub.style.fontWeight='600';sub.style.fontSize='11px';sub.style.background='var(--hover-bg)';sub.style.padding='2px 6px';sub.style.borderRadius='6px';sub.style.border='1px solid var(--border)';
const toggleQueue=()=>{
const activeSid=S.session&&S.session.session_id;
if(!activeSid) return;
const qCard=document.getElementById('queueCard');
if(!qCard) return;
if(qCard.classList.contains('visible')){
qCard.classList.remove('visible');
// Show pill since flyout is now hidden
const liveCount=_getSessionQueue(activeSid,false).length;
_updateQueuePill(activeSid,liveCount);
} else {
qCard.classList.add('visible');
// Hide pill since flyout is now showing
_updateQueuePill(activeSid,0);
if(typeof scrollToBottom==='function') scrollToBottom();
}
};
sub.onclick=toggleQueue;
sub.onkeydown=(e)=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();toggleQueue();}};
sub.title='Click to show/hide queue panel';
} else {
sub.textContent='';sub.hidden=true;
sub.removeAttribute('role');sub.removeAttribute('tabindex');sub.removeAttribute('aria-label');
sub.style.cssText='';
sub.onclick=null;sub.onkeydown=null;
}
}
function updateQueueBadge(sessionId){
const sid=sessionId||(S.session&&S.session.session_id);
const count=sid?getQueuedSessionCount(sid):0;
let badge=$('queueBadge');
if(count>0){
if(!badge){
badge=document.createElement('div');
badge.id='queueBadge';
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
document.body.appendChild(badge);
if(count>0&&S.session&&sid===S.session.session_id){
_renderQueueChips(sid);
// If card is visible, hide pill. If card is collapsed, update pill count.
const _cardEl=document.getElementById('queueCard');
_updateQueuePill(sid,(_cardEl&&_cardEl.classList.contains('visible'))?0:count);
} else {
// Always clean up per-session data
if(sid){delete _queueRenderKeys[sid];delete _queueCollapsed[sid];}
// Only wipe global DOM if this is the currently active session
const isActive=S.session&&sid===S.session.session_id;
if(isActive){
const card=document.getElementById('queueCard');
const chips=document.getElementById('queueChips');
if(card) card.classList.remove('visible');
// Defer clear until after slide-out transition so content doesn't vanish mid-animation
if(chips){const _chips=chips;const _card=card;setTimeout(()=>{if(!_card||!_card.classList.contains('visible'))_chips.innerHTML='';},360);}
const _msgsEl=document.getElementById('messages');
if(_msgsEl) _msgsEl.classList.remove('queue-open');
_updateQueuePill(sid,0);
}
badge.textContent=count===1?'1 message queued':`${count} messages queued`;
} else if(badge) {
badge.remove();
}
// Only update titlebar if this is the active session
if(!S.session||sid===S.session.session_id) _syncQueueTitlebar(sid,count);
}
function showToast(msg,ms,type){const el=$('toast');if(!el)return;const s=String(msg==null?'':msg);let t=type;if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';}el.textContent=s;el.className='toast show '+t;clearTimeout(el._t);el._t=setTimeout(()=>{el.classList.remove('show');},ms||2800);}
@ -1415,6 +1710,7 @@ function syncTopbar(){
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
const _topbarMeta=$('topbarMeta');if(_topbarMeta)_topbarMeta.textContent=t('n_messages',vis.length);
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
if(typeof _syncQueueTitlebar==='function'){const _qs=S.session&&S.session.session_id;_syncQueueTitlebar(_qs,_qs?getQueuedSessionCount(_qs):0);}
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;