2026-05-31 23:58:26 +09:00
// static/js/chatRenderer.js
// Extracted from chat.js — message rendering, sources, images, metrics
import uiModule from './ui.js' ;
import markdownModule from './markdown.js' ;
import { addAITTSButton } from './tts-ai.js' ;
import { providerLogo } from './providers.js' ;
import settingsModule from './settings.js' ;
import spinnerModule from './spinner.js' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
import { bindMenuDismiss } from './escMenuStack.js' ;
2026-05-31 23:58:26 +09:00
const SEARCH _ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>' ;
const REPORT _ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>' ;
const CHAT _ABOUT _ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' ;
const COPY _ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' ;
const CHECK _ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' ;
/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
function _safeHref ( url ) {
if ( ! url ) return '#' ;
try {
var parsed = new URL ( url , window . location . origin ) ;
if ( parsed . protocol === 'http:' || parsed . protocol === 'https:' ) return uiModule . esc ( url ) ;
} catch ( e ) { /* invalid URL */ }
return '#' ;
}
function _makeActionBtn ( className , title , text , handler ) {
const btn = document . createElement ( 'button' ) ;
btn . className = className ;
btn . type = 'button' ;
btn . title = title ;
btn . textContent = text ;
btn . addEventListener ( 'click' , handler ) ;
return btn ;
}
// Attachment card helpers
function _attachIcon ( mimeOrName ) {
const s = ( mimeOrName || '' ) . toLowerCase ( ) ;
if ( s . startsWith ( 'image/' ) || /\.(png|jpg|jpeg|gif|webp|svg)$/i . test ( s ) )
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>' ;
if ( s . startsWith ( 'audio/' ) || /\.(mp3|wav|ogg|m4a|webm)$/i . test ( s ) )
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' ;
if ( s === 'application/pdf' || /\.pdf$/i . test ( s ) )
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>' ;
// Default: generic document
return '<svg class="attach-card-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' ;
}
function _formatSize ( bytes ) {
if ( bytes < 1024 ) return bytes + ' B' ;
if ( bytes < 1048576 ) return ( bytes / 1024 ) . toFixed ( 1 ) + ' KB' ;
return ( bytes / 1048576 ) . toFixed ( 1 ) + ' MB' ;
}
// Build the `.attach-cards` element for a message's attachment list. Shared by
// addMessage and updateMessageAttachments so a live (optimistic) user bubble
// can be re-rendered with real upload ids once the upload resolves.
function buildAttachCards ( attachments ) {
const attachWrap = document . createElement ( 'div' ) ;
attachWrap . className = 'attach-cards' ;
for ( const att of attachments ) {
const isImage = ( att . mime || '' ) . startsWith ( 'image/' ) || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i . test ( att . name || '' ) ;
if ( isImage ) {
// Image preview. Shown for both uploaded (att.id present) and still-
// uploading attachments. A shimmering skeleton + whirlpool fills the
// space until either the upload resolves (no id yet) or the thumbnail
// image finishes loading, so the photo doesn't pop in abruptly.
const imgWrap = document . createElement ( 'div' ) ;
imgWrap . className = 'attach-image-preview' ;
imgWrap . style . cursor = att . id ? 'zoom-in' : 'default' ;
if ( att . id ) imgWrap . dataset . fileId = att . id ;
if ( att . id ) {
imgWrap . addEventListener ( 'click' , ( e ) => {
// Tapping the corner OCR button shouldn't also open the lightbox.
if ( e . target . closest ( '.attach-ocr-btn' ) ) return ;
_openImageLightbox ( att ) ;
} ) ;
}
let skel = null ;
let sp = null ;
if ( ! att . previewUrl ) {
// Skeleton placeholder with a centered whirlpool. Self-stops when removed.
skel = document . createElement ( 'div' ) ;
skel . className = 'attach-image-skeleton' ;
// Match the photo's aspect ratio when the backend knew it at upload
// time, so the skeleton doesn't sit at a 4:3 default and then snap to
// a portrait shape when the image arrives.
if ( att . width && att . height ) {
skel . style . aspectRatio = att . width + ' / ' + att . height ;
skel . style . width = 'auto' ;
skel . style . height = 'auto' ;
skel . style . maxWidth = '300px' ;
skel . style . maxHeight = '200px' ;
skel . style . minWidth = '80px' ;
}
sp = spinnerModule . createWhirlpool ( 20 ) ;
skel . appendChild ( sp . element ) ;
imgWrap . appendChild ( skel ) ;
}
if ( att . id || att . previewUrl ) {
const img = document . createElement ( 'img' ) ;
// Small cached thumbnail — the preview is tiny, no need to pull the
// full-resolution photo. Click still opens the full image.
img . alt = att . name || 'Image' ;
img . loading = 'lazy' ;
img . style . cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + ( att . previewUrl ? 'block' : 'none' ) + ';' ;
let _revealed = false ;
let _revealTimer = null ;
const _reveal = ( ) => {
if ( _revealed ) return ;
_revealed = true ;
if ( _revealTimer ) { clearTimeout ( _revealTimer ) ; _revealTimer = null ; }
img . style . display = 'block' ;
try { sp && sp . stop ( ) ; } catch { }
if ( skel ) skel . remove ( ) ;
} ;
img . addEventListener ( 'load' , _reveal ) ;
img . addEventListener ( 'error' , _reveal ) ;
img . src = att . previewUrl || ` /api/upload/ ${ att . id } ?thumb=1 ` ;
// Cached images can be complete before the load listener attaches.
if ( img . complete && img . naturalWidth ) _reveal ( ) ;
// Failsafe: if neither load nor error fires within 8s, reveal anyway.
// The timer is cleared on reveal AND when updateMessageAttachments
// replaces the card (which scrubs the img / skel from the DOM), so
// repeated re-renders don't accumulate stranded timers.
if ( ! att . previewUrl ) _revealTimer = setTimeout ( _reveal , 8000 ) ;
imgWrap . appendChild ( img ) ;
if ( att . id ) {
// Small corner button → opens the vision/OCR editor so the user can
// correct what the vision model extracted. The edit is cached on the
// server keyed by file id, so any later message referencing this same
// image picks up the corrected text instead of re-running the model.
const ocrBtn = document . createElement ( 'button' ) ;
ocrBtn . type = 'button' ;
ocrBtn . className = 'attach-ocr-btn' ;
ocrBtn . title = 'View / edit OCR text' ;
ocrBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg><span class="attach-ocr-label">Caption</span>' ;
ocrBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_openVisionEditor ( att , ocrBtn . closest ( '.msg' ) ) ;
} ) ;
imgWrap . appendChild ( ocrBtn ) ;
}
}
if ( att . vision _model ) {
const visionLabel = document . createElement ( 'div' ) ;
visionLabel . className = 'attach-vision-model' ;
visionLabel . textContent = 'Vision: ' + String ( att . vision _model ) . split ( '/' ) . pop ( ) ;
imgWrap . appendChild ( visionLabel ) ;
}
if ( att . name ) {
const label = document . createElement ( 'div' ) ;
label . className = 'attach-image-name' ;
label . textContent = att . name ;
imgWrap . appendChild ( label ) ;
}
attachWrap . appendChild ( imgWrap ) ;
} else {
// Non-image file card
const card = document . createElement ( 'div' ) ;
card . className = 'attach-card' ;
card . dataset . name = att . name ;
if ( att . id ) {
card . dataset . fileId = att . id ;
card . style . cursor = 'pointer' ;
card . addEventListener ( 'click' , ( ) => {
// PDFs & text/code/markdown → open in the Documents viewer
// (others fall back to the raw file).
if ( window . chatModule ? . openAttachment ) window . chatModule . openAttachment ( att , false ) ;
else window . open ( ` /api/upload/ ${ att . id } ` , '_blank' ) ;
} ) ;
}
const icon = _attachIcon ( att . mime || att . name ) ;
const nameSpan = document . createElement ( 'span' ) ;
nameSpan . className = 'attach-card-name' ;
nameSpan . textContent = att . name ;
card . innerHTML = icon ;
card . appendChild ( nameSpan ) ;
if ( att . size ) {
const sizeSpan = document . createElement ( 'span' ) ;
sizeSpan . className = 'attach-card-size' ;
sizeSpan . textContent = _formatSize ( att . size ) ;
card . appendChild ( sizeSpan ) ;
}
attachWrap . appendChild ( card ) ;
}
}
return attachWrap ;
}
// Re-render the attachment cards of an already-rendered message. Used to swap
// in real upload ids (and image thumbnails) on the optimistic user bubble once
// uploadPending() resolves — otherwise image previews only appear after a
// refresh, because the bubble is rendered before the upload assigns ids.
export function updateMessageAttachments ( msgWrap , attachments ) {
if ( ! msgWrap || ! attachments ? . length ) return ;
const body = msgWrap . querySelector ( '.body' ) || msgWrap ;
const existing = body . querySelector ( '.attach-cards' ) ;
const fresh = buildAttachCards ( attachments ) ;
if ( existing ) existing . replaceWith ( fresh ) ;
else body . appendChild ( fresh ) ;
}
// Quick full-size preview when the user taps a chat photo thumbnail. Just an
// overlay with the original image centered — no Gallery panel, no editor.
function _openImageLightbox ( att ) {
if ( ! att ? . id ) return ;
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'attach-lightbox' ;
// Show the cached thumb immediately so the overlay doesn't sit blank
// while a 25MB original streams in. The full image swaps in once loaded;
// if the full load fails (404 / network), we keep the thumb + show an
// error label rather than a blank overlay forever.
const img = document . createElement ( 'img' ) ;
img . alt = att . name || '' ;
img . src = ` /api/upload/ ${ att . id } ?thumb=1 ` ;
overlay . appendChild ( img ) ;
const full = new Image ( ) ;
full . addEventListener ( 'load' , ( ) => { img . src = full . src ; } ) ;
full . addEventListener ( 'error' , ( ) => {
const err = document . createElement ( 'div' ) ;
err . className = 'attach-lightbox-err' ;
err . textContent = 'Failed to load full-resolution image.' ;
overlay . appendChild ( err ) ;
} ) ;
full . src = ` /api/upload/ ${ att . id } ` ;
const _onKey = ( e ) => { if ( e . key === 'Escape' ) _close ( ) ; } ;
const _close = ( ) => {
document . removeEventListener ( 'keydown' , _onKey ) ;
if ( _overlayObs ) { try { _overlayObs . disconnect ( ) ; } catch { } }
overlay . remove ( ) ;
} ;
// If the overlay is removed via any path other than our close handler
// (session switch, parent re-render, external cleanup), still drop the
// document-level keydown listener so it doesn't leak.
let _overlayObs = null ;
try {
_overlayObs = new MutationObserver ( ( ) => {
if ( ! document . body . contains ( overlay ) ) {
document . removeEventListener ( 'keydown' , _onKey ) ;
_overlayObs . disconnect ( ) ;
}
} ) ;
_overlayObs . observe ( document . body , { childList : true , subtree : false } ) ;
} catch { }
overlay . addEventListener ( 'click' , _close ) ;
document . addEventListener ( 'keydown' , _onKey ) ;
document . body . appendChild ( overlay ) ;
}
// Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo
// thumbnail. Lets the user view and correct the text the vision model fed to
// the LLM (e.g. when OCR misreads a word). Persists to the server's vision
// cache (PUT /api/upload/{id}/vision), so any subsequent message that
// references the same file picks up the corrected text.
let _visionEditorEl = null ;
let _visionEditorEsc = null ;
function _closeVisionEditor ( ) {
if ( _visionEditorEsc ) { document . removeEventListener ( 'keydown' , _visionEditorEsc ) ; _visionEditorEsc = null ; }
if ( _visionEditorEl ) { _visionEditorEl . remove ( ) ; _visionEditorEl = null ; }
}
function _openVisionEditor ( att , userMsgEl ) {
if ( ! att ? . id ) return ;
_closeVisionEditor ( ) ;
const overlay = document . createElement ( 'div' ) ;
overlay . className = 'vision-editor-overlay' ;
overlay . addEventListener ( 'click' , ( e ) => { if ( e . target === overlay ) _closeVisionEditor ( ) ; } ) ;
const panel = document . createElement ( 'div' ) ;
panel . className = 'vision-editor-panel' ;
const title = document . createElement ( 'div' ) ;
title . className = 'vision-editor-title' ;
// Eye icon matches the one in Settings → Vision so users recognise where
// this text originates.
title . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Vision text</span>' ;
panel . appendChild ( title ) ;
const desc = document . createElement ( 'div' ) ;
desc . className = 'vision-editor-desc' ;
desc . textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.' ;
panel . appendChild ( desc ) ;
const ta = document . createElement ( 'textarea' ) ;
ta . className = 'vision-editor-text' ;
ta . rows = 10 ;
ta . placeholder = 'Loading…' ;
ta . disabled = true ;
panel . appendChild ( ta ) ;
const actions = document . createElement ( 'div' ) ;
actions . className = 'vision-editor-actions' ;
const closeBtn = document . createElement ( 'button' ) ;
closeBtn . type = 'button' ;
closeBtn . className = 'vision-editor-btn' ;
closeBtn . innerHTML = '<span class="vision-btn-label">Close</span>' ;
closeBtn . addEventListener ( 'click' , _closeVisionEditor ) ;
const _saveVisionText = async ( ) => {
const res = await fetch ( ` /api/upload/ ${ att . id } /vision ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'same-origin' ,
body : JSON . stringify ( { text : ta . value } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'save failed' ) ;
} ;
const saveBtn = document . createElement ( 'button' ) ;
saveBtn . type = 'button' ;
saveBtn . className = 'vision-editor-btn vision-editor-btn-primary' ;
saveBtn . innerHTML = '<span class="vision-btn-label">Save</span>' ;
saveBtn . disabled = true ;
saveBtn . addEventListener ( 'click' , async ( ) => {
saveBtn . disabled = true ;
saveBtn . innerHTML = '<span class="vision-btn-label">Saving…</span>' ;
try {
await _saveVisionText ( ) ;
if ( uiModule ? . showToast ) uiModule . showToast ( 'Saved' ) ;
_closeVisionEditor ( ) ;
} catch ( e ) {
saveBtn . disabled = false ;
saveBtn . innerHTML = '<span class="vision-btn-label">Save</span>' ;
if ( uiModule ? . showError ) uiModule . showError ( 'Failed to save OCR text' ) ;
}
} ) ;
// Regenerate-message: save the edited text, close, then trigger a resend of
// the user message so the new AI reply uses the edit immediately.
const regenBtn = document . createElement ( 'button' ) ;
regenBtn . type = 'button' ;
regenBtn . className = 'vision-editor-btn vision-editor-btn-primary' ;
regenBtn . title = 'Save and regenerate the message' ;
regenBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="vision-btn-label">Regenerate message</span>' ;
regenBtn . disabled = true ;
regenBtn . addEventListener ( 'click' , async ( ) => {
regenBtn . disabled = true ;
saveBtn . disabled = true ;
try {
await _saveVisionText ( ) ;
_closeVisionEditor ( ) ;
if ( userMsgEl && window . chatModule ? . resendUserMessage ) {
window . chatModule . resendUserMessage ( userMsgEl ) ;
} else if ( uiModule ? . showToast ) {
uiModule . showToast ( 'Saved' ) ;
}
} catch ( e ) {
regenBtn . disabled = false ;
saveBtn . disabled = false ;
if ( uiModule ? . showError ) uiModule . showError ( 'Failed to save OCR text' ) ;
}
} ) ;
actions . appendChild ( closeBtn ) ;
actions . appendChild ( saveBtn ) ;
actions . appendChild ( regenBtn ) ;
panel . appendChild ( actions ) ;
overlay . appendChild ( panel ) ;
document . body . appendChild ( overlay ) ;
_visionEditorEl = overlay ;
// ESC closes the popup. Registered on document so it works regardless of
// focus (the textarea swallows the event otherwise).
_visionEditorEsc = ( e ) => { if ( e . key === 'Escape' ) _closeVisionEditor ( ) ; } ;
document . addEventListener ( 'keydown' , _visionEditorEsc ) ;
fetch ( ` /api/upload/ ${ att . id } /vision ` , { credentials : 'same-origin' } )
. then ( r => r . ok ? r . json ( ) : Promise . reject ( r ) )
. then ( data => {
ta . value = data . text || '' ;
ta . placeholder = '' ;
ta . disabled = false ;
saveBtn . disabled = false ;
regenBtn . disabled = ! userMsgEl ;
ta . focus ( ) ;
} )
. catch ( ( ) => {
ta . value = '' ;
ta . placeholder = 'Could not load OCR text — type your correction and save.' ;
ta . disabled = false ;
saveBtn . disabled = false ;
regenBtn . disabled = ! userMsgEl ;
} ) ;
}
// Tool call syntax patterns to strip from displayed text
const TOOL _CALL _RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi ;
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
const EXEC _FENCE _RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi ;
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
const XML _TOOL _CALL _RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi ;
const XML _INVOKE _RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi ;
// DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that
// leaks into content when the model emits a text tool call instead of a native
// one. Strip the whole block; the second pattern catches stray/partial tags
// (e.g. mid-stream before the closing tag arrives).
const DSML _TOOL _RE = /<\s*[| |]+\s*DSML\s*[| |]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[| |]+\s*DSML\s*[| |]+\s*tool_calls\s*>|$)/gi ;
const DSML _STRAY _RE = /<\s*\/?\s*[| |]+\s*DSML\s*[| |]+[^>]*>/gi ;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL _NARRATION _RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi ;
// Model pricing table — per million tokens
// Model info: pricing (per 1M tokens) + context window length
const MODEL _INFO = {
// --- Anthropic ---
'claude-sonnet-4-5' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'claude-sonnet-4-6' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'claude-sonnet-4' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'claude-opus-4' : { input : 15.00 , output : 75.00 , ctx : 200000 } ,
'claude-opus-4-6' : { input : 15.00 , output : 75.00 , ctx : 200000 } ,
'claude-haiku-4' : { input : 0.80 , output : 4.00 , ctx : 200000 } ,
'claude-haiku-3-5' : { input : 0.80 , output : 4.00 , ctx : 200000 } ,
'claude-3-5-sonnet' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'claude-3-5-haiku' : { input : 0.80 , output : 4.00 , ctx : 200000 } ,
'claude-3-opus' : { input : 15.00 , output : 75.00 , ctx : 200000 } ,
'claude-3-sonnet' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'claude-3-haiku' : { input : 0.25 , output : 1.25 , ctx : 200000 } ,
// --- OpenAI ---
'gpt-5' : { input : 2.00 , output : 8.00 , ctx : 400000 } ,
'gpt-4.1' : { input : 2.00 , output : 8.00 , ctx : 1047576 } ,
'gpt-4.1-mini' : { input : 0.40 , output : 1.60 , ctx : 1047576 } ,
'gpt-4.1-nano' : { input : 0.10 , output : 0.40 , ctx : 1047576 } ,
'gpt-4o' : { input : 2.50 , output : 10.00 , ctx : 128000 } ,
'gpt-4o-mini' : { input : 0.15 , output : 0.60 , ctx : 128000 } ,
'gpt-4-turbo' : { input : 10.00 , output : 30.00 , ctx : 128000 } ,
'o1' : { input : 15.00 , output : 60.00 , ctx : 200000 } ,
'o1-mini' : { input : 3.00 , output : 12.00 , ctx : 128000 } ,
'o1-pro' : { input : 150.0 , output : 600.0 , ctx : 200000 } ,
'o3' : { input : 2.00 , output : 8.00 , ctx : 200000 } ,
'o3-mini' : { input : 1.10 , output : 4.40 , ctx : 200000 } ,
'o4-mini' : { input : 1.10 , output : 4.40 , ctx : 200000 } ,
// --- DeepSeek ---
'deepseek-chat' : { input : 0.27 , output : 1.10 , ctx : 64000 } ,
'deepseek-coder' : { input : 0.27 , output : 1.10 , ctx : 64000 } ,
'deepseek-reasoner' : { input : 0.55 , output : 2.19 , ctx : 64000 } ,
'deepseek-r1' : { input : 0.55 , output : 2.19 , ctx : 64000 } ,
'deepseek-v3' : { input : 0.27 , output : 1.10 , ctx : 64000 } ,
'deepseek-v2' : { input : 0.14 , output : 0.28 , ctx : 64000 } ,
// --- Google ---
'gemini-2.5-pro' : { input : 1.25 , output : 10.00 , ctx : 1048576 } ,
'gemini-2.5-flash' : { input : 0.15 , output : 0.60 , ctx : 1048576 } ,
'gemini-2.0-flash' : { input : 0.10 , output : 0.40 , ctx : 1048576 } ,
'gemini-1.5-pro' : { input : 1.25 , output : 5.00 , ctx : 1048576 } ,
'gemini-1.5-flash' : { input : 0.075 , output : 0.30 , ctx : 1048576 } ,
'gemma-3' : { input : 0.10 , output : 0.10 , ctx : 128000 } ,
// --- Mistral ---
'mistral-large' : { input : 2.00 , output : 6.00 , ctx : 128000 } ,
'mistral-medium' : { input : 2.00 , output : 6.00 , ctx : 32000 } ,
'mistral-small' : { input : 0.20 , output : 0.60 , ctx : 32000 } ,
'mistral-nemo' : { input : 0.15 , output : 0.15 , ctx : 128000 } ,
'mixtral' : { input : 0.24 , output : 0.24 , ctx : 32000 } ,
'codestral' : { input : 0.30 , output : 0.90 , ctx : 32000 } ,
'pixtral' : { input : 2.00 , output : 6.00 , ctx : 128000 } ,
// --- xAI ---
'grok-4' : { input : 3.00 , output : 15.00 , ctx : 131072 } ,
'grok-3' : { input : 3.00 , output : 15.00 , ctx : 131072 } ,
'grok-2' : { input : 2.00 , output : 10.00 , ctx : 131072 } ,
// --- Meta ---
'llama-4' : { input : 0.20 , output : 0.20 , ctx : 1048576 } ,
'llama-3.3' : { input : 0.20 , output : 0.20 , ctx : 131072 } ,
'llama-3.2' : { input : 0.20 , output : 0.20 , ctx : 131072 } ,
'llama-3.1' : { input : 0.20 , output : 0.20 , ctx : 131072 } ,
'llama-3' : { input : 0.20 , output : 0.20 , ctx : 131072 } ,
// --- Qwen ---
'qwen3' : { input : 0.30 , output : 1.20 , ctx : 131072 } ,
'qwen2.5' : { input : 0.30 , output : 1.20 , ctx : 131072 } ,
'qwq' : { input : 0.30 , output : 1.20 , ctx : 32768 } ,
// --- Cohere ---
'command-a' : { input : 2.50 , output : 10.00 , ctx : 256000 } ,
'command-r-plus' : { input : 2.50 , output : 10.00 , ctx : 128000 } ,
'command-r' : { input : 0.15 , output : 0.60 , ctx : 128000 } ,
// --- Perplexity ---
'sonar-pro' : { input : 3.00 , output : 15.00 , ctx : 200000 } ,
'sonar' : { input : 1.00 , output : 1.00 , ctx : 128000 } ,
// --- MiniMax ---
'minimax' : { input : 0.70 , output : 0.70 , ctx : 1000000 } ,
// --- Kimi / Moonshot ---
'moonshot' : { input : 1.00 , output : 1.00 , ctx : 128000 } ,
'kimi' : { input : 1.00 , output : 1.00 , ctx : 128000 } ,
// --- Microsoft ---
'phi-4' : { input : 0.07 , output : 0.14 , ctx : 16000 } ,
'phi-3' : { input : 0.07 , output : 0.14 , ctx : 128000 } ,
// --- Nvidia ---
'nemotron' : { input : 0.30 , output : 1.20 , ctx : 131072 } ,
// --- Nous ---
'hermes' : { input : 0.20 , output : 0.20 , ctx : 131072 } ,
} ;
// Compat alias
const MODEL _PRICING = MODEL _INFO ;
// Image generation cost lookup (per-image, by model × quality × size)
const IMAGE _PRICING = {
'gpt-image-1.5' : { 'low' : { '1024x1024' : 0.009 , '1024x1536' : 0.013 , '1536x1024' : 0.013 } , 'medium' : { '1024x1024' : 0.034 , '1024x1536' : 0.05 , '1536x1024' : 0.05 } , 'high' : { '1024x1024' : 0.133 , '1024x1536' : 0.2 , '1536x1024' : 0.2 } } ,
'gpt-image-1' : { 'low' : { '1024x1024' : 0.011 , '1024x1536' : 0.016 , '1536x1024' : 0.016 } , 'medium' : { '1024x1024' : 0.042 , '1024x1536' : 0.063 , '1536x1024' : 0.063 } , 'high' : { '1024x1024' : 0.167 , '1024x1536' : 0.25 , '1536x1024' : 0.25 } } ,
'gpt-image-1-mini' : { 'low' : { '1024x1024' : 0.005 , '1024x1536' : 0.006 , '1536x1024' : 0.006 } , 'medium' : { '1024x1024' : 0.011 , '1024x1536' : 0.015 , '1536x1024' : 0.015 } , 'high' : { '1024x1024' : 0.036 , '1024x1536' : 0.052 , '1536x1024' : 0.052 } } ,
} ;
export function shortModel ( name ) {
if ( ! name ) return '...' ;
if ( typeof name !== 'string' ) name = String ( name ) ;
let short = name . split ( '/' ) . pop ( ) ;
// Strip .gguf extension
short = short . replace ( /\.gguf$/i , '' ) ;
// Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers
short = short . replace ( /-0000\d-of-\d+$/ , '' ) ;
short = short . replace ( /[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i , '' ) ;
// Truncate if still too long (keep first meaningful part)
if ( short . length > 25 ) {
// Try to find a natural break point (dash after model size like -35B or -7B)
const sizeMatch = short . match ( /^(.+?-\d+[BbMm])/ ) ;
if ( sizeMatch ) short = sizeMatch [ 1 ] ;
else short = short . substring ( 0 , 22 ) + '…' ;
}
return short ;
}
/ * *
* Generate a consistent HSL color for a model name .
* Returns an hsl ( ) string . The hue is derived from a string hash ,
* saturation and lightness are fixed for readability on dark / light themes .
* /
export function modelColor ( name ) {
if ( ! name ) return null ;
const key = name . toLowerCase ( ) ;
let hash = 0 ;
for ( let i = 0 ; i < key . length ; i ++ ) {
hash = ( ( hash << 5 ) - hash + key . charCodeAt ( i ) ) | 0 ;
}
const hue = ( ( hash % 360 ) + 360 ) % 360 ;
return ` hsl( ${ hue } , 55%, 65%) ` ;
}
/** Look up model info (pricing + context) by substring match */
export function getModelInfo ( modelName ) {
if ( ! modelName ) return null ;
const name = modelName . toLowerCase ( ) ;
for ( const [ key , info ] of Object . entries ( MODEL _INFO ) ) {
if ( name . includes ( key ) ) return { key , ... info } ;
}
return null ;
}
function _fmtCtx ( n ) {
if ( n >= 1000000 ) return ( n / 1000000 ) . toFixed ( 1 ) . replace ( /\.0$/ , '' ) + 'M' ;
return Math . round ( n / 1000 ) + 'K' ;
}
/ * *
* Apply model color to a role element ( sets color + dot color ) .
* /
export function applyModelColor ( roleEl , modelName ) {
if ( ! modelName ) return ;
const color = modelColor ( modelName ) ;
if ( color ) {
roleEl . style . color = color ;
roleEl . style . setProperty ( '--model-dot' , color ) ;
}
// Replace generic dot with provider logo if available
const logo = providerLogo ( modelName ) ;
if ( logo && ! roleEl . querySelector ( '.role-provider-logo' ) ) {
const span = document . createElement ( 'span' ) ;
span . className = 'role-provider-logo' ;
span . innerHTML = logo ;
roleEl . classList . add ( 'has-logo' ) ;
roleEl . prepend ( span ) ;
}
// Click to show model info popup
if ( ! roleEl . _hasInfoClick ) {
roleEl . _hasInfoClick = true ;
roleEl . style . cursor = 'pointer' ;
roleEl . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
document . querySelectorAll ( '.ctx-popup' ) . forEach ( p => { if ( typeof p . _dismiss === 'function' ) p . _dismiss ( ) ; else p . remove ( ) ; } ) ;
2026-05-31 23:58:26 +09:00
const info = getModelInfo ( modelName ) ;
const short = shortModel ( modelName ) ;
const logoHtml = providerLogo ( modelName ) ;
const popup = document . createElement ( 'div' ) ;
popup . className = 'ctx-popup' ;
let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">' ;
if ( logoHtml ) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>' ;
html += short + '</div>' ;
html += '<div><span class="ctx-label">Model</span> ' + modelName . split ( '/' ) . pop ( ) + '</div>' ;
// Show static context initially, then fetch real from server
const _realCtx = window . _realContextLengths && window . _realContextLengths [ modelName ] ;
if ( _realCtx ) {
html += '<div><span class="ctx-label">Context</span> ' + _fmtCtx ( _realCtx ) + ' tokens' ;
if ( info && info . ctx && info . ctx !== _realCtx ) html += ' <span style="opacity:0.35">(spec: ' + _fmtCtx ( info . ctx ) + ')</span>' ;
html += '</div>' ;
} else if ( info && info . ctx ) {
html += '<div><span class="ctx-label">Context</span> <span id="_ctx-val">' + _fmtCtx ( info . ctx ) + ' tokens</span></div>' ;
}
// Fetch real context from server async
if ( ! _realCtx && window . sessionModule ) {
const _sid = window . sessionModule . getCurrentSessionId ( ) ;
if ( _sid ) {
fetch ( '/api/session/' + _sid + '/context_info' ) . then ( r => r . ok ? r . json ( ) : null ) . then ( d => {
if ( d && d . context _length ) {
if ( ! window . _realContextLengths ) window . _realContextLengths = { } ;
window . _realContextLengths [ modelName ] = d . context _length ;
const el = document . getElementById ( '_ctx-val' ) ;
if ( el ) {
el . innerHTML = _fmtCtx ( d . context _length ) + ' tokens' ;
if ( info && info . ctx && info . ctx !== d . context _length ) {
el . innerHTML += ' <span style="opacity:0.35">(spec: ' + _fmtCtx ( info . ctx ) + ')</span>' ;
}
}
}
} ) . catch ( ( ) => { } ) ;
}
}
// Show configured max tokens if set
if ( window . presetsModule ) {
const _pid = window . presetsModule . getSelectedPreset ( ) ;
const _preset = _pid ? window . presetsModule . getPreset ( _pid ) : null ;
const _mt = _preset ? . max _tokens ;
if ( _mt && _mt > 0 && _mt <= 8192 ) {
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt . toLocaleString ( ) + ' <span style="opacity:0.4">(configured)</span></div>' ;
}
}
if ( info && info . input != null ) html += '<div><span class="ctx-label">Input</span> $' + info . input . toFixed ( 2 ) + ' / 1M</div>' ;
if ( info && info . output != null ) html += '<div><span class="ctx-label">Output</span> $' + info . output . toFixed ( 2 ) + ' / 1M</div>' ;
if ( ! info ) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>' ;
popup . innerHTML = html ;
const rect = roleEl . getBoundingClientRect ( ) ;
popup . style . top = ( rect . bottom + 4 ) + 'px' ;
popup . style . left = rect . left + 'px' ;
document . body . appendChild ( popup ) ;
const pr = popup . getBoundingClientRect ( ) ;
if ( pr . bottom > window . innerHeight - 8 ) popup . style . top = ( rect . top - pr . height - 4 ) + 'px' ;
if ( pr . right > window . innerWidth - 8 ) popup . style . left = ( window . innerWidth - pr . width - 8 ) + 'px' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
bindMenuDismiss ( popup , ( ) => popup . remove ( ) ) ;
2026-05-31 23:58:26 +09:00
} ) ;
}
}
export function getModelCost ( modelName , inputTokens , outputTokens ) {
if ( ! modelName ) return null ;
const name = modelName . toLowerCase ( ) ;
for ( const [ key , price ] of Object . entries ( MODEL _PRICING ) ) {
if ( name . includes ( key ) ) {
return ( inputTokens * price . input + outputTokens * price . output ) / 1_000_000 ;
}
}
return null ;
}
/ * *
* Is this endpoint a local / self - hosted model server ( vLLM , Ollama , … ) ?
* Local models are free , so we must NOT bill them at cloud rates — the
* pricing table matches on a name substring , so a local ` qwen2.5-coder `
* would otherwise be charged like cloud ` qwen2.5 ` . When the serving host is
* loopback , a private LAN range , Tailscale CGNAT ( 100.64 – 100.127 . x ) , a
* ` .local ` name , or the app ' s own host , the model is local → free .
* Unknown / missing endpoint also counts as local ( bias to not over - bill ) .
* /
export function isLocalEndpoint ( url ) {
if ( ! url ) return true ;
let host ;
try { host = new URL ( url ) . hostname ; } catch ( _e ) { return true ; }
if ( ! host ) return true ;
2026-06-01 10:43:35 +09:00
if ( host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host . endsWith ( '.local' ) ) return true ;
2026-05-31 23:58:26 +09:00
if ( typeof window !== 'undefined' && window . location && host === window . location . hostname ) return true ;
2026-06-02 10:47:58 +08:00
// A single-label hostname (no dot) is an internal/Docker service name
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
// never a public API, which always needs an FQDN. Treat as local → free.
// (Without this, container-name endpoints get billed at cloud rates because
// the pricing table matches on a name substring, e.g. "nemotron".)
if ( ! host . includes ( '.' ) ) return true ;
2026-05-31 23:58:26 +09:00
if ( /^127\./ . test ( host ) ) return true ;
if ( /^10\./ . test ( host ) ) return true ;
if ( /^192\.168\./ . test ( host ) ) return true ;
if ( /^172\.(1[6-9]|2\d|3[01])\./ . test ( host ) ) return true ;
const cg = host . match ( /^100\.(\d+)\./ ) ; // Tailscale CGNAT
if ( cg && + cg [ 1 ] >= 64 && + cg [ 1 ] <= 127 ) return true ;
return false ;
}
/** Cost for the current turn, returning null (free) for local endpoints. */
function _billableCost ( model , inputTokens , outputTokens ) {
const url = ( window . sessionModule && window . sessionModule . getCurrentEndpointUrl )
? window . sessionModule . getCurrentEndpointUrl ( ) : null ;
if ( isLocalEndpoint ( url ) ) return null ;
return getModelCost ( model , inputTokens , outputTokens ) ;
}
export function getImageCost ( model , quality , size ) {
if ( ! model ) return null ;
const m = model . toLowerCase ( ) ;
for ( const [ key , quals ] of Object . entries ( IMAGE _PRICING ) ) {
if ( m . includes ( key ) ) {
const q = quals [ ( quality || 'medium' ) . toLowerCase ( ) ] || quals [ 'medium' ] ;
return q ? ( q [ size ] || q [ '1024x1024' ] || null ) : null ;
}
}
return null ;
}
/* ── Session cost helpers ─────────────────────────────────────────── */
const _COST _KEY = 'ody-session-cost' ;
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost ( sessionId ) {
const sid = sessionId || ( window . sessionModule && window . sessionModule . getCurrentSessionId ( ) ) ;
if ( ! sid ) return 0 ;
try {
const costs = JSON . parse ( localStorage . getItem ( _COST _KEY ) || '{}' ) ;
return costs [ sid ] || 0 ;
} catch ( _e ) { return 0 ; }
}
/** Reset session cost for the given session (defaults to current). */
export function resetSessionCost ( sessionId ) {
const sid = sessionId || ( window . sessionModule && window . sessionModule . getCurrentSessionId ( ) ) ;
if ( ! sid ) return ;
try {
const costs = JSON . parse ( localStorage . getItem ( _COST _KEY ) || '{}' ) ;
delete costs [ sid ] ;
localStorage . setItem ( _COST _KEY , JSON . stringify ( costs ) ) ;
} catch ( _e ) { /* ignore */ }
updateSessionCostUI ( ) ;
}
/** Update the persistent session-cost badge in the input bar. */
export function updateSessionCostUI ( ) {
const el = document . getElementById ( 'session-cost-display' ) ;
if ( ! el ) return ;
// Local model? It's free — hide the badge and clear any stale cost that a
// previous (buggy) cloud-rate billing left in localStorage for this session.
const _url = ( window . sessionModule && window . sessionModule . getCurrentEndpointUrl )
? window . sessionModule . getCurrentEndpointUrl ( ) : null ;
if ( isLocalEndpoint ( _url ) ) {
const sid = window . sessionModule && window . sessionModule . getCurrentSessionId ( ) ;
if ( sid && getSessionCost ( sid ) > 0 ) {
try {
const costs = JSON . parse ( localStorage . getItem ( _COST _KEY ) || '{}' ) ;
delete costs [ sid ] ;
localStorage . setItem ( _COST _KEY , JSON . stringify ( costs ) ) ;
} catch ( _e ) { /* ignore */ }
}
el . style . display = 'none' ;
return ;
}
const cost = getSessionCost ( ) ;
if ( cost > 0 ) {
el . textContent = '$' + ( cost < 0.01 ? cost . toFixed ( 4 ) : cost < 1 ? cost . toFixed ( 3 ) : cost . toFixed ( 2 ) ) ;
el . style . display = '' ;
} else {
el . style . display = 'none' ;
}
}
/ * * C r e a t e a t i m e s t a m p s p a n f o r r o l e l a b e l s .
* Pass an ISO string / Date / epoch - ms to render the message ' s own time
* ( used when replaying history ) . Falls back to "now" when no value is given . * /
export function roleTimestamp ( when ) {
const ts = document . createElement ( 'span' ) ;
ts . className = 'role-timestamp' ;
let d ;
if ( when instanceof Date ) d = when ;
else if ( typeof when === 'number' ) d = new Date ( when ) ;
else if ( typeof when === 'string' && when ) d = new Date ( when ) ;
else d = new Date ( ) ;
if ( isNaN ( d . getTime ( ) ) ) d = new Date ( ) ;
ts . textContent = d . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ;
ts . title = d . toLocaleString ( ) ;
return ts ;
}
/ * *
* Strip tool invocation blocks from text before rendering .
* /
export function stripToolBlocks ( text ) {
let cleaned = text . replace ( TOOL _CALL _RE , '' ) ;
cleaned = cleaned . replace ( EXEC _FENCE _RE , '' ) ;
cleaned = cleaned . replace ( DSML _TOOL _RE , '' ) ;
cleaned = cleaned . replace ( DSML _STRAY _RE , '' ) ;
cleaned = cleaned . replace ( XML _TOOL _CALL _RE , '' ) ;
cleaned = cleaned . replace ( XML _INVOKE _RE , '' ) ;
cleaned = cleaned . replace ( TOOL _NARRATION _RE , '' ) ;
cleaned = cleaned . replace ( /\n{3,}/g , '\n\n' ) ;
return cleaned . trim ( ) ;
}
/ * *
* Build a collapsible sources box ( used by both research and web search ) .
* /
export function buildSourcesBox ( sources , type , expanded ) {
var esc = uiModule . esc ;
var id = 'sources-' + Date . now ( ) + '-' + Math . random ( ) . toString ( 36 ) . substr ( 2 , 5 ) ;
var count = sources . length ;
var label = type === 'research' ? 'Research sources' : 'Web sources' ;
var lines = '' ;
for ( var i = 0 ; i < count ; i ++ ) {
var s = sources [ i ] ;
var domain = '' ;
try { domain = new URL ( s . url ) . hostname . replace ( 'www.' , '' ) ; } catch ( e ) { domain = s . url ; }
var title = esc ( s . title || domain || '' ) ;
var safeUrl = _safeHref ( s . url ) ;
lines += '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
+ '<span class="source-num">' + ( i + 1 ) + '</span>'
+ '<span class="source-title">' + title + '</span>'
+ '<span class="source-domain">' + esc ( domain ) + '</span>'
+ '</a>' ;
}
var arrow = expanded ? 'down' : 'right' ;
var expandedClass = expanded ? ' expanded' : '' ;
return '<div class="sources-section">'
+ '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
+ '<div class="sources-header-left">' + SEARCH _ICON + '<span>' + count + ' ' + label + '</span></div>'
+ '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
+ '</div>'
+ '<div class="sources-content' + expandedClass + '" id="' + id + '">'
+ '<div class="sources-content-inner">' + lines + '</div>'
+ '</div></div>' ;
}
/ * *
* Build the RAG "Sources (N documents)" box — mirrors the live render in
* chat . js so persisted rag _sources survive a refresh . Items carry a
* filename , similarity % , and snippet ( not URLs , unlike web sources ) .
* @ param { Array < { filename , similarity , snippet } > } sources
* /
export function buildRagSourcesBox ( sources ) {
if ( ! sources || ! sources . length ) return '' ;
var esc = uiModule . esc ;
var items = '' ;
for ( var i = 0 ; i < sources . length ; i ++ ) {
var s = sources [ i ] || { } ;
var pct = ( typeof s . similarity === 'number' ) ? ( s . similarity * 100 ) . toFixed ( 1 ) + '%' : '' ;
items += '<div class="rag-source-item"><strong>' + esc ( s . filename || '' ) + '</strong>'
+ ( pct ? ' <span class="rag-similarity">' + pct + '</span>' : '' )
+ '<div class="rag-snippet">' + esc ( s . snippet || '' ) + '</div></div>' ;
}
return '<details class="rag-sources"><summary>Sources (' + sources . length + ' documents)</summary>' + items + '</details>' ;
}
/ * *
* Build a collapsible "Raw collected findings" section , styled like the sources box .
* @ param { Array < { url , title , summary } > } findings
* @ param { boolean } [ expanded = false ]
* /
export function buildFindingsBox ( findings , expanded ) {
if ( ! findings || ! findings . length ) return '' ;
var esc = uiModule . esc ;
var id = 'findings-' + Date . now ( ) + '-' + Math . random ( ) . toString ( 36 ) . substr ( 2 , 5 ) ;
var count = findings . length ;
var lines = '' ;
for ( var i = 0 ; i < count ; i ++ ) {
var f = findings [ i ] ;
var domain = '' ;
try { domain = new URL ( f . url ) . hostname . replace ( 'www.' , '' ) ; } catch ( e ) { domain = f . url ; }
var title = esc ( f . title || domain || '' ) ;
var summary = esc ( f . summary || '' ) ;
var safeUrl = _safeHref ( f . url ) ;
lines += '<div class="finding-item">'
+ '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer" class="source-link">'
+ '<span class="source-num">' + ( i + 1 ) + '</span>'
+ '<span class="source-title">' + title + '</span>'
+ '<span class="source-domain">' + esc ( domain ) + '</span>'
+ '</a>'
+ '<div class="finding-summary">' + summary + '</div>'
+ '</div>' ;
}
var FINDINGS _ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>' ;
var arrow = expanded ? 'down' : 'right' ;
var expandedClass = expanded ? ' expanded' : '' ;
return '<div class="sources-section">'
+ '<div class="sources-header" data-sources-id="' + id + '" onclick="window.toggleSources(\'' + id + '\')">'
+ '<div class="sources-header-left">' + FINDINGS _ICON + '<span>' + count + ' Raw collected findings</span></div>'
+ '<span class="sources-toggle" id="' + id + '-toggle" data-arrow="' + arrow + '"></span>'
+ '</div>'
+ '<div class="sources-content' + expandedClass + '" id="' + id + '">'
+ '<div class="sources-content-inner">' + lines + '</div>'
+ '</div></div>' ;
}
/** Append report button + continue research prompt. */
export function appendReportButton ( container , sessionId ) {
_appendReportButton ( container , sessionId ) ;
_appendContinuePrompt ( container ) ;
}
function _appendContinuePrompt ( container ) {
var wrap = document . createElement ( 'div' ) ;
wrap . className = 'continue-research-wrap' ;
wrap . innerHTML =
'<div class="continue-research-hint">'
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'
+ '<span>Dig deeper? Activate Research again and type a follow-up question to continue this research.</span>'
+ '</div>' ;
container . appendChild ( wrap ) ;
}
function _appendReportButton ( container , sessionId ) {
var apiBase = window . API _BASE || '' ;
// Wrapper holds report button + chat-about button
var wrap = document . createElement ( 'div' ) ;
wrap . className = 'report-btn-wrap' ;
var btn = document . createElement ( 'button' ) ;
btn . type = 'button' ;
btn . className = 'view-report-btn' ;
btn . innerHTML = REPORT _ICON + ' Open Visual Report' ;
var reportUrl = apiBase + '/api/research/report/' + sessionId ;
btn . addEventListener ( 'click' , function ( ) {
window . open ( reportUrl , '_blank' ) ;
} ) ;
wrap . appendChild ( btn ) ;
var chatBtn = document . createElement ( 'button' ) ;
chatBtn . type = 'button' ;
chatBtn . className = 'view-report-btn chat-about-btn' ;
chatBtn . innerHTML = CHAT _ABOUT _ICON + ' Discuss' ;
chatBtn . addEventListener ( 'click' , async function ( ) {
if ( chatBtn . disabled ) return ;
var origLabel = chatBtn . innerHTML ;
chatBtn . disabled = true ;
chatBtn . innerHTML = CHAT _ABOUT _ICON + ' Creating…' ;
try {
var res = await fetch ( apiBase + '/api/research/spinoff/' + sessionId , { method : 'POST' } ) ;
if ( ! res . ok ) {
var detail = '' ;
try { detail = ( await res . json ( ) ) . detail || '' ; } catch { }
throw new Error ( detail || ( 'HTTP ' + res . status ) ) ;
}
var payload = await res . json ( ) ;
if ( window . sessionModule && payload . session _id ) {
await window . sessionModule . loadSessions ( ) . catch ( ( ) => { } ) ;
await window . sessionModule . selectSession ( payload . session _id ) ;
}
} catch ( e ) {
chatBtn . disabled = false ;
chatBtn . innerHTML = origLabel ;
if ( window . uiModule && uiModule . showError ) {
uiModule . showError ( 'Could not start follow-up chat: ' + e . message ) ;
} else {
alert ( 'Could not start follow-up chat: ' + e . message ) ;
}
}
} ) ;
wrap . appendChild ( chatBtn ) ;
container . appendChild ( wrap ) ;
}
window . toggleSources = function ( id ) {
// Debounce to prevent double-fire from both inline onclick and delegation
var now = Date . now ( ) ;
if ( window . _lastSourcesToggle && now - window . _lastSourcesToggle < 100 ) return ;
window . _lastSourcesToggle = now ;
var content = document . getElementById ( id ) ;
var toggle = document . getElementById ( id + '-toggle' ) ;
if ( content && toggle ) {
var expanded = content . classList . contains ( 'expanded' ) ;
content . classList . toggle ( 'expanded' , ! expanded ) ;
toggle . dataset . arrow = expanded ? 'right' : 'down' ;
}
} ;
// Event delegation for sources toggle (capture phase, handles SVG targets)
document . addEventListener ( 'click' , function ( e ) {
// Walk up from target manually to handle SVG elements that may not support closest()
var el = e . target ;
while ( el && el !== document ) {
if ( el . classList && el . classList . contains ( 'sources-header' ) && el . dataset && el . dataset . sourcesId ) {
e . stopPropagation ( ) ;
window . toggleSources ( el . dataset . sourcesId ) ;
return ;
}
el = el . parentElement || el . parentNode ;
}
} , true ) ;
// Jump-to-entity anchors — the agent emits links like
// [New Chat](#session-89effa28)
// [Notes](#document-abc123)
// [Reminder](#note-42)
// and the chat-history click delegate turns them into navigation
// instead of default in-page anchor jumps. Each prefix routes to the
// matching module via a dynamic import (avoids circular deps —
// sessions.js itself imports chatRenderer.js).
document . addEventListener ( 'click' , function ( e ) {
const a = e . target && e . target . closest && e . target . closest ( 'a[href]' ) ;
if ( ! a ) return ;
const href = a . getAttribute ( 'href' ) || '' ;
if ( ! href . startsWith ( '#' ) ) return ;
const m = href . match ( /^#(session|document|note|image|email|event|task|skill|research)-(.+)$/ ) ;
if ( ! m ) return ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
const [ , kind , id ] = m ;
if ( kind === 'session' ) {
import ( './sessions.js' ) . then ( mod => {
const fn = mod . selectSession || ( mod . default && mod . default . selectSession ) ;
if ( fn ) fn ( id ) ;
} ) ;
} else if ( kind === 'document' ) {
import ( './document.js' ) . then ( mod => {
const open = mod . loadDocument
|| mod . openDocument
|| ( mod . default && ( mod . default . loadDocument || mod . default . openDocument ) ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'note' ) {
import ( './notes.js' ) . then ( mod => {
const open = mod . openNote || ( mod . default && mod . default . openNote ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'image' ) {
import ( './gallery.js' ) . then ( mod => {
const open = mod . openGalleryImage || ( mod . default && mod . default . openGalleryImage ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'email' ) {
import ( './emailLibrary.js' ) . then ( mod => {
const open = mod . openEmailLibrary || ( mod . default && mod . default . openEmailLibrary ) ;
if ( open ) open ( { uid : id } ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'event' ) {
import ( './calendar.js' ) . then ( mod => {
const open = mod . openCalendarTo || ( mod . default && mod . default . openCalendarTo ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'task' ) {
import ( './tasks.js' ) . then ( mod => {
const open = mod . openTasks || ( mod . default && mod . default . openTasks ) ;
if ( open ) open ( id ) ;
else { const b = document . getElementById ( 'tasks-btn' ) ; if ( b ) b . click ( ) ; }
} ) . catch ( ( ) => { const b = document . getElementById ( 'tasks-btn' ) ; if ( b ) b . click ( ) ; } ) ;
} else if ( kind === 'skill' ) {
import ( './skills.js' ) . then ( mod => {
const open = mod . openSkill || ( mod . default && mod . default . openSkill ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
} else if ( kind === 'research' ) {
import ( './research/panel.js' ) . then ( mod => {
const open = mod . openPanel || ( mod . default && mod . default . openPanel ) ;
if ( open ) open ( id ) ;
} ) . catch ( ( ) => { } ) ;
}
} ) ;
/ * *
* Build a generated - image bubble element .
* /
export function buildImageBubble ( imageUrl , prompt , model , size , quality , imageId ) {
var esc = uiModule . esc ;
const wrap = document . createElement ( 'div' ) ;
wrap . className = 'msg msg-ai generated-image-wrap' ;
const role = document . createElement ( 'div' ) ;
role . className = 'role' ;
role . textContent = ( model || 'image' ) . split ( '/' ) . pop ( ) ;
wrap . appendChild ( role ) ;
const body = document . createElement ( 'div' ) ;
body . className = 'body' ;
const img = document . createElement ( 'img' ) ;
img . className = 'generated-image' ;
img . alt = prompt || 'Generated image' ;
img . title = prompt || 'Generated image' ;
img . src = imageUrl ;
img . addEventListener ( 'click' , ( ) => { window . open ( img . src , '_blank' ) ; } ) ;
body . appendChild ( img ) ;
if ( prompt ) {
const caption = document . createElement ( 'div' ) ;
caption . className = 'generated-image-caption' ;
caption . textContent = prompt ;
body . appendChild ( caption ) ;
}
wrap . appendChild ( body ) ;
const footer = document . createElement ( 'div' ) ;
footer . className = 'msg-footer' ;
const actions = document . createElement ( 'span' ) ;
actions . className = 'msg-actions' ;
const copyBtn = document . createElement ( 'button' ) ;
copyBtn . className = 'footer-copy-btn' ;
copyBtn . type = 'button' ;
copyBtn . title = 'Copy prompt' ;
copyBtn . innerHTML = COPY _ICON ;
copyBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
uiModule . copyToClipboard ( prompt || '' ) ;
copyBtn . innerHTML = CHECK _ICON ;
setTimeout ( ( ) => { copyBtn . innerHTML = COPY _ICON ; } , 1500 ) ;
} ) ;
actions . appendChild ( copyBtn ) ;
const dlBtn = document . createElement ( 'button' ) ;
dlBtn . className = 'footer-copy-btn' ;
dlBtn . type = 'button' ;
dlBtn . title = 'Download image' ;
dlBtn . textContent = '\u2913' ;
dlBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
try {
const resp = await fetch ( imageUrl ) ;
const blob = await resp . blob ( ) ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = ( prompt || 'image' ) . slice ( 0 , 40 ) . replace ( /[^a-zA-Z0-9 ]/g , '' ) + '.png' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
URL . revokeObjectURL ( a . href ) ;
dlBtn . textContent = '\u2713' ;
setTimeout ( ( ) => { dlBtn . textContent = '\u2913' ; } , 1500 ) ;
} catch { dlBtn . textContent = '\u2717' ; setTimeout ( ( ) => { dlBtn . textContent = '\u2913' ; } , 1500 ) ; }
} ) ;
actions . appendChild ( dlBtn ) ;
const editBtn = document . createElement ( 'button' ) ;
editBtn . className = 'footer-copy-btn' ;
editBtn . type = 'button' ;
editBtn . title = 'Edit in image editor' ;
editBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>' ;
editBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
try {
const [ galleryMod , editorMod ] = await Promise . all ( [
import ( './gallery.js' ) ,
import ( './galleryEditor.js' ) ,
] ) ;
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
galleryMod . default . openGallery ( ) ;
const modal = document . getElementById ( 'gallery-modal' ) ;
if ( modal ) {
modal . querySelectorAll ( '.gallery-tab' ) . forEach ( t => t . classList . remove ( 'active' ) ) ;
modal . querySelector ( '.gallery-tab[data-tab="editor"]' ) ? . classList . add ( 'active' ) ;
}
const imagesContainer = document . getElementById ( 'gallery-images-container' ) ;
const albumsContainer = document . getElementById ( 'gallery-albums-container' ) ;
if ( imagesContainer ) imagesContainer . style . display = 'none' ;
if ( albumsContainer ) albumsContainer . style . display = 'none' ;
const editorContainer = document . getElementById ( 'gallery-editor-container' ) ;
if ( editorContainer ) editorContainer . style . display = 'flex' ;
const label = ( prompt || '' ) . trim ( ) . slice ( 0 , 60 ) || 'Generated image' ;
editorMod . openEditor ( imageUrl , null , null , label ) ;
} catch ( err ) {
console . error ( '[chat] open in editor failed' , err ) ;
}
} ) ;
actions . appendChild ( editBtn ) ;
const delBtn = document . createElement ( 'button' ) ;
delBtn . className = 'footer-copy-btn footer-delete-btn' ;
delBtn . type = 'button' ;
delBtn . title = 'Delete image' ;
delBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>' ;
delBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const ok = await uiModule . styledConfirm ( 'Delete this image?' , {
confirmText : 'Delete' ,
cancelText : 'Cancel' ,
danger : true ,
} ) ;
if ( ! ok ) return ;
// If we have a gallery id, delete server-side; otherwise just remove
// the bubble from chat (e.g. external DALL-E url that wasn't saved).
if ( imageId ) {
try {
const res = await fetch ( ` /api/gallery/ ${ encodeURIComponent ( imageId ) } ` , {
method : 'DELETE' , credentials : 'same-origin' ,
} ) ;
if ( ! res . ok && res . status !== 404 ) {
uiModule . showToast ? . ( 'Delete failed' , 4000 ) ;
return ;
}
window . dispatchEvent ( new CustomEvent ( 'gallery-refresh' ) ) ;
} catch ( _ ) {
uiModule . showToast ? . ( 'Delete failed' , 4000 ) ;
return ;
}
}
wrap . remove ( ) ;
} ) ;
actions . appendChild ( delBtn ) ;
footer . appendChild ( actions ) ;
const metrics = document . createElement ( 'span' ) ;
metrics . className = 'response-metrics' ;
const parts = [ ] ;
if ( model ) parts . push ( model . split ( '/' ) . pop ( ) ) ;
if ( size ) parts . push ( size ) ;
if ( quality ) parts . push ( quality ) ;
const cost = getImageCost ( model , quality , size ) ;
if ( cost !== null ) parts . push ( '$' + ( cost < 0.01 ? cost . toFixed ( 4 ) : cost . toFixed ( 3 ) ) ) ;
metrics . textContent = parts . join ( ' \u00B7 ' ) ;
footer . appendChild ( metrics ) ;
wrap . appendChild ( footer ) ;
return wrap ;
}
export function hideWelcomeScreen ( ) {
const ws = document . getElementById ( 'welcome-screen' ) ;
const cc = document . getElementById ( 'chat-container' ) ;
if ( ws ) ws . classList . add ( 'hidden' ) ;
if ( cc ) cc . classList . remove ( 'welcome-active' ) ;
// Update send button — switches from muted arrow to + Chat
if ( window . _updateSendBtnIcon ) setTimeout ( window . _updateSendBtnIcon , 50 ) ;
const ib = document . getElementById ( 'incognito-btn' ) ;
if ( ib ) ib . style . display = ib . classList . contains ( 'active' ) ? '' : 'none' ;
}
export function showWelcomeScreen ( ) {
const ws = document . getElementById ( 'welcome-screen' ) ;
const cc = document . getElementById ( 'chat-container' ) ;
if ( ws ) ws . classList . remove ( 'hidden' ) ;
if ( cc ) cc . classList . add ( 'welcome-active' ) ;
2026-06-03 03:07:31 +08:00
// Entering the New Chat / welcome state: discard any stale draft left in the
// composer from the previous session so the input starts empty (issue #1343).
// Switching between existing sessions loads them directly and does NOT call
// this, so genuine drafts are not erased. Reset the autosized height and fire
// an `input` event so the send button + autosize listeners update.
const _msg = document . getElementById ( 'message' ) ;
if ( _msg ) {
_msg . value = '' ;
_msg . style . height = '' ;
_msg . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
}
2026-05-31 23:58:26 +09:00
// Re-trigger the L→R clip-wipe reveal on the welcome name each time the
// welcome screen is shown (new session, deleted last session, etc.) — without
// this, the CSS animation only fires on initial DOM insertion.
const wn = document . querySelector ( '.welcome-name' ) ;
if ( wn ) {
wn . style . animation = 'none' ;
// force reflow so the next assignment registers as a new animation
void wn . offsetHeight ;
wn . style . animation = '' ;
}
// Update send button — switches from + Chat to muted arrow on empty session
if ( window . _updateSendBtnIcon ) setTimeout ( window . _updateSendBtnIcon , 50 ) ;
const ib = document . getElementById ( 'incognito-btn' ) ;
const _researchChk = document . getElementById ( 'research-toggle' ) ;
if ( ib && ! ( _researchChk && _researchChk . checked ) ) ib . style . display = '' ;
if ( window . innerWidth > 768 ) {
const msg = document . getElementById ( 'message' ) ;
if ( msg ) msg . focus ( ) ;
}
}
// ── Dynamic action buttons (show 3 most recent, rest under ···) ──
const _ACTION _RECENTS _KEY = 'odysseus-msg-actions-recent' ;
const _MAX _VISIBLE = 2 ;
function _getRecentActions ( ) {
try { return JSON . parse ( localStorage . getItem ( _ACTION _RECENTS _KEY ) || '[]' ) ; } catch { return [ ] ; }
}
function _trackAction ( id ) {
let recent = _getRecentActions ( ) . filter ( x => x !== id ) ;
recent . unshift ( id ) ;
if ( recent . length > 10 ) recent . length = 10 ;
localStorage . setItem ( _ACTION _RECENTS _KEY , JSON . stringify ( recent ) ) ;
}
/ * *
* Create a footer row for an AI message with timestamp and action buttons .
* /
export function createMsgFooter ( msgElement ) {
const footer = document . createElement ( 'div' ) ;
footer . className = 'msg-footer' ;
const actions = document . createElement ( 'span' ) ;
actions . className = 'msg-actions' ;
// Define all available actions: { id, icon, title, className, handler }
const allActions = [
{ id : 'copy' , icon : COPY _ICON , title : 'Copy message' , cls : 'footer-copy-btn' , html : true , handler ( e ) {
e . stopPropagation ( ) ;
const btn = e . currentTarget ;
uiModule . copyToClipboard ( msgElement . dataset . raw || msgElement . querySelector ( '.body' ) ? . textContent || '' ) ;
btn . innerHTML = CHECK _ICON ;
setTimeout ( ( ) => { btn . innerHTML = COPY _ICON ; } , 1500 ) ;
} } ,
{ id : 'edit' , icon : '\u270E' , title : 'Edit' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . editAIMessage ) window . chatModule . editAIMessage ( msgElement ) ;
} } ,
{ id : 'regen' , icon : '\u21BB' , title : 'Regenerate from here' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . regenerateFrom ) window . chatModule . regenerateFrom ( msgElement ) ;
} } ,
{ id : 'shorten' , icon : '\u2702' , title : 'Rewrite shorter' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . rewriteWith ) window . chatModule . rewriteWith ( msgElement , 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.' ) ;
} } ,
{ id : 'explain' , icon : '?' , title : 'Explain simpler' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . rewriteWith ) window . chatModule . rewriteWith ( msgElement , 'Explain your last response in simpler terms. Use plain language and short sentences.' ) ;
} } ,
{ id : 'fork' , icon : '\u2ADD' , title : 'Fork conversation' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . forkFrom ) window . chatModule . forkFrom ( msgElement ) ;
} } ,
{ id : 'delete' , icon : '\u2715' , title : 'Delete message' , cls : 'msg-action-btn msg-delete-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . deleteMessage ) window . chatModule . deleteMessage ( msgElement ) ;
} } ,
] ;
// Filter out unavailable actions (e.g. TTS when not enabled)
const availableActions = allActions . filter ( a => ! a . available || a . available ( ) ) ;
// Determine which 3 to show: use recent order, fallback to defaults
const recent = _getRecentActions ( ) ;
const defaults = [ 'copy' , 'delete' , 'fork' ] ;
const order = recent . length > 0 ? recent : defaults ;
const sorted = [ ... availableActions ] . sort ( ( a , b ) => {
const ai = order . indexOf ( a . id ) , bi = order . indexOf ( b . id ) ;
if ( ai >= 0 && bi >= 0 ) return ai - bi ;
if ( ai >= 0 ) return - 1 ;
if ( bi >= 0 ) return 1 ;
return 0 ;
} ) ;
const visible = sorted . slice ( 0 , _MAX _VISIBLE ) ;
const overflow = sorted . slice ( _MAX _VISIBLE ) ;
// Render visible buttons
function _addBtn ( action , container ) {
const btn = _makeActionBtn ( action . cls , action . title , action . html ? '' : action . icon , ( e ) => {
_trackAction ( action . id ) ;
action . handler ( e ) ;
} ) ;
if ( action . html ) btn . innerHTML = action . icon ;
btn . dataset . action = action . id ;
container . appendChild ( btn ) ;
}
visible . forEach ( a => _addBtn ( a , actions ) ) ;
// Overflow "···" button
if ( overflow . length > 0 ) {
const moreBtn = document . createElement ( 'button' ) ;
moreBtn . className = 'msg-action-btn msg-more-btn' ;
moreBtn . type = 'button' ;
moreBtn . title = 'More actions' ;
moreBtn . textContent = '\u00B7\u00B7\u00B7' ;
moreBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
// Toggle overflow menu — close any existing one first (through its own
// dismiss so the Escape registry entry goes with it).
2026-05-31 23:58:26 +09:00
const existing = document . querySelector ( '.msg-overflow-menu' ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
if ( existing ) {
if ( typeof existing . _dismiss === 'function' ) existing . _dismiss ( ) ; else existing . remove ( ) ;
if ( existing . _trigger === moreBtn ) return ;
}
2026-05-31 23:58:26 +09:00
const menu = document . createElement ( 'div' ) ;
menu . className = 'msg-overflow-menu' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
let closeMenu = ( ) => menu . remove ( ) ;
2026-05-31 23:58:26 +09:00
overflow . forEach ( a => {
const item = document . createElement ( 'button' ) ;
item . className = 'msg-overflow-item' ;
item . type = 'button' ;
item . title = a . title ;
item . innerHTML = ` <span class="overflow-icon"> ${ a . icon } </span> ${ a . title } ` ;
item . addEventListener ( 'click' , ( ev ) => {
ev . stopPropagation ( ) ;
_trackAction ( a . id ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu ( ) ;
2026-05-31 23:58:26 +09:00
a . handler ( ev ) ;
} ) ;
menu . appendChild ( item ) ;
} ) ;
menu . _trigger = moreBtn ;
document . body . appendChild ( menu ) ;
// Position fixed relative to the ··· button
const btnRect = moreBtn . getBoundingClientRect ( ) ;
menu . style . top = ( btnRect . top - menu . offsetHeight - 4 ) + 'px' ;
menu . style . left = btnRect . left + 'px' ;
// Flip down if above viewport
if ( parseFloat ( menu . style . top ) < 8 ) menu . style . top = ( btnRect . bottom + 4 ) + 'px' ;
// Keep within right edge
const mr = menu . getBoundingClientRect ( ) ;
if ( mr . right > window . innerWidth - 8 ) menu . style . left = ( window . innerWidth - mr . width - 8 ) + 'px' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
// Close on outside click or Escape. The trigger button is treated as
// "inside" so its own click toggles rather than double-fires.
closeMenu = bindMenuDismiss ( menu , ( ) => menu . remove ( ) , ( ev ) => ! menu . contains ( ev . target ) && ev . target !== moreBtn ) ; } ) ;
2026-05-31 23:58:26 +09:00
actions . appendChild ( moreBtn ) ;
}
// Memory-used indicator pill
const mems = msgElement . _memoriesUsed ;
if ( mems && mems . length > 0 ) {
const pill = document . createElement ( 'button' ) ;
pill . className = 'memory-used-pill' ;
pill . type = 'button' ;
const pinnedCount = mems . filter ( m => m . type === 'pinned' ) . length ;
const recalledCount = mems . filter ( m => m . type === 'recalled' ) . length ;
const parts = [ ] ;
if ( pinnedCount ) parts . push ( ` ${ pinnedCount } pinned ` ) ;
if ( recalledCount ) parts . push ( ` ${ recalledCount } recalled ` ) ;
pill . innerHTML = ` <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.8-3.5 6-.3.2-.5.5-.5.9V18h-6v-2.1c0-.4-.2-.7-.5-.9C6.3 13.8 5 11.5 5 9a7 7 0 0 1 7-7z"/><path d="M9 18h6v1a3 3 0 0 1-6 0v-1z"/><path d="M12 2v7"/><path d="M8.5 6.5L12 9l3.5-2.5"/></svg><span class="memory-used-pill-text"> ${ parts . join ( ', ' ) } </span> ` ;
pill . title = mems . map ( m => ` [ ${ m . type } ] ${ m . text } ` ) . join ( '\n' ) ;
pill . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
let detail = pill . _openDetail || document . querySelector ( '.memory-used-detail' ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
if ( detail ) {
if ( typeof detail . _dismiss === 'function' ) detail . _dismiss ( ) ;
else { detail . remove ( ) ; pill . _openDetail = null ; }
return ;
}
2026-05-31 23:58:26 +09:00
detail = document . createElement ( 'div' ) ;
detail . className = 'memory-used-detail' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
let closeDetail = ( ) => { detail . remove ( ) ; pill . _openDetail = null ; } ;
2026-05-31 23:58:26 +09:00
mems . forEach ( m => {
const row = document . createElement ( 'div' ) ;
row . className = 'memory-used-row' ;
row . style . cursor = 'pointer' ;
row . title = 'Click to open memory manager' ;
const badge = document . createElement ( 'span' ) ;
badge . className = 'memory-used-badge ' + ( m . type === 'pinned' ? 'pinned' : 'recalled' ) ;
badge . textContent = m . type === 'pinned' ? '\u25CF' : '\u21BB' ;
const text = document . createElement ( 'span' ) ;
text . className = 'memory-used-text' ;
text . textContent = m . text ;
row . appendChild ( badge ) ;
row . appendChild ( text ) ;
row . addEventListener ( 'click' , ( ev ) => {
ev . stopPropagation ( ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeDetail ( ) ;
2026-05-31 23:58:26 +09:00
const memModal = document . getElementById ( 'memory-modal' ) ;
if ( memModal ) memModal . classList . remove ( 'hidden' ) ;
} ) ;
detail . appendChild ( row ) ;
} ) ;
detail . style . visibility = 'hidden' ;
document . body . appendChild ( detail ) ;
const pillRect = pill . getBoundingClientRect ( ) ;
const detailRect = detail . getBoundingClientRect ( ) ;
const spaceAbove = pillRect . top ;
const spaceBelow = window . innerHeight - pillRect . bottom ;
if ( spaceAbove >= detailRect . height + 8 || spaceAbove > spaceBelow ) {
detail . style . top = ( pillRect . top - detailRect . height - 8 ) + 'px' ;
} else {
detail . style . top = ( pillRect . bottom + 8 ) + 'px' ;
}
detail . style . left = pillRect . left + 'px' ;
if ( pillRect . left + detailRect . width > window . innerWidth - 8 ) {
detail . style . left = ( window . innerWidth - detailRect . width - 8 ) + 'px' ;
}
if ( parseFloat ( detail . style . left ) < 8 ) detail . style . left = '8px' ;
detail . style . visibility = '' ;
pill . _openDetail = detail ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
// Close on outside click or Escape (pill click toggles, so it's inside).
closeDetail = bindMenuDismiss ( detail , ( ) => { detail . remove ( ) ; pill . _openDetail = null ; } , ( ev ) => ! detail . contains ( ev . target ) && ev . target !== pill ) ; } ) ;
2026-05-31 23:58:26 +09:00
footer . appendChild ( pill ) ;
}
footer . appendChild ( actions ) ;
return footer ;
}
/ * *
* Create a footer row for a user message with action buttons ( same system as AI footer ) .
* /
const _USER _ACTION _RECENTS _KEY = 'odysseus-user-actions-recent' ;
function _getUserRecentActions ( ) {
try { return JSON . parse ( localStorage . getItem ( _USER _ACTION _RECENTS _KEY ) || '[]' ) ; } catch { return [ ] ; }
}
function _trackUserAction ( id ) {
let recent = _getUserRecentActions ( ) . filter ( x => x !== id ) ;
recent . unshift ( id ) ;
if ( recent . length > 10 ) recent . length = 10 ;
localStorage . setItem ( _USER _ACTION _RECENTS _KEY , JSON . stringify ( recent ) ) ;
}
export function createUserMsgFooter ( msgElement ) {
const footer = document . createElement ( 'div' ) ;
footer . className = 'msg-footer' ;
const actions = document . createElement ( 'span' ) ;
actions . className = 'msg-actions' ;
const allActions = [
{ id : 'edit' , icon : '\u270E' , title : 'Edit message' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . editUserMessage ) window . chatModule . editUserMessage ( msgElement ) ;
} } ,
{ id : 'delete' , icon : '\u2715' , title : 'Delete message' , cls : 'msg-action-btn msg-delete-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . deleteMessage ) window . chatModule . deleteMessage ( msgElement ) ;
} } ,
{ id : 'copy' , icon : COPY _ICON , title : 'Copy message' , cls : 'footer-copy-btn' , html : true , handler ( e ) {
e . stopPropagation ( ) ;
const btn = e . currentTarget ;
uiModule . copyToClipboard ( msgElement . querySelector ( '.body' ) ? . textContent || '' ) ;
btn . innerHTML = CHECK _ICON ;
setTimeout ( ( ) => { btn . innerHTML = COPY _ICON ; } , 1500 ) ;
} } ,
{ id : 'resend' , icon : '\u21BB' , title : 'Resend message' , cls : 'msg-action-btn' , handler ( e ) {
e . stopPropagation ( ) ;
if ( window . chatModule ? . resendUserMessage ) window . chatModule . resendUserMessage ( msgElement ) ;
} } ,
] ;
const recent = _getUserRecentActions ( ) ;
const defaults = [ 'edit' , 'delete' , 'copy' ] ;
const order = recent . length > 0 ? recent : defaults ;
const sorted = [ ... allActions ] . sort ( ( a , b ) => {
const ai = order . indexOf ( a . id ) , bi = order . indexOf ( b . id ) ;
if ( ai >= 0 && bi >= 0 ) return ai - bi ;
if ( ai >= 0 ) return - 1 ;
if ( bi >= 0 ) return 1 ;
return 0 ;
} ) ;
const visible = sorted . slice ( 0 , _MAX _VISIBLE ) ;
const overflow = sorted . slice ( _MAX _VISIBLE ) ;
visible . forEach ( a => {
const btn = _makeActionBtn ( a . cls , a . title , a . html ? '' : a . icon , ( ev ) => {
_trackUserAction ( a . id ) ;
a . handler ( ev ) ;
} ) ;
if ( a . html ) btn . innerHTML = a . icon ;
btn . dataset . action = a . id ;
actions . appendChild ( btn ) ;
} ) ;
if ( overflow . length > 0 ) {
const moreBtn = document . createElement ( 'button' ) ;
moreBtn . className = 'msg-action-btn msg-more-btn' ;
moreBtn . type = 'button' ;
moreBtn . title = 'More actions' ;
moreBtn . textContent = '\u00B7\u00B7\u00B7' ;
moreBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const existing = document . querySelector ( '.msg-overflow-menu' ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
if ( existing ) {
if ( typeof existing . _dismiss === 'function' ) existing . _dismiss ( ) ; else existing . remove ( ) ;
if ( existing . _trigger === moreBtn ) return ;
}
2026-05-31 23:58:26 +09:00
const menu = document . createElement ( 'div' ) ;
menu . className = 'msg-overflow-menu' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
let closeMenu = ( ) => menu . remove ( ) ;
2026-05-31 23:58:26 +09:00
overflow . forEach ( a => {
const item = document . createElement ( 'button' ) ;
item . className = 'msg-overflow-item' ;
item . type = 'button' ;
item . title = a . title ;
item . innerHTML = ` <span class="overflow-icon"> ${ a . icon } </span> ${ a . title } ` ;
item . addEventListener ( 'click' , ( ev ) => {
ev . stopPropagation ( ) ;
_trackUserAction ( a . id ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu ( ) ;
2026-05-31 23:58:26 +09:00
a . handler ( ev ) ;
} ) ;
menu . appendChild ( item ) ;
} ) ;
menu . _trigger = moreBtn ;
document . body . appendChild ( menu ) ;
const btnRect = moreBtn . getBoundingClientRect ( ) ;
menu . style . top = ( btnRect . top - menu . offsetHeight - 4 ) + 'px' ;
menu . style . left = btnRect . left + 'px' ;
if ( parseFloat ( menu . style . top ) < 8 ) menu . style . top = ( btnRect . bottom + 4 ) + 'px' ;
const mr = menu . getBoundingClientRect ( ) ;
if ( mr . right > window . innerWidth - 8 ) menu . style . left = ( window . innerWidth - mr . width - 8 ) + 'px' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
closeMenu = bindMenuDismiss ( menu , ( ) => menu . remove ( ) , ( ev ) => ! menu . contains ( ev . target ) && ev . target !== moreBtn ) ; } ) ;
2026-05-31 23:58:26 +09:00
actions . appendChild ( moreBtn ) ;
}
footer . appendChild ( actions ) ;
return footer ;
}
/ * *
* Display performance metrics for a message .
* /
export function displayMetrics ( messageElement , metrics ) {
const existingMetrics = messageElement . querySelector ( '.response-metrics' ) ;
if ( existingMetrics ) existingMetrics . remove ( ) ;
const metricsContainer = document . createElement ( 'span' ) ;
metricsContainer . className = 'response-metrics' ;
const responseTime = metrics . response _time ;
const inputTokens = metrics . input _tokens || 0 ;
const outputTokens = metrics . output _tokens || 0 ;
const tps = metrics . tokens _per _second ;
const isReal = metrics . usage _source === 'real' ;
const ctxPct = metrics . context _percent ;
const model = metrics . model || 'Unknown' ;
const cost = _billableCost ( model , inputTokens , outputTokens ) ;
// Nothing useful to show — bail out (only if ALL metrics are missing)
if ( ! responseTime && ! outputTokens && tps == null && ! ctxPct ) return ;
// Accumulate session cost (only on fresh metrics, not history reload)
if ( ! metrics . _fromHistory ) {
const _sid = window . sessionModule && window . sessionModule . getCurrentSessionId ( ) ;
if ( _sid && cost !== null ) {
try {
const _costs = JSON . parse ( localStorage . getItem ( _COST _KEY ) || '{}' ) ;
_costs [ _sid ] = ( _costs [ _sid ] || 0 ) + cost ;
localStorage . setItem ( _COST _KEY , JSON . stringify ( _costs ) ) ;
} catch ( _e ) { /* ignore */ }
updateSessionCostUI ( ) ;
}
}
// Default: show tok/s if available, else fall back to other stats
const costStr0 = cost !== null ? ` $ ${ cost < 0.01 ? cost . toFixed ( 4 ) : cost . toFixed ( 3 ) } ` : null ;
const metricsLabel = tps != null && tps !== 'undefined'
? ` ${ tps } tok/s `
: costStr0
? ` ${ outputTokens } tok · ${ costStr0 } `
: outputTokens
? ` ${ outputTokens } tok · ${ responseTime != null ? responseTime + 's' : '' } `
: responseTime != null
? ` ${ responseTime } s `
: '' ;
if ( ! metricsLabel ) return ;
metricsContainer . textContent = metricsLabel ;
metricsContainer . style . cursor = 'pointer' ;
metricsContainer . title = 'Click for details' ;
const metricsDivider = document . createElement ( 'span' ) ;
metricsDivider . textContent = ' | ' ;
metricsDivider . style . color = 'var(--color-muted-alt)' ;
metricsDivider . style . pointerEvents = 'none' ;
metricsContainer . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
document . querySelectorAll ( '.ctx-popup' ) . forEach ( p => { if ( typeof p . _dismiss === 'function' ) p . _dismiss ( ) ; else p . remove ( ) ; } ) ;
2026-05-31 23:58:26 +09:00
const costStr = cost !== null ? ` $ ${ cost < 0.01 ? cost . toFixed ( 4 ) : cost . toFixed ( 3 ) } ` : 'n/a' ;
const speedStr = tps != null && tps !== 'undefined' ? ` ${ tps } tok/s ` : 'n/a' ;
const totalTok = inputTokens + outputTokens ;
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)' ;
const prepTime = metrics . agent _prep _time ;
const modelWaitTime = metrics . agent _model _wait _time ;
const prepBreakdown = metrics . agent _prep _breakdown || null ;
const prepDetails = prepBreakdown
? Object . entries ( prepBreakdown ) . map ( ( [ k , v ] ) => ` ${ k } : ${ v } s ` ) . join ( '<br>' )
: '' ;
// Session total cost
let sessionCostStr = '' ;
const sc = getSessionCost ( ) ;
if ( sc > 0 ) {
sessionCostStr = ` <div><span class="ctx-label">Session</span> $ ${ sc < 0.01 ? sc . toFixed ( 4 ) : sc . toFixed ( 3 ) } </div> ` ;
}
const popup = document . createElement ( 'div' ) ;
popup . className = 'ctx-popup' ;
popup . innerHTML = `
< div style = "font-weight:600;margin-bottom:6px;color:var(--fg);" > Message Stats < / d i v >
< div > < span class = "ctx-label" > Model < /span> ${model.split('/ ' ) . pop ( ) } < / d i v >
< div > < span class = "ctx-label" > Input < / s p a n > $ { i n p u t T o k e n s . t o L o c a l e S t r i n g ( ) } t o k e n s $ { i s R e a l ? ' ' : ' ~ ' } < / d i v >
< div > < span class = "ctx-label" > Output < / s p a n > $ { o u t p u t T o k e n s . t o L o c a l e S t r i n g ( ) } t o k e n s $ { i s R e a l ? ' ' : ' ~ ' } < / d i v >
< div > < span class = "ctx-label" > Total < / s p a n > $ { t o t a l T o k . t o L o c a l e S t r i n g ( ) } t o k e n s < / d i v >
< div > < span class = "ctx-label" > Speed < / s p a n > $ { s p e e d S t r } < / d i v >
< div > < span class = "ctx-label" > Time < / s p a n > $ { r e s p o n s e T i m e } s < / d i v >
$ { prepTime != null ? ` <div><span class="ctx-label">Prep</span> ${ prepTime } s</div> ` : '' }
$ { modelWaitTime != null ? ` <div><span class="ctx-label">Model wait</span> ${ modelWaitTime } s</div> ` : '' }
< div > < span class = "ctx-label" > Cost < / s p a n > $ { c o s t S t r } < / d i v >
$ { sessionCostStr }
$ { prepDetails ? ` <div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
< div style = "font-weight:600;margin-bottom:4px;color:var(--fg);" > Agent prep < / d i v >
$ { prepDetails }
< / d i v > ` : ' ' }
$ { ctxPct !== undefined && ctxPct > 0 ? ` <div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);">
< span class = "ctx-label" > Context < / s p a n > < s p a n s t y l e = " c o l o r : $ { c t x C o l o r } ; f o n t - w e i g h t : 6 0 0 ; " > $ { c t x P c t } % < / s p a n > u s e d
< / d i v > ` : ' ' }
$ { isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>' }
` ;
const rect = metricsContainer . getBoundingClientRect ( ) ;
popup . style . left = rect . left + 'px' ;
popup . style . visibility = 'hidden' ;
document . body . appendChild ( popup ) ;
const pr = popup . getBoundingClientRect ( ) ;
const spaceAbove = rect . top ;
const spaceBelow = window . innerHeight - rect . bottom ;
if ( spaceAbove >= pr . height + 8 || spaceAbove > spaceBelow ) {
popup . style . top = ( rect . top - pr . height - 8 ) + 'px' ;
} else {
popup . style . top = ( rect . bottom + 8 ) + 'px' ;
}
if ( pr . right > window . innerWidth - 8 ) popup . style . left = ( window . innerWidth - pr . width - 8 ) + 'px' ;
if ( parseFloat ( popup . style . left ) < 8 ) popup . style . left = '8px' ;
popup . style . visibility = '' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
bindMenuDismiss ( popup , ( ) => popup . remove ( ) ) ;
2026-05-31 23:58:26 +09:00
} ) ;
// Store real context length for model info popup
if ( metrics . context _length && metrics . model ) {
if ( ! window . _realContextLengths ) window . _realContextLengths = { } ;
window . _realContextLengths [ metrics . model ] = metrics . context _length ;
}
// Context usage ring
let ctxRing = null ;
const ctxLen = metrics . context _length || 0 ;
if ( ctxPct !== undefined && ctxPct > 0 ) {
const r = 6 , stroke = 1.5 ;
const circ = 2 * Math . PI * r ;
const fill = circ * ( ctxPct / 100 ) ;
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)' ;
ctxRing = document . createElement ( 'span' ) ;
ctxRing . className = 'ctx-ring' ;
ctxRing . title = ` ${ ctxPct } % context used — click for details ` ;
ctxRing . style . cursor = 'pointer' ;
ctxRing . style . setProperty ( '--ctx-color' , ctxColor ) ;
ctxRing . innerHTML = ` <svg width="14" height="14" viewBox="0 0 14 14">
< circle cx = "7" cy = "7" r = "${r}" fill = "none" stroke = "var(--border, #333)" stroke - width = "${stroke}" opacity = "0.3" / >
< circle cx = "7" cy = "7" r = "${r}" fill = "none" stroke = "var(--ctx-stroke)" stroke - width = "${stroke}"
stroke - dasharray = "${fill} ${circ - fill}" stroke - dashoffset = "${circ * 0.25}"
stroke - linecap = "round" transform = "rotate(-90 7 7)" / >
< / s v g > < s p a n c l a s s = " c t x - r i n g - p c t " > $ { M a t h . r o u n d ( c t x P c t ) } % < / s p a n > ` ;
ctxRing . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
document . querySelectorAll ( '.ctx-detail-popup' ) . forEach ( p => { if ( typeof p . _dismiss === 'function' ) p . _dismiss ( ) ; else p . remove ( ) ; } ) ;
2026-05-31 23:58:26 +09:00
const usedTokens = inputTokens || 0 ;
const totalCtx = ctxLen || 0 ;
const modelShort = model . split ( '/' ) . pop ( ) ;
const fmtNum = n => n ? n . toLocaleString ( ) : '?' ;
const popup = document . createElement ( 'div' ) ;
popup . className = 'ctx-detail-popup' ;
popup . innerHTML = `
< div style = "font-weight:600;margin-bottom:8px;color:var(--fg);" > Context Window < / d i v >
< div class = "ctx-bar-wrap" >
< div class = "ctx-bar-fill" style = "width:${Math.min(ctxPct, 100)}%;background:${ctxColor};" > < / d i v >
< / d i v >
< div style = "display:flex;justify-content:space-between;font-size:0.75rem;margin-top:4px;opacity:0.6;" >
< span > $ { fmtNum ( usedTokens ) } used < / s p a n >
< span > $ { fmtNum ( totalCtx ) } total < / s p a n >
< / d i v >
< div style = "margin-top:8px;font-size:0.8rem;" >
< div > < span class = "ctx-label" > Model < / s p a n > $ { m o d e l S h o r t } < / d i v >
< div > < span class = "ctx-label" > Usage < / s p a n > < s p a n s t y l e = " c o l o r : $ { c t x C o l o r } ; f o n t - w e i g h t : 6 0 0 ; " > $ { c t x P c t } % < / s p a n > < / d i v >
< div > < span class = "ctx-label" > Window < / s p a n > $ { f m t N u m ( t o t a l C t x ) } t o k e n s < / d i v >
< / d i v >
$ { ctxPct >= 70 ? ` <button class="ctx-compact-btn" title="Summarize older messages to free up context">Compact context</button> ` : '' }
` ;
const compactBtn = popup . querySelector ( '.ctx-compact-btn' ) ;
if ( compactBtn ) {
compactBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const sid = window . sessionModule && window . sessionModule . getCurrentSessionId ( ) ;
if ( ! sid ) return ;
popup . remove ( ) ;
// Add a spinner bubble at the bottom of chat
const chatBox = document . getElementById ( 'chat-history' ) ;
if ( ! chatBox ) return ;
const compactMsg = document . createElement ( 'div' ) ;
compactMsg . className = 'msg msg-ai' ;
const compactRole = document . createElement ( 'div' ) ;
compactRole . className = 'role' ;
compactRole . textContent = 'Odysseus' ;
const compactBody = document . createElement ( 'div' ) ;
compactBody . className = 'body' ;
compactBody . innerHTML = 'Compacting context <span class="compact-wave">▁▂▃▅▂▁</span>' ;
compactMsg . appendChild ( compactRole ) ;
compactMsg . appendChild ( compactBody ) ;
chatBox . appendChild ( compactMsg ) ;
chatBox . scrollTop = chatBox . scrollHeight ;
// Animate the wave
const waveFrames = [ '▁▂▃▅▂▁' , '▂▃▅▃▂▁' , '▃▅▃▂▁▂' , '▅▃▂▁▂▃' , '▃▂▁▂▃▅' , '▂▁▂▃▅▃' ] ;
let frame = 0 ;
const waveEl = compactBody . querySelector ( '.compact-wave' ) ;
const waveInterval = setInterval ( ( ) => {
frame = ( frame + 1 ) % waveFrames . length ;
if ( waveEl ) waveEl . textContent = waveFrames [ frame ] ;
} , 150 ) ;
try {
const res = await fetch ( window . location . origin + '/api/session/' + sid + '/compact' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
} ) ;
clearInterval ( waveInterval ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
// Reload session — the compacted history will show
if ( window . sessionModule ) await window . sessionModule . selectSession ( sid ) ;
// Scroll to the compacted message (first msg with compacted metadata)
setTimeout ( ( ) => {
const msgs = document . querySelectorAll ( '#chat-history .msg' ) ;
for ( const m of msgs ) {
if ( m . querySelector ( '.body' ) ? . textContent . includes ( 'Conversation compacted' ) ) {
m . scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) ;
break ;
}
}
} , 200 ) ;
} else {
compactBody . innerHTML = '<span style="color:var(--red);">Compaction failed. Try again later.</span>' ;
}
} catch ( err ) {
clearInterval ( waveInterval ) ;
console . warn ( 'compact failed:' , err ) ;
compactBody . innerHTML = '<span style="color:var(--red);">Compaction failed: ' + err . message + '</span>' ;
}
} ) ;
}
const rect = ctxRing . getBoundingClientRect ( ) ;
popup . style . visibility = 'hidden' ;
document . body . appendChild ( popup ) ;
const pr = popup . getBoundingClientRect ( ) ;
// Position above the ring, right-aligned
popup . style . left = Math . max ( 8 , rect . right - pr . width ) + 'px' ;
const spaceAbove = rect . top ;
if ( spaceAbove >= pr . height + 8 ) {
popup . style . top = ( rect . top - pr . height - 8 ) + 'px' ;
} else {
popup . style . top = ( rect . bottom + 8 ) + 'px' ;
}
popup . style . visibility = '' ;
fix: make transient dropdown/popup menus close on Escape
The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.
Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.
Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.
tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
2026-06-01 14:23:22 -04:00
bindMenuDismiss ( popup , ( ) => popup . remove ( ) , ( ev ) => ! popup . contains ( ev . target ) && ev . target !== ctxRing && ! ctxRing . contains ( ev . target ) ) ;
2026-05-31 23:58:26 +09:00
} ) ;
}
let footer = messageElement . querySelector ( '.msg-footer' ) ;
if ( footer ) {
const actions = footer . querySelector ( '.msg-actions' ) ;
if ( actions ) {
footer . insertBefore ( metricsDivider , actions ) ;
footer . insertBefore ( metricsContainer , metricsDivider ) ;
} else {
footer . appendChild ( metricsContainer ) ;
footer . appendChild ( metricsDivider ) ;
}
if ( ctxRing ) {
const ctxDiv = document . createElement ( 'span' ) ;
ctxDiv . textContent = ' | ' ;
ctxDiv . style . color = 'var(--color-muted-alt)' ;
ctxDiv . style . pointerEvents = 'none' ;
ctxDiv . className = 'ctx-divider' ;
footer . appendChild ( ctxDiv ) ;
footer . appendChild ( ctxRing ) ;
}
} else {
messageElement . appendChild ( metricsContainer ) ;
if ( ctxRing ) messageElement . appendChild ( ctxRing ) ;
}
if ( uiModule ) uiModule . scrollHistory ( ) ;
}
/ * *
* Add a message to the chat history .
* /
export function addMessage ( role , content , modelName , metadata ) {
try {
hideWelcomeScreen ( ) ;
const box = document . getElementById ( 'chat-history' ) ;
if ( ! box ) { console . error ( 'Chat history element not found' ) ; return ; }
var esc = uiModule . esc ;
const textRaw = Array . isArray ( content ) ? markdownModule . renderContent ( content ) : content ;
// --- Agent multi-bubble reconstruction from saved metadata ---
if ( role === 'assistant' && metadata && metadata . tool _events && metadata . tool _events . length > 0 ) {
const roundTexts = metadata . round _texts || [ ] ;
const toolEvents = metadata . tool _events ;
let lastWrap = null ;
let firstMsgAi = null ;
let lastMsgAi = null ;
const toolsByRound = { } ;
for ( const ev of toolEvents ) {
const r = ev . round || 1 ;
if ( ! toolsByRound [ r ] ) toolsByRound [ r ] = [ ] ;
toolsByRound [ r ] . push ( ev ) ;
}
const maxRound = Math . max ( ... Object . keys ( toolsByRound ) . map ( Number ) , roundTexts . length ) ;
for ( let r = 0 ; r < maxRound ; r ++ ) {
const roundNum = r + 1 ;
const txt = ( roundTexts [ r ] || '' ) . trim ( ) ;
if ( txt ) {
const wrap = document . createElement ( 'div' ) ;
wrap . className = 'msg msg-ai' + ( r > 0 ? ' msg-continuation' : '' ) ;
const roleEl = document . createElement ( 'div' ) ;
roleEl . className = 'role' ;
const contModel = modelName || metadata ? . model ;
roleEl . textContent = shortModel ( contModel ) ;
applyModelColor ( roleEl , contModel ) ;
if ( r === 0 ) roleEl . appendChild ( roleTimestamp ( metadata ? . timestamp ) ) ;
wrap . appendChild ( roleEl ) ;
const body = document . createElement ( 'div' ) ;
body . className = 'body' ;
// Check if this is the last text round — sources go on top of final response
var agentSourcesPrefix = '' ;
var isLastTextRound = true ;
for ( let rr = r + 1 ; rr < maxRound ; rr ++ ) {
if ( ( roundTexts [ rr ] || '' ) . trim ( ) ) { isLastTextRound = false ; break ; }
}
var agentFindingsSuffix = '' ;
if ( isLastTextRound && metadata ? . web _sources ? . length ) {
agentSourcesPrefix = buildSourcesBox ( metadata . web _sources , 'web' ) ;
} else if ( isLastTextRound && metadata ? . research _sources ? . length ) {
agentSourcesPrefix = buildSourcesBox ( metadata . research _sources , 'research' ) ;
}
if ( isLastTextRound && metadata ? . research _findings ? . length ) {
agentFindingsSuffix = buildFindingsBox ( metadata . research _findings ) ;
}
// RAG document sources — restored on the final text round.
if ( isLastTextRound && metadata ? . rag _sources ? . length ) {
agentFindingsSuffix += buildRagSourcesBox ( metadata . rag _sources ) ;
}
body . innerHTML = agentSourcesPrefix + markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( txt ) ) + agentFindingsSuffix ;
wrap . appendChild ( body ) ;
wrap . dataset . raw = txt ;
if ( metadata ? . _db _id ) wrap . dataset . dbId = metadata . _db _id ;
box . appendChild ( wrap ) ;
lastWrap = wrap ;
if ( ! firstMsgAi ) firstMsgAi = wrap ;
lastMsgAi = wrap ;
}
const roundTools = toolsByRound [ roundNum ] || [ ] ;
if ( roundTools . length > 0 ) {
// Reuse previous thread if no text separated us (merge consecutive tool rounds)
let threadWrap = null ;
if ( ! txt && lastWrap && lastWrap . classList . contains ( 'agent-thread' ) ) {
threadWrap = lastWrap ;
} else {
threadWrap = document . createElement ( 'div' ) ;
threadWrap . className = 'agent-thread' ;
// Extend line up if there's a chat bubble above
if ( txt ) threadWrap . classList . add ( 'has-top' ) ;
box . appendChild ( threadWrap ) ;
}
for ( const ev of roundTools ) {
const ok = ( ev . exit _code === 0 || ev . exit _code == null ) ;
let outHtml = '' ;
if ( ev . output && ev . output . trim ( ) ) {
outHtml = ` <details class="agent-tool-output"><summary>Output</summary><pre> ${ esc ( ev . output ) } </pre></details> ` ;
}
if ( ev . screenshot ) {
outHtml += ` <details class="agent-tool-output"><summary>Screenshot</summary><img src=" ${ esc ( ev . screenshot ) } " style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details> ` ;
}
const node = document . createElement ( 'div' ) ;
node . className = 'agent-thread-node' + ( ok ? '' : ' error' ) ;
const evCmdHtml = ev . command ? ` <pre class="agent-thread-cmd"> ${ esc ( ev . command ) } </pre> ` : '' ;
node . innerHTML = ` <div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon"> ${ ok ? '\u2713' : '\u2717' } </span><span class="agent-thread-tool"> ${ esc ( ev . tool ) } </span><span class="agent-thread-status"> ${ ok ? 'done' : 'failed' } </span><span class="agent-thread-chevron"> \u 25B6</span></div><div class="agent-thread-content"> ${ evCmdHtml } ${ outHtml } </div> ` ;
// Click handling is delegated globally \u2014 see chat.js init.
threadWrap . appendChild ( node ) ;
}
// Check if next round has text — extend line down to connect
const nextTxt = ( roundTexts [ r + 1 ] || '' ) . trim ( ) ;
if ( nextTxt ) threadWrap . classList . add ( 'has-bottom' ) ;
lastWrap = threadWrap ;
for ( const ev of roundTools ) {
if ( ev . image _url ) {
box . appendChild ( buildImageBubble ( ev . image _url , ev . image _prompt , ev . image _model , ev . image _size , ev . image _quality , ev . image _id ) ) ;
}
}
}
}
const firstWrap = lastMsgAi || lastWrap ;
if ( firstWrap && firstWrap . classList . contains ( 'msg-ai' ) ) {
if ( metadata ? . memories _used ? . length ) firstWrap . _memoriesUsed = metadata . memories _used ;
firstWrap . appendChild ( createMsgFooter ( firstWrap ) ) ;
if ( metadata ) displayMetrics ( firstWrap , metadata ) ;
}
if ( window . hljs ) {
box . querySelectorAll ( 'pre code:not(.hljs)' ) . forEach ( b => window . hljs . highlightElement ( b ) ) ;
}
if ( markdownModule . renderMermaid ) markdownModule . renderMermaid ( box ) ;
return lastWrap ;
}
// --- Standard single-bubble message ---
const wrap = document . createElement ( 'div' ) ;
wrap . className = 'msg ' + ( role === 'user' ? 'msg-user' : 'msg-ai' ) ;
const r = document . createElement ( 'div' ) ;
r . className = 'role' ;
const isSlash = metadata ? . source === 'slash' ;
const isCompacted = metadata ? . compacted ;
const resolvedModel = modelName || metadata ? . model ;
var _roleText = role === 'user' ? 'You' : ( isSlash || isCompacted ) ? 'Odysseus' : shortModel ( resolvedModel ) ;
if ( role === 'assistant' && ( metadata ? . research || metadata ? . research _clarification ) ) {
_roleText += ' (Research)' ;
}
if ( metadata ? . group _model && role !== 'user' ) {
_roleText = metadata . group _model ;
} else if ( metadata ? . character _name && role !== 'user' && ! isSlash && ! isCompacted ) {
_roleText = metadata . character _name ;
}
r . textContent = _roleText ;
if ( role !== 'user' ) {
if ( ! isSlash && ! isCompacted ) applyModelColor ( r , resolvedModel ) ;
r . appendChild ( roleTimestamp ( metadata ? . timestamp ) ) ;
}
const b = document . createElement ( 'div' ) ;
b . className = 'body' ;
let text = markdownModule . squashOutsideCode ( stripToolBlocks ( textRaw || '' ) ) ;
// For user messages, pull out vision-model image descriptions ([Image: name]\n
// <multi-line desc>) into a collapsible "image description" section. Done for
// ALL user messages (not just ones with attachment metadata) so it rebuilds
// from the stored text even after a browser restart drops the cached attachments.
const attachments = metadata ? . attachments ;
const _visionBlocks = [ ] ;
if ( role === 'user' ) {
text = text . replace (
/\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g ,
( _m , name , desc ) => { const d = desc . trim ( ) ; if ( d ) _visionBlocks . push ( { name : name , desc : d } ) ; return '' ; }
) ;
}
// With attachments present, also strip the embedded file/PDF/image-marker text.
if ( role === 'user' && attachments ? . length ) {
// Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines
text = text
. replace ( /\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g , '' )
. replace ( /\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g , '' )
. replace ( /\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g , '' )
. replace ( /\n*\[Image attached: [^\]]+\]/g , '' )
. replace ( /\n*\[Attached (?:document|non-text) file\]/g , '' )
. trim ( ) ;
}
wrap . dataset . raw = text ;
if ( metadata ? . _db _id ) wrap . dataset . dbId = metadata . _db _id ;
// Prepend sources box if saved in metadata
var sourcesPrefix = '' ;
var findingsSuffix = '' ;
if ( role === 'assistant' && metadata ? . research _sources ? . length ) {
sourcesPrefix = buildSourcesBox ( metadata . research _sources , 'research' ) ;
} else if ( role === 'assistant' && metadata ? . web _sources ? . length ) {
sourcesPrefix = buildSourcesBox ( metadata . web _sources , 'web' ) ;
}
if ( role === 'assistant' && metadata ? . research _findings ? . length ) {
findingsSuffix = buildFindingsBox ( metadata . research _findings ) ;
}
// RAG document sources — restored from metadata so they survive refresh.
if ( role === 'assistant' && metadata ? . rag _sources ? . length ) {
findingsSuffix += buildRagSourcesBox ( metadata . rag _sources ) ;
}
// If thinking is stored in metadata (not in text), reconstruct the full display
if ( role === 'assistant' && metadata ? . thinking ) {
const thinkTime = metadata . thinking _time || null ;
const thinkHtml = markdownModule . processWithThinking (
'<think' + ( thinkTime ? ` time=" ${ thinkTime } " ` : '' ) + '>' + metadata . thinking + '</think>\n\n' + text
) ;
b . innerHTML = sourcesPrefix + thinkHtml + findingsSuffix ;
} else {
b . innerHTML = sourcesPrefix + markdownModule . processWithThinking ( text ) + findingsSuffix ;
}
// The vision/OCR caption is stripped from the displayed text above (so the
// bubble doesn't show the raw model output) but no longer rendered as an
// inline collapsible — the user can still view/edit it via the "Caption"
// button on the photo thumbnail. _visionBlocks is intentionally left unused
// so the parsing-and-strip side-effect on `text` still happens.
void _visionBlocks ;
// Add "Open Visual Report" button for persisted research messages
if ( role === 'assistant' && metadata ? . research ) {
var _sid = window . sessionModule ? . getCurrentSessionId ? . ( ) ;
if ( _sid ) _appendReportButton ( b , _sid ) ;
}
// Style [Doc edit: ...] prefix in user messages
if ( role === 'user' ) {
// Match compact format: [Doc edit: line X] instruction
b . innerHTML = b . innerHTML . replace (
/\[Doc edit: (lines? [\d– \-]+)\]\s*/ ,
'<span class="doc-edit-tag">Doc edit: $1</span> '
) ;
// Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..."
// After markdown processing this becomes a <p> + <pre><code> block + <p>Instruction: text</p>
const rawDocMatch = b . innerHTML . match ( /In the document, edit this specific text \((lines? [\d– \-]+)\)/ ) ;
if ( rawDocMatch ) {
const lineRef = rawDocMatch [ 1 ] ;
// Extract instruction text (after "Instruction: ")
const instrMatch = b . textContent . match ( /Instruction:\s*([\s\S]*)$/ ) ;
const instrText = instrMatch ? instrMatch [ 1 ] . trim ( ) : '' ;
b . innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule . processWithThinking ( instrText ) ;
}
// Render attachment cards
if ( attachments ? . length ) {
b . appendChild ( buildAttachCards ( attachments ) ) ;
}
}
wrap . appendChild ( r ) ;
wrap . appendChild ( b ) ;
// Add stopped indicator + continue button for messages that were stopped by user
if ( role === 'assistant' && metadata ? . stopped ) {
const stoppedIndicator = document . createElement ( 'div' ) ;
stoppedIndicator . className = 'stopped-indicator' ;
const stoppedLabel = document . createElement ( 'span' ) ;
// Differentiate between "stopped mid-stream" (had content, can continue)
// and "cancelled before any content" — the latter has no Continue affordance.
stoppedLabel . textContent = metadata . cancelled
? '[Cancelled by user]'
: '[Message interrupted]' ;
stoppedIndicator . appendChild ( stoppedLabel ) ;
// Continue button only makes sense when there's partial content to
// resume from \u2014 skip it for fully-cancelled (empty) turns.
if ( ! metadata . cancelled ) {
const continueBtn = document . createElement ( 'button' ) ;
continueBtn . className = 'continue-btn' ;
continueBtn . title = 'Continue' ;
continueBtn . textContent = '\u25B8' ;
continueBtn . addEventListener ( 'click' , ( ) => {
stoppedIndicator . remove ( ) ;
if ( window . chatModule ) {
window . chatModule . setHideUserBubble ( ) ;
window . chatModule . setPendingContinue ( wrap ) ;
const rawText = wrap . dataset . raw || wrap . querySelector ( '.body' ) ? . textContent || '' ;
const cutoff = rawText ;
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) {
msgInput . value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff . slice ( - 500 ) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.' ;
const sb = document . querySelector ( '.send-btn' ) ;
if ( sb ) sb . click ( ) ;
}
}
} ) ;
stoppedIndicator . appendChild ( continueBtn ) ;
}
b . appendChild ( stoppedIndicator ) ;
}
if ( metadata ? . edited ) {
const editedIndicator = document . createElement ( 'div' ) ;
editedIndicator . className = 'edited-indicator' ;
editedIndicator . textContent = '[Message edited]' ;
b . appendChild ( editedIndicator ) ;
}
// Restore variant navigation from saved metadata
if ( role === 'assistant' && metadata ? . variants && metadata . variants . length > 1 ) {
wrap . dataset . variants = JSON . stringify ( metadata . variants ) ;
const idx = metadata . variantIndex ? ? metadata . variants . length - 1 ;
wrap . dataset . variantIndex = String ( idx ) ;
// Re-render from `raw` markdown rather than trusting cached `v.html`.
// Variants ride through localStorage / chat export-import; cached HTML
// would let an attacker-controlled session JSON inject markup.
const _renderVariant = ( v ) => ( v && v . raw )
? markdownModule . processWithThinking ( markdownModule . squashOutsideCode ( v . raw ) )
: ( v && v . html ) || '' ;
// Show the selected variant's content
const v = metadata . variants [ idx ] ;
if ( v ) {
b . innerHTML = _renderVariant ( v ) ;
wrap . dataset . raw = v . raw ;
}
// Render nav
const nav = document . createElement ( 'span' ) ;
nav . className = 'variant-nav' ;
nav . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
const divider = document . createElement ( 'span' ) ;
divider . className = 'variant-divider' ;
divider . textContent = '|' ;
nav . appendChild ( divider ) ;
const tagLabel = document . createElement ( 'span' ) ;
const _icons = { regen : '\u21BB' , shorter : '\u2702' , simpler : '?' , original : '\u25CB' } ;
const _tl0 = metadata . variants [ idx ] ? . label ;
tagLabel . className = 'variant-tag' + ( _tl0 === 'shorter' ? ' variant-tag-scissors' : '' ) ;
tagLabel . textContent = _icons [ _tl0 ] || '' ;
nav . appendChild ( tagLabel ) ;
const prevBtn = document . createElement ( 'button' ) ;
prevBtn . className = 'variant-btn' ;
prevBtn . textContent = '<' ;
prevBtn . disabled = idx === 0 ;
nav . appendChild ( prevBtn ) ;
const numLeft = document . createElement ( 'button' ) ;
numLeft . className = 'variant-num' ;
numLeft . textContent = String ( idx + 1 ) ;
numLeft . disabled = idx === 0 ;
nav . appendChild ( numLeft ) ;
const slash = document . createElement ( 'span' ) ;
slash . className = 'variant-slash' ;
slash . textContent = '/' ;
nav . appendChild ( slash ) ;
const numRight = document . createElement ( 'button' ) ;
numRight . className = 'variant-num' ;
numRight . textContent = String ( metadata . variants . length ) ;
numRight . disabled = idx === metadata . variants . length - 1 ;
nav . appendChild ( numRight ) ;
const nextBtn = document . createElement ( 'button' ) ;
nextBtn . className = 'variant-btn' ;
nextBtn . textContent = '>' ;
nextBtn . disabled = idx === metadata . variants . length - 1 ;
nav . appendChild ( nextBtn ) ;
const switchFn = ( newIdx ) => {
const vars = metadata . variants ;
if ( newIdx < 0 || newIdx >= vars . length ) return ;
const sv = vars [ newIdx ] ;
b . innerHTML = _renderVariant ( sv ) ;
wrap . dataset . raw = sv . raw ;
wrap . dataset . variantIndex = String ( newIdx ) ;
if ( window . hljs ) wrap . querySelectorAll ( 'pre code' ) . forEach ( bl => window . hljs . highlightElement ( bl ) ) ;
tagLabel . textContent = _icons [ sv . label ] || '' ;
tagLabel . className = 'variant-tag' + ( sv . label === 'shorter' ? ' variant-tag-scissors' : '' ) ;
numLeft . textContent = String ( newIdx + 1 ) ;
numLeft . disabled = newIdx === 0 ;
numRight . disabled = newIdx === vars . length - 1 ;
prevBtn . disabled = newIdx === 0 ;
nextBtn . disabled = newIdx === vars . length - 1 ;
} ;
prevBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; switchFn ( parseInt ( wrap . dataset . variantIndex ) - 1 ) ; } ) ;
numLeft . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; switchFn ( parseInt ( wrap . dataset . variantIndex ) - 1 ) ; } ) ;
numRight . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; switchFn ( parseInt ( wrap . dataset . variantIndex ) + 1 ) ; } ) ;
nextBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; switchFn ( parseInt ( wrap . dataset . variantIndex ) + 1 ) ; } ) ;
r . appendChild ( nav ) ;
}
if ( role === 'assistant' ) {
// The "N pinned" / "N recalled" pill in the footer reads from
// wrap._memoriesUsed — propagate it from saved metadata so the pill
// survives a page refresh (live-stream path sets it via SSE, but
// history reloads need this assignment).
if ( metadata ? . memories _used ? . length ) wrap . _memoriesUsed = metadata . memories _used ;
wrap . appendChild ( createMsgFooter ( wrap ) ) ;
if ( metadata ) displayMetrics ( wrap , metadata ) ;
} else {
// Add timestamp to user header (like AI messages)
r . appendChild ( roleTimestamp ( metadata ? . timestamp ) ) ;
wrap . appendChild ( createUserMsgFooter ( wrap ) ) ;
}
box . appendChild ( wrap ) ;
// TTS is now part of the msg-actions system
if ( role === 'assistant' && markdownModule . renderMermaid ) {
markdownModule . renderMermaid ( wrap ) ;
}
return wrap ;
} catch ( error ) {
console . error ( 'Error in addMessage:' , error ) ;
if ( uiModule ) uiModule . showError ( 'Failed to add message: ' + error . message ) ;
}
}
const chatRenderer = {
shortModel ,
modelColor ,
applyModelColor ,
getModelCost ,
getImageCost ,
getSessionCost ,
resetSessionCost ,
updateSessionCostUI ,
roleTimestamp ,
stripToolBlocks ,
buildSourcesBox ,
buildFindingsBox ,
appendReportButton ,
buildImageBubble ,
hideWelcomeScreen ,
showWelcomeScreen ,
createMsgFooter ,
displayMetrics ,
addMessage ,
updateMessageAttachments ,
} ;
export default chatRenderer ;