From 4b60f9c0203fe3bacd35b9912d93756015f0f82f Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 09:17:05 -0400 Subject: [PATCH] Use down-arrow for Messages replies and label latest navigation --- .../static/js/components/messagePanel.js | 11 ++++++-- .../web/von_interface/static/styles.css | 19 +++++++++++++ tests/browser/messageUnreadNavigation.cjs | 28 ++++++++++++++++++- .../frontend/messagePanelSendFailure.test.js | 9 ++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index f9b88e734..92a9935a6 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -231,6 +231,7 @@ function updateMessageNavigation() { focusConversationTarget(content.querySelector('.message-list')?.lastElementChild || content); updateMessageNavigation(); }); + latest.textContent = 'Latest message'; navigation.append(unread, latest); compose.prepend(navigation); content.addEventListener('scroll', updateMessageNavigation, { passive: true }); @@ -442,7 +443,7 @@ function renderMessagesTabContent() {
- +
@@ -1323,7 +1324,13 @@ function renderComposePendingUi(scope) { if (composer) composer.dataset.hasAttachments = String(imageItems(attachmentScope(scope)).length > 0); } button.disabled = pending || imagesBlocked(attachmentScope(scope)) || (scope === COMPOSE_SCOPE_REPLY && _exchange?.other_participant_ids?.length > 1); - button.textContent = pending ? 'Sending…' : 'Send'; + if (scope === COMPOSE_SCOPE_REPLY) { + button.innerHTML = ``; + button.setAttribute('aria-label', pending ? 'Sending message' : 'Send message'); + button.title = pending ? 'Sending message' : 'Send message'; + } else { + button.textContent = pending ? 'Sending…' : 'Send'; + } button.setAttribute('aria-busy', pending ? 'true' : 'false'); if (scope === COMPOSE_SCOPE_NEW_MESSAGE) { diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index d726ecfb7..df7b1432d 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18712,6 +18712,25 @@ body.settings-body { } .message-navigation { display: flex; align-items: center; gap: 8px; width: 100%; } .message-navigation:not(:has(.visible, .message-jump-unread:not([hidden]))) { display: none; } +/* The reply arrow sends; transcript navigation has an explicit text label. */ +#sendMessageBtn { + min-width: 44px; + min-height: 44px; + padding: 0; + font-size: 24px; + flex-shrink: 0; +} +.message-navigation > .chat-scroll-to-end-btn { + width: auto; + height: auto; + min-height: 44px; + flex-basis: auto; + padding: 8px 12px; + border-radius: 8px; + font-size: 14px; + box-shadow: none; +} + .message-jump-unread, .new-shared-messages-indicator { min-height: 44px; box-sizing: border-box; background: #1d4ed8; color: #fff; border: 0; border-radius: 8px; padding: 8px 12px; diff --git a/tests/browser/messageUnreadNavigation.cjs b/tests/browser/messageUnreadNavigation.cjs index 5f45dba99..9ec40b2af 100644 --- a/tests/browser/messageUnreadNavigation.cjs +++ b/tests/browser/messageUnreadNavigation.cjs @@ -10,6 +10,7 @@ const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8 const evidence = process.argv[2] || '.run/unread-navigation'; fs.mkdirSync(evidence, { recursive: true }); let cursors = []; +let sent = []; let background = false; let pendingPage = null; const message = (id, unread, day) => ({ concept_id: id, created_at: `2026-09-${day}T12:00:00Z`, @@ -28,6 +29,11 @@ const server = http.createServer(async (req, res) => { } } res.setHeader('Content-Type', 'application/json'); + if (req.url === '/api/messages/' && req.method === 'POST') { + let body = ''; for await (const chunk of req) body += chunk; + sent.push(JSON.parse(body)); + return res.end(JSON.stringify({ success: true, message_id: '#V#fixture_sent' })); + } if (req.url === '/api/messages/exchange') { let body = ''; for await (const chunk of req) body += chunk; const { before } = JSON.parse(body); cursors.push(before); @@ -49,6 +55,7 @@ const server = http.createServer(async (req, res) => { const results = []; for (const width of [390, 1280]) { cursors = []; + sent = []; background = false; const page = await browser.newPage({ viewport: { width, height: 850 } }); const errors = []; page.on('pageerror', error => errors.push(error.message)); @@ -65,6 +72,21 @@ const server = http.createServer(async (req, res) => { }); const content = page.locator('#messageViewContent'); assert.equal(await content.evaluate(el => el.scrollTop), 0, 'default loading preserves the top'); + const send = page.getByRole('button', { name: 'Send message', exact: true }); + const input = page.locator('#messageInput'); + await input.fill('A reply from the down-arrow control.'); + await expect(send).toBeVisible(); + await expect(send).toHaveText('↓'); + const latest = page.getByRole('button', { name: 'Scroll to latest message', exact: true }); + await expect(latest).toHaveText('Latest message'); + const box = await send.boundingBox(); + assert.ok(box.width >= 44 && box.height >= 44, 'send target is at least 44px'); + assert.ok(box.x >= 0 && box.x + box.width <= width, 'send stays in viewport'); + await page.screenshot({ path: path.join(evidence, `composer-${width}.png`) }); + await latest.click(); + await expect(latest).toBeHidden(); + assert.equal(sent.length, 0, 'latest navigation does not send the draft'); + await expect(input).toHaveValue('A reply from the down-arrow control.'); await content.evaluate(el => { el.scrollTop = el.scrollHeight - el.clientHeight - 10; }); const bottomPosition = await content.evaluate(el => el.scrollTop); assert.ok(bottomPosition > 0, 'fixture must overflow'); @@ -108,9 +130,13 @@ const server = http.createServer(async (req, res) => { await expect(target).toBeFocused(); await expect(target).toBeInViewport(); assert.deepEqual(cursors, ['middle', 'older']); + await send.click(); + await expect(input).toHaveValue(''); + assert.equal(sent.length, 1); + assert.equal(sent[0].content, 'A reply from the down-arrow control.'); assert.deepEqual(errors, []); await page.screenshot({ path: path.join(evidence, `unread-${width}.png`) }); - results.push({ width, cursors: [...cursors], defaultTopPreserved: true, backgroundPosition: bottomPosition, intermediateAnchorOffset: anchor.offset, mostRecentUnreadFocused: true, targetInViewport: true }); + results.push({ width, cursors: [...cursors], sendArrow: true, sendTarget: box, sends: sent.length, labelledLatestNavigation: true, defaultTopPreserved: true, backgroundPosition: bottomPosition, intermediateAnchorOffset: anchor.offset, mostRecentUnreadFocused: true, targetInViewport: true }); await page.close(); } fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2)); diff --git a/tests/frontend/messagePanelSendFailure.test.js b/tests/frontend/messagePanelSendFailure.test.js index a1863b317..5d29ab67d 100644 --- a/tests/frontend/messagePanelSendFailure.test.js +++ b/tests/frontend/messagePanelSendFailure.test.js @@ -390,7 +390,8 @@ describe('message panel send failure recovery', () => { expect(postJsonDetailed).toHaveBeenCalledTimes(1); expect(sendButton.disabled).toBe(true); - expect(sendButton.textContent).toBe('Sending…'); + expect(sendButton.textContent).toBe('…'); + expect(sendButton.getAttribute('aria-label')).toBe('Sending message'); expect(sendButton.getAttribute('aria-busy')).toBe('true'); expect(replyInput.value).toBe('Please review this once.'); @@ -407,7 +408,8 @@ describe('message panel send failure recovery', () => { await flushUi(); expect(sendButton.disabled).toBe(false); - expect(sendButton.textContent).toBe('Send'); + expect(sendButton.textContent).toBe('↓'); + expect(sendButton.getAttribute('aria-label')).toBe('Send message'); expect(sendButton.getAttribute('aria-busy')).toBe('false'); expect(replyInput.value).toBe('Please review this once.'); @@ -427,7 +429,8 @@ describe('message panel send failure recovery', () => { expect(replyInput.value).toBe(''); expect(sendButton.disabled).toBe(false); - expect(sendButton.textContent).toBe('Send'); + expect(sendButton.textContent).toBe('↓'); + expect(sendButton.getAttribute('aria-label')).toBe('Send message'); }); test('new-message retries get new delivery keys after recipient or content changes', async () => {