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
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -442,7 +443,7 @@ function renderMessagesTabContent() {
</div>
<div class="message-compose-row">
<textarea id="messageInput" aria-label="Reply" class="message-input" placeholder="Type your message..." rows="3"></textarea>
<button id="sendMessageBtn" class="send-message-btn" type="button" aria-busy="false">Send</button>
<button id="sendMessageBtn" class="send-message-btn" type="button" aria-busy="false" aria-label="Send message" title="Send message"><span aria-hidden="true">↓</span></button>
</div>
</div>
</div>
Expand Down Expand Up @@ -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 = `<span aria-hidden="true">${pending ? '…' : '↓'}</span>`;
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) {
Expand Down
19 changes: 19 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion tests/browser/messageUnreadNavigation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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');
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 6 additions & 3 deletions tests/frontend/messagePanelSendFailure.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');

Expand All @@ -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.');

Expand All @@ -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 () => {
Expand Down
Loading