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
10 changes: 10 additions & 0 deletions docs/engineering/chat_steering_and_queueing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 21 additions & 4 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -27746,7 +27747,7 @@ function renderChatSessionTabs(sessions, activeSessionId) {
});
}

openChatSessionMenu(event.clientX, event.clientY, menuItems);
openChatSessionMenu(x, y, menuItems, options);
});

fragment.appendChild(tab);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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));
Expand All @@ -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 */ }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
});
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 13 additions & 4 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
50 changes: 41 additions & 9 deletions tests/browser/messageConversationMenu.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><div id="chatSessionTabs"></div>'); }
if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><link rel="stylesheet" href="/static/css/participantProfile.css">${markup}`); }
if (req.url.startsWith('/static/')) {
const file = path.resolve(root, `.${req.url}`);
if (file.startsWith(root + '/static/') && fs.existsSync(file)) {
Expand All @@ -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 }; });
Expand All @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions tests/frontend/chatSessionAgentCreatedFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}));

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading