diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 7d77100d9..470080695 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -11675,6 +11675,10 @@ body[data-active-tab="settingsTab"] .tab-content-area { font-weight: 600; } +.composer-more-icon { + display: none; +} + .chat-composer-more-actions[open]>.button-row { justify-content: flex-end; margin-top: 8px; @@ -11744,6 +11748,101 @@ body[data-active-tab="settingsTab"] .tab-content-area { margin-bottom: 8px; } +/* Desktop: keep the draft and its controls on one row. Mobile retains the + existing stacked composer and inline disclosure. */ +@media (min-width: 801px) and (hover: hover) and (pointer: fine) { + .chat-composer { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + + .chat-composer>.prompt-input-wrapper, + .chat-composer #promptInput { + grid-column: 1; + grid-row: 1; + min-width: 0; + margin-bottom: 0; + } + + .chat-composer-actions { + grid-column: 2; + grid-row: 1; + flex-wrap: nowrap; + align-items: flex-start; + } + + .chat-composer-primary-actions { + width: 96px; + } + + .chat-composer-primary-actions>button:not(.composer-icon-button) { + max-width: 100%; + white-space: normal; + } + + .chat-composer-more-actions>summary { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 50%; + list-style: none; + background: #f1f5f9; + } + + .chat-composer-more-actions>summary::-webkit-details-marker { + display: none; + } + + .chat-composer-more-actions>summary:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 3px; + } + + .composer-more-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .composer-more-icon { + display: inline; + font-size: 24px; + } + + .chat-composer-more-actions[open]>.button-row { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + width: min(420px, calc(100vw - 48px)); + max-height: min(55vh, 420px); + overflow-y: auto; + box-sizing: border-box; + margin: 0; + padding: 12px; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #fff; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.15); + } + + .chat-composer>.chat-attachment-status, + .chat-composer>.speech-settings-note { + grid-column: 1 / -1; + } + + .chat-composer>.speech-settings-note:empty { + display: none; + } +} + .chat-task-queue-panel { margin: 2px 0 10px; border: 1px solid #cbd5e1; diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 6622c4fd4..071e603a5 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -245,7 +245,7 @@

- More actions + More actions
diff --git a/tests/browser/composerControls.cjs b/tests/browser/composerControls.cjs new file mode 100644 index 000000000..369cda72b --- /dev/null +++ b/tests/browser/composerControls.cjs @@ -0,0 +1,144 @@ +// Run: node tests/browser/composerControls.cjs [evidence-directory] +// Local fixture: production template/CSS and prompt/dictation modules, no Von +// server, identity, model calls or microphone access. Send behaviour is covered +// by chatTabSpeechPlanning.test.js; this checks control placement and reachability. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); + +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8') + .replace(/{%[\s\S]*?%}/g, '').replace(/{{[\s\S]*?}}/g, ''); +const evidence = process.argv[2]; +if (evidence) fs.mkdirSync(evidence, { recursive: true }); +const server = http.createServer((req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(` + ${markup}`); + return; + } + const file = path.resolve(root, `.${req.url}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) { + res.writeHead(404).end(); + return; + } + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); +}); + +async function bounds(page, selector) { + return page.locator(selector).boundingBox(); +} + +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + for (const profile of [ + { name: 'desktop', width: 1440, desktop: true }, + { name: 'small-desktop', width: 900, desktop: true }, + { name: 'mobile', width: 390, desktop: false, touch: true }, + { name: 'wide-touch', width: 1024, desktop: false, touch: true } + ]) { + const page = await browser.newPage({ + viewport: { width: profile.width, height: 900 }, + hasTouch: !!profile.touch, isMobile: !!profile.touch + }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async desktop => { + document.querySelector('#conversationWorkspace').dataset.effectiveTabsLayout = desktop ? 'vertical' : 'horizontal'; + document.querySelector('#chatSessionTabs').innerHTML = ''; + document.querySelector('#scrollableField').textContent = 'A saved conversation with a draft ready to send.'; + document.querySelector('#scrollableField').style.minHeight = '300px'; + const { initializePromptCartoucheOverlay } = await import('/static/js/components/promptCartoucheOverlay.js'); + initializePromptCartoucheOverlay(document.querySelector('#promptInput')); + }, profile.desktop); + const input = page.locator('#promptInput'); + const send = page.getByRole('button', { name: 'Send Prompt', exact: true }); + const mic = page.getByRole('button', { name: 'Dictate', exact: true }); + const summary = page.locator('.chat-composer-more-actions>summary'); + await expect(summary).toHaveAccessibleName('More actions'); + await input.fill('Draft for the interaction check'); + await input.focus(); + await page.keyboard.press('Tab'); + await expect(send).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(mic).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(summary).toBeFocused(); + const draft = await bounds(page, '#promptInput'); + const sendBox = await send.boundingBox(); + const micBox = await mic.boundingBox(); + const moreBox = await summary.boundingBox(); + const composer = await bounds(page, '.chat-composer'); + assert(composer.x >= 0 && composer.x + composer.width <= profile.width, 'composer stays inside viewport'); + await send.click({ trial: true }); + await summary.focus(); + if (profile.desktop) { + for (const box of [sendBox, micBox, moreBox]) { + assert(box.x >= draft.x + draft.width - 1, `controls must be to the right of the draft: ${JSON.stringify({ draft, box })}`); + assert(Math.abs(box.y - draft.y) < 2, 'controls must share the draft row'); + assert(box.width >= 44 && box.height >= 44, 'controls retain usable targets'); + } + assert(composer.height < 80, 'idle composer must not reserve another control/status row'); + const tray = await bounds(page, '.chat-conversation-nav'); + assert(tray.x + tray.width <= composer.x, 'desktop tray stays beside the conversation'); + } else { + assert(sendBox.y >= draft.y + draft.height, 'mobile keeps controls below the draft'); + await expect(page.locator('.composer-more-label')).toBeVisible(); + await expect(page.locator('.composer-more-icon')).toBeHidden(); + } + await page.keyboard.press('Enter'); + await expect(page.locator('.chat-composer-more-actions')).toHaveAttribute('open', ''); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Start voice', exact: true })).toBeFocused(); + await expect(page.getByRole('button', { name: 'Upload File', exact: true })).toBeVisible(); + if (profile.desktop) { + const panel = await bounds(page, '.chat-composer-more-actions>.button-row'); + assert(panel.y >= 0 && panel.x >= 0 && panel.x + panel.width <= profile.width); + 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 }); + await summary.focus(); + await page.keyboard.press('Space'); + await expect(page.locator('.chat-composer-more-actions')).not.toHaveAttribute('open', ''); + + // Bind the production dictation controller with a fake recorder. + await page.evaluate(async () => { + const { createDictationController } = await import('/static/js/dictation.js'); + class Recorder { + static isTypeSupported() { return true; } + constructor() { this.state = 'inactive'; } + start() { this.state = 'recording'; } + stop() { this.state = 'inactive'; this.onstop?.(); } + } + window.fixtureDictation = createDictationController({ + input: document.querySelector('#promptInput'), button: document.querySelector('#dictateButton'), + status: document.querySelector('#dictationStatus'), cancelButton: document.querySelector('#cancelDictationButton'), + retryButton: document.querySelector('#retryDictationButton'), engineSelect: document.querySelector('#dictationEngineSelect'), + getContext: () => ({ key: 'composer-fixture' }), setValue: value => { document.querySelector('#promptInput').value = value; }, + onStart: () => {}, onAlternateClick: () => { window.alternateMicClicked = true; }, + root: { isSecureContext: true, MediaRecorder: Recorder, navigator: { mediaDevices: { + getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) + } } }, + fetchImpl: async () => ({ ok: true, json: async () => ({ transcription: { available: true } }) }) + }); + }); + await page.locator('#dictateButton').click(); + await expect(page.locator('#dictateButton')).toHaveAttribute('aria-pressed', 'true'); + await page.getByRole('button', { name: 'Cancel dictation', exact: true }).click(); + await expect(page.locator('#dictateButton')).toHaveAttribute('aria-pressed', 'false'); + await page.locator('#dictateButton').click({ modifiers: ['Shift'] }); + assert(await page.evaluate(() => window.alternateMicClicked)); + await expect(input).toHaveValue('Draft for the interaction check'); + await page.evaluate(() => window.fixtureDictation.dispose()); + console.log(JSON.stringify({ profile: profile.name, draft, send: sendBox, microphone: micBox, more: moreBox, composerHeight: composer.height, passed: true })); + await page.close(); + } + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => server.close());