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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,42 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
const menu = composer.querySelector('.chat-composer-more-actions');
const panel = menu?.querySelector('.button-row');
if (!panel) return;
const summary = menu.querySelector('summary');
const label = summary?.querySelector('.composer-more-label');
let closeWatcher;
function dismiss(restoreFocus = false) {
if (!menu.open) return;
const focusWasInPanel = panel.contains(document.activeElement);
menu.open = false;
if (restoreFocus || focusWasInPanel) summary?.focus();
updateDisclosure();
}
function updateDisclosure() {
const name = menu.open ? 'Close actions' : 'More actions';
if (label) label.textContent = name;
if (summary) summary.title = name;
// Use the browser's close-request stack (including Android Back where
// supported), without adding synthetic entries to conversation history.
if (menu.open && isCompactComposer() && menu.isConnected) {
if (!closeWatcher && typeof window.CloseWatcher === 'function') {
closeWatcher = new window.CloseWatcher();
closeWatcher.addEventListener('close', () => dismiss(true));
}
} else {
closeWatcher?.destroy();
closeWatcher = undefined;
}
}
menu.addEventListener('toggle', updateDisclosure);
// Wait for the completed click: collapsing during pointer/focus events can
// move Send before touch activation reaches it.
document.addEventListener('click', event => {
if (menu.isConnected && isCompactComposer() && !menu.contains(event.target)) dismiss();
});
// Keyboard activation also dismisses before the existing submission handler.
composer.querySelector('#sendButton')?.addEventListener('click', () => {
if (isCompactComposer()) dismiss();
}, { capture: true });
const active = document.createElement('div');
active.className = 'compact-composer-active';
composer.append(active);
Expand Down Expand Up @@ -74,6 +110,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
}
}
}
updateDisclosure();
resizeDraft(input);
}
const observer = new MutationObserver(update);
Expand All @@ -84,15 +121,14 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
}
menu.addEventListener('keydown', event => {
if (event.key === 'Escape') {
menu.open = false;
menu.querySelector('summary').focus();
event.preventDefault();
dismiss(true);
}
});
menu.addEventListener('click', event => {
if (isCompactComposer() && event.target.closest('#dictateButton, #voiceConversationButton, #uploadFileButton')) {
menu.open = false;
// Recording controls become visible on the controller's next render.
menu.querySelector('summary').focus();
dismiss(true);
}
});
window.matchMedia?.(COMPACT_COMPOSER_QUERY).addEventListener?.('change', update);
Expand Down
1 change: 1 addition & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -18527,6 +18527,7 @@ body.settings-body {
}
.composer-more-icon { display: none; }
.composer-add-icon { display: inline; font-size: 24px; font-weight: 400; }
.chat-composer-more-actions[open] > summary .composer-add-icon { transform: rotate(45deg); }
.chat-composer-more-actions:not([open]) > .button-row { display: none; }
.chat-composer-more-actions[open] > .button-row {
grid-column: 1 / -1; grid-row: 2;
Expand Down
35 changes: 35 additions & 0 deletions tests/browser/composerControls.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ async function bounds(page, selector) {
await page.keyboard.press('Space');
await expect(page.locator('.chat-composer-more-actions')).not.toHaveAttribute('open', '');

if (!profile.desktop) {
const menu = page.locator('.chat-composer-more-actions');
const closedHeight = (await bounds(page, '.chat-composer')).height;
for (let repeat = 0; repeat < 3; repeat++) {
await summary.tap();
await expect(menu).toHaveAttribute('open', '');
await expect(summary).toHaveAccessibleName('Close actions');
await summary.tap();
await expect(menu).not.toHaveAttribute('open', '');
await expect(summary).toHaveAccessibleName('More actions');
}
await summary.tap();
await input.tap();
await expect(menu).not.toHaveAttribute('open', '');
await expect(input).toBeFocused();
await expect(input).toHaveValue('Draft for the interaction check');
await summary.tap();
await page.locator('#uploadFileButton').focus();
await page.keyboard.press('Escape');
await expect(menu).not.toHaveAttribute('open', '');
await expect(summary).toBeFocused();
await page.evaluate(() => {
window.fixtureSubmissions = [];
document.querySelector('#sendButton').addEventListener('click', () => {
window.fixtureSubmissions.push(document.querySelector('#promptInput').value);
});
});
await summary.tap();
await send.tap();
assert.deepEqual(await page.evaluate(() => window.fixtureSubmissions), ['Draft for the interaction check']);
await expect(menu).not.toHaveAttribute('open', '');
assert.equal((await bounds(page, '.chat-composer')).height, closedHeight, 'dismissal restores compact layout');
if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-dismissed.png`) });
}

// Bind the production dictation controller with a fake recorder.
await page.evaluate(async () => {
const { createDictationController } = await import('/static/js/dictation.js');
Expand Down
103 changes: 103 additions & 0 deletions tests/frontend/compactComposer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const {
initialiseCompactChatComposer,
shouldSubmitComposerKey
} = require('../../src/frontend/web/von_interface/static/js/components/compactComposer.js');

let menu, summary, input, send;
const flushToggle = () => menu.dispatchEvent(new Event('toggle'));

beforeEach(() => {
window.matchMedia = jest.fn(() => ({ matches: true, addEventListener: jest.fn() }));
document.body.innerHTML = `<div class="chat-composer">
<textarea id="promptInput">Preserved draft</textarea><button id="sendButton">Send</button>
<details class="chat-composer-more-actions"><summary><span class="composer-more-label">More actions</span></summary>
<div class="button-row"><button id="uploadFileButton">Upload</button><select><option>Engine</option></select></div></details>
</div><button id="outside">Outside</button>`;
menu = document.querySelector('details');
summary = document.querySelector('summary');
input = document.querySelector('textarea');
send = document.querySelector('#sendButton');
initialiseCompactChatComposer(document.querySelector('.chat-composer'), () => {});
});

afterEach(() => {
menu.open = false;
flushToggle();
delete window.CloseWatcher;
document.body.innerHTML = '';
});

function open() {
menu.open = true;
flushToggle();
}

test('outside click dismisses without clearing the draft; option interactions remain open', () => {
open();
document.querySelector('select').click();
expect(menu.open).toBe(true);
document.querySelector('#outside').click();
expect(menu.open).toBe(false);
expect(input.value).toBe('Preserved draft');
});

test('Send reaches its existing handler exactly once with the draft intact and options closed', () => {
const submit = jest.fn(() => {
expect(menu.open).toBe(false);
expect(input.value).toBe('Preserved draft');
});
send.addEventListener('click', submit);
open();
send.click();
expect(submit).toHaveBeenCalledTimes(1);
});

test('Escape restores toggle focus; leaving the options preserves destination focus', () => {
open();
const option = document.querySelector('select');
option.focus();
option.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(menu.open).toBe(false);
expect(document.activeElement).toBe(summary);
open();
option.focus();
input.focus();
expect(menu.open).toBe(true);
input.click();
expect(menu.open).toBe(false);
expect(document.activeElement).toBe(input);
});

test('browser close requests close the menu, restore focus and release the watcher', () => {
const watchers = [];
window.CloseWatcher = class extends EventTarget {
constructor() { super(); this.destroy = jest.fn(); watchers.push(this); }
};
open();
expect(watchers).toHaveLength(1);
watchers[0].dispatchEvent(new Event('close'));
expect(menu.open).toBe(false);
expect(document.activeElement).toBe(summary);
expect(watchers[0].destroy).toHaveBeenCalledTimes(1);
open();
expect(watchers).toHaveLength(2);
summary.click();
flushToggle();
expect(watchers[1].destroy).toHaveBeenCalledTimes(1);
});

test('desktop outside clicks keep existing disclosure behaviour', () => {
window.matchMedia.mockReturnValue({ matches: false });
open();
document.querySelector('#outside').click();
expect(menu.open).toBe(true);
});

test('mobile Enter remains a newline; desktop submission and composition rules are preserved', () => {
expect(shouldSubmitComposerKey({ key: 'Enter' })).toBe(false);
window.matchMedia.mockReturnValue({ matches: false });
expect(shouldSubmitComposerKey({ key: 'Enter' })).toBe(true);
expect(shouldSubmitComposerKey({ key: 'Enter', shiftKey: true })).toBe(false);
expect(shouldSubmitComposerKey({ key: 'Enter', isComposing: true })).toBe(false);
expect(shouldSubmitComposerKey({ key: 'Enter', keyCode: 229 })).toBe(false);
});
Loading