AI Clinical Assistant
diff --git a/public/css/assistant.css b/public/css/assistant.css
index b699cc5b..62ae3058 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -135,6 +135,10 @@
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
+/* Open WebUI-style recency headings above each run of chats. */
+.assistant-saved-chat-group { display:block; }
+.assistant-saved-chat-group + .assistant-saved-chat-group { margin-top:10px; }
+.assistant-saved-chat-heading { margin:0 0 2px; padding:0 10px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); }
.assistant-saved-chat { border:1px solid transparent; border-radius:10px; padding:7px 10px; background:transparent; display:flex; flex-direction:column; gap:1px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; min-width:0; }
.assistant-saved-chat:hover { background:var(--g100, #f3f4f6); border-color:transparent; }
.assistant-saved-chat.active { border-color:var(--accent, #0f766e); }
@@ -270,11 +274,16 @@
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
-.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; }
+.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; line-height:1; }
.assistant-toggle-history:hover { background:var(--g100); color:var(--g800); }
+.assistant-toggle-history.is-collapsed { color:var(--purple); background:var(--purple-light); }
+.assistant-layout { transition:grid-template-columns .18s ease; }
+/* Open WebUI-style rail: the column collapses to zero and the chat takes the
+ space, animated so the change reads as a fold rather than a jump. */
.assistant-layout { transition:grid-template-columns .18s ease; }
.assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr) 330px; }
.assistant-layout.history-collapsed .assistant-history { display:none; }
+@media (prefers-reduced-motion:reduce) { .assistant-layout { transition:none; } }
body.assistant-workspace .assistant-layout > * { min-height: 0; }
body.assistant-workspace .assistant-main,
body.assistant-workspace .assistant-main #assistant-chat-view { min-height: 0; }
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 3bf0cca1..62e27a2e 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -149,6 +149,18 @@ import {
openCreateImageDialog();
});
var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu');
+ // The button is the only affordance once the rail is gone, so it must always
+ // say which way it goes.
+ var syncHistoryToggle = function(collapsed) {
+ var btn = document.getElementById('btn-assistant-toggle-history');
+ if (!btn) return;
+ var label = collapsed ? 'Show saved chats' : 'Hide saved chats';
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+ btn.setAttribute('aria-label', label);
+ btn.setAttribute('title', label);
+ btn.classList.toggle('is-collapsed', !!collapsed);
+ };
+
var closeDrawer = function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
@@ -167,15 +179,15 @@ import {
var layout = document.getElementById('assistant-layout');
if (!layout) return;
var collapsed = layout.classList.toggle('history-collapsed');
+ syncHistoryToggle(collapsed);
try { localStorage.setItem('ped_assistant_history_collapsed', collapsed ? '1' : '0'); } catch (e) {}
});
if (typeof window !== 'undefined' && window.innerWidth > 640) {
- try {
- if (localStorage.getItem('ped_assistant_history_collapsed') === '1') {
- var layoutForPref = document.getElementById('assistant-layout');
- if (layoutForPref) layoutForPref.classList.add('history-collapsed');
- }
- } catch (e) {}
+ var startCollapsed = false;
+ try { startCollapsed = localStorage.getItem('ped_assistant_history_collapsed') === '1'; } catch (e) {}
+ var layoutForPref = document.getElementById('assistant-layout');
+ if (layoutForPref && startCollapsed) layoutForPref.classList.add('history-collapsed');
+ syncHistoryToggle(startCollapsed);
}
if (form) form.addEventListener('submit', onAsk);
@@ -2013,6 +2025,51 @@ import {
loadImageGallery();
}
+ // Open WebUI groups saved chats by recency rather than showing one long list.
+ // Pinned chats stay in their own group at the top.
+ var SAVED_CHAT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'];
+
+ function startOfDay(date) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+ }
+
+ function savedChatGroup(value, now) {
+ var when = new Date(value);
+ if (!value || isNaN(when.getTime())) return { key: 'undated', label: 'Undated', order: 900 };
+ var days = Math.floor((startOfDay(now) - startOfDay(when)) / 86400000);
+ if (days <= 0) return { key: 'today', label: 'Today', order: 0 };
+ if (days === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 };
+ if (days <= 3) return { key: 'd3', label: 'Previous 3 days', order: 2 };
+ if (days <= 7) return { key: 'd7', label: 'Previous 7 days', order: 3 };
+ if (days <= 30) return { key: 'd30', label: 'Previous 30 days', order: 4 };
+ // Older than a month: by calendar month, with the year once it is not this one.
+ var label = SAVED_CHAT_MONTHS[when.getMonth()] +
+ (when.getFullYear() === now.getFullYear() ? '' : ' ' + when.getFullYear());
+ return {
+ key: 'm' + when.getFullYear() + '-' + when.getMonth(),
+ label: label,
+ // Newer months first, always after the relative groups.
+ order: 100 + (9999 - when.getFullYear()) * 12 + (11 - when.getMonth())
+ };
+ }
+
+ function groupSavedChats(chats, now) {
+ var groups = [];
+ var byKey = {};
+ chats.forEach(function (chat) {
+ var group = isChatPinned(chat.id)
+ ? { key: 'pinned', label: 'Pinned', order: -1 }
+ : savedChatGroup(chat.updated_at || chat.created_at, now);
+ if (!byKey[group.key]) {
+ byKey[group.key] = { key: group.key, label: group.label, order: group.order, chats: [] };
+ groups.push(byKey[group.key]);
+ }
+ byKey[group.key].chats.push(chat);
+ });
+ return groups.sort(function (a, b) { return a.order - b.order; });
+ }
+
function renderSavedChats(chats) {
savedChatCache = chats || [];
var wrap = document.getElementById('assistant-saved-chats');
@@ -2028,8 +2085,7 @@ import {
return;
}
// Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons.
- wrap.innerHTML = chats.map(function (chat) {
- // Open WebUI behavior: the row opens the chat; a hover trash icon deletes it.
+ function chatRow(chat) {
var title = chat.title || 'Saved chat';
var pinned = isChatPinned(chat.id);
return '
';
+ }
+ wrap.innerHTML = groupSavedChats(chats, new Date()).map(function (group) {
+ return '
' +
+ '
' + escapeHtml(group.label) + '
' +
+ group.chats.map(chatRow).join('') +
+ '';
}).join('');
}
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index 5965c33d..e02f143e 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -51,7 +51,10 @@ var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
router.use(authMiddleware);
-var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
+// Acknowledgements get the prompt for a real question instead of a retrieval and
+// a paid generation. Deliberately NOT here: "yes"/"sure"/"no"/"more" — answers end
+// with "would you like more detail?", so those must still reach the model.
+var GREETING_RE = /^[\s.!?,]*(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|thank u|ty|ok|okay|okey|k|kk|sup|nice|cool|great|perfect|excellent|awesome|got it|understood|noted|alright|all right|fine|good|very good|well done|bravo|окей|ок|хорошо|спасибо|отлично|vale|gracias|bien|genial|perfecto|merci|d'accord|super|danke|gut|prima|obrigado|obrigada|grazie|bene|ótimo|otimo)[\s.!?,]*$/iu;
var MAX_SAVED_CHATS_PER_USER = 100;
var MAX_SAVED_CHAT_TITLE = 160;
var promptPool = createClinicalPromptPool({
diff --git a/test/assistant-image-intent.test.js b/test/assistant-image-intent.test.js
index f39a6df6..e311fb34 100644
--- a/test/assistant-image-intent.test.js
+++ b/test/assistant-image-intent.test.js
@@ -84,3 +84,20 @@ test('the in-chat image is a clickable thumbnail, not a full-width picture', asy
assert.match(rule, /max-height:240px/);
assert.match(rule, /cursor:zoom-in/, 'the thumbnail invites the click');
});
+
+test('acknowledgements ask for a clinical question instead of costing a generation', () => {
+ const fs = require('node:fs');
+ const src = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
+ const line = src.split('\n').find(l => l.startsWith('var GREETING_RE'));
+ assert.ok(line, 'GREETING_RE located');
+ const RE = new Function(line + '; return GREETING_RE;')();
+ for (const ack of ['ok', 'Okay', 'Окей', 'ок', 'Nice', 'Perfect', 'cool', 'great',
+ 'thanks!', 'Thank you', 'got it', 'noted', 'bien', 'gracias', 'merci', 'danke', 'спасибо']) {
+ assert.equal(RE.test(ack), true, JSON.stringify(ack) + ' should get the "ask me something" reply');
+ }
+ // Answers end with "would you like more detail?", so these must reach the model.
+ for (const real of ['yes', 'sure', 'no', 'more', 'yes please', 'tell me more',
+ 'amoxicillin dose', 'PVL', 'what is IVH', 'okay but what about grade 3']) {
+ assert.equal(RE.test(real), false, JSON.stringify(real) + ' must still be answered');
+ }
+});
diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js
index 3b0381c7..800b299a 100644
--- a/test/assistant-saved-tables.test.js
+++ b/test/assistant-saved-tables.test.js
@@ -427,3 +427,42 @@ test('durable jobs preserve legacy provenance, provisional/clicked turn sources
app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(modal.isConnected, false);
assert.equal(JSON.stringify(c.messages), before, 'private preparation and display provenance leave canonical messages unchanged');
});
+
+test('saved chats are grouped by recency the way Open WebUI groups them', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8');
+ const start = src.indexOf('var SAVED_CHAT_MONTHS');
+ const end = src.indexOf(' function renderSavedChats(chats) {');
+ assert.ok(start > 0 && end > start, 'grouping helpers located');
+ const pinned = new Set(['pin-me']);
+ const scope = new Function('isChatPinned', src.slice(start, end) + '; return { savedChatGroup, groupSavedChats };')(
+ id => pinned.has(id));
+
+ const now = new Date('2026-09-09T12:00:00Z');
+ const label = value => scope.savedChatGroup(value, now).label;
+ assert.equal(label('2026-09-09T08:00:00Z'), 'Today');
+ assert.equal(label('2026-09-08T09:00:00Z'), 'Yesterday');
+ assert.equal(label('2026-09-07T10:00:00Z'), 'Previous 3 days');
+ assert.equal(label('2026-09-04T10:00:00Z'), 'Previous 7 days');
+ assert.equal(label('2026-08-20T10:00:00Z'), 'Previous 30 days');
+ assert.equal(label('2026-07-15T10:00:00Z'), 'July', 'older than a month falls back to the calendar month');
+ assert.equal(label('2025-08-15T10:00:00Z'), 'August 2025', 'a previous year is named');
+ assert.equal(label(null), 'Undated', 'a chat with no timestamp is not dropped');
+
+ const groups = scope.groupSavedChats([
+ { id: 'a', updated_at: '2026-07-15T10:00:00Z' },
+ { id: 'pin-me', updated_at: '2026-07-15T10:00:00Z' },
+ { id: 'b', updated_at: '2026-09-09T09:00:00Z' },
+ { id: 'c', updated_at: '2026-09-08T09:00:00Z' }
+ ], now);
+ assert.deepEqual(groups.map(g => g.label), ['Pinned', 'Today', 'Yesterday', 'July'],
+ 'pinned first, then newest to oldest');
+ assert.deepEqual(groups[0].chats.map(c => c.id), ['pin-me']);
+
+ const older = scope.groupSavedChats([
+ { id: 'x', updated_at: '2025-12-02T10:00:00Z' },
+ { id: 'y', updated_at: '2026-07-15T10:00:00Z' }
+ ], now);
+ assert.deepEqual(older.map(g => g.label), ['July', 'December 2025'], 'months run newest first');
+});
diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js
index e3dbcb44..5a9ebd39 100644
--- a/test/assistant-workspace-layout.test.js
+++ b/test/assistant-workspace-layout.test.js
@@ -115,3 +115,23 @@ test('mobile drawer rows carry no chat icons and keep the options menu', () => {
assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:100dvh;/.test(css), 'mobile layout locks the viewport');
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile');
});
+
+test('the saved-chats rail toggle is visible and states which way it goes', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const root = path.join(__dirname, '..');
+ const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
+ const toggle = html.split('\n').find(line => line.includes('btn-assistant-toggle-history'));
+ assert.ok(toggle, 'the toggle exists');
+ // fa-sidebar is Font Awesome PRO; on the Free 6.5.0 build this app loads it
+ // renders nothing, so the toggle was an invisible button.
+ assert.doesNotMatch(toggle, /fa-sidebar/, 'no Pro-only icon');
+ assert.match(toggle, /fa-table-columns/, 'a Font Awesome Free panel icon');
+ assert.match(toggle, /aria-expanded="true"/, 'exposes its state');
+ assert.match(toggle, /aria-controls="assistant-history"/);
+
+ const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
+ assert.match(js, /syncHistoryToggle\(collapsed\)/, 'the toggle is synced when clicked');
+ assert.match(js, /syncHistoryToggle\(startCollapsed\)/, 'and on restore from localStorage');
+ assert.match(js, /Show saved chats/, 'the label flips so the collapsed state is escapable');
+});