feat: Assistant / Workspace mode switch in the rail, the way Home and Code swap the page

The rail's Workspace section was a collapsible extra sitting under the chats.
It is now the second of two modes, selected by a segmented switch at the top of
the rail:

- Assistant keeps the chat column, the saved-chat list, New chat and Create image.
- Workspace REPLACES the chat column and the Sources panel with the app's own
  menu — a card grid in the main column and the same list in the rail — so
  nothing from the assistant is left half-visible beside it.

Both the rail list and the cards are built from the app's real .tab-btn
elements, so a tab added, renamed or hidden in index.html follows automatically.

Learning Hub moved down from second place to sit immediately before Content
Manager, with the other content tools rather than above the clinical ones.

Also removed dead CSS for .assistant-view-switch, .assistant-learning-view and
#assistant-learning-root: that markup no longer exists anywhere in the app.

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 23:47:41 +02:00
parent 9788b167f2
commit 750efc4106
5 changed files with 107 additions and 33 deletions

View file

@ -15,6 +15,10 @@
<div class="assistant-drawer-backdrop" id="assistant-drawer-backdrop"></div> <div class="assistant-drawer-backdrop" id="assistant-drawer-backdrop"></div>
<aside class="assistant-history"> <aside class="assistant-history">
<button id="btn-assistant-drawer-close" class="assistant-drawer-close" type="button" aria-label="Close saved chats"><i class="fas fa-xmark"></i></button> <button id="btn-assistant-drawer-close" class="assistant-drawer-close" type="button" aria-label="Close saved chats"><i class="fas fa-xmark"></i></button>
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace">
<button type="button" role="tab" id="btn-assistant-mode-assistant" class="active" aria-selected="true" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
<button type="button" role="tab" id="btn-assistant-mode-workspace" aria-selected="false" data-assistant-mode="workspace"><i class="fas fa-grip"></i> Workspace</button>
</div>
<button id="btn-assistant-goback-rail" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button> <button id="btn-assistant-goback-rail" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button> <button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button> <button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
@ -26,17 +30,21 @@
<p class="assistant-muted">Chats save automatically as you go.</p> <p class="assistant-muted">Chats save automatically as you go.</p>
</div> </div>
</div> </div>
<nav class="assistant-rail-workspace" aria-label="Workspace">
<button type="button" class="assistant-rail-section" id="btn-assistant-workspace-toggle" aria-expanded="false" aria-controls="assistant-workspace-links">
<i class="fas fa-chevron-right assistant-rail-caret"></i> Workspace
</button>
<!-- Populated from the app's real .tab-btn list so this never drifts <!-- Populated from the app's real .tab-btn list so this never drifts
out of sync with the menu it mirrors. --> out of sync with the menu it mirrors. Shown in Workspace mode. -->
<div id="assistant-workspace-links" class="assistant-rail-links" hidden></div> <nav class="assistant-rail-workspace" id="assistant-rail-workspace" aria-label="Workspace" hidden>
<div id="assistant-workspace-links" class="assistant-rail-links"></div>
</nav> </nav>
</aside> </aside>
<section class="assistant-main card"> <section class="assistant-main card">
<div id="assistant-workspace-view" class="assistant-workspace-view" hidden>
<div class="assistant-workspace-head">
<h2>Workspace</h2>
<p>Open any part of the app. The assistant keeps its conversation while you are away.</p>
</div>
<div id="assistant-workspace-cards" class="assistant-workspace-cards"></div>
</div>
<div id="assistant-chat-view"> <div id="assistant-chat-view">
<div class="assistant-toolbar"> <div class="assistant-toolbar">
<div> <div>

View file

@ -17,14 +17,9 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; } .assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; }
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; } .assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
.assistant-new-chat:hover { border-color:var(--purple); background:var(--purple-light); } .assistant-new-chat:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-view-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; padding:4px; }
.assistant-view-switch button { border:0; border-radius:7px; padding:7px 6px; font-size:12px; font-weight:600; color:var(--g500); background:transparent; cursor:pointer; }
.assistant-view-switch button.active { background:white; color:var(--purple); box-shadow:var(--shadow); }
.assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; } .assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; }
#assistant-chat-view { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 200px); min-width:0; } #assistant-chat-view { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 200px); min-width:0; }
#assistant-chat-view[hidden], .assistant-learning-view[hidden] { display:none; } #assistant-chat-view[hidden], .assistant-workspace-view[hidden] { display:none; }
.assistant-learning-view { min-height:calc(100vh - 200px); }
#assistant-learning-root { padding:4px 2px; }
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); } .assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; } .assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; } .assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
@ -139,6 +134,23 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; } .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; } .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. */ /* Open WebUI-style recency headings above each run of chats. */
/* Two-mode switch at the top of the rail, the way Home and Code swap the page. */
.assistant-mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; padding:4px; margin-bottom:8px; }
.assistant-mode-switch button { display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:7px; padding:7px 6px; font-size:12px; font-weight:600; color:var(--g500); background:transparent; cursor:pointer; }
.assistant-mode-switch button i { font-size:11px; }
.assistant-mode-switch button.active { background:white; color:var(--purple); box-shadow:var(--shadow); }
/* Workspace mode fills the chat column with the app's own menu. */
.assistant-workspace-view { padding:22px; overflow-y:auto; min-height:0; }
.assistant-workspace-head h2 { margin:0 0 4px; font-size:18px; color:var(--g900); }
.assistant-workspace-head p { margin:0 0 18px; font-size:13px; color:var(--g500); }
.assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; }
.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:12px; padding:14px; font-size:13px; color:var(--g800); text-align:left; cursor:pointer; }
.assistant-workspace-card:hover { border-color:var(--purple-light); background:#faf5ff; color:var(--purple); }
.assistant-workspace-card i { width:18px; text-align:center; color:var(--g400); font-size:14px; }
.assistant-workspace-card:hover i { color:var(--purple); }
@media (max-width:640px) { .assistant-workspace-view { padding:14px; } .assistant-workspace-cards { grid-template-columns:1fr 1fr; } }
/* Workspace section in the rail: the app's own menu, one level down, the way /* Workspace section in the rail: the app's own menu, one level down, the way
Claude's sidebar carries both its chats and its top-level navigation. */ Claude's sidebar carries both its chats and its top-level navigation. */
.assistant-rail-workspace { display:flex; flex-direction:column; gap:1px; margin-top:8px; padding-top:8px; border-top:1px solid var(--g100); } .assistant-rail-workspace { display:flex; flex-direction:column; gap:1px; margin-top:8px; padding-top:8px; border-top:1px solid var(--g100); }
@ -319,7 +331,6 @@ body.assistant-workspace .assistant-side { height: 100%; overflow: hidden; }
body.assistant-workspace .assistant-side .card { min-height: 0; } body.assistant-workspace .assistant-side .card { min-height: 0; }
body.assistant-workspace .assistant-main { min-height: 0; } body.assistant-workspace .assistant-main { min-height: 0; }
body.assistant-workspace #assistant-chat-view { min-height: 0; } body.assistant-workspace #assistant-chat-view { min-height: 0; }
body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh; overflow-y: auto; }
.assistant-saved-chat { position:relative; } .assistant-saved-chat { position:relative; }
.assistant-saved-chat-delete { position:absolute; top:6px; right:8px; display:flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:6px; color:var(--g500, #6b7280); opacity:0; transition:opacity .12s ease; pointer-events:none; } .assistant-saved-chat-delete { position:absolute; top:6px; right:8px; display:flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:6px; color:var(--g500, #6b7280); opacity:0; transition:opacity .12s ease; pointer-events:none; }
.assistant-saved-chat:hover .assistant-saved-chat-delete, .assistant-saved-chat:focus-within .assistant-saved-chat-delete { opacity:1; pointer-events:auto; } .assistant-saved-chat:hover .assistant-saved-chat-delete, .assistant-saved-chat:focus-within .assistant-saved-chat-delete { opacity:1; pointer-events:auto; }

View file

@ -201,10 +201,6 @@
<i class="fas fa-brain" style="color:var(--purple);"></i> <i class="fas fa-brain" style="color:var(--purple);"></i>
<span>AI Assistant</span> <span>AI Assistant</span>
</button> </button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<span class="sidebar-section-label">Encounters</span> <span class="sidebar-section-label">Encounters</span>
<button class="tab-btn active" data-tab="encounter"> <button class="tab-btn active" data-tab="encounter">
<i class="fas fa-comments"></i> <i class="fas fa-comments"></i>
@ -272,6 +268,10 @@
<i class="fas fa-diagram-project"></i> <i class="fas fa-diagram-project"></i>
<span>Diagrams</span> <span>Diagrams</span>
</button> </button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn"> <button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn">
<i class="fas fa-pen-to-square"></i> <i class="fas fa-pen-to-square"></i>
<span>Content Manager</span> <span>Content Manager</span>

View file

@ -165,8 +165,10 @@ import {
// added, renamed or hidden in index.html shows up here with no extra work. // added, renamed or hidden in index.html shows up here with no extra work.
var renderWorkspaceLinks = function() { var renderWorkspaceLinks = function() {
var host = document.getElementById('assistant-workspace-links'); var host = document.getElementById('assistant-workspace-links');
var cards = document.getElementById('assistant-workspace-cards');
if (!host) return; if (!host) return;
host.innerHTML = ''; host.innerHTML = '';
if (cards) cards.innerHTML = '';
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) { Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
var name = tab.getAttribute('data-tab'); var name = tab.getAttribute('data-tab');
if (!name || name === 'assistant' || tab.classList.contains('hidden')) return; if (!name || name === 'assistant' || tab.classList.contains('hidden')) return;
@ -179,19 +181,49 @@ import {
link.innerHTML = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' + link.innerHTML = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' +
'<span>' + escapeHtml(label ? label.textContent : name) + '</span>'; '<span>' + escapeHtml(label ? label.textContent : name) + '</span>';
host.appendChild(link); host.appendChild(link);
if (cards) {
var card = document.createElement('button');
card.type = 'button';
card.className = 'assistant-workspace-card';
card.setAttribute('data-assistant-workspace-tab', name);
card.innerHTML = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' +
'<span>' + escapeHtml(label ? label.textContent : name) + '</span>';
cards.appendChild(card);
}
}); });
}; };
var workspaceToggle = document.getElementById('btn-assistant-workspace-toggle'); // Two modes, the way Home and Code swap the whole page: Assistant keeps the
if (workspaceToggle) workspaceToggle.addEventListener('click', function() { // chat and its saved-chat list; Workspace replaces both the rail contents and
var host = document.getElementById('assistant-workspace-links'); // the chat column with the app's own menu.
if (!host) return; var setAssistantMode = function(mode) {
var open = host.hidden; var workspace = mode === 'workspace';
host.hidden = !open; var rail = document.getElementById('assistant-rail-workspace');
workspaceToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); var chats = document.querySelector('.assistant-history .card');
workspaceToggle.classList.toggle('is-open', open); var newChat = document.getElementById('btn-assistant-clear');
try { localStorage.setItem('ped_assistant_workspace_open', open ? '1' : '0'); } catch (e) {} var createImage = document.getElementById('btn-assistant-create-image');
if (open) renderWorkspaceLinks(); var chatView = document.getElementById('assistant-chat-view');
var workspaceView = document.getElementById('assistant-workspace-view');
if (workspace) renderWorkspaceLinks();
if (rail) rail.hidden = !workspace;
if (chats) chats.hidden = workspace;
if (newChat) newChat.hidden = workspace;
if (createImage) createImage.hidden = workspace;
if (chatView) chatView.hidden = workspace;
if (workspaceView) workspaceView.hidden = !workspace;
var side = document.querySelector('.assistant-side');
if (side) side.hidden = workspace || !citationsOn;
document.body.classList.toggle('assistant-mode-workspace', workspace);
document.querySelectorAll('[data-assistant-mode]').forEach(function(button) {
var on = button.getAttribute('data-assistant-mode') === mode;
button.classList.toggle('active', on);
button.setAttribute('aria-selected', on ? 'true' : 'false');
});
};
document.addEventListener('click', function(event) {
var pill = event.target.closest && event.target.closest('[data-assistant-mode]');
if (pill) setAssistantMode(pill.getAttribute('data-assistant-mode'));
}); });
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]'); var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
@ -201,9 +233,6 @@ import {
document.body.classList.remove('assistant-workspace'); document.body.classList.remove('assistant-workspace');
if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab')); if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab'));
}); });
try {
if (localStorage.getItem('ped_assistant_workspace_open') === '1' && workspaceToggle) workspaceToggle.click();
} catch (e) {}
var closeDrawer = function() { var closeDrawer = function() {
var layout = document.getElementById('assistant-layout'); var layout = document.getElementById('assistant-layout');

View file

@ -180,14 +180,40 @@ test('workspace mode does not override the mobile layout with a desktop header o
assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box'); assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box');
}); });
test('the rail switches between Assistant and Workspace like Home and Code', () => {
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-mode-switch/, 'a two-pill switch sits at the top of the rail');
assert.match(html, /data-assistant-mode="assistant"/);
assert.match(html, /data-assistant-mode="workspace"/);
assert.match(html, /id="assistant-workspace-view"[^>]*hidden/, 'workspace view starts hidden');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
// Workspace must REPLACE the chat column and the sources panel, not sit beside them.
assert.match(js, /if \(chatView\) chatView\.hidden = workspace;/);
assert.match(js, /if \(workspaceView\) workspaceView\.hidden = !workspace;/);
assert.match(js, /if \(side\) side\.hidden = workspace \|\| !citationsOn;/, 'sources go too');
assert.match(js, /if \(chats\) chats\.hidden = workspace;/, 'and the saved-chat list');
});
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 rail carries the workspace menu without restating it', () => { test('the assistant rail carries the workspace menu without restating it', () => {
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const root = path.join(__dirname, '..'); const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8'); const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
assert.match(html, /assistant-rail-workspace/, 'the rail has a Workspace section'); assert.match(html, /assistant-rail-workspace/, 'the rail has a Workspace section');
assert.match(html, /id="assistant-workspace-links"[^>]*hidden/, 'collapsed until asked for'); assert.match(html, /id="assistant-rail-workspace"[^>]*hidden/, 'hidden until Workspace mode');
assert.match(html, /aria-controls="assistant-workspace-links"/);
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8'); const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
// Built from the app's real tabs, so renaming or hiding one in index.html is // Built from the app's real tabs, so renaming or hiding one in index.html is