diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 865e24b6..6f1bcb84 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -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; } @@ -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; @@ -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') @@ -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; diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 01cb6310..788237de 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -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') { @@ -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(); @@ -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 [ @@ -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; }); @@ -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'); @@ -12849,7 +12854,7 @@ describe('scroll to latest message affordance', () => { button.click(); expect(window.scrollTo).toHaveBeenCalledWith({ - top: 1200, + top: 524, behavior: 'smooth' }); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 83ab9aa3..01aff3aa 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -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; +} diff --git a/tests/browser/conversationEndNavigation.cjs b/tests/browser/conversationEndNavigation.cjs new file mode 100644 index 00000000..8792f3fa --- /dev/null +++ b/tests/browser/conversationEndNavigation.cjs @@ -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(/]*>[\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) => + `

Contribution ${i + 1}

${'Research observations and next steps. '.repeat(20)}

`).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 + ? '
Two attached screenshot previews (fixture)
' : ''; + 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 = '

Short conversation

'; + 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; });