pediatric-ai-scribe-v3/test/assistant-workspace-layout.test.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00

334 lines
21 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) {
// Real .tab-btn elements: the rail and cards mirror these, so a harness
// without them cannot show whether the mirroring works.
const tabs = '<button class="tab-btn" data-tab="notes"><i class="fas fa-note"></i><span>Notes</span></button>' +
'<button class="tab-btn" data-tab="calculators"><i class="fas fa-calculator"></i><span>Calculators</span></button>' +
'<button class="tab-btn hidden" data-tab="admin"><i class="fas fa-lock"></i><span>Admin</span></button>' +
'<button class="tab-btn" data-tab="settings"><i class="fas fa-gear"></i><span>Settings</span></button>';
const dom = new JSDOM(tabs + '<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);
// Go back is gone: the Assistant/Workspace switch is how you leave, so a
// second exit control in the topbar was one affordance too many.
assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar');
assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it');
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
// The top bar is gone entirely: everything that acts on the conversation moved
// into the composer's + menu, so both views start at the same vertical
// position and switching cannot nudge the page up or down.
assert.equal(app.document.querySelector('.assistant-toolbar'), null, 'no top bar above the transcript');
const menu = app.document.querySelector('#assistant-plus-menu');
assert.ok(menu, 'the + menu exists');
const items = [...menu.querySelectorAll('button')].map(b => b.id);
assert.deepEqual(items, ['btn-assistant-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'],
'Patient take home, Export PDF and Download transcript live in the + menu');
assert.ok(menu.querySelector('label[for="assistant-attach-input"]'), 'and attaching images with them');
assert.equal(menu.hidden, true, 'closed until asked for');
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', async t => {
const app = workspace(t);
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');
});
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, /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('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('one menu toggle serves both the app sidebar and the assistant rail', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /data-menu-toggle/, where + ' has the toggle');
assert.match(markup, /title="Hide menu"/, where + ' names it Hide menu');
}
// The app used to carry a pin button AND a floating expand button, with a
// third control inside the assistant — three affordances for one idea.
assert.doesNotMatch(index, /btn-sidebar-pin|btn-sidebar-expand/, 'the old pin/expand pair is gone');
assert.doesNotMatch(rail, /btn-assistant-toggle-history/, 'and the assistant-only toggle with it');
const app = read('public/js/app.js');
assert.match(app, /classList\.toggle\('menu-hidden'\)/, 'one class drives both');
assert.match(app, /'Show menu' : 'Hide menu'/, 'the label flips so the collapsed state is escapable');
// Shared chrome lives in styles.css: the app page never loads assistant.css.
const css = read('public/css/styles.css');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:0/);
// The toggle lives inside the box it collapses, so it must leave that box or
// there is no way to bring the menu back.
assert.match(css, /body\.menu-hidden \.assistant-menu-toggle,\n[^\n]*assistant-menu-toggle \{\n\s*position:fixed/,
'the toggle pins itself outside the collapsed menu');
assert.match(css, /body\.menu-hidden \.sidebar-nav > \*:not\(\.assistant-menu-toggle\) \{ display:none; \}/,
'everything else in the collapsed sidebar is hidden');
assert.match(read('public/js/app.js'), /'Show menu' : 'Hide menu'/, 'and it says how to get back');
});
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('collapsing the rail does not hand the chat column to the citations panel', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
const collapsed = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed .assistant-history'));
assert.ok(collapsed, 'the collapsed rail rule exists');
// display:none removes the rail from grid flow, so .assistant-main inherits the
// 0 column and #assistant-sources takes the 1fr — the chat vanishes and the
// citations render in its place.
assert.doesNotMatch(collapsed, /display:\s*none/, 'the rail must stay a grid item');
assert.match(collapsed, /width:0/);
assert.match(collapsed, /visibility:hidden/);
const track = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed {'));
assert.match(track, /grid-template-columns:0 minmax\(0,1fr\) 330px/, 'three tracks for three items');
});
test('the assistant workspace does not repeat its own name in the topbar', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
const rule = css.split('\n').find(l => l.startsWith('body.assistant-workspace .assistant-topbar-title h2'));
assert.ok(rule, 'the title is hidden inside the workspace');
assert.match(rule, /clip:rect\(0 0 0 0\)/, 'visually hidden, still read by screen readers');
const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/assistant.html'), 'utf8');
assert.match(html, /AI Clinical Assistant/, 'and still present in the DOM');
});
test('workspace mode does not override the mobile layout with a desktop header offset', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// body.assistant-workspace .assistant-layout is both later in the file and more
// specific than the .assistant-layout rule inside @media (max-width:640px), so
// the mobile rule needs a matching-specificity override to win at all.
// Both now subtract the app header, which the assistant keeps rather than hides.
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - 66px);');
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 66px)');
assert.ok(desktop > 0, 'the desktop workspace rule exists');
assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
const rule = css.slice(override, css.indexOf('}', override));
assert.match(rule, /display:flex/, 'the mobile flex layout is restored');
assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box');
});
test('Go back is gone: Workspace is how you leave the assistant', () => {
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');
assert.doesNotMatch(html, /btn-assistant-goback/, 'no redundant Go back button');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind');
});
test('Create image sits beside New chat instead of taking its own row', () => {
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');
assert.match(html, /assistant-rail-actions/, 'the two share a row');
const button = html.split('\n').find(l => l.includes('btn-assistant-create-image'));
assert.match(button, /title="Create image"/, 'it is a tooltip now, not a label');
assert.match(button, /aria-label="Create image"/, 'still named for screen readers');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
const rule = css.split('\n').find(l => l.startsWith('.assistant-create-image {'));
assert.doesNotMatch(rule, /width:100%/, 'no longer a full-width banner');
assert.match(rule, /width:36px/);
});
test('Learning Hub sits with the content tools, not above the clinical ones', () => {
const fs = require('node:fs');
const path = require('node:path');
const index = fs.readFileSync(path.join(__dirname, '..', 'public/index.html'), 'utf8');
const order = [...index.matchAll(/data-tab="([a-z]+)"/g)].map(m => m[1]);
assert.ok(order.indexOf('learning') > order.indexOf('encounter'), 'moved down past the clinical tabs');
assert.equal(order[order.indexOf('learning') + 1], 'cms', 'and sits immediately before Content Manager');
});
test('the Assistant/Workspace switch is the same control in both places', () => {
const root = path.join(__dirname, '..');
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
// The app IS workspace mode. The switch appears in the app sidebar with
// Workspace highlighted and in the assistant rail with Assistant highlighted
// — the only difference between the two screens.
for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.match(markup, /class="assistant-mode-switch"/, where + ' has the switch');
assert.match(markup, /data-assistant-mode="assistant"/, where);
assert.match(markup, /data-assistant-mode="workspace"/, where);
}
const appActive = index.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(appActive && appActive[1], 'workspace', 'the app highlights Workspace');
const railActive = rail.match(/class="active"[^>]*data-assistant-mode="(\w+)"/);
assert.equal(railActive && railActive[1], 'assistant', 'the assistant highlights Assistant');
assert.doesNotMatch(index, /data-tab="assistant"/, 'the old AI Assistant nav entry is gone');
});
test('the switch navigates from the app and opens the launcher inside the assistant', () => {
const app = read('public/js/app.js');
const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;'));
// In the app you ARE the workspace, so only the Assistant pill goes anywhere.
assert.match(handler, /if \(wantsAssistant\) window\.location\.href = '\/assistant';/);
assert.match(handler, /assistantShowWorkspaceLauncher\(!wantsAssistant\)/, 'in the assistant it swaps the view in place');
// The in-page mode machinery is gone, and with it the whole class of
// hidden-vs-display bugs that made the chat vanish and never come back.
const assistant = read('public/js/clinicalAssistant.js');
for (const dead of ['setAssistantMode', 'renderWorkspaceLinks', 'goBackToMainMenu']) {
assert.ok(!assistant.includes(dead), dead + ' is gone');
}
// Cards are clinical work surfaces only.
assert.match(assistant, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq', 'cms'\]/,
'no account or content-management tabs among the cards');
const css = read('public/css/assistant.css');
assert.match(css, /body\.assistant-mode-workspace #assistant-chat-view \{ display:none; \}/,
'the launcher replaces the chat rather than stacking with it');
});
test('the assistant uses the app accent, so neither screen looks like a different product', () => {
const styles = read('public/css/styles.css');
assert.match(styles, /\.tab-btn\.active\{background:var\(--blue-light\);color:var\(--blue\)/,
'the app accent is blue');
assert.match(read('public/css/styles.css'), /\.assistant-mode-switch button\.active \{ background:white; color:var\(--blue\)/,
'the switch highlights in the app accent');
const css = read('public/css/assistant.css');
assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--blue\)/);
// Citations keep their own purple chip so evidence stays distinct from
// interactive blue; nothing else may reintroduce a second accent.
const stray = css.split('\n').filter(l => l.includes('var(--purple') && !l.includes('assistant-cite'));
assert.deepEqual(stray, [], 'no second accent outside citation chips');
});
test('chrome rendered on the main app page is styled by the stylesheet that page loads', () => {
const index = read('public/index.html');
const styles = read('public/css/styles.css');
const assistantCss = read('public/css/assistant.css');
// assistant.css is pulled in by the assistant COMPONENT, so it is absent on
// "/". Anything the app page renders must be styled from styles.css or it
// shows up as an unstyled native control.
assert.doesNotMatch(index, /css\/assistant\.css/, 'the app page does not load assistant.css');
for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) {
assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page');
assert.ok(styles.includes('.' + cls + ' {'), cls + ' must be defined in styles.css');
// Defined in both files, the two copies drift and one silently wins.
assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css');
}
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:0/, 'the collapse state is styled where the app can see it');
});
test('the model selector sits with send, and only when there is a choice', () => {
const html = read('public/components/assistant.html');
// ChatGPT/OWUI placement: the model belongs beside the send control in the
// composer, not in a bar above the transcript.
const right = html.slice(html.indexOf('assistant-composer-right'), html.indexOf('</form>'));
for (const id of ['assistant-model-pill', 'btn-assistant-send']) {
assert.ok(right.includes(id), id + ' is on the composer right');
}
assert.match(html, /id="assistant-model-pill"[^>]*hidden/, 'hidden until there is something to pick');
const js = read('public/js/clinicalAssistant.js');
assert.match(js, /One model means no choice: hide the selector entirely/);
assert.match(js, /var show = Array\.isArray\(allowed\) && allowed\.length > 1;/);
assert.match(js, /if \(pill\) pill\.hidden = !show;/, 'the pill follows the select');
assert.doesNotMatch(js, /assistant-model-label/, 'the old always-on model label is gone');
});
test('nothing sits above the transcript, so switching cannot shift the page', () => {
const css = read('public/css/assistant.css');
// Both views fill the same box from the same top edge. With a toolbar above
// one of them, switching moved everything down by its height.
assert.match(css, /#assistant-chat-view \{ display:grid; grid-template-rows:minmax\(0,1fr\) auto;/,
'chat is transcript + composer, nothing above');
assert.doesNotMatch(css, /^\.assistant-toolbar/m, 'no toolbar styling left');
assert.match(css, /\.assistant-workspace-view \{ flex:1 1 auto; min-height:0;/,
'the launcher fills the same box');
const html = read('public/components/assistant.html');
assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup');
});
test('the empty state puts the composer on the tiled ground, not against a seam', () => {
const css = read('public/css/assistant.css');
// The tiles belong to the whole view so they run behind AND below the
// composer; previously they stopped at the transcript edge.
assert.match(css, /#assistant-chat-view:has\(\.assistant-messages:not\(:has\(\.assistant-msg\)\)\) \{\n\s*background-image:linear-gradient/);
assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:flex-end;/,
'the empty state hugs the composer');
assert.match(css, /margin:0 auto 12vh; max-width:760px/, 'and the composer sits above centre');
});