Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/frontend/web/von_interface/static/js/domUtils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/web/von_interface/static/js/settingsPage.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
]),
}),
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
67 changes: 59 additions & 8 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
7 changes: 7 additions & 0 deletions src/frontend/web/von_interface/templates/settings_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ <h2>Current Organisation Configuration</h2>
</div>
</section>

<section id="footer-settings" data-settings-concern="conversations">
<h2>Footer</h2>
<p class="settings-note">Choose what appears in the footer. These preferences are saved in this browser.
Extra information scrolls horizontally.</p>
<div id="footerVisibilitySettings" class="footer-visibility-settings"></div>
</section>

<section id="conversation-history-settings" data-settings-concern="conversations">
<h2>Conversations</h2>
<p class="settings-note">These preferences are stored in this browser and control which conversations appear
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/web/von_interface/templates/von_interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,13 @@ <h1 class="global-organisation-title" title="Current organisation">
</div>

<!-- Footer for Model Info -->
<div class="footer-container">
<div class="footer-container" tabindex="0" role="region" aria-label="Footer information">
<p id="modelInfoFooter" class="footer-info">

</p>
<p id="languageIndicator" class="language-indicator">🌐 en-NZ</p>
<p id="serverUptimeFooter" class="pid-indicator" title="Process uptime">
<span class="footer-label">Uptime:</span> <span id="serverUptimeValue" title="Process uptime">-</span>
<span id="serverUptimeInfo"><span class="footer-label">Uptime:</span> <span id="serverUptimeValue" title="Process uptime">-</span></span>
<span id="serverRuntimeCostInfo" class="conversation-runtime-cost-segment server-runtime-cost-segment" hidden>
<span class="footer-separator">|</span>
<button id="serverRuntimeCostButton"
Expand Down
104 changes: 104 additions & 0 deletions tests/browser/footerPresentation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Candidate-source browser fixture: no backend, credentials, or model requests.
const { chromium, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
const template = fs.readFileSync(path.join(root, 'templates/von_interface.html'), 'utf8');
const footer = template.slice(template.indexOf(' <!-- Footer for Model Info -->'), template.indexOf(' <!-- RAG Status Modal -->')).replace(/{%.*?%}/g, '');
const settings = fs.readFileSync(path.join(root, 'templates/settings_tab.html'), 'utf8').match(/<section id="footer-settings"[\s\S]*?<\/section>/)[0];
const html = `<link rel="stylesheet" href="/static/styles.css">${settings}${footer}<script type="module">
import { setModelInfoFooterText } from '/static/js/domUtils.js';
import { mountFooterPreferences } from '/static/js/utils/footerPreferences.js';
localStorage.setItem('von_current_user', JSON.stringify({name:'Footer Test User', concept_id:'#V#footer_test'}));
const summary = { estimated_cost: {status:'estimated', currency:'USD', amount:0.014889} };
window.__vonConversationRuntimeCostSnapshot = {context:{conversation_session_id:'footer-fixture'}, baseline:{conversation:summary,since_restart:summary}};
await setModelInfoFooterText();
mountFooterPreferences();
document.querySelector('#serverBuildInfo').hidden = false;
document.querySelector('#serverBuildValue').textContent = 'abcdef123456 2026-09-13T16:40:50.344900+00:00';
window.fixtureReady = true;
</script>`;
const server = http.createServer((req, res) => {
if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(html); }
if (!req.url.startsWith('/static/') || req.url.includes('..')) { res.writeHead(404); return res.end(); }
const file = path.join(root, req.url.split('?')[0]);
res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css');
try { res.end(fs.readFileSync(file)); } catch { res.writeHead(404); res.end(); }
});
(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({headless:true});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/api/**', route => {
const url = route.request().url();
let body = {};
if (/\/settings\/?$/.test(url)) body = {active_llm:{model:'gpt-6-astra',provider:'openai'}};
if (url.includes('/auth/status')) body = {authenticated:true,email:'footer@example.test'};
if (url.includes('capability-index/status')) body = {ready:true,status:'ready'};
if (url.includes('my_organisations')) body = {organisations:[{concept_id:'#V#footer_org',name:'Footer Fixture Organisation',role:'member'}]};
return route.fulfill({json:body});
});
const evidence = [];
try {
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.waitForFunction(() => window.fixtureReady);
await expect(page.locator('#serverUptimeFooter')).toBeHidden();
await expect(page.locator('#languageIndicator')).toBeHidden();
await expect(page.locator('[data-cost-scope="conversation"]')).toContainText('US$0.01');
for (const checkbox of await page.locator('[data-footer-preference]').all()) await checkbox.check();
await expect(page.locator('#serverBuildInfo')).toBeVisible();
await page.reload();
await page.waitForFunction(() => window.fixtureReady);
await expect(page.locator('[data-footer-preference="build"]')).toBeChecked();
for (const width of [1297, 493, 320]) {
await page.setViewportSize({width,height:800});
await expect(page.locator('#serverBuildInfo')).toBeVisible();
const measurement = await page.locator('.footer-container').evaluate(el => {
const items = [...el.children].filter(child => getComputedStyle(child).display !== 'none');
const centres = items.map(child => { const r = child.getBoundingClientRect(); return r.y+r.height/2; });
el.scrollLeft = el.scrollWidth;
return {width:innerWidth, clientWidth:el.clientWidth, scrollWidth:el.scrollWidth, scrollLeft:el.scrollLeft, centreSpread:Math.max(...centres)-Math.min(...centres), height:el.getBoundingClientRect().height};
});
expect(measurement.centreSpread).toBeLessThan(2);
if (width <= 493) expect(measurement.scrollLeft).toBeGreaterThan(0);
evidence.push(measurement);
await page.locator('.footer-container').evaluate(el => { el.scrollLeft = 0; });
await page.locator('[data-cost-scope="conversation"]').click();
const panel = page.locator('#modelInfoFooter .conversation-runtime-cost-details');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
expect(await panel.evaluate(el => {const r=el.getBoundingClientRect();return el.contains(document.elementFromPoint(r.x+5,r.y+5));})).toBe(true);
await page.locator('[data-cost-scope="conversation"]').click();
}
await page.locator('.footer-org-menu-trigger').click();
const organisationMenu = page.locator('.footer-org-menu');
await expect(organisationMenu).toBeVisible();
expect(await organisationMenu.evaluate(el => {const r=el.getBoundingClientRect();return el.contains(document.elementFromPoint(r.x+5,r.y+5));})).toBe(true);
await page.locator('.footer-org-menu-trigger').click();
// The responsive shell reparents the same footer into its details panel.
await page.evaluate(() => {
const panel = document.createElement('div');
panel.className = 'mobile-footer-details-panel';
document.body.append(panel);
panel.append(document.querySelector('.footer-container'));
});
const mobile = await page.locator('.footer-container').evaluate(el => ({height:el.getBoundingClientRect().height, width:el.clientWidth, scrollWidth:el.scrollWidth, direction:getComputedStyle(el).flexDirection}));
expect(mobile.direction).toBe('row');
expect(mobile.scrollWidth).toBeGreaterThan(mobile.width);
expect(mobile.height).toBeLessThan(80);
evidence.push({mobileDetails:mobile});
await page.locator('[data-footer-preference="conversationCost"]').uncheck();
await expect(page.locator('[data-cost-scope="conversation"]')).toBeHidden();
expect(errors).toEqual([]);
const output = process.argv[2] || '.run/footer-presentation';
fs.mkdirSync(output,{recursive:true});
await page.screenshot({path:path.join(output,'footer-320.png'),fullPage:true});
fs.writeFileSync(path.join(output,'measurements.json'), JSON.stringify({fixture:'candidate source with mocked API reads',evidence},null,2));
console.log(JSON.stringify(evidence));
} finally { await browser.close(); server.close(); }
})().catch(error => { console.error(error); server.close(); process.exitCode=1; });
9 changes: 0 additions & 9 deletions tests/frontend/footerBuildInfo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,4 @@ describe('footer build information', () => {
expect(mainSource).toContain('commitTimestamp ? `Commit time ${commitTimestamp}` : null');
});

test('hides build information, but not uptime, below the footer crowding threshold', () => {
expect(styles).toMatch(
/@media \(max-width:\s*760px\)\s*\{\s*\.server-build-info\s*\{\s*display:\s*none;/
);
expect(styles).not.toMatch(
/@media \(max-width:\s*760px\)[\s\S]*?#serverUptimeFooter\s*\{\s*display:\s*none;/
);
expect(styles).toMatch(/#serverUptimeFooter\s*\{\s*white-space:\s*nowrap;/);
});
});
Loading
Loading