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 67f2f5c5..b15d20a6 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,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp const menu = composer.querySelector('.chat-composer-more-actions'); const panel = menu?.querySelector('.button-row'); if (!panel) return; + const speechActions = panel.querySelector('.composer-speech-actions') || panel; const summary = menu.querySelector('summary'); const label = summary?.querySelector('.composer-more-label'); let closeWatcher; @@ -107,8 +108,8 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp const compact = isCompactComposer(); if (compact) { if (mic?.classList.contains('active-dictation') || voice?.getAttribute('aria-pressed') === 'true') menu.open = false; - place(mic, mic?.classList.contains('active-dictation') ? active : panel); - place(voice, voice?.getAttribute('aria-pressed') === 'true' ? active : panel); + place(mic, mic?.classList.contains('active-dictation') ? active : speechActions); + place(voice, voice?.getAttribute('aria-pressed') === 'true' ? active : speechActions); place(cancel, active); place(retry, active); place(status, status?.dataset.passive === 'true' ? panel : composer); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 7b324515..741eb5d5 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18952,3 +18952,94 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu margin: 0; padding: 0; } + +/* Group the composer's secondary actions without changing their disclosure. */ +.chat-composer-more-actions[open] > .composer-options-panel { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + justify-content: flex-start; + gap: 0; + padding: 0; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.composer-options-section { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; + padding: 16px; +} +.composer-options-section + .composer-options-section { + border-top: 1px solid #e2e8f0; +} +.composer-options-section h3 { + flex-basis: 100%; + margin: 0; + color: #334155; + font-size: 0.875rem; + font-weight: 650; + line-height: 1.4; +} +.composer-speech-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.composer-options-section .btn, +.composer-options-section select { + min-height: 44px; + margin: 0; + border-radius: 8px; + font-size: 0.875rem; +} +.composer-options-section .btn { + white-space: normal; + text-align: left; +} +.composer-options-section label[for="dictationEngineSelect"] { + flex-basis: 100%; + color: #475569; + font-size: 0.8125rem; +} +.composer-options-section #dictationEngineSelect { + width: 100%; + min-width: 0; + padding: 8px; + border: 1px solid #cbd5e1; + background: #fff; + color: #334155; +} +.composer-options-section .annotation-toggle { + min-height: 44px; + margin: 0; + padding: 6px 10px; + box-sizing: border-box; +} +.composer-options-section .compact-speech-help, +.composer-options-section #imageAttachmentHelp { + display: block; + flex-basis: 100%; + margin: 0; + color: #64748b; + font-size: 0.75rem; + line-height: 1.6; + overflow-wrap: anywhere; +} +.composer-options-section :is(button, select, input):focus-visible { + outline: 3px solid #2563eb; + outline-offset: 2px; +} +@media (max-width: 800px), (max-width: 1024px) and (pointer: coarse) { + .chat-composer-more-actions[open] > .composer-options-panel { + max-height: 55dvh; + overflow-y: auto; + border-radius: 8px; + background: #f8fafc; + } + .composer-options-section { padding: 12px; } +} diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index a8637dda..73b6c049 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -247,27 +247,38 @@

More actions -
-

Von dictation and voice send audio and conversation context to OpenAI. Von does not save the audio. Browser recognition uses your browser’s speech service.

- - - - - - -

If your keyboard cannot paste a screenshot, use Paste image and allow clipboard access, or choose Attach image. Your caption stays in the draft. Attach up to eight still PNG, JPEG or WebP images, each up to 8 MiB and 25 million pixels. Review previews before sending. Remove an image to replace it.

- - + - + - {% if expert_tabs_enabled %} -
diff --git a/tests/browser/composerControls.cjs b/tests/browser/composerControls.cjs index 0325df85..19c325df 100644 --- a/tests/browser/composerControls.cjs +++ b/tests/browser/composerControls.cjs @@ -124,6 +124,23 @@ async function bounds(page, selector) { assert.equal((await bounds(page, '.chat-composer')).height, composer.height, 'menu must not expand desktop composer'); } if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-menu.png`), fullPage: true }); + // Each group remains reachable within the scrolling disclosure. + const options = page.locator('.composer-options-panel'); + for (const id of ['dictationEngineSelect', 'ttsToggle', 'pasteImageButton', 'uploadFileButton', 'annotationToggle', 'resetButton']) { + const control = page.locator(`#${id}`); + await control.scrollIntoViewIfNeeded(); + await control.click({ trial: true }); + const box = await control.boundingBox(); + const panelBox = await options.boundingBox(); + assert(box.x >= panelBox.x && box.x + box.width <= panelBox.x + panelBox.width + 1, `${id} fits panel`); + } + await page.locator('#ttsToggle').check(); + await expect(page.locator('#ttsToggle')).toBeChecked(); + await page.locator('#dictationEngineSelect').selectOption('browser'); + await expect(page.locator('#dictationEngineSelect')).toHaveValue('browser'); + await page.locator('#dictationEngineSelect').selectOption('recorded'); + await page.locator('#resetButton').scrollIntoViewIfNeeded(); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-menu-bottom.png`), fullPage: true }); await summary.focus(); await page.keyboard.press('Space'); await expect(page.locator('.chat-composer-more-actions')).not.toHaveAttribute('open', '');