diff --git a/docs/engineering/conversation_affordances_2026-09-13.md b/docs/engineering/conversation_affordances_2026-09-13.md new file mode 100644 index 000000000..89d93262c --- /dev/null +++ b/docs/engineering/conversation_affordances_2026-09-13.md @@ -0,0 +1,67 @@ +# Conversation affordance reconciliation — 13 September 2026 + +**Kind:** implementation and acceptance record +**Lifecycle:** dated evidence +**Authority:** evidence only; task remains the delivery authority +**Task:** #V#task_agent_63185986b5f7204bf1c718922c4a54f4 + +## Diagnosis and decisions + +The Messages rectangle was created only after a refresh while scrolled away, +inside the transcript. The chat arrow was independent of arrivals. They both +navigated to the end, despite the rectangle claiming “New messages”. Chat also +had a separate new-arrival boundary, which moved forward on successive arrivals. + +| Affordance | Disposition and rationale | +| --- | --- | +| Latest navigation | Reconciled: shared native circular down-arrow button, “Scroll to latest message”, 44 × 44 pixels at every width, keyboard activation, visible focus and pressed states. Visible when away from the end, regardless of new arrivals. Each adapter scrolls its own surface and transfers focus to the destination. | +| Placement | Reconciled: latest navigation occupies a composer row rather than overlaying transcript text or being clipped outside the mobile composer. Messages places its unread action beside the arrow. | +| New/unread navigation | Semantically distinct from latest navigation. Messages offers “Jump to first unread message” for loaded canonical unread contributions; opening starts at the first loaded unread. Chat offers “Jump to first new message” for arrivals since scrolling away; subsequent arrivals preserve the first boundary. The different names deliberately distinguish canonical unread state from an ephemeral new-arrival boundary. | +| Read acknowledgement | Retained canonical mechanisms: Messages acknowledges visible recipient-addressed contribution IDs with receipt checking; chat uses a server-validated observed last-incoming turn/timestamp cursor. Navigation itself never calls either read endpoint. Messages detaches observers during render/position restoration and excludes the portion hidden behind the fixed composer. Earlier pages remain unread until displayed. Chat's cursor is a conversation-level read model, not a per-contribution receipt; unifying storage semantics would be a separate backend change. | +| Scrolling, refresh and paging | Retained document scrolling for chat and an internal pane for Messages, matching their existing layouts. Messages explicit and background refresh now both preserve position when away from the bottom; earlier-page loading preserves the viewport. Reconnect refresh retains loaded earlier pages and exposes the existing earlier-page cursor for gaps. | +| Unread badges/filter | Retained common conversation catalogue's canonical counts and Unread filter. Messages retains visible Unread labels and outlines, including older pages; failed receipts retain labels and Retry. Chat retains new-arrival separator and its server cursor badge. Loading a conversation is not a bulk message-read operation. | +| Composer | Retained shared compact draft sizing and Enter/Shift+Enter/IME handling. Desktop Enter sends; narrow Enter inserts a newline. Both retain explicit send and draft recovery. Chat's speech, attachments, model/steering and More actions are supported chat capabilities; Messages keeps recipient/context display, per-exchange drafts and group-reply limitation. Adding unsupported speech or dropping group recipients would be incorrect. | +| Alignment and sender identity | Already equivalent: own contributions right, others/Von left; sender names and avatars remain visible. Messages uses participant profiles; chat preserves author identity and assistant role. No alignment rewrite needed. | +| Widths/content | Retained readable proportional widths rather than identical fixed widths: Messages caps bubbles at 760px within a 900px list, with 94% on narrow screens; chat accommodates structured assistant output and its existing controls. Both wrap ordinary text and contain long/code content. | +| Marking and per-message actions | Retained chat's turn-specific editing/annotation/runtime controls; Messages preserves Copy Markdown, Discuss with Von, concept references, and recommendation review where applicable. These act on different canonical objects; showing unsupported chat actions on message concepts would misrepresent capability. | +| Header/tools | Retained recipient profiles and Refresh in Messages; chat's session, model and runtime tools remain chat-specific. Both expose Tasks and context/reference access through existing paths. | + +No new prompt, workflow, authority or backend schema is introduced. Shared code +owns presentation and native button semantics only. Existing APIs own reads and +writes. This is Tier 1 client behaviour acceptance with canonical in-memory +fixture read-back, not a new authority boundary or a claim of backend redesign. + +## Validation and limits + +- Targeted Jest coverage includes both render paths, shared button semantics, + first-new-boundary retention, scroll/pagination restoration, destination focus, + no navigation-triggered acknowledgement, failed and partial read receipts, + stale observers, hidden documents, exchange switching and draft isolation. +- `tests/browser/conversationReadability.cjs` serves the actual templates, + styles and modules with isolated in-memory APIs. At 1440px and 360px it checks + opening without clearing unseen/unloaded contributions, failed-read retry, + incoming content while scrolled away, latest activation by Enter and Space, + 44px hit targets, keyboard focus, pagination, refresh/reopening, badge read-back, + participant alignment, sender labels and no horizontal overflow. It records + screenshots and measurements in `/tmp/conversation-affordance-evidence`. +- The browser fixture is deliberately unauthenticated and never forwards APIs + to a running Von service. Operator-retained authenticated profiles were bound + to other tasks; they were not repurposed or claimed as candidate evidence. +- The frontend is served as source ES modules, with no npm build script. + Browser module loading, targeted static lint and a versioned `git archive` + release package provide the applicable build evidence. +- No model calls, live message mutations, deployment, migrations or infrastructure + changes are requested or performed. Public deployment is not instructed in + the task title/description, so the worker's deployment request remains empty. + +## Supplied screenshot provenance + +- #V#computer_file_copy_d278592f3ce44fb9a7ce07505fccd1a4 — image.png, + 556×198, SHA-256 + `1929efb9f4cacf1975dbc4f37f5dfb3f92fc874777421fcf788bb086bed14336`. +- #V#computer_file_copy_8644a620dd2c4e83a7de1bb6d70c4453 — image.png, + 203×177, SHA-256 + `f410329d24b5122b447945a253774a882f43f38f10a39ac88a78c33ec47d525e`. + +The task's descriptions of these crops informed diagnosis; their image bytes +were not available in the assigned context and are not claimed as inspected. diff --git a/src/frontend/web/von_interface/static/css/participantProfile.css b/src/frontend/web/von_interface/static/css/participantProfile.css index d7c76e788..7f92be4cf 100644 --- a/src/frontend/web/von_interface/static/css/participantProfile.css +++ b/src/frontend/web/von_interface/static/css/participantProfile.css @@ -35,7 +35,6 @@ .unified-message-content .message-content pre { overflow: auto; white-space: pre; max-width: 100%; } .unified-message-content .message-author { font-weight: 600; margin-bottom: 8px; } .message-reply-destination { margin: 0 0 6px; font-size: 12px; opacity: .8; } -.message-jump-latest { position: sticky; bottom: 8px; display: block; margin: auto; } .message-conversation-row .chat-session-tab-header { display: flex; align-items: center; gap: 8px; } .conversation-source-status { font-size: 12px; padding: 8px; } @media (max-width: 800px) { .unified-message-content .message-view-header { flex-wrap: wrap; } .unified-message-content .message-bubble { padding: 12px; } } diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index fd1548e60..993243105 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,4 @@ +import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './components/conversationNavigation.js'; import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js'; import { updateExecutionCost } from './components/executionCost.js'; import { canUseWorkflowStudio } from './workflowStudioAccess.js'; @@ -31147,22 +31148,15 @@ function ensureScrollToEndButton(scrollableField = null) { } if (!(button instanceof HTMLButtonElement)) { - button = document.createElement('button'); - button.type = 'button'; - button.id = CHAT_SCROLL_TO_END_BUTTON_ID; - button.className = 'chat-scroll-to-end-btn'; - button.setAttribute('aria-label', 'Scroll to latest message'); - button.title = 'Scroll to latest message'; - button.innerHTML = 'Scroll to latest message'; - button.setAttribute('aria-hidden', 'true'); - button.tabIndex = -1; - button.addEventListener('click', (event) => { + button = createLatestMessageButton((event) => { if (event?.shiftKey && scrollConversationToSessionList({ smooth: true })) { event.preventDefault(); return; } void scrollConversationToEnd(targetField, { smooth: true }); + focusConversationTarget(targetField.lastElementChild || targetField); }); + button.id = CHAT_SCROLL_TO_END_BUTTON_ID; controlHost.appendChild(button); } @@ -31249,9 +31243,7 @@ function updateScrollToEndButtonVisibility(scrollableField = null) { const nearBottom = isScrollableFieldNearBottom(targetField); const shouldShow = hasOverflow && !nearBottom; - button.classList.toggle('visible', shouldShow); - button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true'); - button.tabIndex = shouldShow ? 0 : -1; + setNavigationVisible(button, shouldShow); } function handleConversationScrollPositionChange(scrollableField) { @@ -31284,15 +31276,15 @@ function setLatestUnreadBoundary(messageElement) { } if (latestUnreadBoundaryElement?.isConnected) { - latestUnreadBoundaryElement.remove(); + return latestUnreadBoundaryElement; } const boundary = document.createElement('div'); boundary.className = 'latest-unread-boundary'; boundary.setAttribute('role', 'separator'); - boundary.setAttribute('aria-label', 'Latest unread boundary'); + boundary.setAttribute('aria-label', 'First new message'); boundary.setAttribute('tabindex', '-1'); - boundary.textContent = 'Latest unread'; + boundary.textContent = 'New messages'; messageElement.parentElement.insertBefore(boundary, messageElement); latestUnreadBoundaryElement = boundary; return boundary; @@ -31338,8 +31330,8 @@ function showNewSharedMessagesIndicator() { indicator.type = 'button'; indicator.id = 'newSharedMessagesIndicator'; indicator.className = 'new-shared-messages-indicator'; - indicator.setAttribute('aria-label', 'Jump to latest unread message'); - indicator.textContent = 'Jump to latest unread'; + indicator.setAttribute('aria-label', 'Jump to first new message'); + indicator.textContent = 'New messages · Jump to first new message'; indicator.addEventListener('click', () => { void jumpToLatestUnreadBoundary(); }); diff --git a/src/frontend/web/von_interface/static/js/components/conversationNavigation.js b/src/frontend/web/von_interface/static/js/components/conversationNavigation.js new file mode 100644 index 000000000..45a62160c --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/conversationNavigation.js @@ -0,0 +1,25 @@ +// Navigation does not acknowledge content. Each adapter owns its scroll surface +// and canonical read receipts. +export function createLatestMessageButton(onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'chat-scroll-to-end-btn'; + button.title = 'Scroll to latest message'; + button.setAttribute('aria-label', button.title); + button.innerHTML = ''; + button.addEventListener('click', onClick); + setNavigationVisible(button, false); + return button; +} + +export function setNavigationVisible(button, visible) { + button.classList.toggle('visible', visible); + button.setAttribute('aria-hidden', String(!visible)); + button.tabIndex = visible ? 0 : -1; +} + +export function focusConversationTarget(target) { + if (!target) return; + target.tabIndex = -1; + target.focus({ preventScroll: true }); +} 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 0beb7a07b..15a60cf88 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -1,3 +1,4 @@ +import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './conversationNavigation.js'; import { resizeCompactDraft, shouldSubmitComposerKey } from './compactComposer.js'; import { initializeConceptAutocomplete } from './conceptAutocomplete.js'; import { simpleMarkdownToHtml } from '../markdownUtils.js'; @@ -140,6 +141,38 @@ function updateUnreadMarkers() { element.prepend(label); } else if (!unread) label?.remove(); }); + updateMessageNavigation(); +} + +function updateMessageNavigation() { + const content = _messagesContainer?.querySelector('#messageViewContent'); + const compose = _messagesContainer?.querySelector('#messageComposeArea'); + if (!content || !compose) return; + let navigation = compose.querySelector('.message-navigation'); + if (!navigation) { + navigation = document.createElement('div'); + navigation.className = 'message-navigation'; + const unread = document.createElement('button'); + unread.type = 'button'; + unread.className = 'message-jump-unread'; + unread.textContent = 'Jump to first unread message'; + unread.onclick = () => { + const target = content.querySelector('.is-unread'); + target?.scrollIntoView({ block: 'start' }); + focusConversationTarget(target); + }; + const latest = createLatestMessageButton(() => { + content.scrollTop = content.scrollHeight; + focusConversationTarget(content.querySelector('.message-list')?.lastElementChild || content); + updateMessageNavigation(); + }); + navigation.append(unread, latest); + compose.prepend(navigation); + content.addEventListener('scroll', updateMessageNavigation, { passive: true }); + } + navigation.querySelector('.message-jump-unread').hidden = !content.querySelector('.is-unread'); + setNavigationVisible(navigation.querySelector('.chat-scroll-to-end-btn'), + content.scrollHeight - content.scrollTop - content.clientHeight > 60); } function observeDisplayedMessages() { @@ -148,6 +181,12 @@ function observeDisplayedMessages() { const root = _messagesContainer?.querySelector('#messageViewContent'); const generation = _exchangeGeneration; const org = getSessionScopedOrgId(); + // A fixed narrow-screen composer can overlap the scroll root. Content + // behind it is not displayed to the reader. + const rootBox = root?.getBoundingClientRect(); + const composeBox = _messagesContainer?.querySelector('#messageComposeArea')?.getBoundingClientRect(); + const coveredBottom = rootBox && composeBox?.height + ? Math.max(0, Math.min(rootBox.height, rootBox.bottom - composeBox.top)) : 0; const observer = new IntersectionObserver(entries => { // Disconnected observers can still deliver entries for the old pane. if (observer !== _readObserver || generation !== _exchangeGeneration @@ -181,7 +220,7 @@ function observeDisplayedMessages() { }).catch(() => { if (generation === _exchangeGeneration) showReadFailure(); }).finally(() => ids.forEach(id => _pendingReads.delete(`${generation}:${id}`))); - }, { root, threshold: 0.01 }); + }, { root, rootMargin: `0px 0px -${coveredBottom}px 0px`, threshold: 0.01 }); _readObserver = observer; root?.querySelectorAll('.is-unread[data-contribution-id]').forEach(el => observer.observe(el)); } @@ -390,7 +429,7 @@ function attachMessagesEventListeners() { if (refreshBtn) { refreshBtn.addEventListener('click', () => { if (_currentConversationUserId) { - loadConversation(_currentConversationUserId); + loadConversation(_currentConversationUserId, { silent: true }); } }); } @@ -722,6 +761,8 @@ async function loadConversation(userId, { silent = false, before = null, observe } if (before || !silent) _olderCursor = response.before || null; _currentUserId = response.current_user_id || null; + _readObserver?.disconnect(); + _readObserver = null; await renderMessages(); if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId()) return; restoreReplyDeliveryAttemptForCurrentScope(); @@ -731,13 +772,13 @@ async function loadConversation(userId, { silent = false, before = null, observe contentEl.prepend(earlier); } if (contentEl && before) contentEl.scrollTop = oldTop + contentEl.scrollHeight - oldHeight; - else if (contentEl && silent && !nearBottom) { - contentEl.scrollTop = oldTop; - const jump = document.createElement('button'); jump.type = 'button'; jump.className = 'message-jump-latest'; jump.textContent = 'New messages · Jump to latest'; - jump.onclick = () => { contentEl.scrollTop = contentEl.scrollHeight; jump.remove(); }; - contentEl.append(jump); - } + else if (contentEl && silent && !nearBottom) contentEl.scrollTop = oldTop; + else if (contentEl) contentEl.scrollTop = contentEl.scrollHeight; updateUnreadMarkers(); + // Opening starts at the first loaded unread contribution, without + // acknowledging earlier/unloaded or skipped contributions. + if (contentEl && !silent) contentEl.querySelector('.is-unread')?.scrollIntoView?.({ block: 'start' }); + updateMessageNavigation(); if (observeReads) observeDisplayedMessages(); } catch (_) { if (generation === _exchangeGeneration && contentEl && !silent) contentEl.innerHTML = '
Could not load this conversation. Try Refresh.
'; @@ -1075,8 +1116,7 @@ async function renderMessages() { }); await hydrateConceptCartouchesInRoot(contentEl); - // Scroll to bottom - contentEl.scrollTop = contentEl.scrollHeight; + // The loader restores the viewport before attaching read observers. } function isComposePending(scope) { diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 7dfa2ecb2..9de251954 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -5570,8 +5570,8 @@ h1 { right: 28px; bottom: 190px; z-index: 12; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border: none; border-radius: 999px; display: none; @@ -18583,3 +18583,29 @@ body.settings-body { .unified-message-content .send-message-btn { padding: 0 12px; font-size: 14px; } .unified-message-content .message-reply-destination { margin: 0 0 4px; font-size: 12px; } } + +/* Shared navigation stays inside the composer so narrow-screen overflow cannot + clip it. It occupies its own row, rather than covering transcript text. */ +.chat-composer > .chat-scroll-to-end-btn, +.message-navigation > .chat-scroll-to-end-btn { + position: static; flex: 0 0 44px; margin: 0 0 0 auto; + grid-column: 1 / -1; justify-self: end; padding: 0; +} +.message-navigation { display: flex; align-items: center; gap: 8px; width: 100%; } +.message-navigation:not(:has(.visible, .message-jump-unread:not([hidden]))) { display: 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; + font-size: 14px; white-space: normal; cursor: pointer; +} +.message-jump-unread[hidden] { display: none; } +.message-jump-unread:hover, .new-shared-messages-indicator:hover { background: #1e40af; } +.chat-scroll-to-end-btn:active, .message-jump-unread:active, +.new-shared-messages-indicator:active { background: #1e3a8a; transform: translateY(1px); } +.chat-scroll-to-end-btn:focus-visible, .message-jump-unread:focus-visible, +.new-shared-messages-indicator:focus-visible { + outline: 3px solid #1e40af; outline-offset: 3px; box-shadow: 0 0 0 3px white; +} +@media (prefers-reduced-motion: reduce) { + .chat-scroll-to-end-btn { transition: none; } +} diff --git a/tests/browser/conversationReadability.cjs b/tests/browser/conversationReadability.cjs index 415c24024..7ab3c1177 100644 --- a/tests/browser/conversationReadability.cjs +++ b/tests/browser/conversationReadability.cjs @@ -125,9 +125,17 @@ async function measure(page, name) { // An arrival stays unread while the reader is inspecting earlier content. messages.push({ concept_id: 'arrival', created_at: '2026-09-12T11:01:00Z', relationships: { '#V#has_sender': [other], '#V#has_recipient': [actor] }, concept_data: { read_by: [], content_fallback: 'A new incoming research update.' } }); await page.evaluate(() => fixturePanel.refreshOpenMessageExchange()); - await expect(page.locator('.message-jump-latest')).toBeVisible(); + await expect(page.locator('#messageComposeArea .chat-scroll-to-end-btn')).toBeVisible(); assert(unread().some(m => m.concept_id === 'arrival')); - await page.locator('.message-jump-latest').click(); + const latest = page.locator('#messageComposeArea .chat-scroll-to-end-btn'); + await expect(latest).toHaveAccessibleName('Scroll to latest message'); + const bounds = await latest.boundingBox(); + assert(bounds.width >= 44 && bounds.height >= 44 && bounds.x >= 0 && bounds.x + bounds.width <= width); + await page.keyboard.press('Tab'); + await latest.focus(); + assert(await latest.evaluate(el => getComputedStyle(el).outlineStyle !== 'none')); + await page.screenshot({ path: path.join(evidence, `messages-navigation-${width}.png`) }); + await latest.press('Enter'); await expect.poll(() => unread().some(m => m.concept_id === 'arrival')).toBe(false); await page.getByRole('button', { name: 'Load earlier messages', exact: true }).click(); await expect(page.locator('[data-contribution-id="fixture-0"]')).toHaveCount(1); @@ -150,7 +158,23 @@ async function measure(page, name) { assert(layout[0].x > layout[1].x + 5 && layout[0].x > layout[2].x + 5, 'Old-style participant alignment'); assert(layout.every(item => item.overflow <= 1), 'Old-style controls/content fit'); assert(layout[1].text.includes('Bob')); - results.push({ name: `chat-${width}`, layout }); + await page.evaluate(async () => { + const chat = await import('/static/js/chatTab.js'); + const field = document.querySelector('#scrollableField'); + for (let i = 0; i < 12; i++) chat.__testOnly_appendMessage('Bob', 'Long fixture content. '.repeat(20), `long-${i}`, false, true); + window.scrollTo(0, 0); + chat.__testOnly_updateScrollToEndButtonVisibility(field); + }); + const chatLatest = page.locator('.chat-composer > .chat-scroll-to-end-btn'); + await expect(chatLatest).toBeVisible(); + await expect(chatLatest).toHaveAccessibleName('Scroll to latest message'); + const boundsChat = await chatLatest.boundingBox(); + assert(boundsChat.width >= 44 && boundsChat.height >= 44); + await chatLatest.focus(); + await page.screenshot({ path: path.join(evidence, `chat-navigation-${width}.png`) }); + await chatLatest.press('Space'); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + results.push({ name: `chat-${width}`, layout, latest: boundsChat }); await page.screenshot({ path: path.join(evidence, `chat-${width}.png`) }); await page.close(); } diff --git a/tests/frontend/conversationNavigation.test.js b/tests/frontend/conversationNavigation.test.js new file mode 100644 index 000000000..5a96f41ba --- /dev/null +++ b/tests/frontend/conversationNavigation.test.js @@ -0,0 +1,31 @@ +const base = '../../src/frontend/web/von_interface/static/js/'; +const nav = require(base + 'components/conversationNavigation.js'); +const chat = require(base + 'chatTab.js'); + +afterEach(() => chat.__testOnly_clearLatestUnreadJumpState()); + +test('chat and shared factory expose identical native button semantics', () => { + document.body.innerHTML = '

Latest

'; + const button = chat.__testOnly_ensureScrollToEndButton(); + const shared = nav.createLatestMessageButton(jest.fn()); + expect(button.outerHTML.replace(/ id="[^"]*"/, '')).toBe(shared.outerHTML); + nav.setNavigationVisible(button, true); + expect(button.type).toBe('button'); + expect(button.tabIndex).toBe(0); + expect(button.getAttribute('aria-hidden')).toBe('false'); +}); + +test('successive arrivals retain the first new boundary and its keyboard focus destination', () => { + document.body.innerHTML = '

First

Last

'; + const first = chat.__testOnly_setLatestUnreadBoundary(document.getElementById('first')); + const next = chat.__testOnly_setLatestUnreadBoundary(document.getElementById('last')); + expect(next).toBe(first); + expect(first.nextElementSibling.id).toBe('first'); + first.scrollIntoView = jest.fn(); + chat.__testOnly_showNewSharedMessagesIndicator(); + const button = document.getElementById('newSharedMessagesIndicator'); + expect(button.getAttribute('aria-label')).toBe('Jump to first new message'); + button.click(); + expect(document.activeElement).toBe(first); + expect(first.scrollIntoView).toHaveBeenCalled(); +}); diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js index 6a97810eb..bd7a0ab61 100644 --- a/tests/frontend/messageExchangeView.test.js +++ b/tests/frontend/messageExchangeView.test.js @@ -159,3 +159,60 @@ test('queued observers from a previous selection and hidden documents cannot mar observers[0].callback([visibleEntry()]); expect(api.postJson.mock.calls.filter(([url]) => url === '/api/messages/read/bulk')).toHaveLength(0); }); + +test('scrolling away exposes the shared latest control without waiting for new messages or writing read state', async () => { + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + const content = document.getElementById('messageViewContent'); + Object.defineProperties(content, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 200 } }); + content.scrollTop = 20; + content.dispatchEvent(new Event('scroll')); + const latest = document.querySelector('.chat-scroll-to-end-btn'); + expect(latest.getAttribute('aria-label')).toBe('Scroll to latest message'); + expect(latest.tabIndex).toBe(0); + latest.click(); + expect(content.scrollTop).toBe(1000); + expect(latest.tabIndex).toBe(-1); + expect(document.activeElement.dataset.contributionId).toBe('one'); + expect(require(base + 'apiService.js').postJson).toHaveBeenCalledTimes(1); +}); + +test('refresh and pagination preserve scroll position and first unread navigation without bulk acknowledgement', async () => { + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValueOnce({ ...unreadResponse(), before: 'older' }); + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + const content = document.getElementById('messageViewContent'); + Object.defineProperties(content, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 200 } }); + content.scrollTop = 25; + const updated = unreadResponse(); + updated.messages.push({ ...response('two').messages[0], created_at: '2026-09-12T12:00:00Z' }); + api.postJson.mockResolvedValueOnce(updated); + await panel.refreshOpenMessageExchange(); + expect(content.scrollTop).toBe(25); + const first = content.querySelector('.is-unread'); + first.scrollIntoView = jest.fn(); + document.querySelector('.message-jump-unread').click(); + expect(first.scrollIntoView).toHaveBeenCalledWith({ block: 'start' }); + expect(document.activeElement).toBe(first); + const older = response('old'); older.messages[0].created_at = '2026-09-10T12:00:00Z'; + api.postJson.mockResolvedValueOnce(older); + [...content.querySelectorAll('button')].find(b => b.textContent === 'Load earlier messages').click(); + await flush(); + expect(content.scrollTop).toBe(25); + expect(content.querySelectorAll('[data-contribution-id]')).toHaveLength(3); + expect(api.postJson.mock.calls.some(([url]) => url.includes('/read/'))).toBe(false); +}); + +test('the visible-read observer excludes the fixed composer overlap', async () => { + const observers = mockReadObserver(); + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValue(unreadResponse()); + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + document.getElementById('messageViewContent').getBoundingClientRect = () => ({ top: 100, bottom: 800, height: 700 }); + document.getElementById('messageComposeArea').getBoundingClientRect = () => ({ top: 650, bottom: 800, height: 150 }); + await panel.refreshOpenMessageExchange(); + expect(observers).toHaveLength(2); + expect(global.IntersectionObserver.mock.calls[1][1].rootMargin).toBe('0px 0px -150px 0px'); +});