From b8d2153cca81f7022309daec487e4961d000abfd Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 11:39:32 +0200 Subject: [PATCH] fix: phone menu links, Workspace pill and sources column; image model discovery Phone - The desktop collapse rules (52px icon rail) were unscoped, and menu-hidden is remembered across viewports, so after collapsing on a desktop the phone sheet opened with its links, the Assistant/Workspace switch and the account name all display:none. Scoped them to min-width:769px. - Workspace pill: the sheet now closes on tap, and the launcher is no longer display:none below 640px, so the pill opens the workspace instead of toggling a view that could never appear. - The menu button has its own 48px row instead of floating over the page; layouts subtract --mobile-bar (0px on desktop, so desktop is unchanged). - Sources open as their own scrollable band under that row, and the toggle sits in the row, instead of a sheet/FAB covering the composer. - The sheet uses dvh, so the account card is not pushed below the visible screen by the browser toolbar. Admin - Image Generation card beside TTS and STT: search the gateway's image models and test one. No Set button: an image model is chosen per workflow. It searches only on demand, so opening Admin still makes one discovery call. - Account card sits at the same height in every rail state; the model availability list points to where new models are added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS --- public/components/admin.html | 34 ++++++ public/css/assistant.css | 55 ++++++---- public/css/styles.css | 91 ++++++++++------ public/js/admin.js | 107 +++++++++++++++++++ public/js/app.js | 7 ++ test/admin-clinical-assistant-wiring.test.js | 27 +++++ test/assistant-mobile.test.js | 39 ++++++- test/assistant-workspace-layout.test.js | 15 +-- 8 files changed, 314 insertions(+), 61 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index 385d984d..dc8923eb 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -296,6 +296,7 @@ Model availability

Models users may select in the Clinical Assistant. Leave a list empty to keep only the configured model.

+

This list is what the gateway already offers. To make a new model appear here, add it in AI Model Management above — search the gateway, press +, and it shows up in this list.

Chat models
@@ -444,6 +445,39 @@
+ +
+
+

Image Generation

+ Gateway +
+
+
Image models come from the LiteLLM gateway. Search to list them, and test one before assigning it to a workflow.
+ + +
+ +
+ + +
+
+

Click Search to list the image models your gateway advertises. Unlike a voice, an image model has no single default: pick which workflow uses it under Clinical Assistant / Learning, and tick it there to offer it to users in chat.

+
+ + +
+ +

Press Test on a model above, or type an id the gateway does not advertise.

+
+ + +
+
+
+
+
+
diff --git a/public/css/assistant.css b/public/css/assistant.css index 2e83394b..355e6c58 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -182,7 +182,10 @@ .assistant-source-modal-meta { font-size:13px; font-weight:700; color:var(--g800); margin-bottom:10px; } .assistant-source-modal-excerpt { white-space:pre-wrap; font-size:12px; line-height:1.6; color:var(--g600); background:var(--g50); border:1px solid var(--g200); border-radius:10px; padding:10px 12px; max-height:52vh; overflow:auto; margin-bottom:10px; } /* Saved chats occupy the full left rail and scroll inside it */ -.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; } +/* 8px below the account card, matching .sidebar-nav in the app. Without it the + card sat flush against the bottom edge here and 8px higher there, so the name + appeared to jump when you opened a workspace tab. The higher position wins. */ +.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; } .assistant-history .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; overflow:hidden; } #assistant-saved-chats { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; } @media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:minmax(0,1fr) auto; } } @@ -193,20 +196,26 @@ #btn-assistant-goback { padding:8px; } .assistant-status { margin-left:auto; font-size:11px; } /* The assistant fills the viewport exactly; nothing behind can scroll */ - .assistant-layout { display:flex; flex-direction:column; height:100dvh; min-height:0; overflow:hidden; } + .assistant-layout { display:flex; flex-direction:column; height:calc(100dvh - var(--mobile-bar, 0px)); min-height:0; overflow:hidden; } .assistant-main { flex:1 1 auto; min-height:0; display:flex; } .assistant-main.card { box-shadow:none; border:none; margin:0; } #assistant-chat-view { flex:1 1 auto; min-height:0; height:auto; display:flex; flex-direction:column; } .assistant-messages { flex:1 1 auto; min-height:0; max-height:none; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:8px 14px 12px; } - /* Sources are not deleted on a phone; they slide up as a sheet, because a - citation with nowhere to lead is worse than a narrow column. */ - .assistant-side { position:fixed; left:0; right:0; bottom:0; z-index:140; display:flex; - max-height:76vh; margin:0; padding:0; background:white; border-top:1px solid var(--g200); - border-radius:16px 16px 0 0; box-shadow:0 -12px 40px rgba(15,23,42,.18); - transform:translateY(102%); transition:transform .2s ease; } - body.assistant-sources-open .assistant-side { transform:translateY(0); } - .assistant-side .card { flex:1 1 auto; min-height:0; border:none; box-shadow:none; border-radius:0; } - .assistant-sources { padding-bottom:calc(16px + env(safe-area-inset-bottom)); } + /* Sources are not deleted on a phone, and they do not cover the conversation + either: opening them gives the column its own band at the foot of the flex + layout, so the chat shrinks above it and the composer stays reachable. + A fixed overlay sat on top of the question box, which is what made the + sheet feel like it had nowhere to live. */ + .assistant-side { order:-1; flex:0 0 auto; display:none; min-height:0; margin:0; padding:0; + background:white; border-bottom:1px solid var(--g200); border-radius:0; + box-shadow:0 8px 24px rgba(15,23,42,.08); overflow:hidden; } + body.assistant-sources-open .assistant-side { display:flex; height:min(46vh,340px); } + .assistant-side .card { flex:1 1 auto; min-height:0; display:flex; flex-direction:column; + border:none; box-shadow:none; border-radius:0; } + .assistant-side .card-header { flex:0 0 auto; } + /* The band is fixed height, so the list inside it is what scrolls. */ + .assistant-sources { flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; + 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; } @@ -288,11 +297,11 @@ /* Full-screen Open WebUI workspace: the assistant replaces the app chrome */ /* No header bar anywhere now, so the layout owns the whole viewport. */ -body.assistant-workspace .assistant-layout { height: 100vh; min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; } +body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px)); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; } /* 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) { - body.assistant-workspace .assistant-layout { height:100dvh; display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; } + body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px)); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; } } /* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */ @@ -453,9 +462,15 @@ body.assistant-mode-workspace .assistant-rail-actions, body.assistant-mode-workspace .assistant-side { display:none; } body.assistant-mode-workspace .assistant-layout { grid-template-columns:210px minmax(0,1fr); } body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr); } +/* Workspace mode works on a phone too: the launcher is the same grid of cards, + one per column. Suppressing it here is what made the Workspace pill do + nothing at all on mobile. */ @media (max-width:640px) { - body.assistant-mode-workspace .assistant-workspace-view { display:none; } - body.assistant-mode-workspace #assistant-chat-view { display:flex; } + .assistant-workspace-cards { grid-template-columns:1fr; } + .assistant-workspace-view { padding:14px 12px calc(14px + env(safe-area-inset-bottom)); } + /* No sources or chat rail belong to the launcher. */ + body.assistant-mode-workspace .assistant-side, + body.assistant-mode-workspace .assistant-sources-fab { display:none; } } /* Rail menu in workspace mode: the app's own .tab-btn shape, so the assistant @@ -527,10 +542,12 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; } ever shown on a phone, and only when the answer actually has citations. */ .assistant-sources-fab { display:none; } @media (max-width:640px) { - .assistant-sources-fab { position:fixed; right:12px; bottom:calc(84px + env(safe-area-inset-bottom)); z-index:120; - display:inline-flex; align-items:center; gap:7px; padding:9px 14px; border:1px solid var(--g200); + /* It sits in the top bar's row, beside the menu button, so it never covers + the conversation — floating above the composer, it did. */ + .assistant-sources-fab { position:fixed; right:8px; top:8px; z-index:81; + display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 12px; border:1px solid var(--g200); border-radius:999px; background:white; color:var(--blue); font-family:inherit; font-size:12.5px; - font-weight:600; box-shadow:0 6px 20px rgba(15,23,42,.16); cursor:pointer; } + font-weight:600; cursor:pointer; } .assistant-sources-fab[hidden] { display:none; } - body.assistant-sources-open .assistant-sources-fab { opacity:0; pointer-events:none; } + body.assistant-sources-open .assistant-sources-fab { background:var(--blue-light); border-color:var(--blue-light); } } diff --git a/public/css/styles.css b/public/css/styles.css index 6619f003..148c13dc 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -59,8 +59,10 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(-- .menu-icon-btn:hover { background:var(--g100); color:var(--g800); } .assistant-history .menu-head { padding:0 0 8px; } .btn-menu-toggle { display:none; } +:root { --mobile-bar:0px; } @media(max-width:768px){ - .btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:10px; left:10px; z-index:150; width:38px; height:38px; border:1px solid var(--g200); border-radius:10px; background:white; color:var(--g700); font-size:15px; box-shadow:var(--shadow); } + .btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:6px; left:8px; z-index:81; width:36px; height:36px; border:none; border-radius:10px; background:none; color:var(--g700); font-size:16px; } + .btn-menu-toggle:hover { background:var(--g100); } } /* APP BODY LAYOUT */ @@ -1293,39 +1295,44 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select, /* Collapsed = a narrow icon rail, not a disappearance. The brand mark, the toggle, search and the account avatar stay reachable, so nothing has to be floated over the content and there is always a way back. */ -body.menu-hidden .sidebar { width:52px; } -body.menu-hidden .sidebar-tabs, -body.menu-hidden .assistant-mode-switch, -body.menu-hidden .sidebar-section-label, -body.menu-hidden .menu-brand h1, -body.menu-hidden .account-id, -body.menu-hidden .account-chevron { display:none; } -/* Collapsed, the head stacks. Every child is the same fixed-size centred box on - one axis — the brand kept its expanded `margin-right:auto`, which pushed the - stethoscope off the line the two buttons sat on. */ -body.menu-hidden .menu-head { flex-direction:column; align-items:center; gap:6px; padding:10px 0 8px; } -body.menu-hidden .menu-head > * { width:32px; height:32px; margin:0; flex:0 0 auto; - display:flex; align-items:center; justify-content:center; } -body.menu-hidden .menu-brand { gap:0; } -body.menu-hidden .menu-brand i { font-size:18px; } -body.menu-hidden .account-card { padding:8px 0; } -body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; } -/* The collapsed rail is 52px and .sidebar clips its overflow, so a menu laid out - inside it is cut off — which hid Settings, FAQ and Log out behind the avatar. - Fixed positioning takes it out of that box entirely. */ -body.menu-hidden .account-menu { position:fixed; left:56px; right:auto; bottom:12px; width:210px; } +@media (min-width:769px) { + /* Collapsing is a desktop idea: it turns the rail into a 52px strip. + The phone sheet has nothing to collapse — the same control closes it — + so none of this may leak past the breakpoint. */ + body.menu-hidden .sidebar { width:52px; } + body.menu-hidden .sidebar-tabs, + body.menu-hidden .assistant-mode-switch, + body.menu-hidden .sidebar-section-label, + body.menu-hidden .menu-brand h1, + body.menu-hidden .account-id, + body.menu-hidden .account-chevron { display:none; } + /* Collapsed, the head stacks. Every child is the same fixed-size centred box on + one axis — the brand kept its expanded `margin-right:auto`, which pushed the + stethoscope off the line the two buttons sat on. */ + body.menu-hidden .menu-head { flex-direction:column; align-items:center; gap:6px; padding:10px 0 8px; } + body.menu-hidden .menu-head > * { width:32px; height:32px; margin:0; flex:0 0 auto; + display:flex; align-items:center; justify-content:center; } + body.menu-hidden .menu-brand { gap:0; } + body.menu-hidden .menu-brand i { font-size:18px; } + body.menu-hidden .account-card { padding:8px 0; } + body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; } + /* The collapsed rail is 52px and .sidebar clips its overflow, so a menu laid out + inside it is cut off — which hid Settings, FAQ and Log out behind the avatar. + Fixed positioning takes it out of that box entirely. */ + body.menu-hidden .account-menu { position:fixed; left:56px; right:auto; bottom:12px; width:210px; } -/* The assistant rail collapses to the same strip rather than to nothing. */ -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); } -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, -body.assistant-workspace.menu-hidden .assistant-mode-switch { display:none; } - -@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } } + /* The assistant rail collapses to the same strip rather than to nothing. */ + 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; } + body.assistant-workspace.menu-hidden .assistant-history .card, + body.assistant-workspace.menu-hidden .assistant-rail-workspace, + body.assistant-workspace.menu-hidden .assistant-rail-actions, + body.assistant-workspace.menu-hidden .assistant-mode-switch { display:none; } +} /* Signed-out preview: the assistant is usable, everything that needs an account is not shown rather than shown and refused. */ @@ -1403,7 +1410,23 @@ body.assistant-preview .account-card { display:none; } .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; } - .account-card{ padding:10px 12px calc(10px + env(safe-area-inset-bottom)); } + .account-card{ padding:8px 12px calc(8px + env(safe-area-inset-bottom)); } .account-name{ font-size:14px; } .account-email{ font-size:12px; } + + /* The menu button gets a row of its own. Floating over the page it sat on + whatever the page began with: the assistant's title, the questions, a card + header. The strip is fixed; the app starts below it. It belongs to + #main-app, so the sign-in screen never shows it. z-index stays under every + modal (the image preview is 99) and over page content (composer 60). */ + :root{ --mobile-bar:48px; } + #main-app::before{ content:''; position:fixed; top:0; left:0; right:0; height:var(--mobile-bar); + z-index:80; background:white; border-bottom:1px solid var(--g200); } + #main-app{ padding-top:var(--mobile-bar); } + .app-body, body.assistant-workspace .app-body{ min-height:calc(100dvh - var(--mobile-bar)); } + .busy-bar{ top:var(--mobile-bar); } + /* 100vh on a phone includes the area behind the browser's toolbar, so the + foot of the sheet — the account card — sat lower than the visible screen + and read as "too far down". dvh is the height actually visible. */ + .sidebar{ height:100vh; height:100dvh; } } diff --git a/public/js/admin.js b/public/js/admin.js index 70a354d7..e7f96bac 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -909,6 +909,19 @@ initImageSettings(); container.innerHTML = '

Querying provider API...

'; if (hint) hint.style.display = 'none'; + // The Clinical Assistant lists models but cannot add them; adding happens in + // AI Model Management, one section up. Without this the two sections look + // unrelated and there is no visible way to get a new model into the list. + document.addEventListener('click', function(event) { + var jump = event.target.closest && event.target.closest('[data-admin-jump]'); + if (!jump) return; + event.preventDefault(); + var target = document.getElementById(jump.getAttribute('data-admin-jump')); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.focus({ preventScroll: true }); + }); + fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1462,6 +1475,100 @@ initImageSettings(); } +// ============================================================ +// ADMIN IMAGE MODEL MANAGEMENT +// ============================================================ +// Unlike TTS and STT there is no single default to Set: an image model is +// chosen per workflow, so discovery here ends in a Test, and the workflow +// pickers in the Clinical Assistant card consume the same discovery call. +// Nothing loads on tab entry: the workflow pickers already make the one +// discovery call opening Admin needs, so this card asks only when searched. +{ + document.addEventListener('click', function(e) { + if (e.target.closest('#btn-discover-image')) discoverImageModels(); + if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || ''); + var pick = e.target.closest('.admin-image-test-btn'); + if (pick) { + var field = document.getElementById('admin-image-test-model'); + if (field) field.value = pick.dataset.id; + testImageModel(pick.dataset.id, pick); + } + }); + document.addEventListener('keydown', function(e) { + if (e.target.id === 'admin-image-search' && e.key === 'Enter') { e.preventDefault(); discoverImageModels(); } + if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); } + }); + + const esc = adminEscapeHtml; + + function discoverImageModels() { + var search = (document.getElementById('admin-image-search') || {}).value || ''; + var container = document.getElementById('admin-image-discovered'); + var hint = document.getElementById('admin-image-discover-hint'); + if (!container) return; + container.innerHTML = '

Querying provider...

'; + if (hint) hint.style.display = 'none'; + + fetch('/api/admin/config/image-models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) { + container.innerHTML = '

Error: ' + esc(data.error || 'Unknown') + '

'; + return; + } + var items = data.models || []; + var badge = document.getElementById('admin-image-provider-badge'); + if (badge && !search) badge.textContent = data.count + ' available'; + if (items.length === 0) { + container.innerHTML = '

No image models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; + return; + } + container.innerHTML = '

Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '

' + + items.map(function(m) { + return '
' + + '' + + '' + esc(m.name || m.id) + '' + + '' + esc(m.source || '') + '' + + '
'; + }).join(''); + }) + .catch(function(err) { + container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; + }); + } + + function testImageModel(modelId, btn) { + var id = String(modelId || '').trim(); + var result = document.getElementById('admin-image-test-result'); + if (!id) { + if (result) result.innerHTML = 'Enter or pick a model id first.'; + return; + } + if (btn) adminSetButtonText(btn, '...', true); + if (result) result.innerHTML = ' Generating with ' + esc(id) + '... this can take a minute.'; + fetch('/api/admin/config/image-models/test', { + method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: id }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (btn) adminSetButtonText(btn, 'Test', false); + if (!data.success) { + if (result) result.innerHTML = '' + esc(data.error || 'Image test failed') + ''; + return; + } + var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); + if (result) { + result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' + + (src ? '
Test image generated by ' + esc(id) + '
' : ''); + } + }) + .catch(function(err) { + if (btn) adminSetButtonText(btn, 'Test', false); + if (result) result.innerHTML = 'Request failed: ' + esc(err.message) + ''; + }); + } +} + // ============================================================ // ADMIN EMBEDDING MODELS MANAGEMENT // ============================================================ diff --git a/public/js/app.js b/public/js/app.js index b710a9cd..2ed0ec41 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -189,6 +189,13 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // On a phone the switch is inside the open sheet, so the view it just changed + // 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'); + }); + // Account card. Rendered in both menus from the same markup, so whichever view // you are in ends the same way. Settings and Log out used to be unlabelled // icon buttons in the header, which the slim bar no longer explains. diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index 1ea992c7..175f4721 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -136,3 +136,30 @@ test('model availability comes from discovery, never hand-typed', () => { assert.match(html, /id="assistant-allowed-image-models"/); }); + +// Image models had a discovery ENDPOINT but no UI, so the only way to reach a +// newly added gateway model was to already know its id. +test('image model discovery sits beside TTS and STT, and ends in a test', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const root = path.join(__dirname, '..'); + const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'); + const js = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8'); + + // Same shape as the two discovery cards it now sits between. + ['admin-image-search', 'btn-discover-image', 'admin-image-discovered', 'admin-image-discover-hint'] + .forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id)); + assert.ok(html.indexOf('id="admin-image-search"') > html.indexOf('id="admin-stt-search"'), + 'the image card follows the STT card'); + assert.ok(html.indexOf('id="admin-image-search"') < html.indexOf('id="admin-embed-search"'), + 'and precedes embeddings'); + + assert.match(js, /if \(e\.target\.closest\('#btn-discover-image'\)\) discoverImageModels\(\);/); + assert.match(js, /'\/api\/admin\/config\/image-models\/discover\?q=' \+ encodeURIComponent\(search\)/, + 'it calls the endpoint that already existed'); + // Unlike a voice there is no single default to Set: an image model belongs to + // a workflow, so discovery ends in Test rather than Set. + assert.match(js, /admin-image-test-btn/); + assert.doesNotMatch(js, /admin-image-set-btn/); + assert.match(js, /'\/api\/admin\/config\/image-models\/test'/); +}); diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js index 6fcb0c5f..1af933b2 100644 --- a/test/assistant-mobile.test.js +++ b/test/assistant-mobile.test.js @@ -37,8 +37,12 @@ test('sources are reachable on a phone instead of deleted', () => { const mobile = css.slice(css.indexOf('@media (max-width: 640px)')); // display:none left every citation pointing at nothing. assert.doesNotMatch(mobile.slice(0, 2000), /\.assistant-side \{ display:none; \}/); - assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ transform:translateY\(0\); \}/, - 'they slide up as a sheet'); + assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ display:flex; height:min\(46vh,340px\); \}/, + 'they open into a band of their own rather than covering the composer'); + assert.doesNotMatch(mobile, /\.assistant-side \{ position:fixed;/, + 'a fixed overlay sat on top of the question box'); + const sources = mobile.slice(mobile.indexOf('.assistant-sources {')); + assert.match(sources.slice(0, 200), /overflow-y:auto/, 'the list inside the band scrolls'); const js = read('public/js/clinicalAssistant.js'); assert.match(js, /button\.hidden = !\(citationsOn && count > 0\);/, @@ -81,3 +85,34 @@ test('the account card sits at the same height in both rails', () => { assert.match(css, /\.account-card-btn \{ min-height:44px; \}/, 'and the same row height in both'); }); + +test('a desktop collapse does not follow the user onto a phone', () => { + const css = read('public/css/styles.css'); + // menu-hidden is remembered in localStorage across viewports. Unscoped, its + // 52px-rail rules hid .sidebar-tabs, the Assistant/Workspace switch and the + // account name INSIDE the phone sheet — the menu opened onto a strip of icons + // and every link looked broken. + const start = css.indexOf('@media (min-width:769px) {\n /* Collapsing is a desktop idea'); + assert.ok(start !== -1, 'the collapse rules are wrapped in a desktop-only query'); + const block = css.slice(start, css.indexOf('\n}\n', start)); + assert.match(block, /body\.menu-hidden \.sidebar-tabs/, 'including the one that hid the links'); + assert.match(block, /body\.menu-hidden \.assistant-mode-switch/, 'and the one that hid Workspace'); + assert.match(block, /body\.menu-hidden \.account-menu \{ position:fixed;/); + // Nothing outside that query may still collapse the phone sheet. + const outside = css.slice(0, start) + css.slice(css.indexOf('\n}\n', start)); + assert.doesNotMatch(outside, /body\.menu-hidden \.sidebar \{ width:/); +}); + +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'\);/); + + const css = read('public/css/assistant.css'); + // The launcher used to be display:none below 640px, so the pill toggled a + // view that could never appear. + assert.doesNotMatch(css, /body\.assistant-mode-workspace \.assistant-workspace-view \{ display:none; \}/); + assert.match(css, /\.assistant-workspace-cards \{ grid-template-columns:1fr; \}/, + 'and its cards stack into one column'); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 0db0b360..236d68d5 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -141,7 +141,7 @@ test('one menu toggle serves both the app sidebar and the assistant rail', () => test('mobile drawer rows carry no chat icons and keep the options menu', () => { const css = read('public/css/assistant.css'); - assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:100dvh;/.test(css), 'mobile layout locks the viewport'); + assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:calc\(100dvh - var\(--mobile-bar, 0px\)\);/.test(css), 'mobile layout locks the viewport, below the menu row'); assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile'); }); @@ -200,8 +200,8 @@ test('workspace mode does not override the mobile layout with a desktop header o // specific than the .assistant-layout rule inside @media (max-width:640px), so // the mobile rule needs a matching-specificity override to win at all. // Both now subtract the app header, which the assistant keeps rather than hides. - const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;'); - const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: 100vh'); + const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px));'); + const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px))'); assert.ok(desktop > 0, 'the desktop workspace rule exists'); assert.ok(override > desktop, 'the mobile override comes after it, so it wins'); const rule = css.slice(override, css.indexOf('}', override)); @@ -297,7 +297,7 @@ test('chrome rendered on the main app page is styled by the stylesheet that page for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) { assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page'); - assert.ok(styles.includes('.' + cls + ' {'), cls + ' must be defined in styles.css'); + assert.match(styles, new RegExp('\\.' + cls + '[\\s{]'), cls + ' must be defined in styles.css'); // Defined in both files, the two copies drift and one silently wins. assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css'); } @@ -413,8 +413,10 @@ test('there is no header bar; the brand heads the menu instead', () => { assert.doesNotMatch(css, stale, 'no stale header offset in styles.css'); assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css'); } - assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: 100vh;/, + // The only offset left is the phone's menu row, a variable that is 0px on desktop. + assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: calc\(100vh - var\(--mobile-bar, 0px\)\);/, 'the layout owns the full viewport'); + assert.match(css, /:root \{ --mobile-bar:0px; \}/, 'which is zero outside the phone breakpoint'); }); test('every view sits in the same shell, so opening a menu item is not a new app', () => { @@ -453,7 +455,8 @@ test('the rail scrolls in both modes, not only after leaving for the app', () => assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/, 'and so does the saved-chat list'); // The rail itself is the flex column that makes those two scroll. - assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; \}/); + assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; \}/, + 'the rail is the flex column that makes those scroll, with the same bottom gap as the app'); }); test('the menu can always be reopened after it is hidden', () => {