From 48afd90463ebe20e43333e563aec1435533ef0d5 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 13:13:16 -0400 Subject: [PATCH] Keep conversation tray expansion beside content and support Escape close --- .../static/js/components/conversationTray.js | 14 ++++-- .../web/von_interface/static/styles.css | 13 ++---- tests/browser/responsiveShell.cjs | 44 ++++++++++++++++++- tests/frontend/conversationTray.test.js | 22 ++++++++++ 4 files changed, 78 insertions(+), 15 deletions(-) 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 9fbb41118..fc2f2f4f4 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationTray.js +++ b/src/frontend/web/von_interface/static/js/components/conversationTray.js @@ -49,7 +49,7 @@ export function createConversationTray(workspace) { tabs.scrollLeft = position.left; } if (toggle) { - const label = collapsed ? 'Keep conversation list open' : 'Collapse conversation list'; + const label = collapsed ? (peek ? 'Keep conversation list open' : 'Open conversation list') : 'Collapse conversation list'; toggle.setAttribute('aria-label', label); toggle.title = label; toggle.setAttribute('aria-expanded', String(!collapsed || peek)); @@ -104,10 +104,16 @@ export function createConversationTray(workspace) { } }); listen(document, 'keydown', (event) => { - if (event.key === 'Escape' && peek && !isMenuOpen()) { + if (event.key === 'Escape' && isVertical() && !isMenuOpen() + && (peek || (!preferences.collapsed && navigation?.contains(event.target)))) { + event.preventDefault(); clearTimers(); - peek = false; - render(); + if (peek) { + peek = false; + render(); + } else { + setCollapsed(true); + } toggle?.focus({ preventScroll: true }); } }); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index e0fb8f42d..128ec3c38 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -8695,7 +8695,7 @@ body[data-active-tab="settingsTab"] .tab-content-area { transition: grid-template-columns 180ms ease; } -.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-collapsed="true"] { +.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-collapsed="true"][data-tray-peek="false"] { grid-template-columns: 48px minmax(0, 1fr); } @@ -8730,14 +8730,8 @@ body[data-active-tab="settingsTab"] .tab-content-area { } .conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-peek="true"] .chat-session-tabs-row { - width: var(--conversation-tray-width, 260px); - box-shadow: 8px 8px 24px rgba(15, 23, 42, 0.16); - animation: conversationTrayPeek 160ms ease-out; -} - -@keyframes conversationTrayPeek { - from { clip-path: inset(0 calc(100% - 48px) 0 0); } - to { clip-path: inset(0 0 0 0); } + /* Use the animated grid track so revealing navigation never covers content. */ + width: 100%; } .conversation-workspace[data-effective-tabs-layout="vertical"] .conversation-tray-header { @@ -8883,7 +8877,6 @@ body[data-active-tab="settingsTab"] .tab-content-area { } @media (prefers-reduced-motion: reduce) { .conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; } - .conversation-workspace[data-tray-peek="true"] .chat-session-tabs-row { animation: none; } } .chat-situation-toggle { diff --git a/tests/browser/responsiveShell.cjs b/tests/browser/responsiveShell.cjs index 0ed0006ff..5b87cef2f 100644 --- a/tests/browser/responsiveShell.cjs +++ b/tests/browser/responsiveShell.cjs @@ -1,4 +1,4 @@ -// Run: node tests/browser/responsiveShell.cjs [evidence-directory] +// Run: node tests/browser/responsiveShell.cjs [evidence-directory] [--tray-only] // Production templates, CSS, layout and tray controller with synthetic content. // This checks rendering and draft retention, not authentication or live sending. const assert = require('node:assert/strict'); @@ -7,6 +7,7 @@ const http = require('node:http'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const baseline = process.env.VON_LAYOUT_BASELINE; +const trayOnly = process.argv.includes('--tray-only'); const { chromium, expect } = require('@playwright/test'); const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); const evidence = process.argv[2]; @@ -98,6 +99,47 @@ async function noOverflow(page) { assert(menu.x >= 0 && menu.x + menu.width <= profile.width + 1 && menu.y >= 0, 'organisation menu fits'); await page.locator('.footer-org-menu-trigger').click(); if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}.png`), fullPage: false }); + if (profile.name === 'desktop') { + const toggle = page.locator('#conversationTrayToggle'); + const selected = page.locator('#chatSessionTabs [role="tab"]').first(); + await selected.evaluate(el => { + el.setAttribute('aria-selected', 'true'); + el.classList.add('has-unread'); + el.dataset.unreadCount = '3'; + }); + const retained = await page.evaluateHandle(() => document.querySelector('#scrollableField').firstChild); + await toggle.focus(); + await page.keyboard.press('Enter'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(toggle).toHaveAccessibleName('Open conversation list'); + await page.keyboard.press('Space'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await selected.focus(); + await page.keyboard.press('Escape'); + await expect(toggle).toBeFocused(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // Hover uses the same grid track, never an overlay on the transcript. + await toggle.hover(); + await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-tray-peek', 'true'); + await page.waitForTimeout(220); + const trayBox = await page.locator('.chat-session-tabs-row').boundingBox(); + const mainBox = await page.locator('.chat-conversation-main').boundingBox(); + assert(trayBox.x + trayBox.width <= mainBox.x, 'expanded tray does not cover conversation'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(selected).toHaveAttribute('aria-selected', 'true'); + await expect(selected).toHaveAttribute('data-unread-count', '3'); + assert(await retained.evaluate(el => el === document.querySelector('#scrollableField').firstChild), 'transcript retained'); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await noOverflow(page); + if (evidence) await page.screenshot({ path: path.join(evidence, 'desktop-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(); + continue; + } if (profile.name === 'pixel-8') { await page.setViewportSize({ width: 412, height: 430 }); await input.focus(); diff --git a/tests/frontend/conversationTray.test.js b/tests/frontend/conversationTray.test.js index 13b638537..4e9023a8f 100644 --- a/tests/frontend/conversationTray.test.js +++ b/tests/frontend/conversationTray.test.js @@ -141,3 +141,25 @@ test('keyboard resize works and horizontal fallback does not erase desktop colla controller.refresh(preferences({ collapsed: true })); expect(workspace.dataset.trayCollapsed).toBe('true'); }); + +test('Escape closes a docked tray from navigation and retains selection and unread state', () => { + tabs.firstChild.setAttribute('data-unread-count', '3'); + tabs.firstChild.focus(); + tabs.firstChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(workspace.dataset.trayCollapsed).toBe('true'); + expect(document.activeElement).toBe(toggle); + expect(toggle.getAttribute('aria-label')).toBe('Open conversation list'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(tabs.firstChild.getAttribute('aria-selected')).toBe('true'); + expect(tabs.firstChild.getAttribute('data-unread-count')).toBe('3'); +}); + +test('Escape in the conversation does not collapse a docked tray', () => { + const draft = document.getElementById('promptInput'); + draft.focus(); + draft.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(workspace.dataset.trayCollapsed).toBe('false'); + expect(document.activeElement).toBe(draft); +});