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 @@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('