diff --git a/src/frontend/web/von_interface/static/js/components/conversationTray.js b/src/frontend/web/von_interface/static/js/components/conversationTray.js index fc2f2f4f4..a0fcc43e5 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationTray.js +++ b/src/frontend/web/von_interface/static/js/components/conversationTray.js @@ -1,6 +1,8 @@ import { clampConversationTrayWidth, CONVERSATION_TRAY_COLLAPSED_KEY, + CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY, + CONVERSATION_NARROW_QUERY, CONVERSATION_TRAY_WIDTH_KEY, saveConversationLayoutPreference } from '../utils/conversationLayoutPreferences.js'; @@ -12,6 +14,8 @@ export function createConversationTray(workspace) { const toggle = workspace.querySelector('#conversationTrayToggle'); const resize = workspace.querySelector('#conversationTrayResize'); const tabs = workspace.querySelector('#chatSessionTabs'); + const context = workspace.querySelector('#conversationTrayContext'); + const narrow = window.matchMedia?.(CONVERSATION_NARROW_QUERY); let preferences = { width: 260, collapsed: false, expandOnHover: true }; let peek = false; let enterTimer; @@ -26,15 +30,32 @@ export function createConversationTray(workspace) { cleanup.push(() => target.removeEventListener(event, callback)); }; const isVertical = () => workspace.dataset.effectiveTabsLayout === 'vertical'; + const isMobile = () => !isVertical() && !!narrow?.matches; + const isCollapsed = () => isMobile() ? !!preferences.mobileCollapsed : isVertical() && preferences.collapsed; const isMenuOpen = () => !!document.querySelector('.chat-session-menu.open'); const maxWidth = () => Math.max(220, Math.min(400, (workspace.clientWidth || window.innerWidth) - 420)); const effectiveWidth = () => Math.min(preferences.width, maxWidth()); const clearTimers = () => { clearTimeout(enterTimer); clearTimeout(leaveTimer); }; + function updateContext(reveal = false) { + const active = tabs?.querySelector('[role="tab"][aria-selected="true"]'); + const label = (active?.querySelector('.chat-session-tab-label') || active)?.textContent.trim(); + const text = label ? `Current: ${label}` : 'Conversations'; + if (context && context.textContent !== text) context.textContent = text; + if (reveal && active && isMobile() && !isCollapsed()) { + // Scroll only the list: scrollIntoView could displace the transcript. + const bounds = tabs.getBoundingClientRect(); + const row = active.getBoundingClientRect(); + if (row.left < bounds.left) tabs.scrollLeft += row.left - bounds.left; + else if (row.right > bounds.right) tabs.scrollLeft += Math.min(row.left - bounds.left, row.right - bounds.right); + } + } + function render() { - const collapsed = isVertical() && preferences.collapsed; - if (!collapsed) peek = false; - const nextPresentation = !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded'; + const collapsed = isCollapsed(); + workspace.dataset.trayMobile = String(isMobile()); + if (!collapsed || !isVertical()) peek = false; + const nextPresentation = isMobile() && collapsed ? 'mobile-closed' : !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded'; const presentationChanged = presentation !== nextPresentation; if (tabs && presentationChanged && presentation) { scrollPositions.set(presentation, { top: tabs.scrollTop, left: tabs.scrollLeft }); @@ -58,14 +79,19 @@ export function createConversationTray(workspace) { resize.setAttribute('aria-valuenow', String(effectiveWidth())); resize.setAttribute('aria-valuemax', String(maxWidth())); } + if (isMobile() && collapsed && document.activeElement !== toggle && navigation?.contains(document.activeElement)) { + toggle?.focus({ preventScroll: true }); + } + updateContext(true); } function setCollapsed(value) { clearTimers(); - preferences.collapsed = value; + if (isMobile()) preferences.mobileCollapsed = value; + else preferences.collapsed = value; peek = false; render(); - saveConversationLayoutPreference(CONVERSATION_TRAY_COLLAPSED_KEY, value); + saveConversationLayoutPreference(isMobile() ? CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY : CONVERSATION_TRAY_COLLAPSED_KEY, value); } function closePeek() { @@ -75,7 +101,7 @@ export function createConversationTray(workspace) { render(); } - listen(toggle, 'click', () => setCollapsed(!preferences.collapsed)); + listen(toggle, 'click', () => setCollapsed(!isCollapsed())); listen(navigation, 'pointerenter', (event) => { clearTimers(); if (event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return; @@ -104,8 +130,8 @@ export function createConversationTray(workspace) { } }); listen(document, 'keydown', (event) => { - if (event.key === 'Escape' && isVertical() && !isMenuOpen() - && (peek || (!preferences.collapsed && navigation?.contains(event.target)))) { + if (event.key === 'Escape' && (isVertical() || isMobile()) && !isMenuOpen() + && (peek || (!isCollapsed() && navigation?.contains(event.target)))) { event.preventDefault(); clearTimers(); if (peek) { @@ -170,6 +196,10 @@ export function createConversationTray(workspace) { listen(window, 'resize', render); const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(render) : null; observer?.observe(workspace); + // Session rendering replaces rows asynchronously, including catalogue selection. + const tabObserver = new MutationObserver(() => updateContext(true)); + if (tabs) tabObserver.observe(tabs, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-selected'] }); + listen(narrow, 'change', render); return { workspace, @@ -186,6 +216,7 @@ export function createConversationTray(workspace) { clearTimers(); finishDrag(true); observer?.disconnect(); + tabObserver.disconnect(); cleanup.forEach(fn => fn()); } }; diff --git a/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js b/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js index 2d35dc8c6..aa1d86fd2 100644 --- a/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js +++ b/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js @@ -2,10 +2,12 @@ export const CONVERSATION_LAYOUT_KEY = 'von:chatSessionTabsLayout'; export const CONVERSATION_TRAY_WIDTH_KEY = 'von:conversationTrayWidth'; export const CONVERSATION_TRAY_COLLAPSED_KEY = 'von:conversationTrayCollapsed'; +export const CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY = 'von:conversationMobileTrayCollapsed'; export const CONVERSATION_TRAY_HOVER_KEY = 'von:conversationTrayExpandOnHover'; export const CONVERSATION_LAYOUT_KEYS = [ CONVERSATION_LAYOUT_KEY, CONVERSATION_TRAY_WIDTH_KEY, - CONVERSATION_TRAY_COLLAPSED_KEY, CONVERSATION_TRAY_HOVER_KEY + CONVERSATION_TRAY_COLLAPSED_KEY, CONVERSATION_TRAY_HOVER_KEY, + CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY ]; export const CONVERSATION_NARROW_QUERY = '(max-width: 800px), (hover: none) and (pointer: coarse)'; export const CONVERSATION_TRAY_MIN_WIDTH = 220; @@ -31,6 +33,7 @@ export function loadConversationLayoutPreferences(reader = (key) => layout: normaliseConversationLayout(read(CONVERSATION_LAYOUT_KEY)), width: clampConversationTrayWidth(read(CONVERSATION_TRAY_WIDTH_KEY)), collapsed: read(CONVERSATION_TRAY_COLLAPSED_KEY) === 'true', + mobileCollapsed: read(CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY) === 'true', expandOnHover: read(CONVERSATION_TRAY_HOVER_KEY) !== 'false' }; } diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 7464df204..622d9a6c6 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -8699,6 +8699,7 @@ body[data-active-tab="settingsTab"] .tab-content-area { /* The same session list serves horizontal navigation, the docked tray and peek. */ .chat-conversation-nav { display: contents; } .conversation-tray-header, +.conversation-tray-context, .conversation-tray-resize { display: none; } .conversation-workspace[data-effective-tabs-layout="vertical"] { @@ -8892,6 +8893,40 @@ body[data-active-tab="settingsTab"] .tab-content-area { .conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; } } +/* Keep current context above the mobile list without moving any conversation. */ +.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs-row { + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + gap: 4px; +} +.conversation-workspace[data-tray-mobile="true"] .conversation-tray-header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.conversation-workspace[data-tray-mobile="true"] .conversation-tray-heading { display: none; } +.conversation-workspace[data-tray-mobile="true"] .conversation-tray-context { + display: block; + min-width: 0; + overflow-wrap: anywhere; + font-size: 13px; + font-weight: 600; +} +.conversation-workspace[data-tray-mobile="true"] .conversation-tray-toggle { + flex-basis: 44px; + width: 44px; + height: 44px; +} +.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs { + width: 100%; + flex: 0 1 auto; +} +.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-tabs, +.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-history-summary, +.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .catalogue-filters { display: none; } + .chat-situation-toggle { min-height: 32px; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 86ad70fe6..6af28542d 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -10,6 +10,7 @@ aria-label="Collapse conversation list" aria-expanded="true" aria-controls="chatSessionTabs" title="Collapse conversation list" data-keep-title="true"> Conversations +
diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 3f2012db4..8e50acafc 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -140,7 +140,7 @@

Conversations

Expand collapsed conversation tray on hover - Mobile and narrow windows keep the horizontal list. Layout, tray width and collapsed state are remembered in this browser. + Mobile and narrow windows start with the horizontal list open and keep the current conversation name above it. Collapse or reopen it using the list button. Layout, tray width and separate mobile/desktop collapsed states are remembered in this browser, across sign-ins; if storage is unavailable, choices last for this page only.
diff --git a/tests/browser/responsiveShell.cjs b/tests/browser/responsiveShell.cjs index 5b87cef2f..3d1d04a72 100644 --- a/tests/browser/responsiveShell.cjs +++ b/tests/browser/responsiveShell.cjs @@ -41,7 +41,7 @@ const server = http.createServer((req, res) => { }); async function noOverflow(page) { const size = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth })); - assert(size.document <= size.viewport + 1, `global overflow: ${JSON.stringify(size)}`); + assert(size.document <= page.viewportSize().width + 1, `global overflow: ${JSON.stringify(size)}`); } (async () => { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); @@ -49,6 +49,7 @@ async function noOverflow(page) { try { for (const profile of [ { name: 'pixel-8', width: 412, height: 915, touch: true }, + { name: 'small-phone', width: 320, height: 740, touch: true }, { name: 'folded', width: 360, height: 740, touch: true }, { name: 'tablet', width: 768, height: 1024, touch: true }, { name: 'unfolded', width: 840, height: 900, touch: true }, @@ -59,7 +60,7 @@ async function noOverflow(page) { await page.goto(`http://127.0.0.1:${server.address().port}`); await noOverflow(page); await expect(page.locator('#vonGoogleLoginButton')).toBeVisible(); - await page.evaluate(async () => { + const preparePage = () => page.evaluate(async () => { // Synthetic signed-in presentation; no credentials or identity bypass. document.body.classList.remove('von-auth-pending'); document.querySelector('#vonAuthenticationGate').hidden = true; @@ -71,22 +72,24 @@ async function noOverflow(page) { Model: configured model`; document.querySelector('#chatSessionTabs').innerHTML = Array.from({ length: 12 }, (_, i) => - ``).join(''); + ``).join(''); document.querySelector('#chatSessionHistoryControls').innerHTML = ''; document.querySelector('#scrollableField').innerHTML = Array.from({ length: 25 }, (_, i) => `

Turn ${i + 1}: A readable saved research conversation with provenance and next steps.

`).join(''); const workspace = document.querySelector('#conversationWorkspace'); const { createConversationTray } = await import('/static/js/components/conversationTray.js'); + const { CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences } = await import('/static/js/utils/conversationLayoutPreferences.js'); const tray = createConversationTray(workspace); const refresh = () => { - workspace.dataset.effectiveTabsLayout = innerWidth <= 900 ? 'horizontal' : 'vertical'; - tray.refresh({ width: 260, collapsed: false, expandOnHover: true }); + workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches ? 'horizontal' : 'vertical'; + tray.refresh(loadConversationLayoutPreferences()); }; refresh(); window.addEventListener('resize', refresh); const { setupDynamicLayout } = await import('/static/js/components/dynamicLayout.js'); setupDynamicLayout(); }); + await preparePage(); await page.waitForTimeout(100); await noOverflow(page); const input = page.locator('#promptInput'); @@ -135,6 +138,49 @@ async function noOverflow(page) { await noOverflow(page); if (evidence) await page.screenshot({ path: path.join(evidence, 'desktop-tray-reopened.png') }); } + if (profile.touch) { + const toggle = page.locator('#conversationTrayToggle'); + const context = page.locator('#conversationTrayContext'); + const selected = page.locator('#chatSessionTabs [aria-selected="true"]'); + await expect(context).toHaveText('Current: Saved research conversation 11'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const listBox = await page.locator('#chatSessionTabs').boundingBox(); + const activeBox = await selected.boundingBox(); + assert(activeBox.x >= listBox.x - 1 && activeBox.x + activeBox.width <= listBox.x + listBox.width + 1, 'active conversation revealed without reordering'); + const longTitle = 'Research conversation with a deliberately long title about observations, provenance and the next collaborative steps'; + await selected.locator('.chat-session-tab-label').evaluate((el, title) => { el.textContent = title; }, longTitle); + await expect(context).toHaveText(`Current: ${longTitle}`); + await noOverflow(page); + await selected.locator('.chat-session-tab-label').evaluate(el => { el.textContent = 'Saved research conversation 11'; }); + await expect(context).toHaveText('Current: Saved research conversation 11'); + const contextBox = await context.boundingBox(); + assert(contextBox.y + contextBox.height <= (await selected.boundingBox()).y, 'current context above list'); + const retained = await page.evaluateHandle(() => document.querySelector('#scrollableField').firstChild); + await selected.focus(); + const scrollY = await page.evaluate(() => window.scrollY); + await page.keyboard.press('Escape'); + await expect(toggle).toBeFocused(); + await expect(toggle).toHaveAccessibleName('Open conversation list'); + await expect(page.locator('#chatSessionTabs')).toBeHidden(); + await expect(context).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.locator('#chatSessionTabs')).toBeVisible(); + assert.equal(await page.evaluate(() => window.scrollY), scrollY, 'reopening list leaves document scroll unchanged'); + await page.keyboard.press('Space'); + await expect(page.locator('#chatSessionTabs')).toBeHidden(); + assert(await retained.evaluate(el => el === document.querySelector('#scrollableField').firstChild), 'mobile transcript retained'); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await noOverflow(page); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-collapsed.png`) }); + await page.reload(); + await preparePage(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(context).toHaveText('Current: Saved research conversation 11'); + await toggle.click(); + await expect(selected).toBeVisible(); + await input.fill('Draft survives resizing and folding.'); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-reopened.png`) }); + } if (trayOnly) { console.log(JSON.stringify({ profile: profile.name, passed: true, scope: 'conversation tray', source: 'synthetic production-template fixture' })); await page.close(); diff --git a/tests/frontend/chatConversationLayout.test.js b/tests/frontend/chatConversationLayout.test.js index 959dc833e..db478878a 100644 --- a/tests/frontend/chatConversationLayout.test.js +++ b/tests/frontend/chatConversationLayout.test.js @@ -38,7 +38,7 @@ describe('conversation single-scroll layout', () => { expect(composerRule).toContain('position: sticky'); expect(template).toContain('class="chat-composer"'); expect(template).toContain('
'); - expect(template).toContain('