diff --git a/src/frontend/web/von_interface/static/js/components/compactComposer.js b/src/frontend/web/von_interface/static/js/components/compactComposer.js index 05881ea7..e81277d2 100644 --- a/src/frontend/web/von_interface/static/js/components/compactComposer.js +++ b/src/frontend/web/von_interface/static/js/components/compactComposer.js @@ -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); @@ -74,6 +110,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp } } } + updateDisclosure(); resizeDraft(input); } const observer = new MutationObserver(update); @@ -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); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 622d9a6c..3b891826 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -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; diff --git a/tests/browser/composerControls.cjs b/tests/browser/composerControls.cjs index 2698aa6f..edb5ca8f 100644 --- a/tests/browser/composerControls.cjs +++ b/tests/browser/composerControls.cjs @@ -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'); diff --git a/tests/frontend/compactComposer.test.js b/tests/frontend/compactComposer.test.js new file mode 100644 index 00000000..b52fdab6 --- /dev/null +++ b/tests/frontend/compactComposer.test.js @@ -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 = `