feat: saved chats grouped by recency, a visible sidebar toggle, and acknowledgements answered cheaply

Saved chats
Grouped the way Open WebUI groups them: Pinned, Today, Yesterday, Previous 3
days, Previous 7 days, Previous 30 days, then calendar months (with the year
once it is not the current one). Chats with no usable timestamp land in Undated
rather than disappearing.

Sidebar toggle
The collapse button used <i class="fas fa-sidebar">, which is a Font Awesome PRO
icon; on the Free 6.5.0 build this app loads it rendered nothing, so the toggle
has been an invisible button since it shipped. It now uses fa-table-columns,
keeps aria-expanded/aria-controls in sync, flips its label between "Hide saved
chats" and "Show saved chats" so the collapsed state is escapable, and animates.

Acknowledgements
"Окей", "Nice", "Perfect", "gracias" and friends now get the existing
"What clinical question would you like me to look up?" reply instead of a
retrieval and a paid generation. "yes", "sure", "no" and "more" are deliberately
excluded: answers end by offering more detail, so those must still be answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
This commit is contained in:
Daniel 2026-09-09 18:59:45 +02:00
parent d04a3fe53b
commit d790d427ac
7 changed files with 161 additions and 11 deletions

View file

@ -1,6 +1,6 @@
<div class="assistant-topbar">
<button id="btn-assistant-goback" class="btn-sm btn-ghost" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-toggle-history" class="assistant-toggle-history" type="button" title="Toggle saved chats sidebar" aria-label="Toggle saved chats sidebar"><i class="fas fa-sidebar"></i></button>
<button id="btn-assistant-toggle-history" class="assistant-toggle-history" type="button" aria-expanded="true" aria-controls="assistant-history" title="Hide saved chats" aria-label="Hide saved chats"><i class="fas fa-table-columns"></i></button>
<button id="btn-assistant-mobile-menu" class="btn-sm btn-ghost assistant-mobile-menu" type="button" title="Saved chats"><i class="fas fa-bars"></i></button>
<div class="assistant-topbar-title">
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>

View file

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

View file

@ -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 '<button type="button" class="assistant-saved-chat' + (pinned ? ' pinned' : '') + '" data-assistant-load-chat="' + escapeAttr(chat.id) + '" aria-label="Open saved chat" title="' + escapeAttr(title) + '">' +
@ -2037,6 +2093,12 @@ import {
'<span class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</span>' +
'<span class="assistant-saved-chat-menu" role="button" tabindex="0" data-assistant-chat-menu="' + escapeAttr(chat.id) + '" aria-label="Chat options" title="Options"><i class="fas fa-ellipsis-vertical"></i></span>' +
'</button>';
}
wrap.innerHTML = groupSavedChats(chats, new Date()).map(function (group) {
return '<div class="assistant-saved-chat-group">' +
'<h4 class="assistant-saved-chat-heading">' + escapeHtml(group.label) + '</h4>' +
group.chats.map(chatRow).join('') +
'</div>';
}).join('');
}

View file

@ -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({

View file

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

View file

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

View file

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