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:
parent
ad8e10304c
commit
3ce7844a7a
7 changed files with 372 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
316
static/ui.js
316
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):'')+
|
||||
`<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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue