diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 47229bccf..3b0f2c5a2 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -13,7 +13,10 @@ import { subscribeToWorkflowCapabilityIndexStatus, } from './utils/workflowCapabilityStatusCoordinator.js'; import { createFooterOrganisationSwitcher } from './components/footerOrganisationSwitcher.js'; -import { selectShortestNameForContext } from './utils/nameSelection.js'; +import { + selectAbbreviationForContext, + selectShortestNameForContext, +} from './utils/nameSelection.js'; export const elements = {}; @@ -92,6 +95,58 @@ const VON_SYSTEM_CONCEPT_ID = '#V#von_system'; const VON_DOCUMENT_TITLE = 'Von'; let _documentTitleRequestGeneration = 0; +const footerCompactIdentityLabelRequests = new Map(); + +function normaliseFooterIdentityText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function getFooterCompactIdentityLabel(conceptId) { + const normalisedConceptId = normaliseFooterIdentityText(conceptId); + if (!normalisedConceptId) return Promise.resolve(null); + + const existing = footerCompactIdentityLabelRequests.get(normalisedConceptId); + if (existing) return existing; + + const request = fetch(`/api/concepts/${encodeURIComponent(normalisedConceptId)}`) + .then(async (response) => { + if (!response.ok) return null; + const concept = await response.json(); + const names = Array.isArray(concept?.names) && concept.names.length + ? concept.names + : (Array.isArray(concept?.raw_doc?.names) ? concept.raw_doc.names : []); + return normaliseFooterIdentityText(selectAbbreviationForContext(names)); + }) + .catch(() => null); + + footerCompactIdentityLabelRequests.set(normalisedConceptId, request); + return request; +} + +function queueFooterCompactIdentityLabel({ + button, + identityKind, + conceptId, + fullName, +}) { + if (!button || !conceptId) return; + const semanticName = normaliseFooterIdentityText(fullName) || conceptId; + button.dataset.footerIdentityConceptId = conceptId; + + void getFooterCompactIdentityLabel(conceptId).then((compactName) => { + if (!compactName || button.dataset.footerIdentityConceptId !== conceptId) return; + + button.textContent = compactName; + button.setAttribute( + 'aria-label', + `${identityKind}: ${semanticName}. Compact label: ${compactName}. Open in Vontology.`, + ); + setKeptNativeTitle( + button, + `${identityKind}: ${semanticName}\nCompact label: ${compactName}\nID: ${conceptId}`, + ); + }); +} function formatVonDocumentTitle(organisationName) { const name = typeof organisationName === 'string' ? organisationName.trim() : ''; @@ -1494,6 +1549,7 @@ export async function setModelInfoFooterText() { // Build dynamic segments (User / Org as concept buttons) const segments = []; + const footerIdentityTargets = []; function makeConceptButton(labelPrefix, displayName, conceptId, conceptName) { const span = document.createElement('span'); @@ -1728,13 +1784,32 @@ export async function setModelInfoFooterText() { // Always render a user segment with a button so downstream highlight logic and tests have a consistent target. const dName = (userInfo && (userInfo.name || userInfo.conceptId)) ? (userInfo.name || userInfo.conceptId) : 'User'; // We do not pass concept id unless actually known to avoid misleading navigation. - segments.push(makeConceptButton('User', dName, userInfo?.conceptId || null, userInfo?.name || null)); + const userSegment = makeConceptButton( + 'User', + dName, + userInfo?.conceptId || null, + userInfo?.name || null, + ); + footerIdentityTargets.push({ + button: userSegment.querySelector('.concept-footer-button'), + identityKind: 'User', + conceptId: userInfo?.conceptId || null, + fullName: userInfo?.name || dName, + }); + segments.push(userSegment); } // Organisation segment: retain concept navigation and add direct switching. - segments.push(createFooterOrganisationSwitcher({ + const organisationSegment = createFooterOrganisationSwitcher({ organisationInfo: orgInfo, onOpenConcept: openFooterConcept, - })); + }); + footerIdentityTargets.push({ + button: organisationSegment.querySelector('.footer-org-current-button'), + identityKind: 'Organisation', + conceptId: orgInfo?.conceptId || null, + fullName: orgInfo?.name || orgInfo?.conceptId || null, + }); + segments.push(organisationSegment); if (workflowCapabilityStatus && workflowCapabilityStatus.ready === false) { const workflowIndexSegment = makeActionButton( @@ -1857,6 +1932,10 @@ export async function setModelInfoFooterText() { } } catch (_) { /* non-fatal */ } + // Apply abbreviations only after the authentication status pass so it cannot + // replace the full identity tooltip with a generic login-only one. + footerIdentityTargets.forEach(queueFooterCompactIdentityLabel); + // Clicking empty space (not concept buttons) still opens settings footer.style.cursor = 'pointer'; setKeptNativeTitle(footer, 'Click empty area to open settings'); diff --git a/src/frontend/web/von_interface/static/js/utils/nameSelection.js b/src/frontend/web/von_interface/static/js/utils/nameSelection.js index 844c6d15a..464efdfef 100644 --- a/src/frontend/web/von_interface/static/js/utils/nameSelection.js +++ b/src/frontend/web/von_interface/static/js/utils/nameSelection.js @@ -272,5 +272,25 @@ export function selectShortestNameForContext(names, preferredLanguage = getPrefe return filtered[0]?.text || null; } -export { DEFAULT_LANGUAGE }; +/** + * Choose a represented abbreviation for dense UI while retaining a separate + * full label for accessible naming and descriptions. An abbreviation is an + * explicit piece of concept data, not a guess made by shortening prose. + */ +export function selectAbbreviationForContext(names, preferredLanguage = getPreferredLanguage()) { + if (!Array.isArray(names) || names.length === 0) { + return null; + } + const abbreviations = names.filter((entry) => { + const type = (entry?.type ?? entry?.name_type ?? '') + .toString() + .trim() + .toUpperCase(); + return type === 'ABBR'; + }); + + return selectShortestNameForContext(abbreviations, preferredLanguage); +} + +export { DEFAULT_LANGUAGE }; diff --git a/tests/frontend/footerCompactIdentityLabels.test.js b/tests/frontend/footerCompactIdentityLabels.test.js new file mode 100644 index 000000000..d1af0edce --- /dev/null +++ b/tests/frontend/footerCompactIdentityLabels.test.js @@ -0,0 +1,103 @@ +/** @jest-environment jsdom */ + +const domUtilsPath = '../../src/frontend/web/von_interface/static/js/domUtils.js'; + +function findFooterSegment(label) { + return [...document.querySelectorAll('.footer-segment')] + .find((segment) => segment.querySelector('.footer-label-inline')?.textContent?.trim() === `${label}:`); +} + +async function flushUiTicks(ticks = 4) { + for (let index = 0; index < ticks; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe('footer compact identity labels', () => { + beforeEach(() => { + jest.resetModules(); + document.body.innerHTML = '
'; + localStorage.clear(); + sessionStorage.clear(); + localStorage.setItem('von_current_user', JSON.stringify({ + concept_id: '#V#michael_witbrock', + name: 'michael.witbrock@example.test', + })); + sessionStorage.setItem('von_current_user', localStorage.getItem('von_current_user')); + sessionStorage.setItem('von_current_org', JSON.stringify({ + concept_id: '#V#university_of_auckland_strong_ai_lab', + name: 'University of Auckland Strong AI Lab', + })); + + global.fetch = jest.fn(async (url) => { + const parsed = new URL(String(url), 'http://localhost'); + if (parsed.pathname === '/api/settings/' || parsed.pathname === '/api/settings') { + return { ok: true, json: async () => ({ resolved_llm: null }) }; + } + if (parsed.pathname === '/api/settings/llm/info' || parsed.pathname === '/api/settings/db/info') { + return { ok: true, json: async () => ({}) }; + } + if (parsed.pathname === '/api/workflows/capability-index/status') { + return { ok: true, json: async () => ({ ready: true, status: 'ready' }) }; + } + if (parsed.pathname === '/von/api/auth/status' || parsed.pathname === '/api/auth/status') { + return { + ok: true, + json: async () => ({ authenticated: true, email: 'michael.witbrock@example.test' }), + }; + } + if (parsed.pathname === '/von/api/organisations/my_organisations') { + return { + ok: true, + json: async () => ({ organisations: [{ + concept_id: '#V#university_of_auckland_strong_ai_lab', + name: 'University of Auckland Strong AI Lab', + role: 'owner', + }] }), + }; + } + if (parsed.pathname === '/api/concepts/%23V%23michael_witbrock') { + return { + ok: true, + json: async () => ({ names: [ + { name: 'Michael Witbrock', language: 'en-NZ', type: 'NL' }, + { name: 'MJW', language: 'en-NZ', type: 'ABBR' }, + ] }), + }; + } + if (parsed.pathname === '/api/concepts/%23V%23university_of_auckland_strong_ai_lab') { + return { + ok: true, + json: async () => ({ names: [ + { name: 'University of Auckland Strong AI Lab', language: 'en-NZ', type: 'NL' }, + { name: 'SAIL', language: 'en-NZ', type: 'ABBR' }, + ] }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + }); + + test('uses canonical abbreviations for the dense footer without losing full identity semantics', async () => { + const { setModelInfoFooterText } = require(domUtilsPath); + + await setModelInfoFooterText(); + await flushUiTicks(); + + const userButton = findFooterSegment('User')?.querySelector('.concept-footer-button'); + expect(userButton?.textContent).toBe('MJW'); + expect(userButton?.getAttribute('aria-label')).toContain('User: michael.witbrock@example.test'); + expect(userButton?.getAttribute('aria-label')).toContain('Compact label: MJW'); + + const orgButton = findFooterSegment('Org')?.querySelector('.footer-org-current-button'); + expect(orgButton?.textContent).toBe('SAIL'); + expect(orgButton?.getAttribute('aria-label')).toContain('Organisation: University of Auckland Strong AI Lab'); + expect(orgButton?.getAttribute('aria-label')).toContain('Compact label: SAIL'); + }); +}); diff --git a/tests/frontend/nameSelection.test.js b/tests/frontend/nameSelection.test.js index 57107b63d..723c49399 100644 --- a/tests/frontend/nameSelection.test.js +++ b/tests/frontend/nameSelection.test.js @@ -1,4 +1,7 @@ -import { selectBestNameForContext } from '../../src/frontend/web/von_interface/static/js/utils/nameSelection.js'; +import { + selectAbbreviationForContext, + selectBestNameForContext, +} from '../../src/frontend/web/von_interface/static/js/utils/nameSelection.js'; describe('nameSelection', () => { test('prefers exact language match over others', () => { @@ -84,4 +87,20 @@ describe('nameSelection', () => { expect(selectBestNameForContext(names, 'en-NZ')).toBe('#V#thing'); }); + + test('selects an explicitly represented abbreviation for compact identity UI', () => { + const names = [ + { name: 'Michael Witbrock', language: 'en-NZ', type: 'NL' }, + { name: 'MJW', language: 'en-NZ', type: 'ABBR' }, + { name: 'MW', language: 'en-US', type: 'ABBR' }, + ]; + + expect(selectAbbreviationForContext(names, 'en-NZ')).toBe('MJW'); + }); + + test('does not invent a compact name when no abbreviation is represented', () => { + expect(selectAbbreviationForContext([ + { name: 'University of Auckland Strong AI Lab', language: 'en-NZ', type: 'NL' }, + ], 'en-NZ')).toBeNull(); + }); });