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
45 changes: 43 additions & 2 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -31005,7 +31005,22 @@ function ensureScrollToEndButton(scrollableField = null) {
}

const wrapper = targetField.closest('.content-wrapper') || targetField.parentElement;
const controlHost = wrapper?.querySelector('.chat-composer') || wrapper;
let controlHost = wrapper?.querySelector('.chat-composer') || wrapper;
if (!isCompactComposer()) {
controlHost = wrapper?.querySelector('.chat-transcript-navigation');
if (!controlHost && wrapper) {
controlHost = document.createElement('nav');
controlHost.className = 'chat-transcript-navigation';
controlHost.setAttribute('aria-label', 'Conversation navigation');
targetField.after(controlHost);
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => updateScrollToEndButtonVisibility(targetField));
observer.observe(targetField);
const composer = wrapper.querySelector('.chat-composer');
if (composer) observer.observe(composer);
}
}
}
if (!(controlHost instanceof HTMLElement)) {
return null;
}
Expand Down Expand Up @@ -31039,10 +31054,26 @@ function isScrollableFieldNearBottom(scrollableField, thresholdPx = CHAT_SCROLL_
const clientHeight = getConversationPageViewportHeight();
const scrollHeight = getConversationPageScrollHeight();
const scrollTop = getConversationPageScrollTop();
if (!isCompactComposer()) {
return Math.abs(getDesktopConversationEndScrollTop(scrollableField) - scrollTop)
<= Math.max(0, Number(thresholdPx) || 0);
}
const distanceToBottom = scrollHeight - (scrollTop + clientHeight);
return distanceToBottom <= Math.max(0, Number(thresholdPx) || 0);
}

// Account for the sticky draft and footer instead of scrolling into the page's
// trailing layout space below the conversation.
function getDesktopConversationEndScrollTop(scrollableField) {
const viewport = getConversationPageViewportHeight();
const composer = scrollableField.closest('.content-wrapper')?.querySelector('.chat-composer');
const draftSpace = (composer?.getBoundingClientRect().height || 0)
+ (composer ? parseFloat(getComputedStyle(composer).bottom) || 0 : 0);
const transcriptEnd = scrollableField.getBoundingClientRect().bottom + getConversationPageScrollTop();
const desiredTop = transcriptEnd + draftSpace + 24 - viewport;
return Math.max(0, Math.min(desiredTop, getConversationPageScrollHeight() - viewport));
}

function getConversationPageScrollTop() {
if (typeof window === 'undefined') return 0;
return Number(window.scrollY ?? window.pageYOffset ?? document.documentElement?.scrollTop ?? 0) || 0;
Expand Down Expand Up @@ -31079,7 +31110,9 @@ function scrollConversationToEnd(scrollableField, options = {}) {
}

const smooth = options?.smooth !== false;
const top = getConversationPageScrollHeight();
const top = isCompactComposer()
? getConversationPageScrollHeight()
: getDesktopConversationEndScrollTop(scrollableField);
scrollConversationPageTo(top, { smooth });

const schedule = (typeof window !== 'undefined' && typeof window.setTimeout === 'function')
Expand Down Expand Up @@ -31108,6 +31141,14 @@ function updateScrollToEndButtonVisibility(scrollableField = null) {
return;
}

if (!isCompactComposer()) {
const composer = targetField.closest('.content-wrapper')?.querySelector('.chat-composer');
const clearance = composer
? getConversationPageViewportHeight() - composer.getBoundingClientRect().top + 12
: 16;
button.parentElement.style.bottom = `${Math.max(16, clearance)}px`;
}

const hasOverflow = (getConversationPageScrollHeight() - getConversationPageViewportHeight()) > 1;
const nearBottom = isScrollableFieldNearBottom(targetField);
const shouldShow = hasOverflow && !nearBottom;
Expand Down
15 changes: 10 additions & 5 deletions src/frontend/web/von_interface/static/js/test/chatTab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8087,6 +8087,9 @@ describe('thinking card toggle accessibility', () => {
jest.spyOn(window, 'scrollTo').mockImplementation(({ top }) => {
window.scrollY = Math.min(top, document.documentElement.scrollHeight - window.innerHeight);
});
document.getElementById('scrollableField').getBoundingClientRect = () => ({
bottom: document.documentElement.scrollHeight - 200 - window.scrollY
});
let resolveGenerate;
global.fetch = jest.fn((url, options = {}) => {
if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') {
Expand All @@ -8109,7 +8112,7 @@ describe('thinking card toggle accessibility', () => {
}
expect(resolveGenerate).toBeDefined();
expect(document.querySelector('.user-turn').textContent).toContain('Show the latest turn');
expect(window.scrollY).toBe(document.documentElement.scrollHeight - window.innerHeight);
expect(window.scrollY).toBe(document.documentElement.scrollHeight - 176 - window.innerHeight);
if (scrollAway) window.scrollY = 100;
window.scrollTo.mockClear();
resolveGenerate();
Expand All @@ -8120,7 +8123,7 @@ describe('thinking card toggle accessibility', () => {
expect(window.scrollY).toBe(100);
} else {
expect(window.scrollTo).toHaveBeenCalled();
expect(window.scrollY).toBe(document.documentElement.scrollHeight - window.innerHeight);
expect(window.scrollY).toBe(document.documentElement.scrollHeight - 176 - window.innerHeight);
}
} finally {
for (const [target, key, descriptor] of [
Expand Down Expand Up @@ -12818,6 +12821,8 @@ describe('scroll to latest message affordance', () => {
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 1200, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 600, configurable: true });
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
document.getElementById('scrollableField').getBoundingClientRect = () => ({ bottom: 1000 - window.scrollY });
document.querySelector('.chat-composer').getBoundingClientRect = () => ({ height: 100 });
window.scrollTo = jest.fn(({ top }) => {
window.scrollY = top;
});
Expand All @@ -12828,11 +12833,11 @@ describe('scroll to latest message affordance', () => {
__testOnly_updateScrollToEndButtonVisibility(scrollableField);
const button = __testOnly_ensureScrollToEndButton(scrollableField);
expect(button).toBeTruthy();
expect(button.parentElement).toBe(document.querySelector('.chat-composer'));
expect(button.parentElement).toBe(document.querySelector('.chat-transcript-navigation'));
expect(button.classList.contains('visible')).toBe(true);
expect(button.getAttribute('aria-hidden')).toBe('false');

window.scrollY = 610;
window.scrollY = 524;
__testOnly_updateScrollToEndButtonVisibility(scrollableField);
expect(button.classList.contains('visible')).toBe(false);
expect(button.getAttribute('aria-hidden')).toBe('true');
Expand All @@ -12849,7 +12854,7 @@ describe('scroll to latest message affordance', () => {
button.click();

expect(window.scrollTo).toHaveBeenCalledWith({
top: 1200,
top: 524,
behavior: 'smooth'
});

Expand Down
17 changes: 17 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -18900,3 +18900,20 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu
.chat-composer > .prompt-input-wrapper, .chat-composer > #promptInput { grid-column: 3; }
.chat-composer #sendButton { grid-column: 4; }
}

/* Desktop navigation belongs to the transcript and never changes composer height.
A zero-height sticky host keeps the control reachable while reading history. */
.chat-transcript-navigation {
position: sticky;
bottom: 16px;
height: 0;
flex: 0 0 0;
z-index: 12;
}
.chat-transcript-navigation > .chat-scroll-to-end-btn {
position: absolute;
right: 12px;
bottom: 0;
margin: 0;
padding: 0;
}
104 changes: 104 additions & 0 deletions tests/browser/conversationEndNavigation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Isolated layout acceptance: production templates, CSS and chat navigation module.
// No authentication, live messages, database writes, or model calls are exercised.
// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/conversationEndNavigation.cjs DIR
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 evidence = process.argv[2] || '.run/conversation-end';
fs.mkdirSync(evidence, { recursive: true });
function template(name) {
return fs.readFileSync(path.join(root, 'templates', name), 'utf8')
.replace(/{% include '([^']+)' %}/g, (_, child) => template(child))
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
.replace(/{{ url_for\('static', filename='([^']+)'\) }}/g, '/static/$1')
.replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, '');
}
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
if (pathname === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(template('von_interface.html')); }
const file = path.resolve(root, `.${pathname}`);
if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end();
res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : file.endsWith('.png') ? 'image/png' : 'text/javascript');
res.end(fs.readFileSync(file));
});

(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ headless: true });
const results = [];
try {
for (const width of [1440, 900]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.evaluate(async () => {
document.body.classList.remove('von-auth-pending');
document.querySelector('#vonAuthenticationGate').hidden = true;
document.querySelector('#vonAuthenticatedApp').hidden = false;
window.chat = await import('/static/js/chatTab.js');
const attachments = document.createElement('div');
attachments.id = 'conversationImageComposer';
document.querySelector('.chat-composer').append(attachments);
window.field = document.querySelector('#scrollableField');
field.innerHTML = Array.from({ length: 25 }, (_, i) =>
`<div class="message-container"><p>Contribution ${i + 1}</p><p>${'Research observations and next steps. '.repeat(20)}</p></div>`).join('');
window.refresh = () => chat.__testOnly_updateScrollToEndButtonVisibility(field);
window.addEventListener('scroll', refresh);
window.addEventListener('resize', refresh);
refresh();
});
const button = page.locator('#chatScrollToEndButton');
for (const expanded of [false, true]) {
await page.evaluate(expanded => {
document.querySelector('#conversationImageComposer').innerHTML = expanded
? '<div style="height:500px">Two attached screenshot previews (fixture)</div>' : '';
window.scrollTo(0, 500);
refresh();
}, expanded);
await expect(button).toBeVisible();
const before = await page.evaluate(() => {
const b = document.querySelector('#chatScrollToEndButton').getBoundingClientRect();
const c = document.querySelector('.chat-composer').getBoundingClientRect();
return { arrowTop: b.top, arrowBottom: b.bottom, composerTop: c.top, composerHeight: c.height };
});
await page.screenshot({ path: path.join(evidence, `${width}-${expanded ? 'attachments' : 'draft'}-history.png`) });
assert(before.arrowTop >= 0 && before.arrowBottom <= 900, 'arrow reachable while reading');
assert(before.arrowBottom <= before.composerTop, 'arrow on conversation above composer');
await button.click();
await expect(button).toBeHidden();
const after = await page.evaluate(() => {
const f = field.getBoundingClientRect();
const c = document.querySelector('.chat-composer').getBoundingClientRect();
return { transcriptBottom: f.bottom, composerTop: c.top, composerHeight: c.height, scrollY,
pageBottomGap: document.documentElement.scrollHeight - innerHeight - scrollY };
});
assert(after.transcriptBottom > 100 && after.transcriptBottom <= after.composerTop, 'latest contribution visible');
assert.equal(after.composerHeight, before.composerHeight, 'visibility does not resize composer');
results.push({ width, expanded, before, after });
await page.screenshot({ path: path.join(evidence, `${width}-${expanded ? 'attachments' : 'draft'}-end.png`) });
}
await page.evaluate(() => {
field.innerHTML = '<div class="message-container"><p>Short conversation</p></div>';
document.querySelector('#conversationImageComposer').replaceChildren();
chat.__testOnly_scrollConversationToEnd(field, { smooth: false });
refresh();
});
await expect(button).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => refresh());
assert.equal(await button.evaluate(el => el.parentElement.className), 'chat-composer');
await page.setViewportSize({ width, height: 900 });
await page.evaluate(() => refresh());
assert.equal(await button.evaluate(el => el.parentElement.className), 'chat-transcript-navigation');
await expect(button).toHaveCount(1);
await page.close();
}
fs.writeFileSync(path.join(evidence, 'measurements.json'), JSON.stringify(results, null, 2));
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
}
})().catch(error => { console.error(error); process.exitCode = 1; });
Loading