feat: one Assistant/Workspace switch and one menu toggle, shared by both screens

The app IS workspace mode, so the switch belongs in both places and the only
difference between the two screens is which pill is highlighted:

- The app sidebar's "AI Assistant" nav entry is replaced by the switch, with
  Workspace highlighted. The assistant rail carries the same control with
  Assistant highlighted. Clicking Assistant from the app navigates to
  /assistant; inside the assistant, Workspace opens the launcher in place.
- Accent corrected: I had it backwards last round and turned the assistant
  purple. The app's accent is blue (.tab-btn.active in styles.css); the
  assistant now uses it throughout, so neither screen looks like a different
  product. Citation chips keep purple so evidence stays distinct from
  interactive blue.
- One menu toggle labelled "Hide menu", shared by the app sidebar and the
  assistant rail. It replaces three separate controls: a pin button, a floating
  expand button, and the assistant's own rail toggle. The toggle stays reachable
  when the rail it collapses is hidden, or there would be no way back.
- Workspace cards list clinical work only. Settings, FAQ, Admin, Docs and
  Content Manager stay in the app sidebar under Account, where they belong.
- Model availability is discovery-only again: a hand-typed id cannot be verified
  at that point, so the manual entry field is gone.

The in-page mode state machine is removed along with the whole class of
hidden-versus-display bugs it carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
This commit is contained in:
Daniel 2026-09-10 02:39:42 +02:00
parent 41ceaca413
commit dd25e0edb7
10 changed files with 233 additions and 356 deletions

View file

@ -299,20 +299,11 @@
<div> <div>
<div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Chat models</div> <div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Chat models</div>
<div id="assistant-allowed-chat-models" aria-label="Chat models available to the Clinical Assistant"></div> <div id="assistant-allowed-chat-models" aria-label="Chat models available to the Clinical Assistant"></div>
<div style="display:flex;gap:6px;margin-top:6px;">
<input type="text" id="assistant-add-chat-model" class="admin-control" placeholder="Add a model id, e.g. openrouter-deepseek-v4-pro" style="flex:1;min-width:0;font-size:12px;">
<button type="button" class="btn-sm" data-assistant-add-model="assistant-allowed-chat-models"><i class="fas fa-plus"></i> Add</button>
</div>
</div> </div>
<div> <div>
<div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Image models</div> <div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Image models</div>
<div id="assistant-allowed-image-models" aria-label="Image models available to the Clinical Assistant"></div> <div id="assistant-allowed-image-models" aria-label="Image models available to the Clinical Assistant"></div>
<div style="display:flex;gap:6px;margin-top:6px;">
<input type="text" id="assistant-add-image-model" class="admin-control" placeholder="Add an image model id" style="flex:1;min-width:0;font-size:12px;">
<button type="button" class="btn-sm" data-assistant-add-model="assistant-allowed-image-models"><i class="fas fa-plus"></i> Add</button>
</div>
</div> </div>
<p style="margin:0;font-size:12px;color:var(--g500);">Discovery only lists what the gateway advertises. Add any id your gateway routes — it is checked on first use, and stays in the list once saved.</p>
</div> </div>
</div> </div>
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;"> <div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">

View file

@ -1,5 +1,4 @@
<div class="assistant-topbar"> <div class="assistant-topbar">
<button id="btn-assistant-toggle-history" class="assistant-toggle-history" type="button" aria-expanded="true" aria-controls="assistant-history" title="Hide saved chats" aria-label="Hide saved chats"><i class="fas fa-table-columns"></i></button>
<button id="btn-assistant-mobile-menu" class="btn-sm btn-ghost assistant-mobile-menu" type="button" title="Saved chats"><i class="fas fa-bars"></i></button> <button id="btn-assistant-mobile-menu" class="btn-sm btn-ghost assistant-mobile-menu" type="button" title="Saved chats"><i class="fas fa-bars"></i></button>
<div class="assistant-topbar-title"> <div class="assistant-topbar-title">
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2> <h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
@ -14,19 +13,15 @@
<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>
<button type="button" class="assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-table-columns"></i></button>
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace"> <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" 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> <button type="button" role="tab" aria-selected="false" data-assistant-mode="workspace"><i class="fas fa-grip"></i> Workspace</button>
</div> </div>
<div class="assistant-rail-actions"> <div class="assistant-rail-actions">
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> <span>New chat</span><kbd class="assistant-kbd">Ctrl+Shift+O</kbd></button> <button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> <span>New chat</span><kbd class="assistant-kbd">Ctrl+Shift+O</kbd></button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button" title="Create image" aria-label="Create image"><i class="fas fa-wand-magic-sparkles"></i></button> <button id="btn-assistant-create-image" class="assistant-create-image" type="button" title="Create image" aria-label="Create image"><i class="fas fa-wand-magic-sparkles"></i></button>
</div> </div>
<!-- Rail menu and the card grid are both rendered from the app's real
.tab-btn list, so neither can drift from the menu it mirrors. -->
<nav class="assistant-rail-workspace" id="assistant-rail-workspace" aria-label="Workspace" hidden>
<div id="assistant-workspace-links" class="assistant-rail-links"></div>
</nav>
<div class="card"> <div class="card">
<button type="button" class="card-header assistant-chats-header" id="btn-assistant-chats-toggle" aria-expanded="true" aria-controls="assistant-saved-chats"> <button type="button" class="card-header assistant-chats-header" id="btn-assistant-chats-toggle" aria-expanded="true" aria-controls="assistant-saved-chats">
<h3><i class="fas fa-bookmark"></i> Saved Chats</h3> <h3><i class="fas fa-bookmark"></i> Saved Chats</h3>
@ -39,6 +34,9 @@
</aside> </aside>
<section class="assistant-main card"> <section class="assistant-main card">
<!-- Workspace launcher, rendered from the app's real .tab-btn list so
it cannot drift from the menu it mirrors. Desktop only: on a phone
the drawer already is the menu. -->
<div id="assistant-workspace-view" class="assistant-workspace-view" hidden> <div id="assistant-workspace-view" class="assistant-workspace-view" hidden>
<div id="assistant-workspace-cards" class="assistant-workspace-cards"></div> <div id="assistant-workspace-cards" class="assistant-workspace-cards"></div>
</div> </div>

View file

@ -15,32 +15,32 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; } .assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; }
.assistant-layout > * { min-width:0; } .assistant-layout > * { min-width:0; }
.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(--blue-light); background:var(--blue-light); color:var(--blue); 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(--blue); background:var(--blue-light); }
.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-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; }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); } .assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; } .assistant-empty i { font-size:34px; color:var(--blue); margin-bottom:10px; }
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; } .assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
.assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; } .assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; }
.assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; } .assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; }
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; } .assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; } .assistant-suggestion-buttons button { border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); } .assistant-suggestion-buttons button:hover { border-color:var(--blue); background:var(--blue-light); }
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; position:relative; } .assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; position:relative; }
.assistant-msg.user { margin-left:auto; max-width:760px; } .assistant-msg.user { margin-left:auto; max-width:760px; }
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; } .assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-msg-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s ease; } .assistant-msg-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s ease; }
.assistant-msg:hover .assistant-msg-actions, .assistant-msg-actions:focus-within { opacity:1; } .assistant-msg:hover .assistant-msg-actions, .assistant-msg-actions:focus-within { opacity:1; }
.assistant-msg-actions button { border:1px solid var(--g200); background:white; color:var(--g500); border-radius:7px; padding:3px 7px; font-size:11px; cursor:pointer; } .assistant-msg-actions button { border:1px solid var(--g200); background:white; color:var(--g500); border-radius:7px; padding:3px 7px; font-size:11px; cursor:pointer; }
.assistant-msg-actions button:hover { color:var(--purple); border-color:var(--purple-light); } .assistant-msg-actions button:hover { color:var(--blue); border-color:var(--blue-light); }
.assistant-translate-pop { display:grid; gap:4px; border:1px solid var(--g200); background:white; border-radius:10px; padding:8px; box-shadow:var(--shadow); position:absolute; right:0; top:100%; z-index:6; min-width:200px; } .assistant-translate-pop { display:grid; gap:4px; border:1px solid var(--g200); background:white; border-radius:10px; padding:8px; box-shadow:var(--shadow); position:absolute; right:0; top:100%; z-index:6; min-width:200px; }
.assistant-translate-providers { display:flex; gap:4px; border-bottom:1px solid var(--g100); padding-bottom:6px; } .assistant-translate-providers { display:flex; gap:4px; border-bottom:1px solid var(--g100); padding-bottom:6px; }
.assistant-translate-providers button { flex:1; border:1px solid var(--g200); background:var(--g50); color:var(--g600); border-radius:7px; padding:4px; font-size:11px; font-weight:700; cursor:pointer; } .assistant-translate-providers button { flex:1; border:1px solid var(--g200); background:var(--g50); color:var(--g600); border-radius:7px; padding:4px; font-size:11px; font-weight:700; cursor:pointer; }
.assistant-translate-providers button.active { background:var(--purple); color:white; border-color:var(--purple); } .assistant-translate-providers button.active { background:var(--blue); color:white; border-color:var(--blue); }
.assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; } .assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; }
.assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); } .assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); }
.assistant-translated-sources { margin-top:10px; padding-top:8px; border-top:1px solid var(--g200); font-size:12px; color:var(--g500); } .assistant-translated-sources { margin-top:10px; padding-top:8px; border-top:1px solid var(--g200); font-size:12px; color:var(--g500); }
@ -50,7 +50,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; } .assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; }
.assistant-message-attachment figcaption { font-size:10px; color:var(--g500); max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .assistant-message-attachment figcaption { font-size:10px; color:var(--g500); max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.assistant-bubble { border:none; border-radius:0; padding:2px 4px; background:transparent; box-shadow:none; font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; } .assistant-bubble { border:none; border-radius:0; padding:2px 4px; background:transparent; box-shadow:none; font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--purple); color:white; border-radius:16px; padding:10px 14px; } .assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--blue); color:white; border-radius:16px; padding:10px 14px; }
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); } .assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; } .assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
.assistant-bubble h1 { font-size:20px; } .assistant-bubble h1 { font-size:20px; }
@ -60,7 +60,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-bubble p:last-child { margin-bottom:0; } .assistant-bubble p:last-child { margin-bottom:0; }
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; } .assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble li { margin:4px 0; } .assistant-bubble li { margin:4px 0; }
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--purple); background:var(--purple-light); color:var(--g700); border-radius:8px; } .assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
.assistant-bubble hr { border:0; border-top:1px solid var(--g200); margin:18px 0; } .assistant-bubble hr { border:0; border-top:1px solid var(--g200); margin:18px 0; }
.assistant-bubble img { max-width:100%; border-radius:8px; margin:10px 0; } .assistant-bubble img { max-width:100%; border-radius:8px; margin:10px 0; }
.assistant-msg.user .assistant-bubble code { background:rgba(255,255,255,.25); color:#fff; } .assistant-msg.user .assistant-bubble code { background:rgba(255,255,255,.25); color:#fff; }
@ -81,7 +81,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-table-actions { position:absolute; top:6px; right:8px; z-index:2; display:flex; gap:4px; opacity:0; transition:opacity .12s ease; } .assistant-table-actions { position:absolute; top:6px; right:8px; z-index:2; display:flex; gap:4px; opacity:0; transition:opacity .12s ease; }
.assistant-table-scroll:hover .assistant-table-actions, .assistant-table-scroll:focus-within .assistant-table-actions { opacity:1; } .assistant-table-scroll:hover .assistant-table-actions, .assistant-table-scroll:focus-within .assistant-table-actions { opacity:1; }
.assistant-table-actions button { border:1px solid var(--g200); background:white; color:var(--g600); border-radius:6px; padding:2px 7px; font-size:10px; font-weight:700; cursor:pointer; } .assistant-table-actions button { border:1px solid var(--g200); background:white; color:var(--g600); border-radius:6px; padding:2px 7px; font-size:10px; font-weight:700; cursor:pointer; }
.assistant-table-actions button:hover { color:var(--purple); border-color:var(--purple-light); } .assistant-table-actions button:hover { color:var(--blue); border-color:var(--blue-light); }
.assistant-codeblock { position:relative; margin:10px 0; } .assistant-codeblock { position:relative; margin:10px 0; }
.assistant-codeblock pre { margin:0; } .assistant-codeblock pre { margin:0; }
.assistant-code-copy { position:absolute; top:6px; right:8px; border:1px solid rgba(255,255,255,.25); background:rgba(17,24,39,.7); color:#fff; border-radius:6px; padding:2px 8px; font-size:10px; font-weight:700; cursor:pointer; } .assistant-code-copy { position:absolute; top:6px; right:8px; border:1px solid rgba(255,255,255,.25); background:rgba(17,24,39,.7); color:#fff; border-radius:6px; padding:2px 8px; font-size:10px; font-weight:700; cursor:pointer; }
@ -92,7 +92,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; } .assistant-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; }
.assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); } .assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); }
.assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; } .assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; }
.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--purple); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; } .assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--blue); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; }
.assistant-thinking-dot:nth-child(2) { animation-delay:.15s; } .assistant-thinking-dot:nth-child(2) { animation-delay:.15s; }
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; } .assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } } @keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
@ -107,9 +107,9 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-attachment-remove { position:absolute; top:3px; right:3px; width:18px; height:18px; border:none; border-radius:50%; background:rgba(0,0,0,.65); color:#fff; font-size:10px; line-height:18px; cursor:pointer; padding:0; } .assistant-attachment-remove { position:absolute; top:3px; right:3px; width:18px; height:18px; border:none; border-radius:50%; background:rgba(0,0,0,.65); color:#fff; font-size:10px; line-height:18px; cursor:pointer; padding:0; }
.assistant-attachment-remove:hover { background:rgba(0,0,0,.85); } .assistant-attachment-remove:hover { background:rgba(0,0,0,.85); }
.assistant-attach { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--g600); cursor:pointer; padding:6px 10px; border:1px dashed var(--g300); border-radius:8px; } .assistant-attach { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--g600); cursor:pointer; padding:6px 10px; border:1px dashed var(--g300); border-radius:8px; }
.assistant-attach:hover { border-color:var(--purple); color:var(--purple); } .assistant-attach:hover { border-color:var(--blue); color:var(--blue); }
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; } .assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); } .assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; } .assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; } .assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; } .assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
@ -121,7 +121,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-generated-image { display:grid; gap:8px; justify-items:start; } .assistant-generated-image { display:grid; gap:8px; justify-items:start; }
/* Thumbnail in the transcript; Preview (or clicking it) opens full resolution. */ /* Thumbnail in the transcript; Preview (or clicking it) opens full resolution. */
.assistant-generated-image img { width:auto; max-width:min(100%,320px); max-height:240px; object-fit:contain; border-radius:10px; border:1px solid var(--g200); background:white; cursor:zoom-in; } .assistant-generated-image img { width:auto; max-width:min(100%,320px); max-height:240px; object-fit:contain; border-radius:10px; border:1px solid var(--g200); background:white; cursor:zoom-in; }
.assistant-generated-image img:focus-visible { outline:2px solid var(--purple); outline-offset:2px; } .assistant-generated-image img:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }
@media (max-width:960px) { .assistant-generated-image img { max-width:100%; max-height:200px; } } @media (max-width:960px) { .assistant-generated-image img { max-width:100%; max-height:200px; } }
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; } .assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-image-preview-open { overflow:hidden; } .assistant-image-preview-open { overflow:hidden; }
@ -137,9 +137,9 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
else competing for attention before the first question is asked. */ else competing for attention before the first question is asked. */
.assistant-empty h3 { color:var(--g900); font-size:26px; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; } .assistant-empty h3 { color:var(--g900); font-size:26px; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; }
.assistant-empty-badge { display:inline-flex; align-items:center; gap:7px; margin:0; padding:7px 14px; border:1px solid var(--g200); border-radius:999px; background:white; box-shadow:var(--shadow); font-size:12.5px; color:var(--g600); } .assistant-empty-badge { display:inline-flex; align-items:center; gap:7px; margin:0; padding:7px 14px; border:1px solid var(--g200); border-radius:999px; background:white; box-shadow:var(--shadow); font-size:12.5px; color:var(--g600); }
.assistant-empty-badge i { color:var(--purple); font-size:12px; } .assistant-empty-badge i { color:var(--blue); font-size:12px; }
.assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:10px; padding:9px 14px; font-size:12.5px; box-shadow:var(--shadow); } .assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:10px; padding:9px 14px; font-size:12.5px; box-shadow:var(--shadow); }
.assistant-examples button:hover { border-color:var(--purple-light); background:#faf5ff; color:var(--purple); } .assistant-examples button:hover { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); }
/* A faint graph-paper ground so the composer card reads as a raised surface. */ /* A faint graph-paper ground so the composer card reads as a raised surface. */
.assistant-messages { background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); background-size:28px 28px; background-position:-1px -1px; } .assistant-messages { background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); background-size:28px 28px; background-position:-1px -1px; }
@ -158,34 +158,13 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.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 { 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 { 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 i { font-size:11px; }
.assistant-mode-switch button.active { background:white; color:var(--purple); box-shadow:var(--shadow); } .assistant-mode-switch button.active { background:white; color:var(--blue); box-shadow:var(--shadow); }
/* Workspace view: same ground, same card language and same spacing as the empty
state, so switching modes reads as the same app rather than a separate page. */
.assistant-workspace-view { padding:24px 18px; overflow-y:auto; min-height:0;
background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
background-size:28px 28px; background-position:-1px -1px; } background-size:28px 28px; background-position:-1px -1px; }
.assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; max-width:860px; margin:0 auto; }
.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:10px; padding:12px 14px; font-size:12.5px; color:var(--g700); text-align:left; cursor:pointer; box-shadow:var(--shadow); }
.assistant-workspace-card:hover { border-color:var(--purple-light); background:#faf5ff; color:var(--purple); }
.assistant-workspace-card i { width:16px; text-align:center; color:var(--g400); font-size:13px; }
.assistant-workspace-card:hover i { color:var(--purple); }
/* On a phone the rail IS the drawer menu; a second copy as cards is redundant /* On a phone the rail IS the drawer menu; a second copy as cards is redundant
and pushes the real list off the screen. */ and pushes the real list off the screen. */
/* 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); }
.assistant-rail-section { display:flex; align-items:center; gap:8px; width:100%; border:none; background:none; cursor:pointer; padding:6px 10px; border-radius:8px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); text-align:left; }
.assistant-rail-section:hover { background:var(--g100); color:var(--g600); }
.assistant-rail-caret { transition:transform .15s ease; font-size:9px; }
.assistant-rail-section.is-open .assistant-rail-caret { transform:rotate(90deg); }
.assistant-rail-links { display:flex; flex-direction:column; gap:1px; }
.assistant-rail-link { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; cursor:pointer; padding:8px 10px; border-radius:8px; font-size:13px; color:var(--g700); text-align:left; }
.assistant-rail-link:hover { background:var(--g100); color:var(--g900); }
.assistant-rail-link i { width:16px; text-align:center; color:var(--g400); font-size:12px; }
.assistant-rail-link:hover i { color:var(--purple); }
.assistant-saved-chat-group { display:block; } .assistant-saved-chat-group { display:block; }
.assistant-saved-chat-group + .assistant-saved-chat-group { margin-top:10px; } .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-heading { margin:0 0 2px; padding:0 10px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); }
@ -196,11 +175,11 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .assistant-saved-chat-meta { font-size:11px; color:var(--g500); display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; } .assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:10px 12px; background:white; box-shadow:var(--shadow); font-size:12px; line-height:1.5; transition:border-color .12s ease; } .assistant-source { border:1px solid var(--g200); border-radius:10px; padding:10px 12px; background:white; box-shadow:var(--shadow); font-size:12px; line-height:1.5; transition:border-color .12s ease; }
.assistant-source:hover { border-color:var(--purple-light); } .assistant-source:hover { border-color:var(--blue-light); }
.assistant-source:target { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); } .assistant-source:target { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-source strong { color:var(--g800); } .assistant-source strong { color:var(--g800); }
.assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; } .assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }
.assistant-source-badges span { border:1px solid var(--purple-light); border-radius:999px; background:#faf5ff; color:var(--purple); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; } .assistant-source-badges span { border:1px solid var(--blue-light); border-radius:999px; background:var(--blue-light); color:var(--blue); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; } .assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
.assistant-source-preview { margin-top:8px; } .assistant-source-preview { margin-top:8px; }
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; } .assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
@ -228,7 +207,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
#btn-assistant-goback { padding:8px; } #btn-assistant-goback { padding:8px; }
.assistant-topbar-title h2 { font-size:15px; } .assistant-topbar-title h2 { font-size:15px; }
.assistant-status { margin-left:auto; font-size:11px; } .assistant-status { margin-left:auto; font-size:11px; }
.assistant-toggle-history { display:none; }
/* The assistant fills the viewport exactly; nothing behind can scroll */ /* The assistant fills the viewport exactly; nothing behind can scroll */
.assistant-layout { display:flex; flex-direction:column; height:100dvh; min-height:0; overflow:hidden; } .assistant-layout { display:flex; flex-direction:column; height:100dvh; min-height:0; overflow:hidden; }
.assistant-main { flex:1 1 auto; min-height:0; display:flex; } .assistant-main { flex:1 1 auto; min-height:0; display:flex; }
@ -262,8 +240,8 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-layout .assistant-saved-chat-menu { position:absolute; right:6px; top:50%; transform:translateY(-50%); opacity:1; pointer-events:auto; } .assistant-layout .assistant-saved-chat-menu { position:absolute; right:6px; top:50%; transform:translateY(-50%); opacity:1; pointer-events:auto; }
.assistant-new-chat { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; color:var(--g700); font-size:14px; padding:10px 12px; border-radius:8px; text-align:left; } .assistant-new-chat { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; color:var(--g700); font-size:14px; padding:10px 12px; border-radius:8px; text-align:left; }
.assistant-new-chat:hover { background:var(--g100); } .assistant-new-chat:hover { background:var(--g100); }
.assistant-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:#faf5ff; color:var(--purple); border-radius:8px; } .assistant-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:var(--blue-light); color:var(--blue); border-radius:8px; }
.assistant-create-image:hover { background:var(--purple-light,#faf5ff); } .assistant-create-image:hover { background:var(--blue-light); }
.assistant-view-switch { margin:6px 8px; } .assistant-view-switch { margin:6px 8px; }
.assistant-empty { margin:28px auto; max-width:100%; } .assistant-empty { margin:28px auto; max-width:100%; }
.assistant-empty i { font-size:26px; } .assistant-empty i { font-size:26px; }
@ -287,7 +265,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-mic i { font-size:18px; } .assistant-mic i { font-size:18px; }
} }
/* Patient take home modal + actions */ /* Patient take home modal + actions */
.assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; } .assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; }
.assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; } .assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; }
@ -332,9 +309,6 @@ body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-hei
body.assistant-workspace .assistant-learning-view { height:100dvh; } body.assistant-workspace .assistant-learning-view { height:100dvh; }
} }
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */ /* 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; 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; } .assistant-layout { transition:grid-template-columns .18s ease; }
/* Open WebUI-style rail: the column collapses to zero and the chat takes the /* 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. */ space, animated so the change reads as a fold rather than a jump. */
@ -362,12 +336,11 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
.assistant-saved-chat-delete:hover { color:var(--danger, #dc2626); background:rgba(220,38,38,.08); } .assistant-saved-chat-delete:hover { color:var(--danger, #dc2626); background:rgba(220,38,38,.08); }
/* Create image entry + Images gallery (ChatGPT-style left rail) */ /* Create image entry + Images gallery (ChatGPT-style left rail) */
/* New chat is the row; Create image rides beside it as a square icon rather
than a second full-width banner competing with it. */ than a second full-width banner competing with it. */
.assistant-rail-actions { display:flex; align-items:stretch; gap:6px; } .assistant-rail-actions { display:flex; align-items:stretch; gap:6px; }
.assistant-rail-actions .assistant-new-chat { flex:1 1 auto; min-width:0; } .assistant-rail-actions .assistant-new-chat { flex:1 1 auto; min-width:0; }
.assistant-create-image { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:36px; border:1px solid var(--purple-light); border-radius:10px; padding:0; background:#faf5ff; color:var(--purple); font-size:13px; cursor:pointer; } .assistant-create-image { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:36px; border:1px solid var(--blue-light); border-radius:10px; padding:0; background:var(--blue-light); color:var(--blue); font-size:13px; cursor:pointer; }
.assistant-create-image:hover { background:var(--purple-light); border-color:var(--purple); } .assistant-create-image:hover { border-color:var(--blue); }
.assistant-gallery-card { flex:0 1 auto; } .assistant-gallery-card { flex:0 1 auto; }
.assistant-image-gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(56px,1fr)); gap:6px; padding:10px 12px; max-height:220px; overflow-y:auto; } .assistant-image-gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(56px,1fr)); gap:6px; padding:10px 12px; max-height:220px; overflow-y:auto; }
.assistant-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); } .assistant-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); }
@ -390,7 +363,7 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
.assistant-check { display:flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:50%; cursor:pointer; color:var(--g500); position:relative; } .assistant-check { display:flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:50%; cursor:pointer; color:var(--g500); position:relative; }
.assistant-check i { font-size:13px; } .assistant-check i { font-size:13px; }
.assistant-check input { position:absolute; inset:0; opacity:0; cursor:pointer; } .assistant-check input { position:absolute; inset:0; opacity:0; cursor:pointer; }
.assistant-check:has(input:checked) { color:var(--purple); background:var(--purple-light,#faf5ff); } .assistant-check:has(input:checked) { color:var(--blue); background:var(--blue-light); }
.btn-send { width:40px; height:40px; border:none; border-radius:50%; background:var(--accent,#0f766e); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:16px; } .btn-send { width:40px; height:40px; border:none; border-radius:50%; background:var(--accent,#0f766e); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:16px; }
.btn-send:disabled { opacity:.5; cursor:not-allowed; } .btn-send:disabled { opacity:.5; cursor:not-allowed; }
.assistant-composer .assistant-muted { flex-basis:100%; } .assistant-composer .assistant-muted { flex-basis:100%; }
@ -413,7 +386,7 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
.assistant-chat-menu button:hover { background:var(--g100); } .assistant-chat-menu button:hover { background:var(--g100); }
.assistant-chat-menu button[data-chat-action="delete"] { color:var(--danger,#dc2626); } .assistant-chat-menu button[data-chat-action="delete"] { color:var(--danger,#dc2626); }
.assistant-chat-menu button i { width:14px; text-align:center; } .assistant-chat-menu button i { width:14px; text-align:center; }
.assistant-saved-chat-rename { width:100%; border:1.5px solid var(--purple); border-radius:6px; padding:4px 6px; font-size:12px; } .assistant-saved-chat-rename { width:100%; border:1.5px solid var(--blue); border-radius:6px; padding:4px 6px; font-size:12px; }
/* Create-image popup: generation progress + full history grid */ /* Create-image popup: generation progress + full history grid */
.create-image-progress { margin-top:10px; padding:8px 10px; border-radius:8px; background:var(--g50,#f9fafb); color:var(--g600); font-size:13px; } .create-image-progress { margin-top:10px; padding:8px 10px; border-radius:8px; background:var(--g50,#f9fafb); color:var(--g600); font-size:13px; }
@ -464,33 +437,53 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
.assistant-history .card-header { padding:2px 4px; } .assistant-history .card-header { padding:2px 4px; }
.assistant-history .card-header h3 { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; } .assistant-history .card-header h3 { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; }
/* Assistant / Workspace switch
Rendered identically in the app sidebar and the assistant rail, so moving
between them changes which pill is highlighted and nothing else. */
.assistant-mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:8px 12px 10px; padding:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; }
.assistant-mode-switch button { display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:7px; padding:7px 6px; font-family:inherit; font-size:12px; font-weight:600; color:var(--g600); background:transparent; cursor:pointer; }
.assistant-mode-switch button i { font-size:11px; }
.assistant-mode-switch button:hover { color:var(--g900); }
.assistant-mode-switch button.active { background:white; color:var(--blue); box-shadow:var(--shadow); }
/* Inside the assistant rail the gutters already exist, so it only needs the gap. */
.assistant-history .assistant-mode-switch { margin:0 0 8px; }
/* The two modes, in one place /* The one menu toggle, identical in the app sidebar and the assistant rail. */
Everything below is driven by a single body class. These elements all carry .assistant-menu-toggle { display:flex; align-items:center; justify-content:center; width:32px; height:32px; margin:10px 12px 0; border:1px solid var(--g200); border-radius:8px; background:white; color:var(--g500); font-size:12px; cursor:pointer; }
their own `display:` rules, so toggling the [hidden] attribute from JS was .assistant-menu-toggle:hover { color:var(--blue); border-color:var(--blue-light); background:var(--blue-light); }
silently overridden which is why the chat list never came back. Owning both .assistant-history .assistant-menu-toggle { margin:0 0 8px; }
modes here keeps them one interface rather than two that half-hide each other. */
/* Assistant mode: the workspace surfaces are simply absent. */ /* Hidden menu: the rail collapses in both places, and the layout takes the
.assistant-workspace-view, space back rather than leaving a gutter. */
.assistant-rail-workspace { display:none; } body.menu-hidden .sidebar { width:0; border:none; overflow:hidden; }
body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr) 330px; }
body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout { grid-template-columns:0 minmax(0,1fr); }
body.assistant-workspace.menu-hidden .assistant-history > *:not(.assistant-menu-toggle) { display:none; }
body.assistant-workspace.menu-hidden .assistant-history { width:0; min-width:0; overflow:visible; padding:0; }
/* The toggle must survive its own collapse, or there is no way back. */
body.assistant-workspace.menu-hidden .assistant-history .assistant-menu-toggle { position:absolute; left:8px; top:8px; z-index:5; }
@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } }
/* Workspace mode: the chat, its list and its sources all step aside together. */ /* Workspace launcher: same ground, card language and spacing as the empty state,
body.assistant-mode-workspace #assistant-chat-view, so the two views are one interface. Desktop only. */
body.assistant-mode-workspace .assistant-history .card, .assistant-workspace-view { flex:1 1 auto; min-height:0; padding:24px 18px; overflow-y:auto;
body.assistant-mode-workspace .assistant-create-image, background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
body.assistant-mode-workspace .assistant-new-chat, background-size:28px 28px; background-position:-1px -1px; }
body.assistant-mode-workspace .assistant-side { display:none; } .assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; max-width:860px; margin:0 auto; }
.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:10px; padding:12px 14px; font-size:12.5px; color:var(--g700); text-align:left; cursor:pointer; box-shadow:var(--shadow); }
.assistant-workspace-card:hover { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); }
.assistant-workspace-card i { width:16px; text-align:center; color:var(--g400); font-size:13px; }
.assistant-workspace-card:hover i { color:var(--blue); }
/* Both views occupy the same box, so switching never moves the page. */
.assistant-workspace-view { display:none; }
body.assistant-mode-workspace .assistant-workspace-view { display:block; } body.assistant-mode-workspace .assistant-workspace-view { display:block; }
body.assistant-mode-workspace .assistant-rail-workspace { display:flex; } body.assistant-mode-workspace #assistant-chat-view { display:none; }
body.assistant-mode-workspace .assistant-history .card,
/* The sources track goes with the panel; hiding one and keeping the other left body.assistant-mode-workspace .assistant-rail-actions,
an empty 330px gutter. */ body.assistant-mode-workspace .assistant-side { display:none; }
body.assistant-mode-workspace .assistant-layout { grid-template-columns:260px minmax(0,1fr); } body.assistant-mode-workspace .assistant-layout { grid-template-columns:260px minmax(0,1fr); }
body.assistant-mode-workspace .assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr); } body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr); }
/* Phones: the drawer already is the menu, so the card grid is redundant there
and the rail list carries workspace on its own. */
@media (max-width:640px) { @media (max-width:640px) {
body.assistant-mode-workspace .assistant-workspace-view { display:none; } body.assistant-mode-workspace .assistant-workspace-view { display:none; }
body.assistant-mode-workspace #assistant-chat-view { display:flex; } body.assistant-mode-workspace #assistant-chat-view { display:flex; }

View file

@ -80,10 +80,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:199;} .sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:199;}
.btn-menu-toggle{display:none;} .btn-menu-toggle{display:none;}
/* Sidebar collapse/expand buttons (desktop only) */ /* Sidebar collapse/expand buttons (desktop only) */
.sidebar-pin-btn{display:flex;align-items:center;justify-content:center;padding:10px;border:none;background:none;color:var(--g400);cursor:pointer;font-size:13px;border-top:1px solid var(--g200);transition:color 0.15s;flex-shrink:0;}
.sidebar-pin-btn:hover{color:var(--blue);}
.sidebar-expand-btn{position:fixed;left:0;top:50%;transform:translateY(-50%);z-index:101;background:white;border:1.5px solid var(--g200);border-left:none;border-radius:0 8px 8px 0;padding:10px 6px;color:var(--g500);cursor:pointer;font-size:13px;box-shadow:2px 0 8px rgba(0,0,0,0.06);transition:color 0.15s;}
.sidebar-expand-btn:hover{color:var(--blue);}
/* MAIN */ /* MAIN */
.main-content{flex:1;min-width:0;padding:20px 20px;} .main-content{flex:1;min-width:0;padding:20px 20px;}

View file

@ -197,10 +197,14 @@
</button> </button>
</div> </div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="tab-btn" data-tab="assistant"> <button type="button" class="assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-table-columns"></i></button>
<i class="fas fa-brain" style="color:var(--purple);"></i> <!-- The app IS workspace mode. One switch, shown in both places and
<span>AI Assistant</span> styled identically, so moving between them is a highlight change
</button> rather than a different-looking screen. -->
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace">
<button type="button" role="tab" aria-selected="false" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
<button type="button" role="tab" class="active" aria-selected="true" data-assistant-mode="workspace"><i class="fas fa-grip"></i> Workspace</button>
</div>
<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>
@ -294,15 +298,7 @@
<span>Admin</span> <span>Admin</span>
</button> </button>
</div> </div>
<!-- Desktop collapse button (hidden on mobile) -->
<button id="btn-sidebar-pin" class="sidebar-pin-btn" title="Collapse sidebar">
<i class="fas fa-angles-left"></i>
</button>
</nav> </nav>
<!-- Desktop sidebar expand button (shown when sidebar is collapsed) -->
<button id="btn-sidebar-expand" class="sidebar-expand-btn" title="Expand sidebar" style="display:none;">
<i class="fas fa-angles-right"></i>
</button>
<div class="sidebar-overlay" id="sidebar-overlay"></div> <div class="sidebar-overlay" id="sidebar-overlay"></div>

View file

@ -51,38 +51,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
container.appendChild(row); container.appendChild(row);
}); });
} }
// Discovery only returns what the gateway advertises, so an admin could never
// offer a model it does not list. Typed ids join the same checkbox list and
// persist through the normal allowed_models save.
function addAssistantModel(containerId, id) {
var container = document.getElementById(containerId);
id = String(id || '').trim();
if (!container || !id) return false;
var existing = Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]'), function(b) { return b.value; });
if (existing.indexOf(id) !== -1) {
var already = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
if (already) already.checked = true;
return true;
}
var roster = containerId === 'assistant-allowed-chat-models' ? chatRoster : imageRoster;
roster.push(id);
renderAssistantCheckboxList(containerId, roster, existing.filter(function(v) {
var box = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(v) : v) + '"]');
return box && box.checked;
}).concat([id]));
return true;
}
document.addEventListener('click', function(event) {
var button = event.target.closest && event.target.closest('[data-assistant-add-model]');
if (!button) return;
var containerId = button.getAttribute('data-assistant-add-model');
var field = document.getElementById(containerId === 'assistant-allowed-chat-models' ? 'assistant-add-chat-model' : 'assistant-add-image-model');
if (!field) return;
if (addAssistantModel(containerId, field.value)) field.value = '';
else if (typeof window.showToast === 'function') window.showToast('Enter a model id first', 'error');
});
function checkedAssistantModels(containerId) { function checkedAssistantModels(containerId) {
var container = document.getElementById(containerId); var container = document.getElementById(containerId);
if (!container) return []; if (!container) return [];

View file

@ -158,6 +158,27 @@ document.addEventListener('DOMContentLoaded', function() {
activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter'); activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter');
}); });
// Assistant / Workspace switch. The app IS workspace mode, so switching is
// navigation between two pages rather than an in-page state machine — which
// is also what keeps the two looking identical: the only difference is which
// pill is highlighted. A reload is fine; each is its own document.
document.addEventListener('click', function(event) {
var pill = event.target.closest && event.target.closest('[data-assistant-mode]');
if (!pill) return;
var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant';
var onAssistant = window.location.pathname === '/assistant';
if (!onAssistant) {
// In the app you ARE the workspace, so only the Assistant pill navigates.
if (wantsAssistant) window.location.href = '/assistant';
return;
}
// Inside the assistant, Workspace opens the launcher in place rather than
// reloading into whichever tab happened to be last.
if (typeof window.assistantShowWorkspaceLauncher === 'function') {
window.assistantShowWorkspaceLauncher(!wantsAssistant);
}
});
// Expose activateTab globally so auth.js can call it after login // Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab; window.activateTab = activateTab;
@ -189,33 +210,31 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// --- DESKTOP SIDEBAR COLLAPSE --- // --- DESKTOP SIDEBAR COLLAPSE ---
var sidebarPinBtn = document.getElementById('btn-sidebar-pin'); // One menu toggle, shared by the app sidebar and the assistant rail. It used
var sidebarExpandBtn = document.getElementById('btn-sidebar-expand'); // to be a pin button plus a separate floating expand button here, and a third
// Restore collapse state // control inside the assistant — three affordances for one idea.
try { function syncMenuToggles(hidden) {
if (localStorage.getItem('ped_sidebar_collapsed') === '1') { var label = hidden ? 'Show menu' : 'Hide menu';
if (sidebar) sidebar.classList.add('collapsed'); document.querySelectorAll('[data-menu-toggle]').forEach(function(button) {
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex'; button.setAttribute('aria-expanded', hidden ? 'false' : 'true');
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none'; button.setAttribute('title', label);
} button.setAttribute('aria-label', label);
} catch(e) {} button.classList.toggle('is-collapsed', hidden);
if (sidebarPinBtn) {
sidebarPinBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.add('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
try { localStorage.setItem('ped_sidebar_collapsed', '1'); } catch(e) {}
});
}
if (sidebarExpandBtn) {
sidebarExpandBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'none';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'flex';
try { localStorage.setItem('ped_sidebar_collapsed', '0'); } catch(e) {}
}); });
} }
var menuHidden = false;
try { menuHidden = localStorage.getItem('ped_sidebar_collapsed') === '1'; } catch (e) {}
document.body.classList.toggle('menu-hidden', menuHidden);
syncMenuToggles(menuHidden);
document.addEventListener('click', function(event) {
if (!(event.target.closest && event.target.closest('[data-menu-toggle]'))) return;
var nowHidden = document.body.classList.toggle('menu-hidden');
syncMenuToggles(nowHidden);
try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {}
});
// Set footer year // Set footer year
var yearEl = document.getElementById('app-year'); var yearEl = document.getElementById('app-year');
if (yearEl) yearEl.textContent = new Date().getFullYear(); if (yearEl) yearEl.textContent = new Date().getFullYear();

View file

@ -165,72 +165,48 @@ import {
// The rail mirrors the app's real tab list rather than restating it, so a tab // The rail mirrors the app's real tab list rather than restating it, so a tab
// 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.
// Cards are the clinical work surface: account and administration tabs stay // Clinical work only. Account and content-management tabs stay in the app
// in the rail list, where someone looking for them will go, but they do not // sidebar, where someone looking for them will go; they are not work
// sit alongside Encounter HPI and Calculators. // surfaces to launch into beside Encounter HPI.
var NON_WORK_TABS = ['settings', 'admin', 'docs', 'faq']; var NON_WORK_TABS = ['settings', 'admin', 'docs', 'faq', 'cms'];
var renderWorkspaceLinks = function() { var renderWorkspaceCards = function() {
var host = document.getElementById('assistant-workspace-links');
var cards = document.getElementById('assistant-workspace-cards'); var cards = document.getElementById('assistant-workspace-cards');
if (!host) return; if (!cards) return;
host.innerHTML = ''; cards.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 || tab.classList.contains('hidden') || NON_WORK_TABS.indexOf(name) !== -1) return;
var icon = tab.querySelector('i'); var icon = tab.querySelector('i');
var label = tab.querySelector('span'); var label = tab.querySelector('span');
var link = document.createElement('button'); var card = document.createElement('button');
link.type = 'button'; card.type = 'button';
link.className = 'assistant-rail-link'; card.className = 'assistant-workspace-card';
link.setAttribute('data-assistant-workspace-tab', name); card.setAttribute('data-assistant-workspace-tab', name);
link.innerHTML = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' + card.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); cards.appendChild(card);
if (cards && NON_WORK_TABS.indexOf(name) === -1) {
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);
}
}); });
}; };
// Two modes, the way Home and Code swap the whole page: Assistant keeps the // In the assistant, Workspace shows the launcher; in the app you are already
// chat and its saved-chat list; Workspace replaces both the rail contents and // there. Either way the switch only changes which pill is highlighted.
// the chat column with the app's own menu. var showWorkspaceLauncher = function(show) {
// One switch, one class. Every element this affects already carries a renderWorkspaceCards();
// `display:` rule, and a class selector beats the UA stylesheet's document.body.classList.toggle('assistant-mode-workspace', !!show);
// [hidden]{display:none} — so toggling .hidden here silently did nothing and
// the chat list never came back. CSS owns what each mode shows.
var setAssistantMode = function(mode) {
var workspace = mode === 'workspace';
if (workspace) renderWorkspaceLinks();
document.body.classList.toggle('assistant-mode-workspace', workspace);
try { localStorage.setItem('ped_assistant_mode', workspace ? 'workspace' : 'assistant'); } catch (e) {}
document.querySelectorAll('[data-assistant-mode]').forEach(function(button) { document.querySelectorAll('[data-assistant-mode]').forEach(function(button) {
var on = button.getAttribute('data-assistant-mode') === mode; var on = (button.getAttribute('data-assistant-mode') === 'workspace') === !!show;
button.classList.toggle('active', on); button.classList.toggle('active', on);
button.setAttribute('aria-selected', on ? 'true' : 'false'); button.setAttribute('aria-selected', on ? 'true' : 'false');
}); });
}; };
window.assistantShowWorkspaceLauncher = showWorkspaceLauncher;
onceOnDocument('click', function(event) { onceOnDocument('click', function(event) {
var pill = event.target.closest && event.target.closest('[data-assistant-mode]'); var card = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
if (pill) setAssistantMode(pill.getAttribute('data-assistant-mode')); if (!card) return;
}); document.body.classList.remove('assistant-workspace', 'assistant-mode-workspace');
onceOnDocument('click', function(event) { if (typeof window.activateTab === 'function') window.activateTab(card.getAttribute('data-assistant-workspace-tab'));
var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]');
if (!link) return;
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
document.body.classList.remove('assistant-workspace');
setAssistantMode('assistant'); // coming back lands on the chat, not an empty rail
if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab'));
}); });
var chatsToggle = document.getElementById('btn-assistant-chats-toggle'); var chatsToggle = document.getElementById('btn-assistant-chats-toggle');

View file

@ -126,36 +126,13 @@ test('real extracted initializer displays the server default only when returned
"source 'default' must not be reported as coming from the environment variable"); "source 'default' must not be reported as coming from the environment variable");
}); });
test('an admin can add a model id discovery never returned', async t => { test('model availability comes from discovery, never hand-typed', () => {
const dom = new JSDOM(read('public/components/admin.html')); const html = read('public/components/admin.html');
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ // A hand-typed id cannot be verified here, so the roster is exactly what the
success: true, config: [], models: [{ id: 'discovered-model', name: 'Discovered' }], // gateway advertises.
conversationBudget: { limit: 120000, source: 'default' } assert.doesNotMatch(html, /assistant-add-chat-model|assistant-add-image-model/, 'no manual entry field');
}) }), []); assert.doesNotMatch(html, /data-assistant-add-model/, 'and no Add button');
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href); assert.match(html, /id="assistant-allowed-chat-models"/, 'the discovered roster stays');
initClinicalAssistantAdmin(value => value); assert.match(html, /id="assistant-allowed-image-models"/);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
const list = document.getElementById('assistant-allowed-chat-models');
const field = document.getElementById('assistant-add-chat-model');
assert.ok(field, 'there is somewhere to type a model id');
const before = list.querySelectorAll('input[type="checkbox"]').length;
field.value = 'openrouter-deepseek-v4-pro';
document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
await tick();
const values = Array.from(list.querySelectorAll('input[type="checkbox"]')).map(b => b.value);
assert.ok(values.includes('openrouter-deepseek-v4-pro'), 'the typed id joins the list');
assert.equal(values.length, before + 1);
const added = list.querySelector('input[value="openrouter-deepseek-v4-pro"]');
assert.equal(added.checked, true, 'and is enabled, since that is why it was added');
assert.equal(field.value, '', 'the field clears for the next one');
// Adding the same id twice must not duplicate the row.
field.value = 'openrouter-deepseek-v4-pro';
document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
await tick();
assert.equal(list.querySelectorAll('input[value="openrouter-deepseek-v4-pro"]').length, 1);
}); });

View file

@ -89,30 +89,6 @@ test('Learning Hub is a top-level main-menu entry, not part of the assistant wor
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section'); assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
}); });
test('a workspace entry leaves the assistant and lands back on the chat next time', t => {
const app = workspace(t);
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
app.document.querySelector('[data-assistant-mode="workspace"]').click();
assert.ok(app.document.body.classList.contains('assistant-mode-workspace'), 'workspace mode engaged');
const entry = app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="notes"]');
assert.ok(entry, 'the rail lists the app tabs');
// Cards are the clinical work surface; the rail keeps everything.
const cardTabs = [...app.document.querySelectorAll('#assistant-workspace-cards [data-assistant-workspace-tab]')]
.map(b => b.getAttribute('data-assistant-workspace-tab'));
assert.deepEqual(cardTabs, ['notes', 'calculators'], 'Settings is not a card; hidden Admin is nowhere');
assert.ok(app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="settings"]'),
'but Settings is still reachable from the rail');
assert.equal(app.document.querySelector('[data-assistant-workspace-tab="admin"]'), null,
'a hidden tab stays hidden in both');
entry.click();
assert.deepEqual(app.activated, ['notes'], 'opens that part of the app');
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'and leaves the assistant chrome');
// Returning must show the chat, not the empty rail the mode was left in.
assert.ok(!app.document.body.classList.contains('assistant-mode-workspace'),
'the assistant reopens on its chat rather than in workspace mode');
});
test('the assistant tab keeps its clean structure next to the restored learning tab', () => { test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
const indexHtml = read('public/index.html'); 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, /<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');
@ -120,15 +96,26 @@ test('the assistant tab keeps its clean structure next to the restored learning
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/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 => { test('one menu toggle serves both the app sidebar and the assistant rail', () => {
const app = workspace(t); const index = read('public/index.html');
const layout = app.document.querySelector('.assistant-layout'); const rail = read('public/components/assistant.html');
const toggle = app.document.getElementById('btn-assistant-toggle-history'); for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) {
assert.ok(toggle, 'history toggle present in the topbar'); assert.match(markup, /data-menu-toggle/, where + ' has the toggle');
toggle.click(); assert.match(markup, /title="Hide menu"/, where + ' names it Hide menu');
assert.ok(layout.classList.contains('history-collapsed'), 'rail collapses'); }
toggle.click(); // The app used to carry a pin button AND a floating expand button, with a
assert.ok(!layout.classList.contains('history-collapsed'), 'rail expands'); // 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');
const css = read('public/css/assistant.css');
assert.match(css, /body\.menu-hidden \.sidebar \{ width:0/);
assert.match(css, /body\.assistant-workspace\.menu-hidden \.assistant-history \.assistant-menu-toggle \{ position:absolute/,
'the toggle survives its own collapse, or there is no way back');
}); });
test('mobile drawer rows carry no chat icons and keep the options menu', () => { test('mobile drawer rows carry no chat icons and keep the options menu', () => {
@ -137,26 +124,6 @@ test('mobile drawer rows carry no chat icons and keep the options menu', () => {
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile'); 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');
});
test('collapsing the rail does not hand the chat column to the citations panel', () => { test('collapsing the rail does not hand the chat column to the citations panel', () => {
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@ -201,49 +168,6 @@ 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-cards"/, 'cards carry the work tabs');
assert.match(html, /id="assistant-workspace-links"/, 'and the rail carries the full menu');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
const setMode = js.slice(js.indexOf('var setAssistantMode = function'), js.indexOf('var setAssistantMode = function') + 900);
// Every element this used to toggle carries its own `display:` rule, and a
// class selector beats the UA stylesheet's [hidden]{display:none} — so the
// .hidden juggling silently did nothing and the chat list never came back.
assert.doesNotMatch(setMode, /\.hidden = /, 'no element.hidden juggling');
assert.match(setMode, /classList\.toggle\('assistant-mode-workspace', workspace\)/, 'one class drives it');
assert.match(js, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq'\]/);
assert.match(js, /cards && NON_WORK_TABS\.indexOf\(name\) === -1/, 'excluded from the cards only');
assert.match(js, /setAssistantMode\('assistant'\); \/\/ coming back/, 'leaving lands back on the chat');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
for (const rule of [
'body.assistant-mode-workspace #assistant-chat-view',
'body.assistant-mode-workspace .assistant-history .card',
'body.assistant-mode-workspace .assistant-side'
]) assert.ok(css.includes(rule), rule + ' is owned by CSS');
assert.match(css, /body\.assistant-mode-workspace \.assistant-layout \{ grid-template-columns:260px minmax\(0,1fr\); \}/,
'the sources track goes with the panel');
});
test('the assistant has one accent colour, not two', () => {
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// Blue user bubbles and focus rings beside purple citations and cards made the
// two halves look like two different apps.
assert.doesNotMatch(css, /var\(--blue/, 'no leftover blue accent');
assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--purple\)/);
assert.match(css, /\.assistant-source:hover \{ border-color:var\(--purple-light\); \}/, 'sources share the card language');
});
test('Go back is gone: Workspace is how you leave the assistant', () => { test('Go back is gone: Workspace is how you leave the assistant', () => {
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@ -278,20 +202,59 @@ test('Learning Hub sits with the content tools, not above the clinical ones', ()
assert.equal(order[order.indexOf('learning') + 1], 'cms', 'and sits immediately before Content Manager'); 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-rail-workspace"[^>]*hidden/, 'hidden until Workspace mode');
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8'); test('the Assistant/Workspace switch is the same control in both places', () => {
// Built from the app's real tabs, so renaming or hiding one in index.html is const root = path.join(__dirname, '..');
// reflected here automatically instead of drifting out of sync. const index = read('public/index.html');
assert.match(js, /querySelectorAll\('\.tab-btn'\)/, 'sourced from the real tab list'); const rail = read('public/components/assistant.html');
assert.match(js, /name === 'assistant'/, "the assistant does not link to itself");
assert.match(js, /tab\.classList\.contains\('hidden'\)/, 'hidden admin tabs stay hidden'); // The app IS workspace mode. The switch appears in the app sidebar with
assert.match(js, /data-assistant-workspace-tab/, 'each entry activates its tab'); // Workspace highlighted and in the assistant rail with Assistant highlighted
assert.match(js, /classList\.remove\('assistant-workspace'\)/, 'leaving the assistant restores the app chrome'); // — 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');
const css = read('public/css/assistant.css');
assert.match(css, /\.assistant-mode-switch button\.active \{ background:white; color:var\(--blue\)/,
'the switch highlights in the app accent');
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');
}); });