From 17ba89208ab32fadd072492c5907997d6acba296 Mon Sep 17 00:00:00 2001 From: witbrock Date: Mon, 14 Sep 2026 18:37:12 -0400 Subject: [PATCH] Use the conversation tray for mobile navigation --- .../web/von_interface/static/js/chatTab.js | 30 +++++-------- .../static/js/components/conversationTray.js | 29 ++++++++---- .../web/von_interface/static/styles.css | 35 ++++++++++++++- .../von_interface/templates/settings_tab.html | 6 +-- tests/browser/responsiveShell.cjs | 40 +++++++++++++++-- .../chatSessionAgentCreatedFilter.test.js | 40 +++++------------ tests/frontend/conversationTray.test.js | 44 ++++++++++++++----- .../settingsConversationLayout.test.js | 9 ++-- 8 files changed, 154 insertions(+), 79 deletions(-) diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 322d4d898..e56609e83 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1354,24 +1354,15 @@ function isChatSessionTabsNarrowViewport() { } function getEffectiveChatSessionTabsLayout(preferredLayout = chatSessionTabsLayout) { - const preferred = normaliseChatSessionTabsLayout(preferredLayout); - if (preferred !== CHAT_SESSION_TABS_LAYOUT_VERTICAL) { - return CHAT_SESSION_TABS_LAYOUT_HORIZONTAL; - } - if (isChatSessionTabsNarrowViewport()) { - return CHAT_SESSION_TABS_LAYOUT_HORIZONTAL; - } - return CHAT_SESSION_TABS_LAYOUT_VERTICAL; + // Desktop layout is a preference; narrow/touch navigation always uses the tray. + return isChatSessionTabsNarrowViewport() + ? CHAT_SESSION_TABS_LAYOUT_VERTICAL + : normaliseChatSessionTabsLayout(preferredLayout); } function getChatSessionTabsLayoutMenuLabel() { - if (chatSessionTabsLayout === CHAT_SESSION_TABS_LAYOUT_VERTICAL) { - return isChatSessionTabsNarrowViewport() - ? 'Use conversation tabs across top on wider screens' - : 'Move conversation tabs to top'; - } - return isChatSessionTabsNarrowViewport() - ? 'Use conversation tabs on left on wider screens' + return chatSessionTabsLayout === CHAT_SESSION_TABS_LAYOUT_VERTICAL + ? 'Move conversation tabs to top' : 'Move conversation tabs to left'; } @@ -1436,9 +1427,8 @@ function setChatSessionTabsLayout(layout, options = {}) { } if (options.announce === true) { let message; - if (result.preferred === CHAT_SESSION_TABS_LAYOUT_VERTICAL - && result.effective === CHAT_SESSION_TABS_LAYOUT_HORIZONTAL) { - message = 'Left conversation tabs saved for wider screens.'; + if (isChatSessionTabsNarrowViewport()) { + message = 'Desktop conversation layout saved.'; } else if (result.preferred === CHAT_SESSION_TABS_LAYOUT_VERTICAL) { message = 'Conversation tabs moved to the left.'; } else { @@ -27792,12 +27782,12 @@ function buildNewChatContextMenuItems() { }); } }, - { + ...(!isChatSessionTabsNarrowViewport() ? [{ label: getChatSessionTabsLayoutMenuLabel(), onClick: () => { toggleChatSessionTabsLayout({ announce: true }); } - } + }] : []) ]; const agentTotalCount = agentCreatedSessionVisibilityState.totalCount || 0; 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 a0fcc43e5..bd2475929 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationTray.js +++ b/src/frontend/web/von_interface/static/js/components/conversationTray.js @@ -30,7 +30,7 @@ export function createConversationTray(workspace) { cleanup.push(() => target.removeEventListener(event, callback)); }; const isVertical = () => workspace.dataset.effectiveTabsLayout === 'vertical'; - const isMobile = () => !isVertical() && !!narrow?.matches; + const isMobile = () => !!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)); @@ -46,16 +46,16 @@ export function createConversationTray(workspace) { // 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); + if (row.top < bounds.top) tabs.scrollTop += row.top - bounds.top; + else if (row.bottom > bounds.bottom) tabs.scrollTop += Math.min(row.top - bounds.top, row.bottom - bounds.bottom); } } function render() { 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'; + if (!collapsed || !isVertical() || isMobile()) peek = false; + const nextPresentation = isMobile() ? (collapsed ? 'mobile-closed' : 'mobile-open') : !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded'; const presentationChanged = presentation !== nextPresentation; if (tabs && presentationChanged && presentation) { scrollPositions.set(presentation, { top: tabs.scrollTop, left: tabs.scrollLeft }); @@ -104,7 +104,7 @@ export function createConversationTray(workspace) { listen(toggle, 'click', () => setCollapsed(!isCollapsed())); listen(navigation, 'pointerenter', (event) => { clearTimers(); - if (event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return; + if (isMobile() || event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return; enterTimer = setTimeout(() => { peek = true; render(); }, 200); }); listen(navigation, 'pointerleave', () => { @@ -114,7 +114,7 @@ export function createConversationTray(workspace) { listen(navigation, 'focusin', (event) => { clearTimers(); // Focusing the collapse button itself must not immediately reopen it. - if (event.target !== toggle && preferences.collapsed && isVertical()) { + if (!isMobile() && event.target !== toggle && preferences.collapsed && isVertical()) { peek = true; render(); } @@ -122,7 +122,15 @@ export function createConversationTray(workspace) { listen(navigation, 'focusout', (event) => { if (!navigation.contains(event.relatedTarget)) leaveTimer = setTimeout(closePeek, 0); }); + listen(document, 'focusin', (event) => { + if (isMobile() && !isCollapsed() && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) { + setCollapsed(true); + } + }); listen(document, 'pointerdown', (event) => { + if (isMobile() && !isCollapsed() && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) { + setCollapsed(true); + } if (peek && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) { clearTimers(); peek = false; @@ -144,6 +152,10 @@ export function createConversationTray(workspace) { } }); + listen(tabs, 'click', (event) => { + if (isMobile() && event.target.closest?.('[role="tab"]')) setCollapsed(true); + }); + // Roving focus follows the actual layout. Enter/Space retain native activation. listen(tabs, 'keydown', (event) => { if (!event.target.matches?.('[role="tab"]')) return; @@ -171,7 +183,7 @@ export function createConversationTray(workspace) { } listen(resize, 'pointerdown', (event) => { - if (event.button !== 0 || !isVertical()) return; + if (event.button !== 0 || !isVertical() || isMobile()) return; event.preventDefault(); drag = { x: event.clientX, width: effectiveWidth(), savedWidth: preferences.width, pointerId: event.pointerId }; resize.setPointerCapture?.(event.pointerId); @@ -186,6 +198,7 @@ export function createConversationTray(workspace) { listen(resize, 'pointercancel', () => finishDrag(true)); listen(resize, 'lostpointercapture', () => finishDrag(true)); listen(resize, 'keydown', (event) => { + if (isMobile()) return; if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); preferences.width = event.key === 'Home' ? 220 : event.key === 'End' ? maxWidth() diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 01aff3aa0..1b2de8481 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -8961,6 +8961,34 @@ body[data-active-tab="settingsTab"] .tab-content-area { .conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; } } +/* Mobile keeps chat full-width and opens the shared left tray over it. */ +.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-mobile="true"][data-tray-collapsed] { + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} +.conversation-workspace[data-tray-mobile="true"] .chat-conversation-nav { + height: 58px; + max-height: none; + margin-bottom: 0; +} +.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs-row { + position: absolute; + top: 0; + left: 0; + width: min(320px, 100%); + height: max(120px, calc(100dvh - var(--von-fixed-shell-height, 104px) - var(--von-fixed-footer-clearance, 64px) - 24px)); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16); +} +.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-tabs-row { + width: 100%; + height: 58px; + box-shadow: none; +} +.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-mobile="true"] .conversation-tray-resize { display: none; } +@media (max-width: 800px), (hover: none) and (pointer: coarse) { + .conversation-desktop-setting { display: 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; @@ -8978,7 +9006,9 @@ body[data-active-tab="settingsTab"] .tab-content-area { .conversation-workspace[data-tray-mobile="true"] .conversation-tray-context { display: block; min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 13px; font-weight: 600; } @@ -8989,8 +9019,9 @@ body[data-active-tab="settingsTab"] .tab-content-area { } .conversation-workspace[data-tray-mobile="true"] .chat-session-tabs { width: 100%; - flex: 0 1 auto; + flex: 1 1 auto; } +.conversation-workspace[data-tray-mobile="true"] .chat-session-tab { min-height: 44px; } .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; } diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 1b373bb90..da9cd73f0 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -147,20 +147,20 @@

Conversations

in the conversation history tabs.

-
+
-
+
- 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. + Mobile and narrow windows use the left conversation tray, initially open, with the current conversation name above it. Collapse or reopen it using the list button; choosing a conversation closes the tray. 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 3d1d04a72..c5a05decf 100644 --- a/tests/browser/responsiveShell.cjs +++ b/tests/browser/responsiveShell.cjs @@ -57,6 +57,7 @@ async function noOverflow(page) { { name: 'desktop', width: 1440, height: 900 } ].filter(profile => !baseline || profile.name === 'desktop')) { const page = await browser.newPage({ viewport: profile, hasTouch: !!profile.touch, isMobile: !!profile.touch }); + page.setDefaultTimeout(20000); await page.goto(`http://127.0.0.1:${server.address().port}`); await noOverflow(page); await expect(page.locator('#vonGoogleLoginButton')).toBeVisible(); @@ -81,7 +82,8 @@ async function noOverflow(page) { const { CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences } = await import('/static/js/utils/conversationLayoutPreferences.js'); const tray = createConversationTray(workspace); const refresh = () => { - workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches ? 'horizontal' : 'vertical'; + workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches + ? 'vertical' : loadConversationLayoutPreferences().layout; tray.refresh(loadConversationLayoutPreferences()); }; refresh(); @@ -92,6 +94,10 @@ async function noOverflow(page) { await preparePage(); await page.waitForTimeout(100); await noOverflow(page); + if (profile.touch) { + await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical'); + await expect(page.locator('#conversationTrayToggle')).toHaveAttribute('aria-expanded', 'true'); + } const input = page.locator('#promptInput'); await input.fill('Draft survives resizing and folding.'); await page.locator('#sendButton').click({ trial: true }); @@ -143,10 +149,13 @@ async function noOverflow(page) { const context = page.locator('#conversationTrayContext'); const selected = page.locator('#chatSessionTabs [aria-selected="true"]'); await expect(context).toHaveText('Current: Saved research conversation 11'); + await toggle.click(); // Outside presses above dismiss the mobile panel. await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical'); + await expect(page.locator('#conversationTrayResize')).toBeHidden(); 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'); + assert(activeBox.y >= listBox.y - 1 && activeBox.y + activeBox.height <= listBox.y + listBox.height + 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}`); @@ -172,16 +181,37 @@ async function noOverflow(page) { 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.evaluate(() => localStorage.setItem('von:chatSessionTabsLayout', 'horizontal')); 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.'); + assert.equal(await page.evaluate(() => localStorage.getItem('von:chatSessionTabsLayout')), 'horizontal'); if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-reopened.png`) }); + await selected.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await input.fill('Draft survives resizing and folding.'); + for (const viewport of [{ width: 915, height: 412 }, { width: profile.width, height: profile.height }]) { + await page.setViewportSize(viewport); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await expect(selected).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical'); + await toggle.click(); + await expect(selected).toBeVisible(); + await toggle.click(); + await expect(page.locator('#chatSessionTabs')).toBeHidden(); + await noOverflow(page); + } } if (trayOnly) { + await page.goto(`http://127.0.0.1:${server.address().port}/settings`); + for (const id of ['settingsConversationLayoutSelect', 'settingsConversationTrayHoverToggle']) { + if (profile.touch) await expect(page.locator(`#${id}`)).toBeHidden(); + else await expect(page.locator(`#${id}`)).toBeVisible(); + } + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-settings.png`) }); console.log(JSON.stringify({ profile: profile.name, passed: true, scope: 'conversation tray', source: 'synthetic production-template fixture' })); await page.close(); continue; @@ -215,6 +245,10 @@ async function noOverflow(page) { await page.goto(`http://127.0.0.1:${server.address().port}/settings`); await noOverflow(page); await expect(page.locator('#preferredLanguageSelect')).toBeVisible(); + for (const id of ['settingsConversationLayoutSelect', 'settingsConversationTrayHoverToggle']) { + if (profile.touch) await expect(page.locator(`#${id}`)).toBeHidden(); + else await expect(page.locator(`#${id}`)).toBeVisible(); + } if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}-settings.png`) }); console.log(JSON.stringify({ profile: profile.name, passed: true, source: 'synthetic production-template fixture' })); await page.close(); diff --git a/tests/frontend/chatSessionAgentCreatedFilter.test.js b/tests/frontend/chatSessionAgentCreatedFilter.test.js index 6c08c8949..641a2bd65 100644 --- a/tests/frontend/chatSessionAgentCreatedFilter.test.js +++ b/tests/frontend/chatSessionAgentCreatedFilter.test.js @@ -348,40 +348,24 @@ describe('chat session agent-created filtering', () => { expect(document.activeElement).toBe(newChatButton); }); - test('describes a left-rail preference truthfully when the narrow layout stays horizontal', async () => { + test('mobile uses the tray without offering a desktop layout command', async () => { window.matchMedia = jest.fn(() => ({ matches: true, addEventListener: jest.fn(), removeEventListener: jest.fn() })); - require(chatTabModulePath); + const chatTab = require(chatTabModulePath); + chatTab.__testOnly_loadChatSessionTabsLayoutPreference(); await window.refreshChatSessionTabsForOrgSwitch(); - - const newChatButton = document.querySelector('#chatSessionTabs .chat-session-tab-new'); - newChatButton.dispatchEvent(new MouseEvent('contextmenu', { - bubbles: true, - clientX: 18, - clientY: 18 - })); - - const chooseLeftButton = Array.from(document.querySelectorAll('.chat-session-menu button')) - .find((button) => button.textContent === 'Use conversation tabs on left on wider screens'); - expect(chooseLeftButton).toBeTruthy(); - chooseLeftButton.click(); - + document.querySelector('#chatSessionTabs .chat-session-tab-new') + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 18, clientY: 18 })); + const menu = document.querySelector('.chat-session-menu'); + expect(menu.classList.contains('open')).toBe(true); + expect(menu.textContent).toContain('New conversation'); + expect(menu.textContent).not.toMatch(/wider screens|Move conversation tabs/); const workspace = document.getElementById('conversationWorkspace'); - expect(workspace.dataset.tabsLayout).toBe('vertical'); - expect(workspace.dataset.effectiveTabsLayout).toBe('horizontal'); - expect(document.querySelector('.toast')?.textContent) - .toBe('Left conversation tabs saved for wider screens.'); - - newChatButton.dispatchEvent(new MouseEvent('contextmenu', { - bubbles: true, - clientX: 22, - clientY: 22 - })); - expect(Array.from(document.querySelectorAll('.chat-session-menu button')) - .some((button) => button.textContent === 'Use conversation tabs across top on wider screens')) - .toBe(true); + expect(workspace.dataset.tabsLayout).toBe('horizontal'); + expect(workspace.dataset.effectiveTabsLayout).toBe('vertical'); + expect(localStorage.getItem('von:chatSessionTabsLayout')).toBe('horizontal'); }); }); diff --git a/tests/frontend/conversationTray.test.js b/tests/frontend/conversationTray.test.js index 26093d12c..f32ca44be 100644 --- a/tests/frontend/conversationTray.test.js +++ b/tests/frontend/conversationTray.test.js @@ -168,7 +168,7 @@ test('Escape in the conversation does not collapse a docked tray', () => { function useMobile() { controller.destroy(); - workspace.dataset.effectiveTabsLayout = 'horizontal'; + workspace.dataset.effectiveTabsLayout = 'vertical'; window.matchMedia.mockReturnValue({ matches: true, addEventListener() {}, removeEventListener() {} }); controller = createConversationTray(workspace); controller.refresh(preferences()); @@ -181,7 +181,7 @@ test('mobile starts open independently of desktop and preserves context, order a expect(toggle.getAttribute('aria-expanded')).toBe('true'); expect(document.getElementById('conversationTrayContext').textContent).toBe('Current: One'); tabs.firstChild.focus(); - tabs.firstChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + tabs.firstChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(document.activeElement).toBe(tabs.lastChild); tabs.lastChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(document.activeElement).toBe(toggle); @@ -199,18 +199,18 @@ test('mobile starts open independently of desktop and preserves context, order a const draft = document.getElementById('promptInput'); draft.focus(); draft.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); }); test('mobile context follows asynchronous selection and reveals only the list', async () => { useMobile(); - tabs.getBoundingClientRect = () => ({ left: 0, right: 300 }); - tabs.lastChild.getBoundingClientRect = () => ({ left: 400, right: 550 }); + tabs.getBoundingClientRect = () => ({ top: 0, bottom: 300 }); + tabs.lastChild.getBoundingClientRect = () => ({ top: 400, bottom: 550 }); tabs.firstChild.setAttribute('aria-selected', 'false'); tabs.lastChild.setAttribute('aria-selected', 'true'); await Promise.resolve(); expect(document.getElementById('conversationTrayContext').textContent).toBe('Current: Two'); - expect(tabs.scrollLeft).toBe(250); + expect(tabs.scrollTop).toBe(250); toggle.click(); tabs.lastChild.textContent = 'Updated current conversation'; await Promise.resolve(); @@ -233,16 +233,38 @@ test('unavailable or invalid mobile storage defaults open and failed writes surv saveConversationLayoutPreference(CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY, false); }); -test('switching to a remembered closed mobile list restores focus and preserves desktop choice', () => { - useMobile(); - workspace.dataset.effectiveTabsLayout = 'vertical'; +test('switching to a remembered closed mobile tray restores focus and preserves desktop choice', () => { + controller.destroy(); + const media = { matches: false, addEventListener() {}, removeEventListener() {} }; + window.matchMedia.mockReturnValue(media); + controller = createConversationTray(workspace); controller.refresh(preferences({ mobileCollapsed: true, collapsed: false })); tabs.firstChild.focus(); - workspace.dataset.effectiveTabsLayout = 'horizontal'; + media.matches = true; controller.refresh(preferences({ mobileCollapsed: true, collapsed: false })); expect(document.activeElement).toBe(toggle); expect(toggle.getAttribute('aria-expanded')).toBe('false'); - workspace.dataset.effectiveTabsLayout = 'vertical'; + media.matches = false; controller.refresh(preferences({ mobileCollapsed: true, collapsed: false })); expect(toggle.getAttribute('aria-expanded')).toBe('true'); }); + +test('mobile ignores desktop hover, closes on selection or outside press, and retains draft', () => { + localStorage.setItem(CONVERSATION_TRAY_COLLAPSED_KEY, 'true'); + useMobile(); + toggle.click(); + pointer(navigation, 'pointerenter'); + jest.advanceTimersByTime(300); + expect(workspace.dataset.trayPeek).toBe('false'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + tabs.lastChild.click(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + const draft = document.getElementById('promptInput'); + draft.value = 'Unsent mobile draft'; + pointer(draft, 'pointerdown'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(draft.value).toBe('Unsent mobile draft'); + expect(localStorage.getItem(CONVERSATION_TRAY_COLLAPSED_KEY)).toBe('true'); +}); diff --git a/tests/frontend/settingsConversationLayout.test.js b/tests/frontend/settingsConversationLayout.test.js index 39be871d4..70964b2dd 100644 --- a/tests/frontend/settingsConversationLayout.test.js +++ b/tests/frontend/settingsConversationLayout.test.js @@ -11,7 +11,7 @@ const settingsTemplate = fs.readFileSync(path.resolve(__dirname, '../../src/fron beforeEach(() => { localStorage.clear(); - window.matchMedia = jest.fn(() => ({ matches: false })); + window.matchMedia = jest.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })); document.body.innerHTML = `
`; const template = document.createElement('template'); template.innerHTML = settingsTemplate; @@ -43,11 +43,12 @@ test('new desktop defaults left; Settings switches immediately without remountin }); test('narrow/coarse layout and cross-window changes keep the explicit preference truthful', () => { - window.matchMedia = jest.fn(() => ({ matches: true })); - expect(loadLayout()).toEqual({ preferred: 'vertical', effective: 'horizontal' }); + window.matchMedia = jest.fn(() => ({ matches: true, addEventListener() {}, removeEventListener() {} })); + expect(loadLayout()).toEqual({ preferred: 'vertical', effective: 'vertical' }); localStorage.setItem(CONVERSATION_LAYOUT_KEY, 'horizontal'); window.dispatchEvent(new StorageEvent('storage', { key: CONVERSATION_LAYOUT_KEY, newValue: 'horizontal' })); expect(document.getElementById('settingsConversationLayoutSelect').value).toBe('horizontal'); - window.matchMedia = jest.fn(() => ({ matches: false })); + expect(document.getElementById('conversationWorkspace').dataset.effectiveTabsLayout).toBe('vertical'); + window.matchMedia = jest.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })); expect(loadLayout()).toEqual({ preferred: 'horizontal', effective: 'horizontal' }); });