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
99 changes: 99 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ <h3 id="conversationReferenceInspectorTitle" class="chat-reference-inspector-tit
</div>

<details class="chat-composer-more-actions">
<summary>More actions</summary>
<summary title="More actions"><span class="composer-more-label">More actions</span><span class="composer-more-icon" aria-hidden="true">⋯</span></summary>
<div class="button-row">
<button id="voiceConversationButton" class="btn btn-secondary" type="button" aria-pressed="false" title="Have a voice conversation: speech is sent automatically and Von replies aloud. You can also Shift-click or long-press the microphone.">Start voice</button>
<label for="dictationEngineSelect">Dictation</label>
Expand Down
144 changes: 144 additions & 0 deletions tests/browser/composerControls.cjs
Original file line number Diff line number Diff line change
@@ -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(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/styles.css">${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 = '<button class="chat-session-tab is-active">Composer acceptance conversation</button>';
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());
Loading