diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index c87feca6..0f98fe17 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1361,24 +1361,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'; } @@ -1443,9 +1434,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 { @@ -27756,12 +27746,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 a0fcc43e..bd247592 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 cab4dcaf..8eddd9cc 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -9021,6 +9021,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; @@ -9038,7 +9066,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; } @@ -9049,8 +9079,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 1b373bb9..da9cd73f 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 3d1d04a7..c5a05dec 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 6c08c894..641a2bd6 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 26093d12..f32ca44b 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 39be871d..70964b2d 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' }); });