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
67 changes: 67 additions & 0 deletions docs/engineering/conversation_affordances_2026-09-13.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }
Expand Down
28 changes: 10 additions & 18 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = '<span aria-hidden="true">↓</span><span class="sr-only">Scroll to latest message</span>';
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);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = '<span aria-hidden="true">↓</span>';
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 });
}
60 changes: 50 additions & 10 deletions src/frontend/web/von_interface/static/js/components/messagePanel.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -390,7 +429,7 @@ function attachMessagesEventListeners() {
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (_currentConversationUserId) {
loadConversation(_currentConversationUserId);
loadConversation(_currentConversationUserId, { silent: true });
}
});
}
Expand Down Expand Up @@ -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();
Expand All @@ -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 = '<div class="message-error">Could not load this conversation. Try Refresh.</div>';
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 28 additions & 2 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
Loading
Loading