From 728ad7ce2784b29f15a59b403bbd48498839337f Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 21:28:45 -0400 Subject: [PATCH] Remove conversation-row ellipses and preserve accessible menu routes --- .../engineering/chat_steering_and_queueing.md | 10 ++++ .../web/von_interface/static/js/chatTab.js | 25 ++++++++-- .../js/components/conversationCatalogue.js | 27 +++------- .../js/components/conversationRowMenu.js | 49 ++++++++++++++++++ .../web/von_interface/static/styles.css | 17 +++++-- tests/browser/messageConversationMenu.cjs | 50 +++++++++++++++---- .../chatSessionAgentCreatedFilter.test.js | 24 +++++++++ .../frontend/messageConversationMenu.test.js | 29 +++++++++-- 8 files changed, 189 insertions(+), 42 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/conversationRowMenu.js diff --git a/docs/engineering/chat_steering_and_queueing.md b/docs/engineering/chat_steering_and_queueing.md index 482a13e3f..7173ca389 100644 --- a/docs/engineering/chat_steering_and_queueing.md +++ b/docs/engineering/chat_steering_and_queueing.md @@ -124,3 +124,13 @@ landscape sizes with deterministic API fixtures. It checks long-receipt draft width, action reachability, preferences, one-shot alternatives, cancellation, keyboard dismissal and exchange menu bounds. This is local fixture-backed UI evidence, not public authentication, physical-device or deployment acceptance. + +Conversation rows have no ellipsis button. Right-click, Control-click, +Shift+F10 or the Context Menu key open the existing options without selecting +another conversation. The list's **Selected conversation options** button gives +touch and keyboard users the same menu for the selected row. Row descriptions +and tooltips explain these routes. The menu supports arrow keys, Home/End, +Escape and focus return; it retains the original source-specific actions, +including message-exchange reference copying and participant profiles. +`tests/browser/messageConversationMenu.cjs` checks these routes, menu bounds and +list collapse/reopening using production markup/modules with isolated transport. diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index a1fe45a69..6d89b19db 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,4 @@ +import { bindConversationRowMenu } from './components/conversationRowMenu.js'; import { initialiseConversationActions } from './components/conversationActions.js'; import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './components/conversationNavigation.js'; import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js'; @@ -27679,11 +27680,11 @@ function renderChatSessionTabs(sessions, activeSessionId) { } void switchToChatSession(sid); }); - tab.addEventListener('dblclick', () => { + tab.addEventListener('dblclick', event => { + if (event.ctrlKey) return; void promptRenameChatSession(sid, session?.session_name || displayName); }); - tab.addEventListener('contextmenu', (event) => { - event.preventDefault(); + bindConversationRowMenu(tab, (x, y, options) => { // JVNAUTOSCI-1014: Build context menu with hide/unhide and optional delete const menuItems = [ { @@ -27746,7 +27747,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { }); } - openChatSessionMenu(event.clientX, event.clientY, menuItems); + openChatSessionMenu(x, y, menuItems, options); }); fragment.appendChild(tab); @@ -28082,6 +28083,19 @@ function ensureChatSessionMenu() { menu.setAttribute('role', 'menu'); menu.setAttribute('aria-hidden', 'true'); document.body.appendChild(menu); + menu.addEventListener('keydown', event => { + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')); + const current = items.indexOf(document.activeElement); + let next; + if (event.key === 'ArrowDown') next = (current + 1) % items.length; + else if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else if (event.key === 'Tab') { closeChatSessionMenu(); return; } + else return; + event.preventDefault(); + items[next]?.focus(); + }); document.addEventListener('click', (event) => { if (menu.classList.contains('open') && !menu.contains(event.target)) { @@ -28122,12 +28136,14 @@ export function openChatSessionMenu(x, y, items, options = {}) { } const menu = populateChatSessionMenu(items); + chatSessionMenuReturnFocusEl?.setAttribute('aria-expanded', 'false'); chatSessionMenuReturnFocusEl = options.returnFocus instanceof HTMLElement ? options.returnFocus : null; const padding = 8; menu.classList.add('open'); menu.setAttribute('aria-hidden', 'false'); + chatSessionMenuReturnFocusEl?.setAttribute('aria-expanded', 'true'); const maxX = window.innerWidth - menu.offsetWidth - padding; const maxY = window.innerHeight - menu.offsetHeight - padding; const left = Math.max(padding, Math.min(x, maxX)); @@ -28150,6 +28166,7 @@ function closeChatSessionMenu() { chatSessionMenuEl.classList.remove('open'); chatSessionMenuEl.setAttribute('aria-hidden', 'true'); chatSessionMenuReturnFocusEl = null; + returnFocusEl?.setAttribute('aria-expanded', 'false'); if (shouldRestoreFocus) { try { returnFocusEl.focus(); } catch (_) { /* ignore */ } } diff --git a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js index 0c342111c..ff07e198e 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js +++ b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js @@ -1,4 +1,5 @@ /** Message-source adapter for the existing conversation tray. */ +import { bindConversationRowMenu, createSelectedConversationOptions } from './conversationRowMenu.js'; import { copyTextWithClipboardFallback } from '../utils/copyJsonButtonState.js'; import { buildMessageStreamReference } from '../utils/messageStreamReference.js'; import { showToast } from '../utils/toast.js'; @@ -212,29 +213,14 @@ export function renderMessageConversationRow(row, { selected = false, pinned = f el.setAttribute('aria-label', el.title); el.append(header, meta, preview); el.addEventListener('click', () => void selectMessageConversation(row)); - const trigger = document.createElement('button'); - trigger.type = 'button'; - trigger.className = 'conversation-menu-trigger'; - trigger.textContent = '⋯'; - trigger.setAttribute('aria-label', `Conversation actions for ${row.session_name}`); - trigger.setAttribute('aria-haspopup', 'menu'); - header.append(trigger); - const open = async event => { - event.preventDefault(); - event.stopPropagation(); + el.dataset.sessionName = row.session_name; + bindConversationRowMenu(el, async (x, y, options) => { const showMenu = openMenu || (await import('../chatTab.js')).openChatSessionMenu; if (!el.isConnected) return; - const rect = trigger.getBoundingClientRect(); - showMenu(event.type === 'contextmenu' ? event.clientX : rect.left, - event.type === 'contextmenu' ? event.clientY : rect.bottom, - buildMessageConversationMenuItems(row, { pinned, togglePin, hide, hidden }), - { returnFocus: trigger, focusFirst: true }); - }; - trigger.addEventListener('click', open); - el.addEventListener('contextmenu', open); + showMenu(x, y, buildMessageConversationMenuItems(row, { pinned, togglePin, hide, hidden }), options); + }); el.addEventListener('keydown', event => { - if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) void open(event); - else if (event.target === el && (event.key === 'Enter' || event.key === ' ')) { + if (event.target === el && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); void selectMessageConversation(row); } }); @@ -275,6 +261,7 @@ export function buildMessageConversationMenuItems(row, { pinned, togglePin, hide } export function mountCatalogueControls(container) { + container.append(createSelectedConversationOptions(container)); const compose = document.createElement('button'); compose.type = 'button'; compose.className = 'chat-session-tab'; compose.textContent = 'Talk to a person or agent'; diff --git a/src/frontend/web/von_interface/static/js/components/conversationRowMenu.js b/src/frontend/web/von_interface/static/js/components/conversationRowMenu.js new file mode 100644 index 000000000..f0f8aeae8 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/conversationRowMenu.js @@ -0,0 +1,49 @@ +// Both conversation sources keep one menu and the same non-selecting gestures. +const rowMenus = new WeakMap(); +const guidance = 'Right-click, Control-click, or press Shift+F10 or the Context Menu key for conversation options. On touch, select a conversation and use Selected conversation options in the list.'; + +export function bindConversationRowMenu(row, showMenu) { + const open = (event, returnFocus = row) => { + event.preventDefault(); + event.stopPropagation(); + const rect = returnFocus.getBoundingClientRect(); + const pointer = (event.type === 'contextmenu' || event.type === 'click') + && (event.clientX || event.clientY); + showMenu(pointer ? event.clientX : rect.left, pointer ? event.clientY : rect.bottom, + { returnFocus, focusFirst: true }); + }; + rowMenus.set(row, open); + row.setAttribute('aria-haspopup', 'menu'); + row.setAttribute('aria-expanded', 'false'); + row.setAttribute('aria-keyshortcuts', 'Shift+F10'); + row.setAttribute('aria-description', guidance); + row.title += `\n${guidance}`; + row.setAttribute('data-keep-title', 'true'); + row.addEventListener('contextmenu', open); + // Capture before the row's ordinary selection handler, including on macOS + // browsers which dispatch a click as well as the native contextmenu event. + row.addEventListener('click', event => { + if (!event.ctrlKey) return; + event.stopImmediatePropagation(); + open(event); + }, true); + row.addEventListener('keydown', event => { + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) open(event); + }); +} + +export function createSelectedConversationOptions(container) { + const selected = container.querySelector('[data-session-id].is-active'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'selected-conversation-options'; + button.textContent = 'Selected conversation options'; + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', 'false'); + button.disabled = !rowMenus.has(selected); + button.title = selected + ? `Options for ${selected.dataset.sessionName || selected.getAttribute('aria-label')}. ${guidance}` + : 'Select a conversation to access its options. Rows also support right-click, Control-click and Shift+F10.'; + button.addEventListener('click', event => rowMenus.get(selected)?.(event, button)); + return button; +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 52868c235..2f5941275 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18638,11 +18638,20 @@ body.settings-body { .chat-scroll-to-end-btn { transition: none; } } -.conversation-menu-trigger { - margin-left: auto; - flex: 0 0 44px; +.selected-conversation-options { + flex: 0 0 auto; min-height: 44px; - padding: 4px; + font-size: 12px; + padding: 6px 10px; + max-width: 100%; + white-space: normal; +} +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .selected-conversation-options { display: none; } +.chat-session-menu button:focus-visible, +.selected-conversation-options:focus-visible, +.message-conversation-row:focus-visible { + outline: 3px solid #1d4ed8; + outline-offset: -3px; } /* Footer presentation preferences also apply to the narrow-screen details panel. */ diff --git a/tests/browser/messageConversationMenu.cjs b/tests/browser/messageConversationMenu.cjs index 59f1f9cb4..ca114ac11 100644 --- a/tests/browser/messageConversationMenu.cjs +++ b/tests/browser/messageConversationMenu.cjs @@ -7,9 +7,10 @@ const { chromium, expect } = require('@playwright/test'); const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); const evidence = process.argv[2] || '.run/message-menu'; fs.mkdirSync(evidence, { recursive: true }); +const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8').replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, ''); let references = 0; const server = http.createServer((req, res) => { - if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end('
'); } + if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(`${markup}`); } if (req.url.startsWith('/static/')) { const file = path.resolve(root, `.${req.url}`); if (file.startsWith(root + '/static/') && fs.existsSync(file)) { @@ -26,19 +27,30 @@ const server = http.createServer((req, res) => { try { const results = []; for (const width of [320, 448, 1280]) { - const page = await browser.newPage({ viewport: { width, height: 891 }, hasTouch: true }); + const page = await browser.newPage({ viewport: { width, height: 891 }, hasTouch: width < 800 }); + page.setDefaultTimeout(10000); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(`http://127.0.0.1:${server.address().port}`); await page.evaluate(async () => { + document.querySelector('#messagesContainer').style.display = 'none'; + document.querySelector('#scrollableField').textContent = 'A saved research conversation.'; + localStorage.setItem('von:conversationTrayExpandOnHover', 'false'); window.copied = []; Object.defineProperty(navigator, 'clipboard', { value: { writeText: async text => window.copied.push(text) } }); - const { renderMessageConversationRow } = await import('/static/js/components/conversationCatalogue.js'); - const { openChatSessionMenu } = await import('/static/js/chatTab.js'); + const { renderMessageConversationRow, mountCatalogueControls } = await import('/static/js/components/conversationCatalogue.js'); + const { openChatSessionMenu, __testOnly_loadChatSessionTabsLayoutPreference } = await import('/static/js/chatTab.js'); + __testOnly_loadChatSessionTabsLayoutPreference(); const row = { session_id: 'messages:fixture', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#participant_with_a_deliberately_long_concept_identifier_for_mobile_wrapping'], other_participant_ids: ['#V#bob'], session_name: 'Coding agent conversation', last_message_at: '2026-09-13', organisation_concept_id: '#V#lab' }; - document.querySelector('#chatSessionTabs').append(renderMessageConversationRow(row, { openMenu: openChatSessionMenu, togglePin: () => {}, hide: () => {} })); + document.querySelector('#chatSessionTabs').append(renderMessageConversationRow(row, { selected: true, openMenu: openChatSessionMenu, togglePin: () => {}, hide: () => {} })); + mountCatalogueControls(document.querySelector('#chatSessionTabs')); }); - const trigger = page.getByRole('button', { name: 'Conversation actions for Coding agent conversation' }); - await trigger.tap(); + const trigger = page.getByRole('tab', { name: /Coding agent conversation/ }); + const touchOptions = page.getByRole('button', { name: 'Selected conversation options', exact: true }); + await expect(page.locator('.conversation-menu-trigger')).toHaveCount(0); + await expect(trigger).toHaveAttribute('aria-description', /Shift\+F10/); + assert((await touchOptions.boundingBox()).height >= 44); + await page.screenshot({ path: path.join(evidence, `rows-${width}.png`) }); + await touchOptions[width < 800 ? 'tap' : 'click'](); const menu = page.getByRole('menu'); await expect(menu).toBeVisible(); const geometry = await menu.evaluate(el => { const r = el.getBoundingClientRect(); return { left: r.left, right: r.right, width: r.width, overflow: el.scrollWidth - el.clientWidth }; }); @@ -48,15 +60,35 @@ const server = http.createServer((req, res) => { await expect.poll(() => page.evaluate(() => window.copied[0])).toBe('#V#message_exchange_reference_fixture'); await trigger.focus(); await page.keyboard.press('Shift+F10'); await expect(menu).toBeVisible(); + await page.keyboard.press('End'); + await expect(page.getByRole('menuitem').last()).toBeFocused(); + await page.keyboard.press('Home'); + await expect(page.getByRole('menuitem').first()).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(page.getByRole('menuitem', { name: 'Copy Concept ID', exact: true })).toBeFocused(); await page.keyboard.press('Escape'); await expect(menu).toBeHidden(); await expect(trigger).toBeFocused(); - await trigger.tap(); + await touchOptions[width < 800 ? 'tap' : 'click'](); await page.getByRole('menuitem', { name: 'Copy conversation reference', exact: true }).click(); const reference = JSON.parse(await page.evaluate(() => window.copied[1])); assert.equal(reference.message_lookup.organisation_concept_id, '#V#lab'); assert.equal(reference.message_lookup.participant_ids.length, 2); + for (const gesture of ['right', 'control']) { + await trigger.click(gesture === 'right' ? { button: 'right' } : { modifiers: ['Control'] }); + await expect(menu).toBeVisible(); + await expect(page.locator('body')).not.toHaveClass(/viewing-message-exchange/); + await page.keyboard.press('Escape'); + await expect(trigger).toBeFocused(); + } + await touchOptions[width < 800 ? 'tap' : 'click'](); + await page.keyboard.press('Escape'); + await expect(touchOptions).toBeFocused(); + await page.getByRole('button', { name: 'Collapse conversation list', exact: true }).click(); + await expect(touchOptions).toBeHidden(); + await page.getByRole('button', { name: 'Open conversation list', exact: true }).click(); + await expect(touchOptions).toBeVisible(); assert.deepEqual(errors, []); - results.push({ width, geometry, copyConceptId: true, copyReference: true, keyboardEscapeFocus: true }); + results.push({ width, geometry, copyConceptId: true, copyReference: true, keyboardEscapeFocus: true, ellipsisAbsent: true, rightClick: true, controlClick: true, touchOptions: true }); await page.close(); } assert.equal(references, 3); diff --git a/tests/frontend/chatSessionAgentCreatedFilter.test.js b/tests/frontend/chatSessionAgentCreatedFilter.test.js index 5bf95edf7..6c08c8949 100644 --- a/tests/frontend/chatSessionAgentCreatedFilter.test.js +++ b/tests/frontend/chatSessionAgentCreatedFilter.test.js @@ -7,6 +7,7 @@ jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => getUserContext: jest.fn(() => ({ user_id: '#V#agent_filter_user', org_id: '#V#test_org' })), postJson: jest.fn(async () => ({ status: 'updated', namespace: '#V#agent_filter_user@test_org' })), getWindowSessionId: jest.fn(() => 'test-window-session-id'), + ensureUniqueWindowSessionId: jest.fn(async () => 'test-window-session-id'), WINDOW_SESSION_HEADER: 'X-Von-Window-Session' })); @@ -223,6 +224,29 @@ describe('chat session agent-created filtering', () => { })); }); + test.each(['ctrlclick', 'F10', 'ContextMenu'])('%s opens normal conversation options with focus recovery', async gesture => { + require(chatTabModulePath); + await window.refreshChatSessionTabsForOrgSwitch(); + const row = document.querySelector('[data-session-id="human-1"]'); + row.focus(); + const before = global.fetch.mock.calls.length; + row.dispatchEvent(gesture === 'ctrlclick' + ? new MouseEvent('click', { ctrlKey: true, bubbles: true, cancelable: true }) + : new KeyboardEvent('keydown', { key: gesture, shiftKey: gesture === 'F10', bubbles: true, cancelable: true })); + expect(global.fetch.mock.calls).toHaveLength(before); + const menu = document.querySelector('.chat-session-menu.open'); + expect(menu).not.toBeNull(); + expect(document.activeElement.textContent).toBe('Rename'); + expect(row.getAttribute('aria-expanded')).toBe('true'); + document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + expect(document.activeElement.textContent).toBe('Move to Organisation…'); + document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement.textContent).toBe('Rename'); + document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.activeElement).toBe(row); + expect(row.getAttribute('aria-expanded')).toBe('false'); + }); + test('modifier-click opens layout options without creating a chat and persists the left list', async () => { const chatTab = require(chatTabModulePath); await window.refreshChatSessionTabsForOrgSwitch(); diff --git a/tests/frontend/messageConversationMenu.test.js b/tests/frontend/messageConversationMenu.test.js index 57d68b0e5..b1b6b43b7 100644 --- a/tests/frontend/messageConversationMenu.test.js +++ b/tests/frontend/messageConversationMenu.test.js @@ -10,15 +10,34 @@ const { postJson } = require(base + 'apiService.js'); const { copyTextWithClipboardFallback: copy } = require(base + 'utils/copyJsonButtonState.js'); const row = { session_id: 'messages:exact', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], organisation_concept_id: '#V#lab', session_name: 'Bob', last_message_at: '2026-09-13' }; beforeEach(() => { document.body.innerHTML = ''; jest.clearAllMocks(); }); -test('touch action and keyboard context key open the shared menu without selecting the conversation', () => { +test.each(['contextmenu', 'ctrlclick', 'F10', 'ContextMenu'])('%s opens row options without selecting the conversation', gesture => { const openMenu = jest.fn(); const el = renderMessageConversationRow(row, { openMenu }); document.body.append(el); - el.querySelector('.conversation-menu-trigger').click(); + expect(el.querySelector('.conversation-menu-trigger')).toBeNull(); + expect(el.textContent).not.toContain('⋯'); + expect(el.getAttribute('aria-description')).toContain('Shift+F10'); + const event = ['F10', 'ContextMenu'].includes(gesture) + ? new KeyboardEvent('keydown', { key: gesture, shiftKey: gesture === 'F10', bubbles: true, cancelable: true }) + : new MouseEvent(gesture === 'ctrlclick' ? 'click' : 'contextmenu', { ctrlKey: gesture === 'ctrlclick', bubbles: true, cancelable: true }); + el.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); expect(openMenu).toHaveBeenCalledTimes(1); expect(require(base + 'components/messagePanel.js').openMessageExchange).not.toHaveBeenCalled(); - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true })); - expect(openMenu).toHaveBeenCalledTimes(2); - expect(openMenu.mock.calls[0][3]).toMatchObject({ focusFirst: true }); + expect(openMenu.mock.calls[0][3]).toMatchObject({ focusFirst: true, returnFocus: el }); + expect(openMenu.mock.calls[0][2].map(item => item.label)).toEqual(expect.arrayContaining(['Open conversation', 'Copy Concept ID', 'Copy conversation reference', 'Profile: #V#bob'])); +}); +test('shared touch control opens the selected row menu and ordinary row activation still selects', () => { + const openMenu = jest.fn(); + const el = renderMessageConversationRow(row, { openMenu, selected: true }); document.body.append(el); + const { mountCatalogueControls } = require(base + 'components/conversationCatalogue.js'); + mountCatalogueControls(document.body); + const control = document.querySelector('.selected-conversation-options'); + control.click(); + expect(openMenu.mock.calls[0][3].returnFocus).toBe(control); + const { openMessageExchange } = require(base + 'components/messagePanel.js'); + expect(openMessageExchange).not.toHaveBeenCalled(); + el.click(); + expect(openMessageExchange).toHaveBeenCalledWith(row); }); test('copies only the server-verified Concept ID and preserves exact group/organisation reference', async () => { const group = { ...row, participant_ids: [...row.participant_ids, '#V#carol'] };