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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
This commit is contained in:
Daniel 2026-09-10 11:39:32 +02:00
parent 924ce8716c
commit b8d2153cca
8 changed files with 314 additions and 61 deletions

View file

@ -296,6 +296,7 @@
<strong class="admin-row-label">Model availability</strong>
<div style="flex:1;display:flex;flex-direction:column;gap:8px;min-width:0;">
<p style="margin:0;font-size:12px;color:var(--g500);">Models users may select in the Clinical Assistant. Leave a list empty to keep only the configured model.</p>
<p style="margin:0;font-size:12px;color:var(--g500);">This list is what the gateway already offers. To make a new model appear here, add it in <a href="#" data-admin-jump="admin-model-search" style="color:var(--blue);font-weight:600;">AI Model Management</a> above — search the gateway, press <strong>+</strong>, and it shows up in this list.</p>
<div>
<div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Chat models</div>
<div id="assistant-allowed-chat-models" aria-label="Chat models available to the Clinical Assistant"></div>
@ -444,6 +445,39 @@
</div>
</div>
<!-- ── Image Model Management ─────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-image"></i> Image Generation</h3>
<span id="admin-image-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Gateway</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div id="admin-image-info" style="font-size:12px;color:var(--g500);">Image models come from the LiteLLM gateway. Search to list them, and test one before assigning it to a workflow.</div>
<!-- Search image models from provider -->
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" id="admin-image-search" placeholder="Filter models (e.g. dall-e, imagen, flux)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
<button id="btn-discover-image" class="btn-sm btn-primary" type="button"><i class="fas fa-magnifying-glass"></i> Search</button>
</div>
<div id="admin-image-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
<p id="admin-image-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">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 <strong>Clinical Assistant / Learning</strong>, and tick it there to offer it to users in chat.</p>
</div>
<!-- Test image generation -->
<div style="border-top:1px solid var(--g100);padding-top:12px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Generation</label>
<p style="font-size:12px;color:var(--g500);margin:0 0 8px;">Press Test on a model above, or type an id the gateway does not advertise.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="text" id="admin-image-test-model" placeholder="Model id to test" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
<button id="btn-test-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</div>
<div id="admin-image-test-result" role="status" style="font-size:12px;color:var(--g500);margin-top:8px;"></div>
</div>
</div>
</div>
<!-- ── Embedding Models ───────────────────────────────────────── -->
<div class="card">
<div class="card-header">

View file

@ -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); }
}

View file

@ -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; }
}

View file

@ -909,6 +909,19 @@ initImageSettings();
container.innerHTML = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
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 = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
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 = '<p style="font-size:13px;color:var(--g400);">No image models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-image-test-btn" type="button" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(m.name || m.id) + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
function testImageModel(modelId, btn) {
var id = String(modelId || '').trim();
var result = document.getElementById('admin-image-test-result');
if (!id) {
if (result) result.innerHTML = '<span style="color:var(--red);">Enter or pick a model id first.</span>';
return;
}
if (btn) adminSetButtonText(btn, '...', true);
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<span style="color:var(--red);">' + esc(data.error || 'Image test failed') + '</span>';
return;
}
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) {
result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' +
(src ? '<div style="margin-top:8px;"><img src="' + esc(src) + '" alt="Test image generated by ' + esc(id) + '" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
}
})
.catch(function(err) {
if (btn) adminSetButtonText(btn, 'Test', false);
if (result) result.innerHTML = '<span style="color:var(--red);">Request failed: ' + esc(err.message) + '</span>';
});
}
}
// ============================================================
// ADMIN EMBEDDING MODELS MANAGEMENT
// ============================================================

View file

@ -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.

View file

@ -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'/);
});

View file

@ -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');
});

View file

@ -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', () => {