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:
parent
3af11e8b4a
commit
adcea2a0ca
10 changed files with 140 additions and 20 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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\)\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue