mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-07 16:21:23 +00:00
296 lines
10 KiB
JavaScript
296 lines
10 KiB
JavaScript
// ── Conversation Outline Panel (#2124) ───────────────────────────────────────
|
|
// Floating panel listing user messages as jump targets.
|
|
// _outlineSid guards against stale renders when the user switches sessions.
|
|
|
|
'use strict';
|
|
|
|
(function() {
|
|
|
|
let _outlineSid = null; // session id the panel was last built for
|
|
let _panelOpen = false; // whether the panel is currently visible
|
|
let _outlineResizeObserver = null;
|
|
let _outlineWorkspaceObserver = null;
|
|
|
|
// Returns the current session id, or null if no session is loaded.
|
|
function _currentSid() {
|
|
return (S && S.session && S.session.session_id) || null;
|
|
}
|
|
|
|
function _outlineAllowed() {
|
|
const compact = window.matchMedia && window.matchMedia('(max-width:900px)').matches;
|
|
// The outline is a chat-view affordance only — never show the toggle or panel
|
|
// while another MAIN panel (settings, tasks, insights, …) is active. _currentPanel
|
|
// is owned by panels.js; treat an undefined/absent value as the chat default.
|
|
// 'todos' is a sidebar-only panel that leaves the chat transcript in <main>, so
|
|
// the outline stays valid there too (and switching to it emits no <main> class
|
|
// mutation for the observer, so allowing it keeps the toggle stable).
|
|
const panel = (typeof _currentPanel === 'undefined') ? 'chat' : (_currentPanel || 'chat');
|
|
const onChatView = panel === 'chat' || panel === 'todos';
|
|
return window._showConversationOutline === true && !compact && onChatView;
|
|
}
|
|
|
|
function _syncOutlinePosition() {
|
|
const root = document.documentElement;
|
|
const panel = document.querySelector('.rightpanel');
|
|
const open = root.dataset.workspacePanel === 'open';
|
|
const width = open && panel ? Math.max(0, Math.round(panel.offsetWidth || 0)) : 0;
|
|
root.style.setProperty('--outline-workspace-offset', width + 'px');
|
|
}
|
|
|
|
function applyConversationOutlinePreference() {
|
|
const toggle = document.getElementById('outlineToggleBtn');
|
|
const wrapper = document.getElementById('outlinePanelWrapper');
|
|
const enabled = _outlineAllowed();
|
|
document.documentElement.dataset.conversationOutline = enabled ? 'enabled' : 'disabled';
|
|
_syncOutlinePosition();
|
|
if (toggle) toggle.hidden = !enabled;
|
|
if (!enabled) {
|
|
_panelOpen = false;
|
|
if (wrapper) wrapper.hidden = true;
|
|
}
|
|
}
|
|
|
|
function _expandOutlineRenderWindow() {
|
|
if (typeof _currentMessageRenderWindowSize !== 'function' ||
|
|
typeof _messageRenderableMessageCount !== 'function' ||
|
|
typeof _messageRenderWindowSize === 'undefined') return;
|
|
_messageRenderWindowSize = Math.max(
|
|
_currentMessageRenderWindowSize(),
|
|
_messageRenderableMessageCount()
|
|
);
|
|
}
|
|
|
|
function _ensureOutlineMessagesLoaded(sid) {
|
|
if (!sid || S.busy || S.activeStreamId) return Promise.resolve(false);
|
|
if (typeof _messagesTruncated === 'undefined' || !_messagesTruncated) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (typeof _ensureAllMessagesLoaded !== 'function') return Promise.resolve(false);
|
|
return _ensureAllMessagesLoaded().then(function() {
|
|
if (!S.session || S.session.session_id !== sid) return false;
|
|
_expandOutlineRenderWindow();
|
|
return true;
|
|
}).catch(function() {
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Extracts the first 60 visible characters from a message content value.
|
|
function _excerptText(content) {
|
|
let text = '';
|
|
if (Array.isArray(content)) {
|
|
text = content
|
|
.filter(p => p && p.type === 'text')
|
|
.map(p => p.text || p.content || '')
|
|
.join(' ');
|
|
} else {
|
|
text = String(content || '');
|
|
}
|
|
text = text.trim().replace(/\s+/g, ' ');
|
|
return text.length > 60 ? text.slice(0, 60) + '…' : text;
|
|
}
|
|
|
|
// Scrolls to a user message row identified by its rawIdx and flashes it.
|
|
function _jumpToMessage(rawIdx) {
|
|
const sid = _currentSid();
|
|
if (!sid) return;
|
|
|
|
const rowId = 'msg-user-' + rawIdx;
|
|
const row = document.getElementById(rowId);
|
|
if (row) {
|
|
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
_flashRow(row);
|
|
return;
|
|
}
|
|
|
|
// Row is outside the render window — reload the full session and retry.
|
|
if (typeof api !== 'function') return;
|
|
if (S.busy || S.activeStreamId) return;
|
|
api('/api/session?session_id=' + encodeURIComponent(sid) +
|
|
'&messages=1&resolve_model=0&msg_limit=9999')
|
|
.then(function(data) {
|
|
if (!data || !data.session) return;
|
|
if (!S.session || S.session.session_id !== sid) return; // session switched
|
|
S.messages = data.session.messages || []; // populate S
|
|
_expandOutlineRenderWindow();
|
|
if (typeof renderMessages === 'function') renderMessages({ preserveScroll: true });
|
|
window.setTimeout(function() {
|
|
if (!S.session || S.session.session_id !== sid) return;
|
|
const r = document.getElementById('msg-user-' + rawIdx);
|
|
if (r) { r.scrollIntoView({ block: 'center', behavior: 'smooth' }); _flashRow(r); }
|
|
}, 120);
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
// Brief highlight flash on a message row after jumping.
|
|
function _flashRow(row) {
|
|
if (!row) return;
|
|
row.classList.remove('outline-jump-flash');
|
|
void row.offsetWidth; // reflow to restart animation
|
|
row.classList.add('outline-jump-flash');
|
|
window.setTimeout(function() { row.classList.remove('outline-jump-flash'); }, 1200);
|
|
}
|
|
|
|
// Builds the list of user messages from S.messages.
|
|
// Returns [{rawIdx, label, excerpt}, …] for every user message with content.
|
|
function _buildEntries() {
|
|
const msgs = (S && S.messages) || [];
|
|
const entries = [];
|
|
let userN = 0;
|
|
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
const m = msgs[i];
|
|
if (!m || m.role !== 'user') continue;
|
|
const text = _excerptText(m.content);
|
|
if (!text) continue;
|
|
userN++;
|
|
entries.push({ rawIdx: i, label: userN, excerpt: text });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// Renders the panel body. Called every time the panel opens or session changes.
|
|
function _renderPanel() {
|
|
const panel = document.getElementById('outlinePanel');
|
|
if (!panel) return;
|
|
|
|
const sid = _currentSid();
|
|
|
|
// Session-scoped staleness guard.
|
|
if (!sid) {
|
|
panel.innerHTML = '<p class="outline-empty">' + t('outline_empty') + '</p>';
|
|
_outlineSid = null;
|
|
return;
|
|
}
|
|
|
|
if (!S.messages) {
|
|
panel.innerHTML = '<p class="outline-empty">' + t('outline_loading') + '</p>';
|
|
_outlineSid = sid;
|
|
return;
|
|
}
|
|
|
|
_outlineSid = sid;
|
|
const entries = _buildEntries();
|
|
|
|
if (!entries.length) {
|
|
panel.innerHTML = '<p class="outline-empty">' + t('outline_empty') + '</p>';
|
|
return;
|
|
}
|
|
|
|
const items = entries.map(function(e) {
|
|
return '<button class="outline-entry" type="button" ' +
|
|
'onclick="window._outlineJump(' + e.rawIdx + ')">' +
|
|
'<span class="outline-entry-num">' + e.label + '</span>' +
|
|
'<span class="outline-entry-text">' + _escHtml(e.excerpt) + '</span>' +
|
|
'</button>';
|
|
});
|
|
|
|
panel.innerHTML = items.join('');
|
|
}
|
|
|
|
// Simple HTML-escape for entry text.
|
|
function _escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// Opens or closes the outline panel.
|
|
function toggleOutlinePanel() {
|
|
if (!_outlineAllowed()) {
|
|
applyConversationOutlinePreference();
|
|
return;
|
|
}
|
|
_panelOpen = !_panelOpen;
|
|
const wrapper = document.getElementById('outlinePanelWrapper');
|
|
if (!wrapper) return;
|
|
|
|
if (_panelOpen) {
|
|
_syncOutlinePosition();
|
|
wrapper.hidden = false;
|
|
const sid = _currentSid();
|
|
const panel = document.getElementById('outlinePanel');
|
|
if (panel) panel.innerHTML = '<p class="outline-empty">' + t('outline_loading') + '</p>';
|
|
_ensureOutlineMessagesLoaded(sid).then(function() {
|
|
if (!_panelOpen || _currentSid() !== sid) return;
|
|
_renderPanel();
|
|
// Keep rendered data fresh after every renderMessages() call.
|
|
_outlineSid = _currentSid();
|
|
});
|
|
} else {
|
|
wrapper.hidden = true;
|
|
}
|
|
}
|
|
|
|
// Jump target exposed on window so inline onclick handlers can reach it.
|
|
window._outlineJump = _jumpToMessage;
|
|
window.applyConversationOutlinePreference = applyConversationOutlinePreference;
|
|
|
|
// Re-render after renderMessages() if the panel is open and the session
|
|
// changed or new messages arrived since the last render.
|
|
(function _hookRenderMessages() {
|
|
if (typeof window._outlineRenderHooked !== 'undefined') return;
|
|
|
|
const _orig = window.renderMessages;
|
|
if (typeof _orig !== 'function') {
|
|
// renderMessages may not be defined yet — retry after DOMContentLoaded.
|
|
if (!window._outlineRenderHookPending) {
|
|
window._outlineRenderHookPending = true;
|
|
document.addEventListener('DOMContentLoaded', _hookRenderMessages, { once: true });
|
|
}
|
|
return;
|
|
}
|
|
window._outlineRenderHooked = true;
|
|
window._outlineRenderHookPending = false;
|
|
window.renderMessages = function() {
|
|
const result = _orig.apply(this, arguments);
|
|
if (_panelOpen) {
|
|
const sid = _currentSid();
|
|
if (sid && (sid !== _outlineSid || (S.messages || []).length > 0)) {
|
|
_renderPanel();
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
})();
|
|
|
|
// Expose public API.
|
|
window.toggleOutlinePanel = toggleOutlinePanel;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
applyConversationOutlinePreference();
|
|
const root = document.documentElement;
|
|
const rightPanel = document.querySelector('.rightpanel');
|
|
if (rightPanel && typeof ResizeObserver !== 'undefined' && !_outlineResizeObserver) {
|
|
_outlineResizeObserver = new ResizeObserver(_syncOutlinePosition);
|
|
_outlineResizeObserver.observe(rightPanel);
|
|
}
|
|
if (!_outlineWorkspaceObserver) {
|
|
_outlineWorkspaceObserver = new MutationObserver(applyConversationOutlinePreference);
|
|
_outlineWorkspaceObserver.observe(root, {
|
|
attributes: true,
|
|
attributeFilter: ['data-workspace-panel']
|
|
});
|
|
// Also re-evaluate when the active main panel changes. switchPanel() is a
|
|
// global function declaration (called via inline onclick), so it can't be
|
|
// reliably wrapped from this script; instead we watch the `showing-<panel>`
|
|
// class it toggles on <main>. The outline is a chat-only affordance, so this
|
|
// hides the toggle + closes the panel when leaving chat (settings, tasks,
|
|
// insights, …) and restores the toggle on return to chat. _outlineAllowed()
|
|
// reads _currentPanel for the actual gate; this observer just triggers it.
|
|
const mainEl = document.querySelector('main.main');
|
|
if (mainEl) {
|
|
_outlineWorkspaceObserver.observe(mainEl, {
|
|
attributes: true,
|
|
attributeFilter: ['class']
|
|
});
|
|
}
|
|
}
|
|
});
|
|
window.addEventListener('resize', applyConversationOutlinePreference);
|
|
|
|
})();
|