From d790d427ac84c769e063587bd88966a16822d4b5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 18:59:45 +0200 Subject: [PATCH] feat: saved chats grouped by recency, a visible sidebar toggle, and acknowledgements answered cheaply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq --- public/components/assistant.html | 2 +- public/css/assistant.css | 11 +++- public/js/clinicalAssistant.js | 78 ++++++++++++++++++++++--- src/routes/clinicalAssistant.js | 5 +- test/assistant-image-intent.test.js | 17 ++++++ test/assistant-saved-tables.test.js | 39 +++++++++++++ test/assistant-workspace-layout.test.js | 20 +++++++ 7 files changed, 161 insertions(+), 11 deletions(-) diff --git a/public/components/assistant.html b/public/components/assistant.html index f6183744..b87a13b6 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,6 +1,6 @@
- +

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