feat: compose the assistant's empty state after the reference UI

Borrowed from the Tinfoil chat layout, keeping our own palette:

- The empty state leads with a title and a single status pill instead of an
  oversized icon plus two paragraphs, so the composer is what the eye lands on.
- Example prompts are raised pill buttons rather than flat outlines.
- A faint graph-paper ground behind the transcript makes the composer read as a
  lifted surface; it switches off as soon as there are messages, where it would
  fight the text.
- New chat carries its Ctrl+Shift+O shortcut on the right, and the shortcut now
  actually works — scoped to the assistant workspace so it does not hijack the
  key elsewhere in the app.
- Saved Chats is a collapsible group with a chevron, remembered per browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
This commit is contained in:
Daniel 2026-09-09 23:50:52 +02:00
parent 750efc4106
commit 9d6bfed3b5
4 changed files with 77 additions and 6 deletions

View file

@ -21,11 +21,12 @@
</div>
<button id="btn-assistant-goback-rail" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
<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="card">
<div class="card-header">
<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>
</div>
<i class="fas fa-chevron-down assistant-chats-caret"></i>
</button>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Chats save automatically as you go.</p>
</div>
@ -60,9 +61,8 @@
<div id="assistant-messages" class="assistant-messages" aria-live="polite">
<div class="assistant-empty">
<i class="fas fa-book-medical"></i>
<h3>Evidence-first pediatric assistant</h3>
<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>
<h3>Pediatric Clinical Assistant</h3>
<p class="assistant-empty-badge"><i class="fas fa-book-medical"></i> Answers built only from your indexed library</p>
<div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>

View file

@ -134,6 +134,27 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
/* Open WebUI-style recency headings above each run of chats. */
/* Composed empty state: a title, one status pill and the composer nothing
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-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-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); }
/* 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:has(.assistant-msg) { background-image:none; }
/* New chat carries its shortcut, the way a command row does. */
.assistant-new-chat { justify-content:flex-start; }
.assistant-kbd { margin-left:auto; font:inherit; font-size:10px; color:var(--g400); background:var(--g100); border:1px solid var(--g200); border-radius:5px; padding:2px 5px; white-space:nowrap; }
/* Saved Chats collapses, like the Chats group in the reference UI. */
.assistant-chats-header { display:flex; align-items:center; justify-content:space-between; width:100%; border:0; background:none; cursor:pointer; text-align:left; }
.assistant-chats-caret { font-size:10px; color:var(--g400); transition:transform .15s ease; }
.assistant-chats-header[aria-expanded="false"] .assistant-chats-caret { transform:rotate(-90deg); }
/* Two-mode switch at the top of the rail, the way Home and Code swap the page. */
.assistant-mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; padding:4px; margin-bottom:8px; }
.assistant-mode-switch button { display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:7px; padding:7px 6px; font-size:12px; font-weight:600; color:var(--g500); background:transparent; cursor:pointer; }

View file

@ -234,6 +234,28 @@ import {
if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab'));
});
var chatsToggle = document.getElementById('btn-assistant-chats-toggle');
if (chatsToggle) chatsToggle.addEventListener('click', function() {
var list = document.getElementById('assistant-saved-chats');
if (!list) return;
var open = list.hidden;
list.hidden = !open;
chatsToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
try { localStorage.setItem('ped_assistant_chats_open', open ? '1' : '0'); } catch (e) {}
});
try {
if (localStorage.getItem('ped_assistant_chats_open') === '0' && chatsToggle) chatsToggle.click();
} catch (e) {}
// Ctrl+Shift+O starts a new chat, as advertised next to the button.
document.addEventListener('keydown', function(event) {
if (!event.ctrlKey || !event.shiftKey || String(event.key).toLowerCase() !== 'o') return;
if (!document.body.classList.contains('assistant-workspace')) return;
event.preventDefault();
var button = document.getElementById('btn-assistant-clear');
if (button) button.click();
});
var closeDrawer = function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');

View file

@ -150,3 +150,31 @@ test('out-of-order CSS completion does not initialize an inactive tab; returning
assert.equal(tab.querySelector('[role="status"]'), null);
dom.window.close();
});
test('the empty state is composed, not a stack of competing blocks', () => {
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-empty-badge/, 'one status pill under the title');
assert.doesNotMatch(html, /<i class="fas fa-book-medical"><\/i>\s*<h3>/, 'no oversized icon above the title');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
// The graph-paper ground reads as a surface only while the transcript is
// empty; once there are messages it would fight the text.
assert.match(css, /\.assistant-messages:has\(\.assistant-msg\) \{ background-image:none; \}/);
assert.match(css, /\.assistant-kbd/, 'New chat advertises its shortcut');
assert.match(css, /\.assistant-chats-header\[aria-expanded="false"\] \.assistant-chats-caret/, 'the chats group collapses');
});
test('the advertised New chat shortcut actually works, and only inside the assistant', () => {
const fs = require('node:fs');
const path = require('node:path');
const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8');
const handler = js.slice(js.indexOf('// Ctrl+Shift+O starts a new chat'), js.indexOf('var closeDrawer'));
assert.match(handler, /event\.ctrlKey && event\.shiftKey|!event\.ctrlKey \|\| !event\.shiftKey/);
assert.match(handler, /toLowerCase\(\) !== 'o'/);
// Must not hijack Ctrl+Shift+O for the rest of the app.
assert.match(handler, /classList\.contains\('assistant-workspace'\)/);
assert.match(handler, /btn-assistant-clear/);
});