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>
<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>
<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-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>
@ -26,17 +30,21 @@
<p class="assistant-muted">Chats save automatically as you go.</p>
</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
out of sync with the menu it mirrors. -->
<div id="assistant-workspace-links" class="assistant-rail-links" hidden></div>
<!-- Populated from the app's real .tab-btn list so this never drifts
out of sync with the menu it mirrors. Shown in Workspace mode. -->
<nav class="assistant-rail-workspace" id="assistant-rail-workspace" aria-label="Workspace" hidden>
<div id="assistant-workspace-links" class="assistant-rail-links"></div>
</nav>
</aside>
<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 class="assistant-toolbar">
<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-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-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-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-learning-view { min-height:calc(100vh - 200px); }
#assistant-learning-root { padding:4px 2px; }
#assistant-chat-view[hidden], .assistant-workspace-view[hidden] { display:none; }
.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-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-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. */
/* 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
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); }
@ -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-main { 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-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; }

View file

@ -201,10 +201,6 @@
<i class="fas fa-brain" style="color:var(--purple);"></i>
<span>AI Assistant</span>
</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>
<button class="tab-btn active" data-tab="encounter">
<i class="fas fa-comments"></i>
@ -272,6 +268,10 @@
<i class="fas fa-diagram-project"></i>
<span>Diagrams</span>
</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">
<i class="fas fa-pen-to-square"></i>
<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.
var renderWorkspaceLinks = function() {
var host = document.getElementById('assistant-workspace-links');
var cards = document.getElementById('assistant-workspace-cards');
if (!host) return;
host.innerHTML = '';
if (cards) cards.innerHTML = '';
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
var name = tab.getAttribute('data-tab');
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>' +
'<span>' + escapeHtml(label ? label.textContent : name) + '</span>';
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');
if (workspaceToggle) workspaceToggle.addEventListener('click', function() {
var host = document.getElementById('assistant-workspace-links');
if (!host) return;
var open = host.hidden;
host.hidden = !open;
workspaceToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
workspaceToggle.classList.toggle('is-open', open);
try { localStorage.setItem('ped_assistant_workspace_open', open ? '1' : '0'); } catch (e) {}
if (open) renderWorkspaceLinks();
// Two modes, the way Home and Code swap the whole page: Assistant keeps the
// chat and its saved-chat list; Workspace replaces both the rail contents and
// the chat column with the app's own menu.
var setAssistantMode = function(mode) {
var workspace = mode === 'workspace';
var rail = document.getElementById('assistant-rail-workspace');
var chats = document.querySelector('.assistant-history .card');
var newChat = document.getElementById('btn-assistant-clear');
var createImage = document.getElementById('btn-assistant-create-image');
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) {
var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
@ -201,9 +233,6 @@ import {
document.body.classList.remove('assistant-workspace');
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 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');
});
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', () => {
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-workspace/, 'the rail has a Workspace section');
assert.match(html, /id="assistant-workspace-links"[^>]*hidden/, 'collapsed until asked for');
assert.match(html, /aria-controls="assistant-workspace-links"/);
assert.match(html, /id="assistant-rail-workspace"[^>]*hidden/, 'hidden until Workspace mode');
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