diff --git a/public/css/assistant.css b/public/css/assistant.css
index d1339ce2..43294b6a 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -195,7 +195,7 @@
.assistant-mobile-menu { display:inline-flex; }
#btn-assistant-goback span, #btn-assistant-goback { font-size:0; }
#btn-assistant-goback { padding:8px; }
- .assistant-status { margin-left:auto; font-size:11px; }
+ .assistant-status, .assistant-autosave-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
/* The assistant fills the viewport exactly; nothing behind can scroll */
.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; }
@@ -361,7 +361,11 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
/* Open WebUI-style composer: a prominent input box with a slim action row below */
.assistant-composer { border:1px solid var(--g200); border-radius:20px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:10px 14px 6px; display:flex; flex-direction:column; gap:2px; }
.assistant-composer > label { display:none; }
-#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; }
+/* 15px is the desktop size. The phone override lives earlier in this file, so
+ at equal specificity this rule used to win and iOS zoomed on focus; the size
+ is set per breakpoint here instead. */
+#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; }
+@media (min-width:641px) { #assistant-input { font-size:15px; } }
.assistant-composer-footer { display:flex; align-items:center; gap:2px; border-top:1px solid var(--g100); padding-top:4px; margin-top:2px; }
.assistant-composer-footer .assistant-tools { display:flex; align-items:center; gap:2px; margin-right:auto; }
.assistant-composer-footer .assistant-attach, .assistant-composer-footer .assistant-voice-btn, .assistant-composer-footer .assistant-mic { width:30px; height:30px; font-size:14px; }
@@ -403,8 +407,20 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
.create-image-history { display:grid; grid-template-columns:repeat(auto-fill,minmax(72px,1fr)); gap:8px; max-height:42vh; overflow-y:auto; margin-top:8px; }
.assistant-gallery-running { display:flex; align-items:center; justify-content:center; min-height:72px; border:1px dashed var(--g300); border-radius:8px; color:var(--g500); }
/* Composer model pill (Open WebUI-style, its own line above the actions) */
-.assistant-model-pill { display:flex; align-items:center; }
+.assistant-model-pill { position:relative; display:flex; align-items:center; }
.assistant-model-pill[hidden] { display:none; }
+/* The select is the state holder only; the button is what people see. */
+.assistant-model-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); }
+.assistant-model-btn { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--g200); background:white; border-radius:999px; padding:5px 11px; font-family:inherit; font-size:11.5px; font-weight:600; color:var(--g600); cursor:pointer; white-space:nowrap; box-shadow:var(--shadow); }
+.assistant-model-btn:hover, .assistant-model-btn[aria-expanded="true"] { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); }
+.assistant-model-btn i { font-size:11px; }
+/* Opens upward: the composer sits at the foot of the view. */
+.assistant-model-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:40; min-width:220px; max-width:min(320px,80vw); max-height:min(320px,50vh); overflow-y:auto; display:flex; flex-direction:column; gap:2px; padding:6px; background:white; border:1px solid var(--g200); border-radius:12px; box-shadow:0 12px 32px rgba(15,23,42,.16); }
+.assistant-model-menu[hidden] { display:none; }
+.assistant-model-option { display:flex; align-items:center; gap:8px; width:100%; border:0; border-radius:8px; padding:8px 10px; background:none; color:var(--g700); font-family:inherit; font-size:12.5px; text-align:left; cursor:pointer; overflow-wrap:anywhere; }
+.assistant-model-option:hover { background:var(--blue-light); color:var(--blue); }
+.assistant-model-option i { width:12px; flex:0 0 auto; font-size:10px; color:var(--blue); }
+.assistant-model-option[aria-selected="false"] i { visibility:hidden; }
/* Model choice reads as a control, not a raw form element: the native chevron is
replaced with one that matches the composer, and the whole pill responds. */
.assistant-model-control { appearance:none; -webkit-appearance:none; border:1px solid var(--g200); background:white; border-radius:999px; padding:5px 26px 5px 11px; font-family:inherit; font-size:11.5px; font-weight:600; color:var(--g600); cursor:pointer; max-width:240px; text-overflow:ellipsis; box-shadow:var(--shadow);
@@ -584,3 +600,8 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
/* Workspace links: the app's .tab-btn, full-bleed with the same inset. */
body.assistant-workspace .assistant-rail-link { padding:10px 16px; }
}
+
+/* The composer says it is working through the send button, which becomes Stop.
+ The status pill and the autosave word repeated that beside the model control,
+ so they are announced but not drawn. */
+.assistant-status, .assistant-autosave-state { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
diff --git a/public/css/styles.css b/public/css/styles.css
index 7f9af4e2..cca27541 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1276,6 +1276,21 @@ body.assistant-workspace #assistant-tab.active { width:100%; }
/* iOS: taps on controls must not trigger double-tap zoom */
button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select, label { touch-action: manipulation; }
+/* iOS Safari zooms the whole page when a text field smaller than 16px takes
+ focus, and never zooms back out — which is why the menu button ended up off
+ screen after typing. 16px is the threshold, so every text control gets it on
+ a phone. This is what keeps other chat apps from zooming; no user-scalable=no,
+ which would also block deliberate pinch-zoom. */
+/* !important is deliberate: roughly 144 controls across twenty components set
+ their own font-size through class rules such as `.demo-field select`, which
+ outrank any plain element selector. This is one platform workaround, not a
+ style choice, so it overrides them all rather than being restated per
+ component (and per new component added later). */
+@media (max-width:768px) {
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
+ textarea, select { font-size:16px !important; }
+}
+
/* ── Shared app chrome: the Assistant/Workspace switch and the menu toggle ────
These render in BOTH the app sidebar and the assistant rail. They used to live
in assistant.css, which only the assistant component loads — so on the main
diff --git a/public/js/app.js b/public/js/app.js
index 408f18d6..7fdfc8f0 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -289,7 +289,7 @@ document.addEventListener('DOMContentLoaded', function() {
// moment rather than clicking into an empty tab.
function openSubItem(sub, tabName, attempts) {
var host = tabName ? document.getElementById(tabName + '-tab') : document;
- var selector = '[data-calc="' + sub + '"], [data-subtab="' + sub + '"], [data-section="' + sub + '"]';
+ var selector = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + '="' + sub + '"]'; }).join(', ');
var target = host && host.querySelector(selector);
if (target) { target.click(); return; }
if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110);
@@ -299,13 +299,16 @@ document.addEventListener('DOMContentLoaded', function() {
// exam regions — is what people actually look for ("bili", not "Calculators").
// Read generically from whatever a loaded component exposes, so this covers
// every tab that has sub-navigation rather than one hard-coded list.
- var SUB_NAV_SELECTOR = '[data-calc], [data-subtab], [data-section], .calc-nav-pill';
+ // One list, so what is listed, what is searched and what a result opens can
+ // never drift apart. data-em is the bedside emergency sections.
+ var SUB_NAV_ATTRS = ['data-calc', 'data-subtab', 'data-section', 'data-em'];
+ var SUB_NAV_SELECTOR = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + ']'; }).join(', ') + ', .calc-nav-pill';
function subItemsFor(tabEl, tabName, tabTitle) {
if (!tabEl || !tabEl.dataset.loaded) return []; // not loaded yet; nothing to read
var seen = {};
return Array.prototype.map.call(tabEl.querySelectorAll(SUB_NAV_SELECTOR), function(node) {
- var key = node.getAttribute('data-calc') || node.getAttribute('data-subtab') || node.getAttribute('data-section');
+ var key = SUB_NAV_ATTRS.reduce(function(found, attr) { return found || node.getAttribute(attr); }, null);
var title = (node.textContent || '').trim();
if (!key || !title || title.length > 48 || seen[key + title]) return null;
seen[key + title] = true;
@@ -338,7 +341,7 @@ document.addEventListener('DOMContentLoaded', function() {
// A component only exposes its sub-navigation once loaded, so warm the tabs
// that have any. Cheap: each is fetched once and cached by the loader.
function warmSearchableTabs() {
- ['calculators', 'wellvisit', 'peguide', 'vaxschedule'].forEach(function(name) {
+ ['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'].forEach(function(name) {
var el = document.getElementById(name + '-tab');
if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el);
});
@@ -348,8 +351,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (!searchResults) return;
var source = searchSources();
var needle = String(term || '').trim().toLowerCase();
+ // With nothing typed the palette lists the main destinations only; the
+ // sub-items (individual calculators, exam sections, schedule tabs) would
+ // bury them, so they appear once there is something to match.
var matches = source.items.filter(function(item) {
- return !needle || String(item.title || '').toLowerCase().indexOf(needle) !== -1;
+ if (!needle) return !item.sub;
+ return String(item.title || '').toLowerCase().indexOf(needle) !== -1;
}).slice(0, 40);
searchResults.innerHTML = '';
if (!matches.length) {
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 1575256f..f2d67206 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -1560,8 +1560,83 @@ import {
select.hidden = !show;
var pill = document.getElementById('assistant-model-pill');
if (pill) pill.hidden = !show;
+ if (select.id === 'assistant-chat-model-select') syncModelMenu();
+ }
+
+ // The composer shows the word "Model", not the model id, which can be as long
+ // as "openrouter-gemini-3.1-flash-image-preview". The list behind it is built
+ // from the select above, which stays the state holder, so choosing here goes
+ // through the same change event that persists every other selection.
+ function syncModelMenu() {
+ var select = document.getElementById('assistant-chat-model-select');
+ var menu = document.getElementById('assistant-model-menu');
+ var button = document.getElementById('btn-assistant-model');
+ if (!select || !menu || !button) return;
+ menu.innerHTML = '';
+ Array.prototype.forEach.call(select.options, function(option) {
+ var row = document.createElement('button');
+ row.type = 'button';
+ row.className = 'assistant-model-option';
+ row.setAttribute('role', 'option');
+ row.setAttribute('data-model-value', option.value);
+ row.setAttribute('aria-selected', option.value === select.value ? 'true' : 'false');
+ var tick = document.createElement('i');
+ tick.className = 'fas fa-check';
+ var text = document.createElement('span');
+ text.textContent = option.textContent;
+ row.appendChild(tick);
+ row.appendChild(text);
+ menu.appendChild(row);
+ });
+ var current = select.options[select.selectedIndex];
+ button.title = current ? 'Chat model: ' + current.textContent : 'Choose the chat model';
+ }
+
+ function closeModelMenu() {
+ var menu = document.getElementById('assistant-model-menu');
+ var button = document.getElementById('btn-assistant-model');
+ if (menu) menu.hidden = true;
+ if (button) button.setAttribute('aria-expanded', 'false');
+ }
+
+ function bindModelMenu() {
+ var docEl = typeof document !== 'undefined' ? document.documentElement : null;
+ if (docEl && docEl.dataset && docEl.dataset.modelMenuBound) return;
+ if (docEl && docEl.dataset) docEl.dataset.modelMenuBound = '1';
+ document.addEventListener('click', function(event) {
+ var target = event.target;
+ if (!target || !target.closest) return;
+ var option = target.closest('.assistant-model-option');
+ if (option) {
+ var select = document.getElementById('assistant-chat-model-select');
+ if (select) {
+ select.value = option.getAttribute('data-model-value');
+ // The delegated [data-model-select-kind] listener does the saving.
+ var changed = document.createEvent('Event');
+ changed.initEvent('change', true, false);
+ select.dispatchEvent(changed);
+ syncModelMenu();
+ }
+ closeModelMenu();
+ return;
+ }
+ if (target.closest('#btn-assistant-model')) {
+ var menu = document.getElementById('assistant-model-menu');
+ var button = document.getElementById('btn-assistant-model');
+ if (!menu || !button) return;
+ var opening = menu.hidden;
+ menu.hidden = !opening;
+ button.setAttribute('aria-expanded', opening ? 'true' : 'false');
+ return;
+ }
+ if (!target.closest('.assistant-model-pill')) closeModelMenu();
+ });
+ document.addEventListener('keydown', function(event) {
+ if (event.key === 'Escape') closeModelMenu();
+ });
}
function bindModelSelects() {
+ bindModelMenu();
// Delegated persistence: any chat/image model select saves immediately,
// even when the popup recreates its element.
var docEl = typeof document !== 'undefined' ? document.documentElement : null;
diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js
index 7f130ec7..0342715a 100644
--- a/test/assistant-mobile.test.js
+++ b/test/assistant-mobile.test.js
@@ -169,3 +169,20 @@ test('the phone top bar is solid behind the status bar too', () => {
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\)\)/);
});
+
+test('no text control is small enough to make iOS zoom the page', () => {
+ const css = read('public/css/styles.css');
+ // iOS Safari zooms the whole page when a field under 16px takes focus and
+ // never zooms back, which is why fixed chrome (the menu button) ended up off
+ // screen after typing. 16px is the threshold.
+ const rule = css.slice(css.indexOf('/* iOS Safari zooms the whole page'));
+ assert.match(rule, /@media \(max-width:768px\) \{\s*\n\s*input:not\(\[type="checkbox"\]\)/);
+ assert.match(rule, /textarea, select \{ font-size:16px !important; \}/,
+ 'it must beat per-component rules such as .demo-field select');
+ // Pinch-zoom stays available: the fix is the font size, not a locked viewport.
+ assert.doesNotMatch(read('public/index.html'), /user-scalable=no|maximum-scale=1/);
+ // The composer set 15px AFTER its own mobile override, so the base rule won.
+ const assistant = read('public/css/assistant.css');
+ assert.doesNotMatch(assistant, /#assistant-input \{ flex:1 1 auto;[^}]*font-size:15px/);
+ assert.match(assistant, /@media \(min-width:641px\) \{ #assistant-input \{ font-size:15px; \} \}/);
+});
diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js
index 6e791fa1..9d01d13c 100644
--- a/test/assistant-workspace-layout.test.js
+++ b/test/assistant-workspace-layout.test.js
@@ -515,7 +515,11 @@ test('search reaches the things inside a tab, not just the tab', () => {
// People look for "bili", not "Calculators". Sub-navigation is read
// generically from whatever a loaded component exposes, so this covers every
// tab with sub-navigation rather than one hard-coded list.
- assert.match(app, /SUB_NAV_SELECTOR = '\[data-calc\], \[data-subtab\], \[data-section\], \.calc-nav-pill'/);
+ // Listing, matching and opening all read one attribute list, so they cannot
+ // drift apart when a component's sub-navigation is added.
+ assert.match(app, /var SUB_NAV_ATTRS = \['data-calc', 'data-subtab', 'data-section', 'data-em'\];/);
+ assert.match(app, /var SUB_NAV_SELECTOR = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '\]'; \}\)\.join\(', '\) \+ ', \.calc-nav-pill';/);
+ assert.match(app, /var selector = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '="' \+ sub \+ '"\]'; \}\)\.join\(', '\);/);
assert.match(app, /function subItemsFor\(/);
assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/,
'an unloaded component exposes nothing, so it is skipped rather than guessed at');
@@ -529,6 +533,9 @@ test('search reaches the things inside a tab, not just the tab', () => {
const calculators = read('public/components/calculators.html');
assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real');
+ // Bedside marks its emergency sections with data-em, so they are searchable too.
+ assert.match(read('public/components/bedside.html'), /data-em="sepsis"/);
+ assert.match(app, /\['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'\]/);
});
test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => {
@@ -610,3 +617,31 @@ test('the create-image chat list never grows wider than its dialog', () => {
assert.match(fn, /words\[i\]/, 'cuts on whole words');
assert.match(js, /window\.addEventListener\('resize', function \(\) \{ fitSelectOptions\(document\.getElementById\('create-image-chat'\)\); \}\);/);
});
+
+test('the composer shows "Model", not the model id, and hides the status chatter', () => {
+ const html = read('public/components/assistant.html');
+ const css = read('public/css/assistant.css');
+ const js = read('public/js/clinicalAssistant.js');
+
+ // The select stays as the state holder, so the existing change listener still
+ // persists the choice; the button is only its compact face.
+ assert.match(html, /