diff --git a/public/css/assistant.css b/public/css/assistant.css index 355e6c58..1356c679 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -218,16 +218,20 @@ padding-bottom:calc(12px + env(safe-area-inset-bottom)); } /* Slim toolbar: icon-only actions in one row */ /* Saved chats: a fixed drawer overlaying the chat */ - .assistant-history { position:fixed; left:0; top:0; bottom:0; width:min(85vw,320px); z-index:130; background:white; border-right:1px solid var(--g100); box-shadow:0 0 40px rgba(15,23,42,.14); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; align-items:stretch; padding:10px 8px; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; gap:2px; border-radius:0; } + .assistant-history { position:fixed; left:0; top:0; bottom:0; width:min(85vw,320px); z-index:130; background:white; border-right:1px solid var(--g100); box-shadow:0 0 40px rgba(15,23,42,.14); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; align-items:stretch; padding:0; overflow:hidden; gap:0; border-radius:0; } + .assistant-history .menu-head { padding:14px 14px 8px; } + .assistant-rail-actions { padding:0 8px; } + .assistant-rail-workspace { padding:2px 8px 8px; } + .assistant-history .account-card { padding:8px 12px calc(8px + env(safe-area-inset-bottom)); } + .assistant-drawer-close { display:none !important; } .assistant-history .card, #assistant-saved-chats { width:100%; max-width:100%; min-width:0; } - /* Open WebUI-style: the whole menu is one scrollable column — the card grows - with its content instead of clipping it, and the drawer itself scrolls. */ - .assistant-history .card { flex:0 0 auto; min-height:0; overflow:visible; } - #assistant-saved-chats { overflow:visible; } + /* The chat list takes the free height and scrolls inside it. */ + .assistant-history .card { flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; } + #assistant-saved-chats { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; } .assistant-layout.mobile-chats-open .assistant-history { transform:translateX(0); } - .assistant-history .card { border:none; box-shadow:none; background:none; padding:0; } + .assistant-history .card { border:none; box-shadow:none; background:none; padding:0 8px; } .assistant-history .card-header { padding:6px 10px 2px; } - #assistant-saved-chats { padding:2px 0 12px; gap:0; overflow:visible; } + #assistant-saved-chats { padding:2px 0 12px; gap:0; } .assistant-saved-chat { flex-direction:row; align-items:center; gap:0; border:none; border-radius:8px; padding:12px 36px 12px 12px; min-width:0; width:100%; max-width:none; position:relative; } .assistant-saved-chat:hover { background:var(--g100); } .assistant-saved-chat.active { background:var(--g100); } @@ -301,6 +305,9 @@ body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-b /* Phones: workspace mode hides .app-header, so there is no 64px to subtract and the mobile flex layout must survive the more specific workspace selectors. */ @media (max-width: 640px) { + /* The layout is sized to the screen below the menu row; the content area's + 8px padding on top of that made the page 16px taller than the phone. */ + body.assistant-workspace .main-content { padding:0; } body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px)); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; } } @@ -425,6 +432,10 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } /* Compact: every menu word fits the drawer width */ .assistant-goback-rail, .assistant-create-image, .assistant-new-chat { font-size:13px; padding:11px 12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .assistant-history .card-header h3 { font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .assistant-rail-link { padding:11px 12px; font-size:14px; color:var(--g700); border-left:none; border-radius:8px; } + /* Plain: no keyboard shortcut on a phone, no icon out of line with the rows. */ + .assistant-kbd, .assistant-history .card-header h3 i { display:none; } + .assistant-rail-link i { display:none; } /* The topbar keeps only the hamburger; Go back is in the menu */ } /* Library item hover download */ @@ -551,3 +562,20 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; } .assistant-sources-fab[hidden] { display:none; } body.assistant-sources-open .assistant-sources-fab { background:var(--blue-light); border-color:var(--blue-light); } } + +/* Desktop: the rail is the same white sidebar the workspace uses, flush to the + window edge. It used to sit inside the content area's 14px padding, on the + tiled ground — so the menu changed background between the two views, the + name card sat 6px lower and further in, and the page was 28px taller than + the window. The 14px now belongs to the panels beside the rail. */ +@media (min-width:961px) { + body.assistant-workspace .main-content { padding:0; } + body.assistant-workspace .assistant-layout { padding-right:14px; } + body.assistant-workspace .assistant-main, + body.assistant-workspace .assistant-side { margin-block:14px; height:auto; } + body.assistant-workspace .assistant-history { padding:0; gap:0; background:white; border-right:1.5px solid var(--g200); } + body.assistant-workspace .assistant-rail-actions { padding:0 8px; } + body.assistant-workspace .assistant-history .card { padding:0 8px; } + /* Workspace links: the app's .tab-btn, full-bleed with the same inset. */ + body.assistant-workspace .assistant-rail-link { padding:10px 16px; } +} diff --git a/public/css/styles.css b/public/css/styles.css index 148c13dc..4055ea77 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -352,9 +352,9 @@ textarea.full-input{resize:vertical;} .sidebar.open{left:0;box-shadow:4px 0 20px rgba(0,0,0,0.18);} .sidebar.open~.sidebar-overlay{display:block;} .sidebar-header{display:flex;} - /* The sidebar slides in as a whole on mobile, so a collapse control inside it - has nothing to collapse — the overlay close is the way out. */ - .assistant-menu-toggle{display:none !important;} + /* The sidebar slides in as a whole on mobile, so the collapse control has + nothing to collapse: below 769px its handler closes the menu instead, and it + is the menu's one close control (the old "Menu" + X header is gone). */ /* Give the drawer room to breathe and keep the account card reachable at the foot of a scrolling menu. */ .sidebar{display:flex;flex-direction:column;overflow-y:auto;} @@ -1325,9 +1325,9 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select, body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr) 330px; } body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout, body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr); } - /* padding:0 would drop the 8px below the account card, so the avatar would sit - lower when collapsed than when expanded. Keep the bottom gap. */ - body.assistant-workspace.menu-hidden .assistant-history { width:auto; min-width:0; overflow:visible; padding:0 0 8px; } + /* The rail has no padding of its own any more — the account card carries its + 8px, exactly as in the app sidebar — so collapsed adds none either. */ + body.assistant-workspace.menu-hidden .assistant-history { width:auto; min-width:0; overflow:visible; padding:0; } body.assistant-workspace.menu-hidden .assistant-history .card, body.assistant-workspace.menu-hidden .assistant-rail-workspace, body.assistant-workspace.menu-hidden .assistant-rail-actions, @@ -1396,7 +1396,7 @@ body.assistant-preview .account-card { display:none; } assistant used to hide it and add a second drawer of its own, which is why two hamburgers appeared and neither did anything useful. */ @media(max-width:768px){ - .sidebar{ width:min(88vw,360px); left:calc(-1 * min(88vw,360px)); padding:0; } + .sidebar{ width:min(85vw,320px); left:calc(-1 * min(85vw,320px)); padding:0; } .sidebar.open{ left:0; } /* Close and search lead, as in the reference. */ .menu-head{ padding:14px 14px 8px; gap:10px; } @@ -1408,8 +1408,14 @@ body.assistant-preview .account-card { display:none; } .assistant-mode-switch button{ padding:9px 6px; font-size:13px; } .sidebar-tabs{ padding:2px 8px 8px; } .sidebar-section-label{ padding:12px 8px 4px; } - .tab-btn{ padding:12px 10px; font-size:14.5px; border-radius:10px; border-left:none; } - .tab-btn.active{ border-left:none; } + .sidebar-header{ display:none; } + /* A persisted desktop collapse flips the arrow; on a phone it always closes. */ + .assistant-menu-toggle.is-collapsed i{ transform:none; } + /* Plain rows, the same as the assistant's chat history: text only, and the + current page is a quiet grey row rather than a blue block. */ + .tab-btn{ padding:11px 12px; font-size:14px; font-weight:500; color:var(--g700); border-radius:8px; border-left:none; } + .tab-btn i{ display:none; } + .tab-btn.active{ border-left:none; background:var(--g100); color:var(--g900); font-weight:600; } .account-card{ padding:8px 12px calc(8px + env(safe-area-inset-bottom)); } .account-name{ font-size:14px; } .account-email{ font-size:12px; } @@ -1430,3 +1436,18 @@ body.assistant-preview .account-card { display:none; } and read as "too far down". dvh is the height actually visible. */ .sidebar{ height:100vh; height:100dvh; } } + +/* ── One menu, two views ───────────────────────────────────────────────────── + Desktop: the assistant rail is laid out exactly like the app sidebar — the + same head row, switch and account card insets — because it now sits flush + at the window edge like the sidebar does (see assistant.css). Collapsed + (body.menu-hidden) rules are more specific and still win. */ +@media (min-width:961px) { + .assistant-history .menu-head { padding:10px 8px 8px 12px; } + .assistant-history .assistant-mode-switch { margin:8px 12px 10px; } + .assistant-history .account-card { padding:8px; } +} +/* Phone: the chat drawer's switch sits where the workspace menu's does. */ +@media (max-width:640px) { + .assistant-history .assistant-mode-switch { margin:6px 14px 10px; } +} diff --git a/public/js/app.js b/public/js/app.js index 2ed0ec41..257dc9d2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -133,11 +133,8 @@ document.addEventListener('DOMContentLoaded', function() { try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {} } syncTabLocation(tabName); - // Close sidebar on mobile after tab click - var sidebar = document.getElementById('sidebar'); - if (sidebar && window.innerWidth <= 768) { - sidebar.classList.remove('open'); - } + // Close whichever menu is open on mobile after a tab click + if (window.innerWidth <= 768) closeMobileMenus(); return true; } @@ -161,6 +158,15 @@ document.addEventListener('DOMContentLoaded', function() { activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter'); }); + // A phone has two menus that never show together: the workspace list (the app + // sidebar) and the assistant's chat history. Every way out closes both. + function closeMobileMenus() { + var appMenu = document.getElementById('sidebar'); + if (appMenu) appMenu.classList.remove('open'); + var layout = document.getElementById('assistant-layout'); + if (layout) layout.classList.remove('mobile-chats-open'); + } + // Assistant / Workspace switch. The app IS workspace mode, so switching is // navigation between two pages rather than an in-page state machine — which // is also what keeps the two looking identical: the only difference is which @@ -193,7 +199,7 @@ document.addEventListener('DOMContentLoaded', function() { // is behind that sheet — tapping Workspace looked like it did nothing. document.addEventListener('click', function(event) { if (!(event.target.closest && event.target.closest('[data-assistant-mode]'))) return; - if (window.innerWidth <= 768 && sidebar) sidebar.classList.remove('open'); + if (window.innerWidth <= 768) closeMobileMenus(); }); // Account card. Rendered in both menus from the same markup, so whichever view @@ -469,7 +475,16 @@ document.addEventListener('DOMContentLoaded', function() { var sidebarOverlay = document.getElementById('sidebar-overlay'); document.addEventListener('click', function(e) { if (e.target.closest('#btn-menu-toggle')) { - if (sidebar) sidebar.classList.toggle('open'); + // The menu follows the view, as on desktop: in the assistant it is the + // chat history (the assistant's own drawer), everywhere else the + // workspace list. On a phone the assistant used to open the workspace + // list, so there was no way to reach a saved chat. + var railLayout = document.getElementById('assistant-layout'); + if (railLayout && window.innerWidth <= 640 && document.body.classList.contains('assistant-workspace')) { + railLayout.classList.toggle('mobile-chats-open'); + } else if (sidebar) { + sidebar.classList.toggle('open'); + } } if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) { if (sidebar) sidebar.classList.remove('open'); @@ -505,7 +520,7 @@ document.addEventListener('DOMContentLoaded', function() { // On a phone the menu is a sheet, not a rail, so there is nothing to // collapse — the same control closes it instead. if (window.innerWidth <= 768) { - if (sidebar) sidebar.classList.remove('open'); + closeMobileMenus(); return; } var nowHidden = document.body.classList.toggle('menu-hidden'); diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js index 1af933b2..6e878ba7 100644 --- a/test/assistant-mobile.test.js +++ b/test/assistant-mobile.test.js @@ -29,7 +29,10 @@ test('the collapse control closes the sheet on a phone rather than collapsing a const app = read('public/js/app.js'); const handler = app.slice(app.indexOf("event.target.closest('[data-menu-toggle]')"), app.indexOf("localStorage.setItem('ped_sidebar_collapsed'")); assert.match(handler, /if \(window\.innerWidth <= 768\)/); - assert.match(handler, /sidebar\.classList\.remove\('open'\)/, 'it closes the sheet'); + assert.match(handler, /closeMobileMenus\(\)/, 'it closes whichever menu is open'); + const close = app.slice(app.indexOf('function closeMobileMenus()'), app.indexOf('function closeMobileMenus()') + 400); + assert.match(close, /appMenu\.classList\.remove\('open'\)/, 'the workspace menu'); + assert.match(close, /layout\.classList\.remove\('mobile-chats-open'\)/, 'and the chat history'); }); test('sources are reachable on a phone instead of deleted', () => { @@ -57,8 +60,11 @@ test('sources are reachable on a phone instead of deleted', () => { test('the mobile menu is sized for a thumb', () => { const css = read('public/css/styles.css'); const mobile = css.slice(css.indexOf('/* ── Mobile menu ─')); - assert.match(mobile, /\.sidebar\{ width:min\(88vw,360px\)/, 'a sheet, not a narrow rail'); - assert.match(mobile, /\.tab-btn\{ padding:12px 10px; font-size:14\.5px/, 'rows are tappable'); + assert.match(mobile, /\.sidebar\{ width:min\(85vw,320px\)/, 'a sheet the width of the chat drawer, not a narrow rail'); + assert.match(mobile, /\.tab-btn\{ padding:11px 12px; font-size:14px/, 'rows are tappable'); + // Plain, like the chat history: text rows and a grey current row. + assert.match(mobile, /\.tab-btn i\{ display:none; \}/); + assert.match(mobile, /\.tab-btn\.active\{ border-left:none; background:var\(--g100\)/); assert.match(mobile, /env\(safe-area-inset-bottom\)/, 'and clear of the home indicator'); }); @@ -107,7 +113,7 @@ test('the Workspace pill works on a phone', () => { const app = read('public/js/app.js'); // The switch lives inside the sheet, so the view it changes is behind the // sheet until the sheet closes. - assert.match(app, /\[data-assistant-mode\]'\)\)\) return;\s*\n\s*if \(window\.innerWidth <= 768 && sidebar\) sidebar\.classList\.remove\('open'\);/); + assert.match(app, /\[data-assistant-mode\]'\)\)\) return;\s*\n\s*if \(window\.innerWidth <= 768\) closeMobileMenus\(\);/); const css = read('public/css/assistant.css'); // The launcher used to be display:none below 640px, so the pill toggled a @@ -116,3 +122,21 @@ test('the Workspace pill works on a phone', () => { assert.match(css, /\.assistant-workspace-cards \{ grid-template-columns:1fr; \}/, 'and its cards stack into one column'); }); + +test('on a phone the menu follows the view: chat history in the assistant', () => { + const app = read('public/js/app.js'); + // The assistant used to open the workspace list, so a saved chat was + // unreachable on a phone. + const handler = app.slice(app.indexOf("e.target.closest('#btn-menu-toggle')"), app.indexOf("e.target.closest('#btn-sidebar-close')")); + assert.match(handler, /window\.innerWidth <= 640 && document\.body\.classList\.contains\('assistant-workspace'\)/); + assert.match(handler, /railLayout\.classList\.toggle\('mobile-chats-open'\)/, 'the assistant opens its chat drawer'); + assert.match(handler, /else if \(sidebar\) \{\s*sidebar\.classList\.toggle\('open'\)/, 'everywhere else, the workspace list'); + assert.match(app, /if \(window\.innerWidth <= 768\) closeMobileMenus\(\);\s*\n\s*return true;/, 'choosing a page closes it'); + + const css = read('public/css/assistant.css'); + const mobile = css.slice(css.indexOf('@media (max-width: 640px)')); + // The name card is pinned at the foot of both menus: only the list scrolls. + assert.match(mobile, /\.assistant-history \{ position:fixed;[^}]*padding:0; overflow:hidden;/, 'the drawer itself does not scroll'); + assert.match(mobile, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto;/, 'the chat list does'); + assert.match(mobile, /\.assistant-drawer-close \{ display:none !important; \}/, 'one close control, the « in the head row'); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 236d68d5..1b8360e0 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -437,9 +437,10 @@ test('every view sits in the same shell, so opening a menu item is not a new app test('the new chrome is handled on phones', () => { const css = read('public/css/styles.css'); const mobile = css.slice(css.indexOf('@media(max-width:768px)')); - // The sidebar slides in whole on mobile, so an in-sidebar collapse control has - // nothing to collapse. - assert.match(mobile, /\.assistant-menu-toggle\{display:none !important;\}/); + // The sidebar slides in whole on mobile, so the collapse control closes it + // instead, and replaces the old "Menu" + X header as the one close control. + assert.doesNotMatch(mobile, /\.assistant-menu-toggle\{display:none/); + assert.match(mobile, /\.sidebar-header\{ display:none; \}/); assert.match(mobile, /\.account-card\{position:sticky;bottom:0/, 'the account card stays reachable'); assert.match(mobile, /\.menu-search-panel\{width:100%/, 'the palette is full-bleed'); assert.match(mobile, /\.sidebar-nav\{flex:1 1 auto;min-height:0;\}/, 'the menu scrolls, the card does not float away'); @@ -533,3 +534,22 @@ test('the menu head reads toggle, mark, search — and account tabs are not list assert.match(index, new RegExp('data-account-tab="' + tab + '"|role="menuitem"'), tab + ' is reachable from the account card'); } }); + +test('desktop: the assistant rail is the app sidebar, flush and white', () => { + const css = read('public/css/assistant.css'); + const styles = read('public/css/styles.css'); + // Inside the content area's 14px padding the rail sat on the tiled ground, its + // name card 6px lower and further in, and the page 28px taller than the window. + const desk = css.slice(css.lastIndexOf('@media (min-width:961px) {')); + assert.match(desk, /body\.assistant-workspace \.main-content \{ padding:0; \}/); + assert.match(desk, /body\.assistant-workspace \.assistant-history \{ padding:0; gap:0; background:white; border-right:1\.5px solid var\(--g200\); \}/); + assert.match(desk, /body\.assistant-workspace \.assistant-side \{ margin-block:14px; height:auto; \}/, 'the panels carry the 14px'); + // Same insets as the sidebar: .menu-head, .assistant-mode-switch, .account-card. + const rail = styles.slice(styles.lastIndexOf('@media (min-width:961px) {')); + assert.match(rail, /\.assistant-history \.menu-head \{ padding:10px 8px 8px 12px; \}/); + assert.match(styles, /^\.menu-head \{ display:flex; align-items:center; gap:4px; padding:10px 8px 8px 12px; \}/m); + assert.match(rail, /\.assistant-history \.assistant-mode-switch \{ margin:8px 12px 10px; \}/); + assert.match(styles, /\.assistant-mode-switch \{[^}]*margin:8px 12px 10px;/); + assert.match(rail, /\.assistant-history \.account-card \{ padding:8px; \}/); + assert.match(styles, /body\.assistant-workspace\.menu-hidden \.assistant-history \{[^}]*padding:0; \}/, 'collapsed adds no gap of its own'); +});