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
137 lines
8.8 KiB
JavaScript
137 lines
8.8 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { JSDOM } = require('jsdom');
|
|
const { marked } = require('marked');
|
|
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
function workspace(t) {
|
|
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
const window = dom.window;
|
|
window.eval(read('public/js/accountBoundary.js'));
|
|
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
|
window.marked = marked;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const fetched = [];
|
|
const activated = [];
|
|
const events = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchAssistantStatus: async () => ({ success: true }),
|
|
fetchAssistantExamples: async () => ({ success: true, examples: [] }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async () => ({ success: true }),
|
|
fetch: async url => { fetched.push(url); return new Response(read('public/components/learning.html')); } };
|
|
vm.createContext(context);
|
|
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
|
|
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
|
|
}
|
|
context.bindEvents();
|
|
window.activateTab = tab => activated.push(tab);
|
|
window.document.addEventListener('tabChanged', e => events.push(e.detail.tab));
|
|
t.after(() => window.close());
|
|
return { context, document: window.document, window, fetched, activated, events };
|
|
}
|
|
|
|
test('assistant area is an OWUI-style three-column workspace with a slim go-back top bar', t => {
|
|
const app = workspace(t);
|
|
const layout = app.document.querySelector('.assistant-layout');
|
|
assert.ok(layout);
|
|
const columns = [...layout.children].map(el => el.className.split(' ')[0]);
|
|
assert.ok(columns.includes('assistant-history'), 'left history rail');
|
|
assert.ok(columns.includes('assistant-main'), 'center chat column');
|
|
assert.ok(columns.includes('assistant-side'), 'right image/sources column');
|
|
const left = app.document.querySelector('.assistant-history');
|
|
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
|
|
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
|
|
assert.equal(left.firstElementChild.id, 'btn-assistant-drawer-close', 'the drawer leads with its close control');
|
|
const right = app.document.querySelector('.assistant-side');
|
|
assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only');
|
|
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
|
|
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
|
|
const topbar = app.document.querySelector('.assistant-topbar');
|
|
assert.ok(topbar);
|
|
assert.ok(app.document.querySelector('.assistant-topbar #btn-assistant-goback'), 'Go back is visible in the topbar');
|
|
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
|
|
const actions = app.document.querySelector('.assistant-toolbar-actions');
|
|
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
|
|
assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome'], 'top bar keeps Export PDF, Download transcript and Patient take home');
|
|
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
|
|
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
|
|
});
|
|
|
|
test('opening the assistant replaces the main menu with the saved chats; Go back restores the app', async t => {
|
|
const app = workspace(t);
|
|
const c = app.context;
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
|
assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied');
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
|
|
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome');
|
|
app.document.body.classList.add('assistant-workspace');
|
|
app.document.getElementById('btn-assistant-goback').click();
|
|
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'Go back removes the workspace class');
|
|
});
|
|
|
|
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
|
|
const indexHtml = read('public/index.html');
|
|
assert.match(indexHtml, /<button class="tab-btn" data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
|
|
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
|
|
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
|
|
});
|
|
|
|
test('go back leaves the assistant workspace for the last non-assistant tab', t => {
|
|
const app = workspace(t);
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'notes' } }));
|
|
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
|
app.document.getElementById('btn-assistant-goback').click();
|
|
assert.deepEqual(app.activated, ['notes'], 'returns to the previous main-menu tab');
|
|
});
|
|
|
|
test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
|
|
const indexHtml = read('public/index.html');
|
|
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
|
|
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
|
|
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/);
|
|
});
|
|
|
|
test('desktop rail collapses and expands via the topbar toggle', t => {
|
|
const app = workspace(t);
|
|
const layout = app.document.querySelector('.assistant-layout');
|
|
const toggle = app.document.getElementById('btn-assistant-toggle-history');
|
|
assert.ok(toggle, 'history toggle present in the topbar');
|
|
toggle.click();
|
|
assert.ok(layout.classList.contains('history-collapsed'), 'rail collapses');
|
|
toggle.click();
|
|
assert.ok(!layout.classList.contains('history-collapsed'), 'rail expands');
|
|
});
|
|
|
|
test('mobile drawer rows carry no chat icons and keep the options menu', () => {
|
|
const css = read('public/css/assistant.css');
|
|
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');
|
|
});
|