fix: image models can be added and offered; solid phone top bar on iOS

Image models
- The Clinical Assistant "Image models" list waited on an old
  #assistant-image-model dropdown that no longer exists, so discovery never
  reached it and only four hard-coded fallbacks appeared — with no way to
  add any of the gateway's 50 image models.
- Image Generation search rows now have + Add / Added. Added models are
  saved as clinical_assistant.image_model_roster (validated server-side:
  up to 100 ids) and appear in the Clinical Assistant list at once; ticking
  one there offers it to users. Anything already allowed or configured
  stays listed. Unsaved ticks survive an add.
- The roster notification is guarded, so it can never fail the settings load.

Phone top bar
- The page is drawn under the status bar (viewport-fit=cover) and its
  theme colour was the removed header's blue, so on an iPhone content showed
  scrolling at the top of the screen. The row is now a real fixed element
  that extends behind the status bar (env(safe-area-inset-top), 0 in a
  normal tab), the menu button, sources pill and drawers clear it, and the
  theme colour is white.

Verified in Chromium: + Add -> saved roster -> listed unticked; tick kept;
remove works. Top bar is the only thing in the top 48px on all 22 pages;
phone menu positions unchanged.

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 13:46:19 +02:00
parent 3af11e8b4a
commit adcea2a0ca
10 changed files with 140 additions and 20 deletions

View file

@ -296,7 +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>
<p style="margin:0;font-size:12px;color:var(--g500);">To make a new model appear here, add it from the gateway: chat models in <a href="#" data-admin-jump="admin-model-search" style="color:var(--blue);font-weight:600;">AI Model Management</a> above, image models in <a href="#" data-admin-jump="admin-image-search" style="color:var(--blue);font-weight:600;">Image Generation</a> below — search, press <strong>+ Add</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>
@ -462,7 +462,7 @@
<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>
<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. <strong>+ Add</strong> puts a model in the Clinical Assistant's <strong>Image models</strong> list; tick it there to offer it to users in chat. Unlike a voice, an image model has no single default — each workflow picks its own under <strong>Clinical Assistant / Learning</strong>.</p>
</div>
<!-- Test image generation -->

View file

@ -222,7 +222,7 @@
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:0; overflow:hidden; gap:0; 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:env(safe-area-inset-top, 0px) 0 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; }
@ -560,7 +560,7 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
@media (max-width:640px) {
/* 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;
.assistant-sources-fab { position:fixed; right:calc(8px + env(safe-area-inset-right, 0px)); top:calc(8px + env(safe-area-inset-top, 0px)); 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; cursor:pointer; }

View file

@ -59,9 +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; }
.mobile-topbar { 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: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 { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:calc(6px + env(safe-area-inset-top, 0px)); left:calc(8px + env(safe-area-inset-left, 0px)); 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); }
}
@ -1412,7 +1413,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(85vw,320px); left:calc(-1 * min(85vw,320px)); padding:0; }
.sidebar{ width:min(85vw,320px); left:calc(-1 * min(85vw,320px)); padding:env(safe-area-inset-top, 0px) 0 0; }
.sidebar.open{ left:0; }
/* Close and search lead, as in the reference. */
.menu-head{ padding:14px 14px 8px; gap:10px; }
@ -1441,8 +1442,14 @@ body.assistant-preview .account-card { display:none; }
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);
/* The row also covers the status-bar area whenever the page is drawn under
it (viewport-fit=cover: the iOS home-screen app, notched phones), so no
content can scroll visibly above it. env() is 0 everywhere else, so the
row is exactly 48px in a normal browser tab. A real element rather than a
pseudo-element: iOS Safari looks for a fixed element at the top edge when
deciding what to paint behind the status bar. */
:root{ --mobile-bar:calc(48px + env(safe-area-inset-top, 0px)); }
.mobile-topbar{ display:block; 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)); }

View file

@ -24,7 +24,7 @@
<script src="/vendor/katex/contrib/mhchem.min.js" defer></script>
<script src="/vendor/mathjax/tex-mml-chtml.js" defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PedScribe">
@ -153,6 +153,7 @@
floats — so a whole band of chrome earned its place back as content.
btn-settings and btn-logout stay as the canonical handlers other code
already calls; they are hidden, not duplicated. -->
<div class="mobile-topbar" aria-hidden="true"></div>
<button id="btn-menu-toggle" class="btn-menu-toggle" title="Menu" aria-label="Toggle menu"><i class="fas fa-bars"></i></button>
<button id="btn-settings" hidden></button>
<button id="btn-logout" hidden></button>

View file

@ -1484,7 +1484,12 @@ initImageSettings();
// Nothing loads on tab entry: the workflow pickers already make the one
// discovery call opening Admin needs, so this card asks only when searched.
{
// + Add puts a model in the Clinical Assistant's Image models list
// (clinical_assistant.image_model_roster); ticking it there offers it to users.
document.addEventListener('assistant-image-roster', syncImageRows);
document.addEventListener('click', function(e) {
var add = e.target.closest('.admin-image-add-btn');
if (add) { toggleImageRoster(add.dataset.id, add); return; }
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');
@ -1526,9 +1531,10 @@ initImageSettings();
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>' +
'<button class="btn-sm btn-ghost 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>' +
imageAddButton(m.id) +
'</div>';
}).join('');
})
@ -1537,6 +1543,45 @@ initImageSettings();
});
}
function currentImageRoster() {
return Array.isArray(window._assistantImageRoster) ? window._assistantImageRoster : [];
}
function imageAddButton(id) {
var added = currentImageRoster().indexOf(id) !== -1;
return added
? '<button class="btn-sm btn-ghost admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="In the Clinical Assistant list. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
: '<button class="btn-sm btn-primary admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the Clinical Assistant\'s Image models list" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
}
// Rows rendered before the roster loaded (or after it changed) catch up here.
function syncImageRows() {
var container = document.getElementById('admin-image-discovered');
if (!container) return;
container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); });
}
function toggleImageRoster(id, btn) {
if (!id) return;
var roster = currentImageRoster();
var added = roster.indexOf(id) !== -1;
var next = added ? roster.filter(function(x) { return x !== id; }) : roster.concat([id]);
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent('clinical_assistant.image_model_roster'), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not update the image model list');
window._assistantImageRoster = next;
document.dispatchEvent(new CustomEvent('assistant-image-roster-changed', { detail: { roster: next.slice() } }));
syncImageRows();
showToast(added ? id + ' removed from the Clinical Assistant list'
: id + ' added. Tick it under Clinical Assistant to offer it to users.', 'success');
})
.catch(function(err) { syncImageRows(); showToast(err.message || 'Request failed', 'error'); });
}
function testImageModel(modelId, btn) {
var id = String(modelId || '').trim();
var result = document.getElementById('admin-image-test-result');

View file

@ -14,7 +14,10 @@ function assistantBudgetMeta(budget, savedOverride) {
export function initClinicalAssistantAdmin(adminEscapeHtml) {
let configState = 'idle';
const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
// Image models an admin added from the Image Generation card. The list below
// offers these, plus anything already allowed or configured so a saved choice
// never drops out of view.
let imageRosterSaved = [];
let savedChatAllowed = [];
let savedImageAllowed = [];
let chatRoster = [];
@ -23,7 +26,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
function parseAssistantList(value) {
return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
function renderAssistantCheckboxList(containerId, candidates, saved) {
function renderAssistantCheckboxList(containerId, candidates, saved, emptyText) {
var container = document.getElementById(containerId);
if (!container) return;
var ids = candidates.slice();
@ -33,7 +36,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (!ids.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:12px;color:var(--g500);';
empty.textContent = 'No models available. Add or enable models first.';
empty.textContent = emptyText || 'No models available. Add or enable models first.';
container.appendChild(empty);
return;
}
@ -56,10 +59,22 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (!container) return [];
return Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]:checked'), function(box) { return box.value; });
}
function renderAssistantImageModelCheckboxes(models) {
imageRoster = (Array.isArray(models) ? models : []).map(function(m) { return m.id; }).concat(IMAGE_MODEL_FALLBACKS, [window._assistantImageModelValue]);
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed);
// Built from the admin's roster, not from discovery: the gateway advertises
// dozens of image models, and this used to wait on a dropdown that no longer
// exists, so only four hard-coded fallbacks ever appeared.
function renderAssistantImageModelCheckboxes() {
imageRoster = imageRosterSaved.concat([window._assistantImageModelValue]);
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed,
'No image models added yet. Add them under Image Generation below, then tick them here.');
}
// Adding or removing in the Image Generation card updates this list at once,
// keeping any ticks made here that have not been saved yet.
document.addEventListener('assistant-image-roster-changed', function(e) {
imageRosterSaved = (e.detail && Array.isArray(e.detail.roster)) ? e.detail.roster.slice() : imageRosterSaved;
if (configState !== 'ready') return;
savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models');
renderAssistantImageModelCheckboxes();
});
let imageModelsLoading = false;
@ -143,9 +158,14 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']);
savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']);
imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']);
window._assistantImageRoster = imageRosterSaved.slice();
// Only a nudge for the Image Generation card's buttons: it must never be
// able to fail the settings load itself.
try { document.dispatchEvent(new CustomEvent('assistant-image-roster', { detail: { roster: imageRosterSaved.slice() } })); } catch (e) {}
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
renderAssistantImageModelCheckboxes([]);
renderAssistantImageModelCheckboxes();
renderAssistantImageModels([], window._assistantImageModelValue);
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
@ -216,7 +236,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
.then(function(data) {
if (!data || data.success === false || !Array.isArray(data.models) ||
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
renderAssistantImageModelCheckboxes(data.models);
renderAssistantImageModelCheckboxes();
renderAssistantImageModels(data.models, sel.value);
})
.catch(function() {

View file

@ -5,7 +5,7 @@
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"theme_color": "#ffffff",
"orientation": "any",
"categories": ["medical", "productivity"],
"prefer_related_applications": false,

View file

@ -866,6 +866,13 @@ router.put('/config/:key(*)', async function(req, res) {
if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Show sources must be true or false' });
}
// The admin's image-model roster: comma-separated gateway model ids.
if (key === 'clinical_assistant.image_model_roster') {
var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
if (rosterIds.length > 100 || rosterIds.some(function(id) { return id.length > 200 || /[\s<>"'`]/.test(id); })) {
return res.status(400).json({ error: 'Image model list must be up to 100 model ids' });
}
}
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
return changePrompt(req, res, 'save', key);

View file

@ -163,3 +163,26 @@ test('image model discovery sits beside TTS and STT, and ends in a test', () =>
assert.doesNotMatch(js, /admin-image-set-btn/);
assert.match(js, /'\/api\/admin\/config\/image-models\/test'/);
});
// The Image models list waited on a dropdown that no longer exists, so only four
// hard-coded fallbacks appeared and there was no way to add a gateway model.
test('image models are added from Image Generation and offered from the Clinical Assistant list', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const admin = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8');
const ca = fs.readFileSync(path.join(root, 'public/js/admin/clinicalAssistant.js'), 'utf8');
const server = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
assert.match(admin, /admin-image-add-btn/, 'each discovered model has + Add');
assert.match(admin, /'\/api\/admin\/config\/' \+ encodeURIComponent\('clinical_assistant\.image_model_roster'\)/);
assert.match(admin, /new CustomEvent\('assistant-image-roster-changed'/, 'and the list updates at once');
assert.doesNotMatch(ca, /IMAGE_MODEL_FALLBACKS/, 'no hard-coded fallbacks');
assert.match(ca, /imageRosterSaved = parseAssistantList\(cfg\['clinical_assistant\.image_model_roster'\]\)/);
assert.match(ca, /imageRoster = imageRosterSaved\.concat\(\[window\._assistantImageModelValue\]\)/);
// A tick made but not saved survives an add from the other card.
assert.match(ca, /savedImageAllowed = checkedAssistantModels\('assistant-allowed-image-models'\);\s*\n\s*renderAssistantImageModelCheckboxes\(\);/);
assert.match(server, /key === 'clinical_assistant\.image_model_roster'/, 'the server validates the roster');
});

View file

@ -136,7 +136,7 @@ test('on a phone the menu follows the view: chat history in the assistant', () =
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-history \{ position:fixed;[^}]*padding:env\(safe-area-inset-top, 0px\) 0 0; overflow:hidden;/, 'the drawer itself does not scroll, and clears the status bar');
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');
});
@ -152,3 +152,20 @@ test('source cards fit the phone: long names wrap, nothing scrolls sideways or i
assert.match(mobile, /\.assistant-source-excerpt \{ max-height:none; overflow:visible; \}/, 'no scroll box inside the scrolling list');
assert.match(mobile, /\.assistant-sources \{[^}]*overflow-x:hidden; touch-action:pan-y;/, 'and no sideways pan');
});
test('the phone top bar is solid behind the status bar too', () => {
const index = read('public/index.html');
const css = read('public/css/styles.css');
// Scrolling an admin page on an iPhone showed text flowing at the top of the
// screen: the page is drawn under the status bar (viewport-fit=cover) and the
// theme colour was the removed header's blue.
assert.match(index, /viewport-fit=cover/);
assert.match(index, /<div class="mobile-topbar" aria-hidden="true"><\/div>/, 'a real element, which iOS looks for at the top edge');
assert.match(index, /<meta name="theme-color" content="#ffffff">/);
assert.match(read('public/manifest.json'), /"theme_color": "#ffffff"/);
assert.doesNotMatch(css, /#main-app::before/);
assert.match(css, /:root\{ --mobile-bar:calc\(48px \+ env\(safe-area-inset-top, 0px\)\); \}/, 'the row reaches up behind the status bar');
assert.match(css, /\.btn-menu-toggle \{[^}]*top:calc\(6px \+ env\(safe-area-inset-top, 0px\)\)/, 'and the button sits below it');
assert.match(css, /\.sidebar\{ width:min\(85vw,320px\); left:calc\(-1 \* min\(85vw,320px\)\); padding:env\(safe-area-inset-top, 0px\) 0 0; \}/);
assert.match(read('public/css/assistant.css'), /\.assistant-sources-fab \{[^}]*top:calc\(8px \+ env\(safe-area-inset-top, 0px\)\)/);
});