fix: actually merge assistant and workspace into one interface
The mode switch was toggling the `hidden` attribute on elements that each carry
their own `display:` rule. A class selector beats the UA stylesheet's
[hidden]{display:none}, so every one of those toggles silently did nothing —
which is why workspace mode still showed the chat, and why coming back never
restored the saved-chat list. Every affected element was in that state.
CSS now owns both modes from a single body class, in one block, and the JS does
nothing but set that class. No element.hidden juggling remains.
One accent. The assistant used --blue for user bubbles, focus rings, blockquotes
and example pills while citations, cards and the mode switch used --purple, so
the two halves read as two different apps. Purple was already dominant (23 uses
to 6) and is the assistant's identity, so blue is gone entirely. Source cards
pick up the same shape, shadow and hover accent as the workspace cards, and a
targeted citation highlights in the accent instead of a bare border.
Go back is removed from both the topbar and the rail: the Assistant/Workspace
switch is how you leave now, and the dead handler went with it.
Create image no longer takes a full-width gradient row competing with New chat;
it is a square icon button beside it with a tooltip and an aria-label.
Also guards the delegated document listeners against double registration.
initIfNeeded already prevented a second bind in production, but nothing stopped
a stray bindEvents() from double-firing every click — which is exactly what the
test harness hit, activating a tab twice from one click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
This commit is contained in:
parent
1971436c26
commit
41ceaca413
4 changed files with 157 additions and 85 deletions
|
|
@ -1,5 +1,4 @@
|
|||
<div class="assistant-topbar">
|
||||
<button id="btn-assistant-goback" class="btn-sm btn-ghost" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
|
||||
<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>
|
||||
<div class="assistant-topbar-title">
|
||||
|
|
@ -19,9 +18,10 @@
|
|||
<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> <span>New chat</span><kbd class="assistant-kbd">Ctrl+Shift+O</kbd></button>
|
||||
<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-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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.assistant-new-chat:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||
.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] { display:none; }
|
||||
/* Phones keep the chat visible: the drawer carries the workspace menu there. */
|
||||
@media (max-width:640px) { body.assistant-mode-workspace #assistant-chat-view[hidden] { display:flex; } }
|
||||
.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; }
|
||||
|
|
@ -29,7 +26,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
||||
.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 button { border:1px solid var(--g200); background:white; color:var(--blue); 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 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:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||
|
|
@ -53,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 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-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--blue); color:white; border-radius:16px; padding:10px 14px; }
|
||||
.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--purple); 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:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
||||
.assistant-bubble h1 { font-size:20px; }
|
||||
|
|
@ -63,7 +60,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.assistant-bubble p:last-child { margin-bottom:0; }
|
||||
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
||||
.assistant-bubble li { margin:4px 0; }
|
||||
.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 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 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-msg.user .assistant-bubble code { background:rgba(255,255,255,.25); color:#fff; }
|
||||
|
|
@ -110,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: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:hover { border-color:var(--blue); color:var(--blue); }
|
||||
.assistant-attach:hover { border-color:var(--purple); color:var(--purple); }
|
||||
.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(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); }
|
||||
.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-assistant-cancel[hidden] { display:none !important; }
|
||||
|
|
@ -124,7 +121,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.assistant-generated-image { display:grid; gap:8px; justify-items:start; }
|
||||
/* 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:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }
|
||||
.assistant-generated-image img:focus-visible { outline:2px solid var(--purple); outline-offset:2px; }
|
||||
@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-preview-open { overflow:hidden; }
|
||||
|
|
@ -168,7 +165,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.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-size:28px 28px; background-position:-1px -1px; }
|
||||
.assistant-workspace-view[hidden] { 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(--purple-light); background:#faf5ff; color:var(--purple); }
|
||||
|
|
@ -176,12 +172,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
|
|||
.assistant-workspace-card:hover i { color:var(--purple); }
|
||||
/* On a phone the rail IS the drawer menu; a second copy as cards is redundant
|
||||
and pushes the real list off the screen. */
|
||||
@media (max-width:640px) { .assistant-workspace-view { display:none !important; } }
|
||||
|
||||
/* Workspace mode: the sources column goes with it, and the track goes too —
|
||||
hiding the panel alone left an empty 330px gutter. */
|
||||
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); }
|
||||
|
||||
/* 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. */
|
||||
|
|
@ -204,10 +195,12 @@ body.assistant-mode-workspace .assistant-layout.history-collapsed { grid-templat
|
|||
.assistant-saved-chat-title { font-size:13px; font-weight:500; color:var(--g800); line-height:1.35; 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-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
|
||||
.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:target { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); }
|
||||
.assistant-source strong { color:var(--g800); }
|
||||
.assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }
|
||||
.assistant-source-badges span { border:1px solid var(--g200); border-radius:999px; background:var(--g50); color:var(--g600); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.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-meta { color:var(--g500); font-size:11px; margin-top:3px; }
|
||||
.assistant-source-preview { margin-top:8px; }
|
||||
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
|
||||
|
|
@ -267,9 +260,9 @@ body.assistant-mode-workspace .assistant-layout.history-collapsed { grid-templat
|
|||
#assistant-saved-chats { gap:7px; }
|
||||
.assistant-saved-chat-meta { display:none; }
|
||||
.assistant-layout .assistant-saved-chat-menu { position:absolute; right:6px; top:50%; transform:translateY(-50%); opacity:1; pointer-events:auto; }
|
||||
.assistant-new-chat, .assistant-goback-rail { 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, .assistant-goback-rail:hover { background:var(--g100); }
|
||||
.assistant-create-image { display:flex; align-items:center; gap:10px; width:100%; background:none; color:var(--purple); font-weight:700; 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-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:#faf5ff; color:var(--purple); border-radius:8px; }
|
||||
.assistant-create-image:hover { background:var(--purple-light,#faf5ff); }
|
||||
.assistant-view-switch { margin:6px 8px; }
|
||||
.assistant-empty { margin:28px auto; max-width:100%; }
|
||||
|
|
@ -369,9 +362,12 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
|
|||
.assistant-saved-chat-delete:hover { color:var(--danger, #dc2626); background:rgba(220,38,38,.08); }
|
||||
|
||||
/* Create image entry + Images gallery (ChatGPT-style left rail) */
|
||||
.assistant-create-image { display:flex; align-items:center; gap:8px; width:100%; border:none; border-radius:12px; padding:10px 12px; background:linear-gradient(90deg,var(--purple,#7c3aed),var(--purple-light,#a78bfa)); color:#fff; font-weight:700; font-size:13px; cursor:pointer; }
|
||||
.assistant-create-image:hover { filter:brightness(1.06); }
|
||||
.assistant-goback-rail { margin-bottom:2px; }
|
||||
/* New chat is the row; Create image rides beside it as a square icon rather
|
||||
than a second full-width banner competing with it. */
|
||||
.assistant-rail-actions { display:flex; align-items:stretch; gap:6px; }
|
||||
.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:hover { background:var(--purple-light); border-color:var(--purple); }
|
||||
.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-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); }
|
||||
|
|
@ -437,7 +433,6 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
|
|||
.assistant-autosave-state.saved { color:var(--accent,#0f766e); font-weight:600; }
|
||||
.assistant-autosave-state.failed { color:var(--danger,#dc2626); }
|
||||
/* Rail goback is mobile-only; the topbar goback is desktop-only */
|
||||
.assistant-goback-rail { display:none; }
|
||||
@media (max-width: 640px) {
|
||||
/* Go back lives in the drawer menu, clearly labeled at the top */
|
||||
.assistant-goback-rail { display:flex; align-items:center; gap:10px; align-self:stretch; width:100%; border:none; background:none; color:var(--g700); font-size:14px; padding:12px; border-radius:8px; text-align:left; margin-bottom:4px; }
|
||||
|
|
@ -468,3 +463,35 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; }
|
|||
.assistant-history .card { border:none; box-shadow:none; background:none; padding:0; }
|
||||
.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; }
|
||||
|
||||
|
||||
/* ── The two modes, in one place ─────────────────────────────────────────────
|
||||
Everything below is driven by a single body class. These elements all carry
|
||||
their own `display:` rules, so toggling the [hidden] attribute from JS was
|
||||
silently overridden — which is why the chat list never came back. Owning both
|
||||
modes here keeps them one interface rather than two that half-hide each other. */
|
||||
|
||||
/* Assistant mode: the workspace surfaces are simply absent. */
|
||||
.assistant-workspace-view,
|
||||
.assistant-rail-workspace { display:none; }
|
||||
|
||||
/* Workspace mode: the chat, its list and its sources all step aside together. */
|
||||
body.assistant-mode-workspace #assistant-chat-view,
|
||||
body.assistant-mode-workspace .assistant-history .card,
|
||||
body.assistant-mode-workspace .assistant-create-image,
|
||||
body.assistant-mode-workspace .assistant-new-chat,
|
||||
body.assistant-mode-workspace .assistant-side { display:none; }
|
||||
body.assistant-mode-workspace .assistant-workspace-view { display:block; }
|
||||
body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
|
||||
|
||||
/* The sources track goes with the panel; hiding one and keeping the other left
|
||||
an empty 330px gutter. */
|
||||
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); }
|
||||
|
||||
/* 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) {
|
||||
body.assistant-mode-workspace .assistant-workspace-view { display:none; }
|
||||
body.assistant-mode-workspace #assistant-chat-view { display:flex; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ import {
|
|||
loadExamples();
|
||||
}
|
||||
|
||||
// Delegated document listeners must be registered once. initIfNeeded already
|
||||
// guards against a second bind, but a stray bindEvents() would otherwise
|
||||
// double-fire every click — activating a tab twice, sending twice, and so on.
|
||||
var documentListenersBound = false;
|
||||
function onceOnDocument(type, handler) {
|
||||
if (documentListenersBound) return;
|
||||
document.addEventListener(type, handler);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
var form = document.getElementById('assistant-form');
|
||||
var clearBtn = document.getElementById('btn-assistant-clear');
|
||||
|
|
@ -135,13 +144,6 @@ import {
|
|||
var exportBtn = document.getElementById('btn-assistant-export-pdf');
|
||||
var attachInput = document.getElementById('assistant-attach-input');
|
||||
var input = document.getElementById('assistant-input');
|
||||
var goBackBtn = document.getElementById('btn-assistant-goback');
|
||||
var goBackRailBtn = document.getElementById('btn-assistant-goback-rail');
|
||||
if (goBackRailBtn) goBackRailBtn.addEventListener('click', function() {
|
||||
var layout = document.getElementById('assistant-layout');
|
||||
if (layout) layout.classList.remove('mobile-chats-open');
|
||||
goBackToMainMenu();
|
||||
});
|
||||
var createImageBtn = document.getElementById('btn-assistant-create-image');
|
||||
if (createImageBtn) createImageBtn.addEventListener('click', function() {
|
||||
var layout = document.getElementById('assistant-layout');
|
||||
|
|
@ -201,24 +203,15 @@ import {
|
|||
// 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.
|
||||
// One switch, one class. Every element this affects already carries a
|
||||
// `display:` rule, and a class selector beats the UA stylesheet's
|
||||
// [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';
|
||||
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');
|
||||
if (workspace) renderWorkspaceLinks();
|
||||
var chatView = document.getElementById('assistant-chat-view');
|
||||
var workspaceView = document.getElementById('assistant-workspace-view');
|
||||
if (chatView) chatView.hidden = workspace;
|
||||
if (workspaceView) workspaceView.hidden = !workspace;
|
||||
if (rail) rail.hidden = !workspace;
|
||||
if (chats) chats.hidden = workspace;
|
||||
if (newChat) newChat.hidden = workspace;
|
||||
if (createImage) createImage.hidden = workspace;
|
||||
var side = document.querySelector('.assistant-side');
|
||||
if (side) side.hidden = workspace || !citationsOn;
|
||||
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) {
|
||||
var on = button.getAttribute('data-assistant-mode') === mode;
|
||||
button.classList.toggle('active', on);
|
||||
|
|
@ -226,16 +219,17 @@ import {
|
|||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
onceOnDocument('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) {
|
||||
onceOnDocument('click', function(event) {
|
||||
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'));
|
||||
});
|
||||
|
||||
|
|
@ -253,7 +247,7 @@ import {
|
|||
} catch (e) {}
|
||||
|
||||
// Ctrl+Shift+O starts a new chat, as advertised next to the button.
|
||||
document.addEventListener('keydown', function(event) {
|
||||
onceOnDocument('keydown', function(event) {
|
||||
if (!event.ctrlKey || !event.shiftKey || String(event.key).toLowerCase() !== 'o') return;
|
||||
if (!document.body.classList.contains('assistant-workspace')) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -302,7 +296,6 @@ import {
|
|||
clearConversation(ev);
|
||||
});
|
||||
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
|
||||
if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu);
|
||||
if (input) {
|
||||
input.addEventListener('input', updateConversationBudget);
|
||||
input.addEventListener('input', resizeAssistantInput);
|
||||
|
|
@ -324,6 +317,7 @@ import {
|
|||
|
||||
bindExampleButtons(document);
|
||||
loadSavedChats();
|
||||
documentListenersBound = true; // every delegated listener above is now registered
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
|
|
@ -1963,16 +1957,6 @@ import {
|
|||
onAsk();
|
||||
}
|
||||
|
||||
// ── Workspace navigation: Go back, chat / learning view switch ─────
|
||||
function goBackToMainMenu() {
|
||||
document.body.classList.remove('assistant-workspace');
|
||||
if (typeof window.activateTab === 'function') {
|
||||
window.activateTab(lastNonAssistantTab || 'encounter');
|
||||
return;
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.dispatchEvent) document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
|
||||
}
|
||||
|
||||
// ── Debounced autosave (800ms after each completed turn/change) ────
|
||||
function scheduleAutosave() {
|
||||
if (typeof setTimeout !== 'function' || !messages.length) return;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ const { marked } = require('marked');
|
|||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
|
||||
function workspace(t) {
|
||||
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
||||
// Real .tab-btn elements: the rail and cards mirror these, so a harness
|
||||
// without them cannot show whether the mirroring works.
|
||||
const tabs = '<button class="tab-btn" data-tab="notes"><i class="fas fa-note"></i><span>Notes</span></button>' +
|
||||
'<button class="tab-btn" data-tab="calculators"><i class="fas fa-calculator"></i><span>Calculators</span></button>' +
|
||||
'<button class="tab-btn hidden" data-tab="admin"><i class="fas fa-lock"></i><span>Admin</span></button>' +
|
||||
'<button class="tab-btn" data-tab="settings"><i class="fas fa-gear"></i><span>Settings</span></button>';
|
||||
const dom = new JSDOM(tabs + '<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
||||
const window = dom.window;
|
||||
window.eval(read('public/js/accountBoundary.js'));
|
||||
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
||||
|
|
@ -56,7 +62,10 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
|
|||
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
|
||||
const topbar = app.document.querySelector('.assistant-topbar');
|
||||
assert.ok(topbar);
|
||||
assert.ok(app.document.querySelector('.assistant-topbar #btn-assistant-goback'), 'Go back is visible in the topbar');
|
||||
// Go back is gone: the Assistant/Workspace switch is how you leave, so a
|
||||
// second exit control in the topbar was one affordance too many.
|
||||
assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar');
|
||||
assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it');
|
||||
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
|
||||
const actions = app.document.querySelector('.assistant-toolbar-actions');
|
||||
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
|
||||
|
|
@ -65,31 +74,43 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
|
|||
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
|
||||
});
|
||||
|
||||
test('opening the assistant replaces the main menu with the saved chats; Go back restores the app', async t => {
|
||||
test('opening the assistant replaces the main menu with the saved chats', async t => {
|
||||
const app = workspace(t);
|
||||
const c = app.context;
|
||||
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
||||
assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied');
|
||||
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } }));
|
||||
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome');
|
||||
app.document.body.classList.add('assistant-workspace');
|
||||
app.document.getElementById('btn-assistant-goback').click();
|
||||
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'Go back removes the workspace class');
|
||||
});
|
||||
|
||||
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
|
||||
const indexHtml = read('public/index.html');
|
||||
assert.match(indexHtml, /<button class="tab-btn" data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
|
||||
assert.match(indexHtml, /data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
|
||||
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
|
||||
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
|
||||
});
|
||||
|
||||
test('go back leaves the assistant workspace for the last non-assistant tab', t => {
|
||||
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: 'notes' } }));
|
||||
app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
||||
app.document.getElementById('btn-assistant-goback').click();
|
||||
assert.deepEqual(app.activated, ['notes'], 'returns to the previous main-menu tab');
|
||||
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', () => {
|
||||
|
|
@ -192,20 +213,60 @@ test('the rail switches between Assistant and Workspace like Home and Code', ()
|
|||
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');
|
||||
assert.match(js, /if \(side\) side\.hidden = workspace \|\| !citationsOn;/, 'sources are hidden in workspace mode');
|
||||
assert.match(js, /if \(chats\) chats\.hidden = workspace;/, 'and the saved-chat list');
|
||||
// Account and administration are not clinical work surfaces.
|
||||
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');
|
||||
// Hiding the panel without dropping its track leaves an empty 330px gutter.
|
||||
assert.match(css, /body\.assistant-mode-workspace \.assistant-layout \{ grid-template-columns:260px minmax\(0,1fr\); \}/);
|
||||
assert.match(css, /body\.assistant-mode-workspace \.assistant-layout\.history-collapsed \{ grid-template-columns:0 minmax\(0,1fr\); \}/);
|
||||
// On a phone the drawer already is the menu; a card grid would push it away.
|
||||
assert.match(css, /@media \(max-width:640px\) \{ \.assistant-workspace-view \{ display:none !important; \} \}/);
|
||||
assert.match(css, /body\.assistant-mode-workspace #assistant-chat-view\[hidden\] \{ display:flex; \}/,
|
||||
'so the chat stays on screen there');
|
||||
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', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
|
||||
assert.doesNotMatch(html, /btn-assistant-goback/, 'no redundant Go back button');
|
||||
const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
|
||||
assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind');
|
||||
});
|
||||
|
||||
test('Create image sits beside New chat instead of taking its own row', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8');
|
||||
assert.match(html, /assistant-rail-actions/, 'the two share a row');
|
||||
const button = html.split('\n').find(l => l.includes('btn-assistant-create-image'));
|
||||
assert.match(button, /title="Create image"/, 'it is a tooltip now, not a label');
|
||||
assert.match(button, /aria-label="Create image"/, 'still named for screen readers');
|
||||
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
|
||||
const rule = css.split('\n').find(l => l.startsWith('.assistant-create-image {'));
|
||||
assert.doesNotMatch(rule, /width:100%/, 'no longer a full-width banner');
|
||||
assert.match(rule, /width:36px/);
|
||||
});
|
||||
|
||||
test('Learning Hub sits with the content tools, not above the clinical ones', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue