From 3ce7844a7ab4509ce1f72f1386ad63cbeb0854b2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 25 Apr 2026 14:21:50 -0700 Subject: [PATCH] feat(queue): Codex-style message queue flyout above composer (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/helpers.py | 6 +- static/i18n.js | 15 +++ static/index.html | 8 ++ static/messages.js | 4 +- static/sessions.js | 10 +- static/style.css | 31 +++++ static/ui.js | 316 +++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 372 insertions(+), 18 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 477d52ae..d68d743d 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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( diff --git a/static/i18n.js b/static/i18n.js index 16f279c8..b2b16da8 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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`, diff --git a/static/index.html b/static/index.html index cd59b5e5..8f682af2 100644 --- a/static/index.html +++ b/static/index.html @@ -252,6 +252,10 @@
+ +
+
+
@@ -298,6 +302,10 @@
+ +
+ +
diff --git a/static/messages.js b/static/messages.js index 5b5a7295..55cd822d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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{ diff --git a/static/sessions.js b/static/sessions.js index d6c8ee23..89e25ec3 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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 = '
Failed to load session. Try switching sessions or refreshing.
'; + if(_msgInner){ + if(e.status===404){ + _msgInner.innerHTML='
Session not available in web UI.
'; + } else { + _msgInner.innerHTML='
Failed to load session. Try switching sessions or refreshing.
'; + 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; diff --git a/static/style.css b/static/style.css index 688f740f..b4417a28 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/ui.js b/static/ui.js index 09fa1e0b..0d31922e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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):'')+ + `${label}`+ + ``+(typeof li==='function'?li('chevron-up',12):'▲')+``; + 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;