(mermaid/pre-header). Everything else is untrusted input.
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|img|div|span)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links.
// Stash existing
tags first so we never re-link a URL already inside href="...".
const _al_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/a>|
]*>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{
// Strip trailing punctuation that was likely not part of the URL
const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';
const clean=trail?url.slice(0,-1):url;
return `${esc(clean)}${trail}`;
});
s=s.replace(/\x00B(\d+)\x00/g,(_,i)=>_al_stash[+i]);
// Restore math stash → katex placeholder spans/divs
// These will be rendered by renderKatexBlocks() after DOM insertion
s=s.replace(/\x00M(\d+)\x00/g,(_,i)=>{
const item=math_stash[+i];
if(item.type==='display'){
return `
${esc(item.src)}
`;
}
return `
${esc(item.src)}`;
});
const parts=s.split(/\n{2,}/);
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `
${p.replace(/\n/g,'
')}
`;}).join('\n');
// ── Restore MEDIA stash → inline images or download links ─────────────────
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
const ref=media_stash[+i];
// HTTP(S) URL
if(/^https?:\/\//i.test(ref)){
if(_IMAGE_EXTS.test(ref.split('?')[0])){
return `
})
`;
}
return `
${esc(ref)}`;
}
// Local file path
const apiUrl='/api/media?path='+encodeURIComponent(ref);
if(_IMAGE_EXTS.test(ref)){
return `
})
`;
}
// Non-image local file — show download link with filename
const fname=esc(ref.split('/').pop()||ref);
return `
📎 ${fname}`;
});
// ── End MEDIA restore ──────────────────────────────────────────────────────
return s;
}
function setStatus(t){
if(!t)return;
showToast(t, 4000);
}
function setComposerStatus(t){
const el=$('composerStatus');
if(!el)return;
if(!t){
el.style.display='none';
el.textContent='';
return;
}
el.textContent=t;
el.style.display='';
}
let _composerLockState=null;
function lockComposerForClarify(placeholderText){
const input=$('msg');
if(!input) return;
if(!_composerLockState){
_composerLockState={
disabled: input.disabled,
placeholder: input.placeholder,
};
}
input.disabled=true;
if(placeholderText) input.placeholder=placeholderText;
updateSendBtn();
}
function unlockComposerForClarify(){
const input=$('msg');
if(!input) return;
if(_composerLockState){
input.disabled=!!_composerLockState.disabled;
if(typeof _composerLockState.placeholder==='string'){
input.placeholder=_composerLockState.placeholder;
}
_composerLockState=null;
}else{
input.disabled=false;
}
updateSendBtn();
}
function updateSendBtn(){
const btn=$('btnSend');
if(!btn) return;
const msg=$('msg');
const hasContent=msg&&msg.value.trim().length>0||S.pendingFiles.length>0;
const canSend=hasContent&&!S.busy&&!(msg&&msg.disabled);
// Hide while busy (cancel button takes its place); show otherwise
btn.style.display=S.busy?'none':'';
btn.disabled=!canSend;
if(canSend&&!btn.classList.contains('visible')){
btn.classList.remove('visible');
requestAnimationFrame(()=>btn.classList.add('visible'));
}
}
function setBusy(v){
S.busy=v;
updateSendBtn();
if(!v){
setStatus('');
setComposerStatus('');
// Always hide Cancel button when not busy
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
const sid=S.session&&S.session.session_id;
updateQueueBadge(sid);
// Drain one queued message for the currently viewed session after UI settles
const next=sid?shiftQueuedSessionMessage(sid):null;
if(next){
updateQueueBadge(sid);
setTimeout(()=>{
$('msg').value=next.text||'';
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
autoResize();
renderTray();
send();
},120);
}
}
}
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);
}
badge.textContent=count===1?'1 message queued':`${count} messages queued`;
} else if(badge) {
badge.remove();
}
}
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
// ── Shared app dialogs ───────────────────────────────────────────────────────
// showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls
// throughout the UI. Both return Promises and support: title, message, confirmLabel,
// cancelLabel, danger (confirm only), placeholder/value/inputType (prompt only).
const APP_DIALOG={resolve:null,kind:null,lastFocus:null};
let _appDialogBound=false;
function _isAppDialogOpen(){
const overlay=$('appDialogOverlay');
return !!(overlay&&overlay.style.display!=='none');
}
function _getAppDialogFocusable(){
return [$('appDialogInput'), $('appDialogCancel'), $('appDialogConfirm'), $('appDialogClose')]
.filter(el=>el&&el.style.display!=='none'&&!el.disabled);
}
function _finishAppDialog(result, restoreFocus=true){
const overlay=$('appDialogOverlay');
const dialog=$('appDialog');
const input=$('appDialogInput');
const confirmBtn=$('appDialogConfirm');
const resolve=APP_DIALOG.resolve;
const lastFocus=APP_DIALOG.lastFocus;
APP_DIALOG.resolve=null;
APP_DIALOG.kind=null;
APP_DIALOG.lastFocus=null;
if(overlay){overlay.style.display='none';overlay.setAttribute('aria-hidden','true');}
if(dialog) dialog.setAttribute('role','dialog');
if(input){input.value='';input.style.display='none';input.placeholder='';}
if(confirmBtn){confirmBtn.classList.remove('danger');confirmBtn.textContent=t('dialog_confirm_btn');}
if(restoreFocus&&lastFocus&&typeof lastFocus.focus==='function'){setTimeout(()=>lastFocus.focus(),0);}
if(resolve) resolve(result);
}
function _ensureAppDialogBindings(){
if(_appDialogBound) return;
_appDialogBound=true;
const overlay=$('appDialogOverlay');
const cancelBtn=$('appDialogCancel');
const confirmBtn=$('appDialogConfirm');
const closeBtn=$('appDialogClose');
if(overlay){
overlay.addEventListener('click',e=>{
if(e.target===overlay) _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
});
}
if(cancelBtn) cancelBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false));
if(closeBtn) closeBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false));
if(confirmBtn){
confirmBtn.addEventListener('click',()=>{
if(APP_DIALOG.kind==='prompt'){
const input=$('appDialogInput');
_finishAppDialog(input?input.value:null);
}else{
_finishAppDialog(true);
}
});
}
document.addEventListener('keydown',e=>{
if(!_isAppDialogOpen()) return;
if(e.key==='Escape'){
e.preventDefault();
_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
return;
}
if(e.key==='Enter'){
const target=e.target as Element;
const isTextarea=target&&target.tagName==='TEXTAREA';
if(!isTextarea){
e.preventDefault();
const targetEl=e.target as HTMLElement;
if(targetEl===cancelBtn||targetEl===closeBtn){
_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false);
}else if(APP_DIALOG.kind==='prompt'){
const input=$('appDialogInput');
_finishAppDialog(input?input.value:null);
}else{
_finishAppDialog(true);
}
}
return;
}
if(e.key==='Tab'){
const nodes=_getAppDialogFocusable();
if(!nodes.length) return;
const targetEl=document.activeElement as unknown as HTMLElement;
const idx=nodes.indexOf(targetEl);
let nextIdx=idx;
if(e.shiftKey){nextIdx=idx<=0?nodes.length-1:idx-1;}
else{nextIdx=idx===-1||idx===nodes.length-1?0:idx+1;}
e.preventDefault();
nodes[nextIdx].focus();
}
}, true);
}
function showConfirmDialog(opts?: DialogOpts){
_ensureAppDialogBindings();
if(APP_DIALOG.resolve) _finishAppDialog(false,false);
const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'),
desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm');
APP_DIALOG.resolve=null;APP_DIALOG.kind='confirm';APP_DIALOG.lastFocus=document.activeElement;
const o = opts || {};
if(title) title.textContent=o.title||t('dialog_confirm_title');
if(desc) desc.textContent=o.message||'';
if(input){input.style.display='none';input.value='';}
if(cancelBtn) cancelBtn.textContent=o.cancelLabel||t('cancel');
if(confirmBtn){
confirmBtn.textContent=o.confirmLabel||t('dialog_confirm_btn');
confirmBtn.classList.toggle('danger',!!o.danger);
}
if(dialog) dialog.setAttribute('role',o.danger?'alertdialog':'dialog');
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
return new Promise(resolve=>{
APP_DIALOG.resolve=resolve;
setTimeout(()=>((o.focusCancel?cancelBtn:confirmBtn)||confirmBtn||cancelBtn).focus(),0);
});
}
function showPromptDialog(opts?: DialogOpts){
_ensureAppDialogBindings();
if(APP_DIALOG.resolve) _finishAppDialog(null,false);
const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'),
desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm');
APP_DIALOG.resolve=null;APP_DIALOG.kind='prompt';APP_DIALOG.lastFocus=document.activeElement;
const o = opts || {};
if(title) title.textContent=o.title||t('dialog_prompt_title');
if(desc) desc.textContent=o.message||'';
if(input){
(input as HTMLInputElement).type=o.inputType||'text';input.style.display='';
input.value=o.value||'';input.placeholder=o.placeholder||'';
(input as HTMLInputElement).autocomplete='off';(input as HTMLInputElement).spellcheck=false;
}
if(cancelBtn) cancelBtn.textContent=o.cancelLabel||t('cancel');
if(confirmBtn){confirmBtn.textContent=o.confirmLabel||t('create');confirmBtn.classList.remove('danger');}
if(dialog) dialog.setAttribute('role','dialog');
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
return new Promise(resolve=>{
APP_DIALOG.resolve=resolve;
setTimeout(()=>{if(input&&input.style.display!=='none')input.focus();else if(confirmBtn)confirmBtn.focus();},0);
});
}
function copyMsg(btn){
const row=btn.closest('.msg-row');
const text=row?row.dataset.rawText:'';
if(!text)return;
navigator.clipboard.writeText(text).then(()=>{
const orig=btn.innerHTML;btn.innerHTML=li('check',13);btn.style.color='var(--blue)';
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
}).catch(()=>showToast('Copy failed'));
}
// ── Reconnect banner (B4/B5: reload resilience) ──
// Per-tab ID prevents multi-tab localStorage conflicts — each browser tab gets its own suffix
const _TAB_ID = (() => {
try {
const existing = sessionStorage.getItem('hermes-tab-id');
if (existing) return existing;
const id = Math.random().toString(36).slice(2, 9);
sessionStorage.setItem('hermes-tab-id', id);
return id;
} catch (_) { return 'default'; }
})();
const INFLIGHT_KEY = `hermes-webui-inflight:${_TAB_ID}`;
const INFLIGHT_STATE_KEY = `hermes-webui-inflight-state:${_TAB_ID}`;
// Cleanup when this tab closes
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
try {
localStorage.removeItem(INFLIGHT_KEY);
localStorage.removeItem(INFLIGHT_STATE_KEY);
} catch (_) {}
});
}
function _readInflightStateMap(){
try{
const raw=localStorage.getItem(INFLIGHT_STATE_KEY);
const parsed=raw?JSON.parse(raw):{};
return parsed&&typeof parsed==='object'?parsed:{};
}catch(_){
return {};
}
}
function saveInflightState(sid, state){
if(!sid||!state) return;
try{
const all=_readInflightStateMap();
all[sid]={...state,updated_at:Date.now(),tabId:_TAB_ID};
localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
}catch(_){ }
}
function loadInflightState(sid, streamId){
if(!sid) return null;
const all=_readInflightStateMap();
const entry=all[sid];
if(!entry) return null;
if(streamId&&entry.streamId&&entry.streamId!==streamId) return null;
if(entry.updated_at&&Date.now()-entry.updated_at>10*60*1000){
clearInflightState(sid);
return null;
}
return entry;
}
function clearInflightState(sid){
if(!sid) return;
try{
const all=_readInflightStateMap();
if(!(sid in all)) return;
delete all[sid];
if(Object.keys(all).length) localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
else localStorage.removeItem(INFLIGHT_STATE_KEY);
}catch(_){ }
}
function markInflight(sid, streamId) {
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now(), tabId:_TAB_ID}));
}
function clearInflight() {
localStorage.removeItem(INFLIGHT_KEY);
}
function showReconnectBanner(msg) {
$('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.';
$('reconnectBanner').classList.add('visible');
}
function dismissReconnect() {
$('reconnectBanner').classList.remove('visible');
clearInflight();
}
async function refreshSession() {
dismissReconnect();
if (!S.session) return;
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session;
S.messages = data.session.messages || [];
const pendingMsg=getPendingSessionMessage(data.session);
if(pendingMsg) S.messages.push(pendingMsg);
S.activeStreamId=data.session.active_stream_id||null;
syncTopbar(); renderMessages();
showToast('Conversation refreshed');
} catch(e) { setStatus('Refresh failed: ' + e.message); }
}
// ── Update banner ──
function _showUpdateBanner(data){
const parts=[];
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
if(!parts.length)return;
const msg=$('updateMsg');
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
const banner=$('updateBanner');
// Respect dismissal for 24 hours
const dismissed=localStorage.getItem('hermes-update-dismissed');
const show=!dismissed||(Date.now()-parseInt(dismissed))>604800000;
if(banner && show) banner.classList.add('visible');
window._updateData=data;
}
function dismissUpdate(){
const b=$('updateBanner');if(b)b.classList.remove('visible');
localStorage.setItem('hermes-update-dismissed',Date.now().toString());
}
async function applyUpdates(){
const btn=$('btnApplyUpdate');
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
const targets=[];
if(window._updateData?.webui?.behind>0) targets.push('webui');
if(window._updateData?.agent?.behind>0) targets.push('agent');
try{
for(const target of targets){
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
if(!res.ok){
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
if(btn){btn.disabled=false;btn.textContent='Update Now';}
return;
}
}
showToast('Updated! Reloading\u2026');
sessionStorage.removeItem('hermes-update-checked');
sessionStorage.removeItem('hermes-update-dismissed');
setTimeout(()=>location.reload(),1500);
}catch(e){
showToast('Update failed: '+e.message);
if(btn){btn.disabled=false;btn.textContent='Update Now';}
}
}
function getPendingSessionMessage(session){
const text=String(session?.pending_user_message||'').trim();
if(!text) return null;
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
const messages=Array.isArray(session?.messages)?session.messages:[];
const lastUser=[...messages].reverse().find(m=>m&&m.role==='user');
if(lastUser){
const lastText=String(msgContent(lastUser)||'').trim();
if(lastText===text){
if(attachments.length&&!lastUser.attachments?.length) lastUser.attachments=attachments;
return null;
}
}
return {
role:'user',
content:text,
attachments:attachments.length?attachments:undefined,
_ts:session?.pending_started_at||Date.now()/1000,
_pending:true,
};
}
async function checkInflightOnBoot(sid) {
const raw = localStorage.getItem(INFLIGHT_KEY);
if (!raw) return;
try {
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
if (inflightSid !== sid) { clearInflight(); return; }
if (S.activeStreamId && S.activeStreamId === streamId) return;
// Only show banner if the in-flight entry is less than 10 minutes old
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
// Check if stream is still active
const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`);
if (status.active) {
// Stream is genuinely still running -- show the banner
showReconnectBanner(t('reconnect_active'));
} else {
// Stream finished. Only show banner if reload happened within 90 seconds
// (longer gap = normal completed session, not a mid-stream reload)
if (Date.now() - ts < 90 * 1000) {
showReconnectBanner(t('reconnect_finished'));
} else {
clearInflight(); // completed normally, no banner needed
}
}
} catch(e) { clearInflight(); }
}
function syncTopbar(){
if(!S.session){
document.title=window._botName||'Hermes';
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
if(typeof syncModelChip==='function') syncModelChip();
if(typeof syncAgentChip==='function') syncAgentChip();
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
else {
const sidebarName=$('sidebarWsName');
if(sidebarName && sidebarName.textContent==='Workspace'){
sidebarName.textContent=t('no_workspace');
}
}
return;
}
const sessionTitle=S.session.title||t('untitled');
const agentMeta=S.session.agent?(AGENT_META[S.session.agent]||null):null;
$('topbarTitle').textContent=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle;
document.title=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle+' \u2014 '+(window._botName||'Hermes');
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=t('n_messages',vis.length);
// 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;
let currentModel=S.session.model||'';
if(modelOverride){
S._pendingProfileModel=null;
_applyModelToDropdown(modelOverride,$('modelSelect'));
currentModel=modelOverride;
} else {
const applied=_applyModelToDropdown(currentModel,$('modelSelect'));
// If the model isn't in the current provider list, add it as a visually marked
// "(unavailable)" entry so the session value is preserved without misleading the user.
// Selecting it will still attempt to send (same as before), but the label makes
// clear it's a stale model from a previous session.
if(!applied && currentModel){
const opt=document.createElement('option');
opt.value=currentModel;
opt.textContent=getModelLabel(currentModel)+t('model_unavailable');
opt.style.color='var(--muted, #888)';
opt.title=t('model_unavailable_title');
$('modelSelect').appendChild(opt);
$('modelSelect').value=currentModel;
}
}
if(typeof syncModelChip==='function') syncModelChip();
if(typeof syncAgentChip==='function') syncAgentChip();
// Show Clear button only when session has messages
const clearBtn=$('btnClearConv');
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
// modelSelect already set above
// Update profile chip label
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
}
function msgContent(m){
// Extract plain text content from a message for filtering
let c=m.content||'';
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim();
return String(c).trim();
}
// ── Message Renderer Helpers ────────────────────────────────────────────────
// Build visible items list from S.messages (shared by renderMessages)
function _buildVisibleItems(): {msg: any; rawIdx: number}[] {
const result: {msg: any; rawIdx: number}[] = [];
let rawIdx = 0;
for (const m of S.messages) {
if (!m || !m.role || m.role === 'tool') { rawIdx++; continue; }
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some((p: any) => p && p.type === 'tool_use');
if (msgContent(m) || m.attachments?.length || (m.role === 'assistant' && (hasTc || hasTu))) {
result.push({ msg: m, rawIdx });
}
rawIdx++;
}
return result;
}
// Simple DOM-based message renderer — no virtual scroller.
// Appends new messages to existing DOM, removes deleted ones.
function renderMessages(){
const inner = $('msgInner');
if (!inner) return;
const vis = _buildVisibleItems();
$('emptyState').style.display = vis.length ? 'none' : '';
// Build a map of the new rendered rows keyed by rawIdx
const newRows = new Map
();
for (let vi = 0; vi < vis.length; vi++) {
const vitem = vis[vi];
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position:relative;width:100%;';
renderRow(vi, vitem, wrapper);
newRows.set(vitem.rawIdx, wrapper as HTMLElement);
}
// Diff against existing DOM rows to preserve live assistant rows
const existing = inner.querySelectorAll('.msg-row[data-msg-idx]');
const existingMap = new Map();
existing.forEach(el => {
const idx = parseInt((el as HTMLElement).dataset.msgIdx || '-1');
if (idx >= 0) existingMap.set(idx, el as HTMLElement);
});
// Remove rows that are no longer in the message list (but preserve live assistant rows)
existing.forEach(el => {
const idx = parseInt((el as HTMLElement).dataset.msgIdx || '-1');
const isLive = (el as HTMLElement).dataset.liveAssistant === '1';
if (!newRows.has(idx) && !isLive) el.remove();
});
// Append new rows after the last matching existing row
let lastAppended: number | null = null;
for (let vi = 0; vi < vis.length; vi++) {
const { rawIdx } = vis[vi];
if (existingMap.has(rawIdx)) {
lastAppended = rawIdx;
continue;
}
const newEl = newRows.get(rawIdx)!;
if (lastAppended === null) {
inner.prepend(newEl);
} else {
const anchor = existingMap.get(lastAppended);
if (anchor && anchor.nextElementSibling) {
anchor.parentElement!.insertBefore(newEl, anchor.nextElementSibling);
} else {
inner.appendChild(newEl);
}
}
lastAppended = rawIdx;
}
// Preserve live assistant rows
const liveRows = inner.querySelectorAll('[data-live-assistant="1"]');
liveRows.forEach(lr => {
if (!inner.contains(lr as Node)) inner.appendChild(lr as Node);
});
// Render usage badge on last assistant message
if(window._showTokenUsage && S.session && (S.session.input_tokens || S.session.output_tokens)){
const rows = inner.querySelectorAll('.msg-row');
let lastAssist = null;
for(let i = rows.length - 1; i >= 0; i--){ if(rows[i].dataset.role === 'assistant'){ lastAssist = rows[i]; break; } }
if(lastAssist && !lastAssist.querySelector('.msg-usage')){
const msg = S.messages && S.messages.length ? [...S.messages].reverse().find(m => m.role === 'assistant') : null;
const usage = document.createElement('div');
usage.className = 'msg-usage';
const u = (msg && msg._usage) || {};
const inTok = u.in || 0;
const outTok = u.out || 0;
const cost = S.session.estimated_cost;
const inSpan = Object.assign(document.createElement('span'), { className: 'usage-in', textContent: _fmtTokens(inTok) });
const sep1 = Object.assign(document.createElement('span'), { className: 'usage-sep', textContent: ' in · ' });
const outSpan = Object.assign(document.createElement('span'), { className: 'usage-out', textContent: _fmtTokens(outTok) });
usage.appendChild(inSpan);
usage.appendChild(sep1);
usage.appendChild(outSpan);
if(cost !== undefined && cost !== null){
const sep2 = Object.assign(document.createElement('span'), { className: 'usage-sep', textContent: ' · ' });
const costSpan = Object.assign(document.createElement('span'), { className: 'usage-cost', textContent: `~$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` });
usage.appendChild(sep2);
usage.appendChild(costSpan);
}
lastAssist.appendChild(usage);
}
}
_syncSessionTotalInFooter(S.session);
scrollToBottom();
requestAnimationFrame(() => { highlightCode(); addCopyButtons(); renderMermaidBlocks(); renderKatexBlocks(); });
}
// Render a single message row into `inner` (used by renderMessages)
// vi = visible index, vitem = {msg, rawIdx}
function renderRow(vi: number, vitem: {msg: any; rawIdx: number}, inner: HTMLElement) {
const { msg: m, rawIdx } = vitem;
let content = m.content||'';
let thinkingText = '';
if (Array.isArray(content)) {
thinkingText = content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content = content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
if (!thinkingText && m.reasoning) thinkingText = m.reasoning;
if (!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/([\s\S]*?)<\/think>/);
if(thinkMatch){
thinkingText=thinkMatch[1].trim();
content=content.replace(/[\s\S]*?<\/think>\s*/,'').trimStart();
}
if(!thinkingText){
const gemmaMatch=content.match(/<\|channel>thought\n([\s\S]*?)/);
if(gemmaMatch){
thinkingText=gemmaMatch[1].trim();
content=content.replace(/<\|channel>thought\n[\s\S]*?\s*/,'').trimStart();
}
}
}
const isUser=m.role==='user';
const isSystem=m.role==='system';
const vis = _buildVisibleItems();
const isLastAssistant=!isUser&&vi===vis.length-1;
if(thinkingText&&!isUser&&!isSystem){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=``;
inner.appendChild(thinkRow);
}
const prevRole = vi > 0 ? vis[vi - 1].msg.role : null;
const isGrouped = prevRole === m.role;
const row=document.createElement('div');row.className='msg-row' + (isGrouped ? ' msg-grouped' : '');
row.dataset.msgIdx=String(rawIdx);row.dataset.role=String(m.role||'assistant');
if(m._live) row.setAttribute('data-live-assistant','1');
if(isSystem){
const sysHtml=`⚙${renderMd(String(content||''))}
`;
row.innerHTML=sysHtml;
row.dataset.rawText=String(content||'').trim();
inner.appendChild(row);
return;
}
let filesHtml='';
if(m.attachments&&m.attachments.length)
filesHtml=`${m.attachments.map(f=>`
${li('paperclip',12)} ${esc(f)}
`).join('')}
`;
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(String(content));
const editBtn = isUser ? `` : '';
const retryBtn = isLastAssistant ? `` : '';
const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
const _bn=window._botName||'Hermes';
const _userEmoji=window._userEmoji||'🙂';
const _userName=window._userName||'You';
const sessionAgent = !isUser && typeof S !== 'undefined' && S.session && S.session.agent;
const effectiveAgent = !isSystem && !isUser ? (m.agent || sessionAgent || null) : null;
const agentMeta = effectiveAgent ? (AGENT_META[effectiveAgent] || null) : null;
const showIcon = isUser ? _userEmoji : (agentMeta ? agentMeta.emoji : esc(_bn.charAt(0).toUpperCase()));
const showName = isUser ? esc(_userName) : (agentMeta ? agentMeta.name : esc(_bn));
const agentColor = !isUser && agentMeta ? (effectiveAgent === 'rose' ? '#f44336' : effectiveAgent === 'lotus' ? '#e91e63' : effectiveAgent === 'forget-me-not' ? '#ff9800' : '#888') : null;
const sessionModel = (typeof S !== 'undefined' && S.session && S.session.model) ? S.session.model : ($('modelSelect')?.value || '');
const modelBadge = !isUser && (m.model || sessionModel) ? `${esc(m.model || sessionModel)}` : '';
row.innerHTML=`${agentMeta&&!isUser?agentMeta.emoji:showIcon}
${showName}${agentMeta&&!isUser?`
via ${agentMeta.name}`:''}${modelBadge}${tsTitle?`
${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}
${editBtn}${retryBtn} ${filesHtml}${bodyHtml}
`;
row.dataset.rawText = String(content).trim();
inner.appendChild(row);
}
// ── Tool Card Sync ───────────────────────────────────────────────────────────
function _syncToolCards() {
const inner = $('msgInner');
if (!inner || S.busy || !S.toolCalls?.length) return;
// Remove existing tool card rows
inner.querySelectorAll('.tool-card-row, .tool-cards-toggle').forEach(el => el.remove());
// Group tool calls by their assistant message index
const byAssistant: Record = {};
for (const tc of S.toolCalls) {
const key = tc.assistant_msg_idx ?? -1;
(byAssistant[key] ??= []).push(tc);
}
const allRows: HTMLElement[] = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]')) as unknown as HTMLElement[];
const anchorNext = new Map();
for (const [key, cards] of Object.entries(byAssistant)) {
const aIdx = parseInt(key);
let anchorRow: HTMLElement | null = null;
// Find anchor row by exact msgIdx match
if (aIdx >= 0) {
anchorRow = allRows.find(r => parseInt(r.dataset.msgIdx || '-1') === aIdx) ?? null;
}
// Fallback: last assistant row at or before aIdx
if (!anchorRow) {
for (let i = allRows.length - 1; i >= 0; i--) {
const ri = parseInt(allRows[i].dataset.msgIdx || '-1');
if (ri >= 0 && ri <= aIdx) {
anchorRow = allRows[i] as HTMLElement;
break;
}
}
}
if (!anchorRow) continue;
// Build tool cards fragment
const frag = document.createDocumentFragment();
for (const tc of cards) frag.appendChild(buildToolCard(tc) as unknown as Node);
// Add expand/collapse for 2+ cards
if (cards.length >= 2) {
const toggle = document.createElement('div');
toggle.className = 'tool-cards-toggle';
const cardEls = Array.from(frag.querySelectorAll('.tool-card'));
const exp = document.createElement('button');
exp.textContent = t('expand_all');
exp.onclick = () => cardEls.forEach(c => c.classList.add('open'));
const col = document.createElement('button');
col.textContent = t('collapse_all');
col.onclick = () => cardEls.forEach(c => c.classList.remove('open'));
toggle.appendChild(exp); toggle.appendChild(col);
frag.insertBefore(toggle as unknown as Node, frag.firstChild);
}
// Insert after anchor row
const insertAfter = anchorNext.get(anchorRow) ?? anchorRow;
if (insertAfter.nextSibling) {
insertAfter.parentNode!.insertBefore(frag, insertAfter.nextSibling);
} else {
insertAfter.parentNode!.appendChild(frag);
}
anchorNext.set(anchorRow, frag.lastChild as HTMLElement);
}
}
function toolIcon(name){
const icons={
terminal: li('terminal'),
read_file: li('file-text'),
write_file: li('file-pen'),
search_files: li('search'),
web_search: li('globe'),
web_extract: li('globe'),
execute_code: li('play'),
patch: li('wrench'),
memory: li('brain'),
skill_manage: li('book-open'),
todo: li('list-todo'),
cronjob: li('clock'),
delegate_task: li('bot'),
send_message: li('message-square'),
browser_navigate:li('globe'),
vision_analyze: li('eye'),
subagent_progress:li('shuffle'),
};
return icons[name]||li('wrench');
}
function buildToolCard(tc){
const row=document.createElement('div');
row.className='msg-row tool-card-row';
const icon=toolIcon(tc.name);
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
let displaySnippet='';
if(tc.snippet){
const s=tc.snippet;
if(s.length<=220){displaySnippet=s;}
else{
const cutoff=s.slice(0,220);
const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; '));
displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff;
}
}
const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length;
const runIndicator=tc.done===false?'':'';
const durationBadge=tc.duration!=null?`${tc.duration.toFixed(1)}s`:'';
const isSubagent=tc.name==='subagent_progress';
const isDelegation=tc.name==='delegate_task';
// Show only active (running) tools when there are running tools
const hasRunningTools = S.toolCalls.some(t=>!t.done);
const isHidden = tc.done===true && hasRunningTools && !isSubagent;
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'')+(isHidden?' hidden':'');
// Clean up legacy subagent prefixes since the Lucide icon already shows it
let displayName=tc.name;
if(isSubagent) displayName='Subagent';
if(isDelegation) displayName='Delegate task';
let previewText=tc.preview||displaySnippet||'';
if(isSubagent) previewText=previewText.replace(/^(?:\u{1F500}|↳)\s*/u,'');
row.innerHTML=`
`;
return row;
}
// ── Live tool card helpers (called during SSE streaming) ──
function appendLiveToolCard(tc){
const container=$('liveToolCards');
if(!container)return;
container.style.display='';
// Update existing card if same tool call id (e.g. snippet arrives after done)
const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`);
if(existing){existing.replaceWith(buildToolCard(tc));return;}
const card=buildToolCard(tc);
if(tc.tid)card.dataset.tid=tc.tid;
container.appendChild(card);
}
function clearLiveToolCards(){
const container=$('liveToolCards');
if(!container)return;
container.innerHTML='';
container.style.display='none';
}
// ── Edit + Regenerate ──
function editMessage(btn) {
if(S.busy) return;
const row = btn.closest('.msg-row');
if(!row) return;
const msgIdx = parseInt(row.dataset.msgIdx, 10);
const originalText = row.dataset.rawText || '';
const body = row.querySelector('.msg-body');
if(!body || row.dataset.editing) return;
row.dataset.editing = '1';
// Replace msg-body with an editable textarea
const ta = document.createElement('textarea');
ta.className = 'msg-edit-area';
ta.value = originalText;
body.replaceWith(ta);
// Resize after DOM insertion so scrollHeight is correct
requestAnimationFrame(() => { autoResizeTextarea(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); });
ta.addEventListener('input', () => autoResizeTextarea(ta));
// Action bar below the textarea
const bar = document.createElement('div');
bar.className = 'msg-edit-bar';
bar.innerHTML = '';
ta.parentElement!.insertBefore(bar as unknown as Node, ta.nextSibling as Node | null);
bar.querySelector('.msg-edit-send').onclick = async () => {
const newText = ta.value.trim();
if(!newText) return;
await submitEdit(msgIdx, newText);
};
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
ta.addEventListener('keydown', e => {
if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); (bar.querySelector('.msg-edit-send') as unknown as HTMLElement).click(); }
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
});
}
function cancelEdit(row, originalText, originalBody) {
delete row.dataset.editing;
const ta = row.querySelector('.msg-edit-area');
const bar = row.querySelector('.msg-edit-bar');
if(ta) ta.replaceWith(originalBody);
if(bar) bar.remove();
}
function autoResizeTextarea(ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 300) + 'px';
}
async function submitEdit(msgIdx, newText) {
if(!S.session || S.busy) return;
// Truncate session at msgIdx (keep messages before the edited one)
// then re-send the edited text
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: S.session.session_id,
keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward
})});
S.messages = S.messages.slice(0, msgIdx);
renderMessages();
// Now send the edited message as a new chat
$('msg').value = newText;
await send();
} catch(e) { setStatus(t('edit_failed') + e.message); }
}
async function regenerateResponse(btn) {
if(!S.session || S.busy) return;
// Find the last user message and re-run it
// Remove the last assistant message first (truncate to before it)
const row = btn.closest('.msg-row');
if(!row) return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
// Find the last user message text (one before this assistant message)
let lastUserText = '';
for(let i = assistantIdx - 1; i >= 0; i--) {
const m = S.messages[i];
if(m && m.role === 'user') { lastUserText = msgContent(m); break; }
}
if(!lastUserText) return;
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: S.session.session_id,
keep_count: assistantIdx // remove the assistant message
})});
S.messages = S.messages.slice(0, assistantIdx);
renderMessages();
$('msg').value = lastUserText;
await send();
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
function highlightCode(container) {
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
const el = container || $('msgInner');
if(!el) return;
Prism.highlightAllUnder(el);
}
function addCopyButtons(container){
const el=container||$('msgInner');
if(!el) return;
// Handle pre > code (normal Prism blocks)
el.querySelectorAll('pre > code').forEach(codeEl=>{
const pre=codeEl.parentElement;
if(pre.querySelector('.code-copy-btn')) return;
const btn=makeCopyBtn(codeEl.textContent||'');
const header=pre.previousElementSibling;
if(header&&header.classList.contains('pre-header')){
header.style.display='flex';
header.style.justifyContent='space-between';
header.style.alignItems='center';
header.appendChild(btn);
}else{
pre.style.position='relative';
btn.style.cssText='position:absolute;top:6px;right:6px;';
pre.appendChild(btn);
}
});
// Handle bare pre without code child (e.g. thinking-card content, plain text)
el.querySelectorAll('pre:not(:has(.code-copy-btn))').forEach(pre=>{
if(pre.querySelector('.code-copy-btn')) return;
const btn=makeCopyBtn(pre.textContent||'');
pre.style.position='relative';
btn.style.cssText='position:absolute;top:6px;right:6px;';
pre.appendChild(btn);
});
}
function makeCopyBtn(text){
const btn=document.createElement('button');
btn.className='code-copy-btn';
btn.textContent=t('copy');
btn.onclick=(e)=>{
e.stopPropagation();
navigator.clipboard.writeText(text).then(()=>{
btn.textContent=t('copied');
setTimeout(()=>{btn.textContent=t('copy');},1500);
});
};
return btn;
}
let _mermaidLoading=false;
let _mermaidReady=false;
function renderMermaidBlocks(){
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
if(!blocks.length) return;
if(!_mermaidReady){
if(!_mermaidLoading){
_mermaidLoading=true;
const script=document.createElement('script');
script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js';
script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT';
script.crossOrigin='anonymous';
script.onload=()=>{
if(typeof mermaid!=='undefined'){
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096',
secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568',
}});
_mermaidReady=true;
renderMermaidBlocks();
}
};
document.head.appendChild(script);
}
return;
}
blocks.forEach(async(block)=>{
block.dataset.rendered='true';
const code=block.textContent;
const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2));
try{
const {svg}=await mermaid.render(id,code);
block.innerHTML=svg;
block.classList.add('mermaid-rendered');
}catch(e){
// Fall back to showing as a code block
block.innerHTML=`${esc(code)}
`;
}
});
}
let _katexLoading=false;
let _katexReady=false;
function renderKatexBlocks(){
const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
if(!blocks.length) return;
if(!_katexReady){
if(!_katexLoading){
_katexLoading=true;
const script=document.createElement('script');
script.src='https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js';
script.integrity='sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6';
script.crossOrigin='anonymous';
script.onload=()=>{
if(typeof katex!=='undefined'){
_katexReady=true;
renderKatexBlocks();
}
};
document.head.appendChild(script);
}
return;
}
blocks.forEach(el=>{
el.dataset.rendered='true';
const src=el.textContent||'';
const displayMode=el.dataset.katex==='display';
try{
katex.render(src,el,{
displayMode,
throwOnError:false,
trust:false,
strict:'ignore',
});
}catch(e){
// Leave as raw text in a code span on failure
el.outerHTML=`${esc(src)}`;
}
});
}
function _thinkingMarkup(text='') {
// Use session agent info if available (mirrors renderOneMsg logic)
const sessionAgent = typeof S !== 'undefined' && S.session && S.session.agent;
const agentMeta = sessionAgent ? (AGENT_META[sessionAgent] || null) : null;
const icon = agentMeta ? agentMeta.emoji : esc((window._botName || 'Hermes').charAt(0).toUpperCase());
const label = agentMeta ? agentMeta.name : esc(window._botName || 'Hermes');
const agentColor = agentMeta ? (sessionAgent === 'rose' ? '#f44336' : sessionAgent === 'lotus' ? '#e91e63' : sessionAgent === 'forget-me-not' ? '#ff9800' : '#888') : null;
const hasText = text && String(text).trim();
const body = hasText
? `${esc(String(text).trim())} `
: ``;
return `${body}`;
}
function appendThinking(text=''){
$('emptyState').style.display='none';
let row=$('thinkingRow');
if(!row){
row=document.createElement('div');
row.className='msg-row';
row.id='thinkingRow';
$('msgInner').appendChild(row);
}
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
row.innerHTML=_thinkingMarkup(text);
scrollToBottom();
}
function updateThinking(text=''){appendThinking(text);}
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function fileIcon(name, type){
if(type==='dir') return li('folder',14);
const e=fileExt(name);
if(IMAGE_EXTS.has(e)) return li('image',14);
if(MD_EXTS.has(e)) return li('file-text',14);
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return li('download',14);
if(e==='.py') return li('file-code',14);
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return li('zap',14);
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return li('settings',14);
if(e==='.sh'||e==='.bash') return li('terminal',14);
if(e==='.pdf') return li('download',14);
return li('file-text',14);
}
function renderBreadcrumb(){
const bar=$('breadcrumbBar');
const upBtn=$('btnUpDir');
if(!bar)return;
if(S.currentDir==='.'){
bar.style.display='none';
if(upBtn)upBtn.style.display='none';
return;
}
bar.style.display='flex';
if(upBtn)upBtn.style.display='';
bar.innerHTML='';
// Root segment
const root=document.createElement('span');
root.className='breadcrumb-seg breadcrumb-link';
root.textContent='~';
root.onclick=()=>loadDir('.');
bar.appendChild(root);
// Path segments
const parts=S.currentDir.split('/');
let accumulated='';
for(let i=0;iloadDir(target);
} else {
seg.className='breadcrumb-seg breadcrumb-current';
}
bar.appendChild(seg);
}
}
// Track expanded directories for tree view
if(!S._expandedDirs) S._expandedDirs=new Set();
// Cache of fetched directory contents: path -> entries[]
if(!S._dirCache) S._dirCache={};
function renderFileTree(){
const box=$('fileTree');box.innerHTML='';
// Cache current dir entries
S._dirCache[S.currentDir||'.']=S.entries;
_renderTreeItems(box, S.entries, 0);
}
function _renderTreeItems(container, entries, depth){
for(const item of entries){
const el=document.createElement('div');el.className='file-item';
el.style.paddingLeft=(8+depth*16)+'px';
if(item.type==='dir'){
// Toggle arrow for directories
const arrow=document.createElement('span');
arrow.className='file-tree-toggle';
const isExpanded=S._expandedDirs.has(item.path);
arrow.textContent=isExpanded?'\u25BE':'\u25B8';
el.appendChild(arrow);
}
// Icon
const iconEl=document.createElement('span');
iconEl.className='file-icon';iconEl.innerHTML=fileIcon(item.name,item.type);
el.appendChild(iconEl);
// Name
const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');
nameEl.ondblclick=(e)=>{
e.stopPropagation();
// For directories, double-click navigates (breadcrumb view)
if(item.type==='dir'){loadDir(item.path);return;}
const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation();
const finish=async(save)=>{
inp.onblur=null;
if(save){
const newName=inp.value.trim();
if(newName&&newName!==item.name){
try{
await api('/api/file/rename',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,path:item.path,new_name:newName
})});
showToast(t('renamed_to')+newName);
// Invalidate cache and re-render
delete S._dirCache[S.currentDir];
await loadDir(S.currentDir);
}catch(err){showToast(t('rename_failed')+err.message);}
}
}
inp.replaceWith(nameEl);
};
inp.onkeydown=(e2)=>{
if(e2.key==='Enter'){e2.preventDefault();finish(true);}
if(e2.key==='Escape'){e2.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
nameEl.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
el.appendChild(nameEl);
// Size -- only for files
if(item.type==='file'&&item.size){
const sizeEl=document.createElement('span');
sizeEl.className='file-size';
sizeEl.textContent=`${(item.size/1024).toFixed(1)}k`;
el.appendChild(sizeEl);
}
// Delete button -- for files
if(item.type==='file'){
const del=document.createElement('button');
del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
el.appendChild(del);
}
if(item.type==='dir'){
// Single-click toggles expand/collapse
el.onclick=async(e)=>{
e.stopPropagation();
if(S._expandedDirs.has(item.path)){
S._expandedDirs.delete(item.path);
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
renderFileTree();
}else{
S._expandedDirs.add(item.path);
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
// Fetch children if not cached
if(!S._dirCache[item.path]){
try{
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`);
S._dirCache[item.path]=data.entries||[];
}catch(e2){S._dirCache[item.path]=[];}
}
renderFileTree();
}
};
}else{
el.onclick=async()=>openFile(item.path);
}
container.appendChild(el);
// Render children if directory is expanded
if(item.type==='dir'&&S._expandedDirs.has(item.path)){
const children=S._dirCache[item.path]||[];
if(children.length){
_renderTreeItems(container, children, depth+1);
}else{
const empty=document.createElement('div');
empty.className='file-item file-empty';
empty.style.paddingLeft=(8+(depth+1)*16)+'px';
empty.textContent=t('empty_dir');
container.appendChild(empty);
}
}
}
}
async function deleteWorkspaceFile(relPath, name){
if(!S.session)return;
const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});
if(!_delFile) return;
try{
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(t('deleted')+name);
// Close preview if we just deleted the viewed file
if($('previewPathText').textContent===relPath)($('btnClearPreview') as unknown as {onclick:()=>void}).onclick();
await loadDir(S.currentDir);
}catch(e){setStatus(t('delete_failed')+e.message);}
}
async function promptNewFile(){
if(!S.session)return;
const _name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
const name=_name as string | null;
if(!name||!name.trim())return;
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
try{
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})});
showToast(t('created')+name.trim());
await loadDir(S.currentDir);
openFile(relPath);
}catch(e){setStatus(t('create_failed')+e.message);}
}
async function promptNewFolder(){
if(!S.session)return;
const _name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
const name=_name as string | null;
if(!name||!name.trim())return;
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
try{
await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(t('folder_created')+name.trim());
await loadDir(S.currentDir);
}catch(e){setStatus(t('folder_create_failed')+e.message);}
}
function renderTray(){
const tray=$('attachTray');tray.innerHTML='';
if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;}
tray.classList.add('has-files');
updateSendBtn();
S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} `;
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
tray.appendChild(chip);
});
}
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
async function uploadPendingFiles(){
if(!S.pendingFiles.length||!S.session)return[];
const names=[];let failures=0;
const bar=$('uploadBar');const barWrap=$('uploadBarWrap');
barWrap.classList.add('active');bar.style.width='0%';
const total=S.pendingFiles.length;
for(let i=0;i0)throw new Error(t('all_uploads_failed',total));
return names;
}
// Update user avatar preview in Settings
function updateUserAvatarPreview(emoji){
const preview=$('userAvatarPreview');
if(preview) preview.textContent=emoji||'🙂';
}