diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index e19ef7ce..85963eb0 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -1,3 +1,4 @@ +import './utils/footerPreferences.js'; import { openSettingsTabAndFocus } from './utils/settingsNavigation.js'; import { parseStoredContextValue } from './utils/runtimeIdentityBootstrap.js'; import { applyLocalModelPreferenceOverlay, getEffectiveLocalModelPreference } from './utils/localModelPreferences.js'; diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 0aa27541..81f59579 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -1,3 +1,4 @@ +import { resetFooterPreferences } from './utils/footerPreferences.js'; import { modelSettingsRequest, renderModelInventory } from './modelInventory.js'; import { profileButton } from './components/participantProfile.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_TRAY_HOVER_KEY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; @@ -222,10 +223,12 @@ const SETTINGS_CONCERN_CONFIG = Object.freeze({ conversations: Object.freeze({ sectionIds: Object.freeze([ 'conversation-history-settings', + 'footer-settings', 'speech-settings', ]), anchorIds: Object.freeze([ 'conversation-history-settings', + 'footer-settings', 'speech-settings', ]), }), @@ -5548,6 +5551,7 @@ document.getElementById('refreshDeprecationMetricsButton')?.addEventListener('cl // Reset local preferences button document.getElementById('resetLocalPrefsButton')?.addEventListener('click', () => { try { + resetFooterPreferences(); setStoredJson(LS_ORG_KEY, null); setSessionScopedNamespace(null); localStorage.removeItem(LS_LANG_KEY); diff --git a/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js b/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js index 4a55ffa7..6043f6ab 100644 --- a/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js +++ b/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js @@ -55,18 +55,18 @@ function mergeCost(base, overlay) { function formatAmount(amount, currency) { if (amount === null) return ''; - const roundToCents = amount > 0.02; + const belowCent = amount > 0 && amount < 0.01; + const displayedAmount = belowCent ? 0.01 : amount; + let formatted; try { - return new Intl.NumberFormat('en-NZ', { - style: 'currency', - currency, - minimumFractionDigits: roundToCents ? 2 : (amount > 0 && amount < 0.01 ? 4 : 2), - maximumFractionDigits: roundToCents ? 2 : 6, - }).format(amount); + formatted = new Intl.NumberFormat('en-NZ', { + style: 'currency', currency, + minimumFractionDigits: 2, maximumFractionDigits: 2, + }).format(displayedAmount); } catch (_) { - const fractionDigits = roundToCents ? 2 : 6; - return `${currency === 'USD' ? 'US$' : `${currency} `}${amount.toFixed(fractionDigits)}`; + formatted = `${currency === 'USD' ? 'US$' : `${currency} `}${displayedAmount.toFixed(2)}`; } + return `${belowCent ? '< ' : ''}${formatted}`; } function describeCost(cost, { compact = false } = {}) { diff --git a/src/frontend/web/von_interface/static/js/utils/footerPreferences.js b/src/frontend/web/von_interface/static/js/utils/footerPreferences.js new file mode 100644 index 00000000..84957882 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/footerPreferences.js @@ -0,0 +1,84 @@ +// Presentation preferences are browser-local, like conversation layout choices. +export const FOOTER_PREFERENCES_KEY = 'von:footerVisibility'; +export const FOOTER_ITEMS = Object.freeze({ + model: ['Model', true], + conversationCost: ['Conversation cost estimate', true], + user: ['User', true], + organisation: ['Organisation', true], + language: ['Language', false], + database: ['Database and connection details', false], + uptime: ['Server uptime', false], + runtimeCost: ['Cost estimate since server restart', false], + build: ['Build revision and timestamp', false], + diagnostics: ['Expert process and indexing details (when available)', false], +}); + +let fallback = null; +export function loadFooterPreferences() { + let stored = fallback; + if (!stored) { + try { stored = JSON.parse(localStorage.getItem(FOOTER_PREFERENCES_KEY)); } catch (_) { } + } + return Object.fromEntries(Object.entries(FOOTER_ITEMS).map(([key, [, defaultValue]]) => + [key, typeof stored?.[key] === 'boolean' ? stored[key] : defaultValue])); +} + +export function applyFooterPreferences() { + const preferences = loadFooterPreferences(); + for (const [key, value] of Object.entries(preferences)) { + document.documentElement.setAttribute(`data-footer-${key.toLowerCase()}`, String(value)); + const control = document.querySelector(`[data-footer-preference="${key}"]`); + if (control) control.checked = value; + } +} + +export function saveFooterPreference(key, value) { + if (!Object.hasOwn(FOOTER_ITEMS, key)) return; + const preferences = { ...loadFooterPreferences(), [key]: Boolean(value) }; + try { + localStorage.setItem(FOOTER_PREFERENCES_KEY, JSON.stringify(preferences)); + fallback = null; + } catch (_) { fallback = preferences; } + applyFooterPreferences(); + // Embedded Settings shares localStorage but does not get a storage event in + // the writing window. Notify its parent without reloading footer data. + try { window.parent.dispatchEvent(new CustomEvent('von-footer-preferences-changed')); } catch (_) { } +} + +export function resetFooterPreferences() { + try { + localStorage.removeItem(FOOTER_PREFERENCES_KEY); + fallback = null; + } catch (_) { + fallback = Object.fromEntries(Object.entries(FOOTER_ITEMS).map(([key, [, value]]) => [key, value])); + } + applyFooterPreferences(); + try { window.parent.dispatchEvent(new CustomEvent('von-footer-preferences-changed')); } catch (_) { } +} + +export function mountFooterPreferences() { + const container = document.getElementById('footerVisibilitySettings'); + if (container && !container.children.length) { + for (const [key, [name]] of Object.entries(FOOTER_ITEMS)) { + const label = document.createElement('label'); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.footerPreference = key; + input.addEventListener('change', () => saveFooterPreference(key, input.checked)); + label.append(input, document.createTextNode(` ${name}`)); + container.append(label); + } + } + applyFooterPreferences(); +} + +window.addEventListener('storage', (event) => { + if (event.key === FOOTER_PREFERENCES_KEY || event.key === null) applyFooterPreferences(); +}); +window.addEventListener('von-footer-preferences-changed', applyFooterPreferences); +applyFooterPreferences(); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mountFooterPreferences); +} else { + mountFooterPreferences(); +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 092b7541..5c2e86e5 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -6566,7 +6566,7 @@ footer { left: 0; right: 0; min-height: 40px; - /* Allow footer to grow if content wraps */ + /* Keep footer information on one scrollable row. */ margin-top: 0; /* REMOVED: margin-top as it's fixed */ font-size: 0.8rem; @@ -6592,7 +6592,7 @@ footer { left: 0; right: 0; min-height: 40px; - /* Allow footer to grow if content wraps */ + /* Keep footer information on one scrollable row. */ display: flex; align-items: center; justify-content: space-between; @@ -13360,12 +13360,6 @@ button.prompt-vontology-cartouche { color: #991b1b; } -@media (max-width: 760px) { - .server-build-info { - display: none; - } -} - .footer-separator { margin: 0 4px; opacity: 0.5; @@ -18639,3 +18633,60 @@ body.settings-body { min-height: 44px; padding: 4px; } + +/* Footer presentation preferences also apply to the narrow-screen details panel. */ +.footer-container, +.mobile-footer-details-panel .footer-container { + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + overflow-x: auto; + white-space: nowrap; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.footer-container > *, +.footer-container .footer-segment, +.footer-container .db-conn-badge { flex: 0 0 auto; } +.footer-container .footer-info, +.mobile-footer-details-panel .footer-info, +.footer-container .pid-indicator { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + white-space: nowrap; +} +.footer-container .footer-separator { display: none; } +.footer-container .footer-info:empty, +.footer-container .pid-indicator:empty { display: none; } +.footer-visibility-settings { display: grid; gap: 10px; } +.footer-visibility-settings label { display: flex; align-items: center; gap: 6px; } +/* Fixed panels escape the scroll container's clipping boundary. */ +.footer-container .footer-org-menu, +.footer-container .conversation-runtime-cost-details { + position: fixed; + left: 12px; + right: auto; + bottom: var(--von-fixed-footer-clearance, 64px); + white-space: normal; + max-height: min(420px, 60vh); + overflow-y: auto; +} +.mobile-footer-details-panel .footer-container .conversation-runtime-cost-details { + top: calc(var(--von-fixed-shell-height, 60px) + 72px); + bottom: auto; +} +html[data-footer-model="false"] .footer-container .conversation-model-controls { display: none; } +html[data-footer-conversationcost="false"] .footer-container #modelInfoFooter > .conversation-runtime-cost-segment { display: none; } +html[data-footer-user="false"] .footer-container .footer-user-segment { display: none; } +html[data-footer-organisation="false"] .footer-container .footer-org-switcher { display: none; } +html[data-footer-language="false"] .footer-container #languageIndicator { display: none; } +html[data-footer-database="false"] .footer-container .db-conn-badge { display: none; } +html[data-footer-uptime="false"] .footer-container #serverUptimeInfo { display: none; } +html[data-footer-runtimecost="false"] .footer-container #serverRuntimeCostInfo { display: none; } +html[data-footer-build="false"] .footer-container #serverBuildInfo { display: none; } +html[data-footer-diagnostics="false"] .footer-container #serverPidFooter { display: none; } +html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-build="false"] #serverUptimeFooter { display: none; } + +.footer-container [hidden] { display: none !important; } diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index ae700ffa..157eb63c 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -121,6 +121,13 @@

Current Organisation Configuration

+ +

Conversations

These preferences are stored in this browser and control which conversations appear diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index eeafaa52..cdcfafb3 100644 --- a/src/frontend/web/von_interface/templates/von_interface.html +++ b/src/frontend/web/von_interface/templates/von_interface.html @@ -188,13 +188,13 @@

-