diff --git a/public/css/styles.css b/public/css/styles.css
index 90946da6..8a739e86 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1186,11 +1186,18 @@ textarea.full-input{resize:vertical;}
.btn-bio-login:active{transform:translateY(0);}
.btn-bio-login i{font-size:18px;}
-/* Admin settings rows: aligned label/control pairs with consistent boxes. */
-.admin-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
-.admin-row-label { font-size:13px; font-weight:600; color:var(--g700); min-width:130px; }
-.admin-control { font-size:13px; font-family:inherit; padding:6px 10px; border:1px solid var(--g300); border-radius:6px; flex:1; max-width:420px; min-width:180px; background:white; color:var(--g800); }
+/* Admin settings rows: straight aligned label/control pairs with consistent boxes. */
+.admin-row { display:grid; grid-template-columns:minmax(140px,200px) minmax(0,1fr) auto; gap:10px; align-items:center; }
+.admin-row-label { font-size:13px; font-weight:600; color:var(--g700); text-align:right; }
+.admin-control { width:100%; font-size:13px; font-family:inherit; padding:6px 10px; border:1px solid var(--g300); border-radius:6px; max-width:420px; background:white; color:var(--g800); }
.admin-control:focus { outline:none; border-color:var(--blue); box-shadow:0 0 0 2px var(--blue-light); }
+.cms-prompt-family > label { display:grid; grid-template-columns:minmax(140px,200px) minmax(0,1fr); gap:10px; align-items:center; }
+@media (max-width:640px) {
+ .admin-row { grid-template-columns:1fr; }
+ .admin-row-label { text-align:left; }
+ .admin-control { max-width:none; }
+ .cms-prompt-family > label { grid-template-columns:1fr; }
+}
/* One consistent font across the whole Admin panel. */
#admin-tab select, #admin-tab input, #admin-tab textarea, #admin-tab button,
diff --git a/public/js/admin.js b/public/js/admin.js
index 867bd52c..70a354d7 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -149,6 +149,10 @@ function adminTabActive() {
.catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
}
+ document.addEventListener('input', function(e) {
+ if (e.target && e.target.id === 'admin-users-search') filterUsers();
+ });
+
function renderUsers(users) {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
@@ -589,69 +593,47 @@ function adminTabActive() {
} finally { promptsLoading = false; }
}
- // Compact per-family editor: one prompt select, one draft textarea, one status
- // line, save/restore/history actions and a compact revision list for the
- // SELECTED prompt. Drafts and revision baselines are kept per key in memory,
- // so switching prompts (or families) never loses another prompt's unsaved draft.
+ // Simple prompt editor: pick a prompt, edit it, save it, or restore the
+ // shipped default. Revision tracking stays internal only so the server
+ // optimistic concurrency contract keeps working; no history UI is shown.
function createPromptFamilyEditor(family, prompts) {
var editor = document.createElement('div');
editor.className = 'cms-prompt-family';
editor.dataset.family = family;
editor.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
- // Only static markup is parsed; all catalogue/revision content is assigned as text.
+ // Only static markup is parsed; all catalogue content is assigned as text.
editor.innerHTML =
'' +
+ '' +
'' +
- '
' +
'' +
'' +
- '' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
';
+ '' +
+ '';
var select = editor.querySelector('.prompt-select');
var text = editor.querySelector('.prompt-draft');
var status = editor.querySelector('.prompt-status');
- var history = editor.querySelector('.prompt-history');
- var revisions = editor.querySelector('.prompt-revisions');
- var preview = editor.querySelector('.prompt-revision-text');
- var meta = editor.querySelector('.prompt-revision-meta');
var buttons = {};
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
- // Per-key state: server value, optimistic save baseline, current server
- // revision (from history) and the live unsaved draft.
+ // Per-key state: server value, revision baseline (internal only) and live draft.
var states = new Map();
prompts.forEach(function(p) {
var option = document.createElement('option');
option.value = p.dbKey;
option.textContent = p.key;
select.appendChild(option);
- states.set(p.dbKey, { value: p.value, revision: p.revision, currentRevision: p.revision, draft: p.value });
+ states.set(p.dbKey, { value: p.value, revision: p.revision, draft: p.value });
});
var key = select.options.length ? select.options[0].value : null;
- var viewedRevision = null;
var busy = false;
if (key) text.value = states.get(key).draft;
function state() { return key ? states.get(key) : null; }
function controls() {
- Object.values(buttons).forEach(function(button) { button.disabled = busy; });
+ buttons.save.disabled = busy;
+ buttons.reset.disabled = busy;
select.disabled = busy;
- revisions.disabled = busy;
- buttons.view.disabled = busy || !revisions.value;
- buttons.restore.disabled = busy || !viewedRevision;
- buttons.baseline.disabled = busy || !viewedRevision || !state() || viewedRevision.id !== state().currentRevision;
- var st = state();
- editor.querySelector('.prompt-baseline').textContent = st ?
- 'Save baseline: revision ' + st.revision + (st.revision === 0 ? ' (no history yet).' : '.') : '';
}
async function run(task) {
if (busy) return;
@@ -660,85 +642,31 @@ function adminTabActive() {
catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; }
finally { busy = false; controls(); }
}
- function clearPreview() {
- viewedRevision = null; preview.value = ''; meta.textContent = ''; controls();
- }
- function revisionLabel(item) {
- return '#' + item.id + ' — ' + item.createdAt + ' — ' + (item.createdBy == null ? 'unknown actor' : item.createdBy) +
- (item.wasDefault ? ' — default snapshot' : '') + (item.restoredFrom == null ? '' : ' — restored from #' + item.restoredFrom);
- }
- function base() { return '/api/admin/config/prompts/' + encodeURIComponent(key); }
- async function mutate(action, revisionId) {
+ async function mutate(action) {
var targetKey = key;
var st = states.get(targetKey);
var submitted = text.value;
st.draft = submitted;
var body = { expectedRevision: st.revision };
if (action === 'save') body.value = submitted;
- if (action === 'restore') body.revisionId = revisionId;
- var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : base() + '/' + action,
+ var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : '/api/admin/config/prompts/' + encodeURIComponent(targetKey) + '/reset',
action === 'save' ? 'PUT' : 'POST', body);
- st.value = data.value;
st.revision = data.revision;
- st.currentRevision = data.revision;
- // Typing while a request is pending must not be overwritten by its response;
- // switching away must not leak the response into another prompt's editor.
- if (select.value === targetKey && text.value === submitted) {
- text.value = data.value;
- st.draft = data.value;
- }
- history.hidden = true; revisions.replaceChildren(); clearPreview();
- status.textContent = 'Saved revision ' + data.revision + (text.value === data.value ? '.' : '. Newer draft edits are still unsaved.');
+ if (text.value === submitted) text.value = data.value;
+ status.textContent = action === 'save' ? 'Saved.' : 'Restored shipped default.';
}
- select.onchange = function() {
- // Stash the outgoing draft before switching: per-key drafts survive switches.
+ select.addEventListener('change', function() {
if (key) states.get(key).draft = text.value;
key = select.value;
- var st = state();
- text.value = st ? st.draft : '';
- viewedRevision = null; preview.value = ''; meta.textContent = '';
- history.hidden = true; revisions.replaceChildren();
+ text.value = states.get(key).draft;
status.textContent = '';
- controls();
- };
- buttons.save.onclick = function() { run(function() { return mutate('save'); }); };
- buttons.reset.onclick = function() {
- showConfirm('Reset only this prompt to its shipped default? This replaces this editor’s draft and creates a revision; other editors are unchanged.', function() {
+ });
+ buttons.save.addEventListener('click', function() { run(function() { return mutate('save'); }); });
+ buttons.reset.addEventListener('click', function() {
+ showConfirm('Restore only this prompt to its shipped default? This replaces the current value.', function() {
run(function() { return mutate('reset'); });
});
- };
- buttons.history.onclick = function() { run(async function() {
- var st = states.get(key);
- var data = await promptRequest(base() + '/history?limit=100');
- st.currentRevision = data.revision;
- revisions.replaceChildren(); clearPreview();
- (data.revisions || []).forEach(function(item) {
- var option = document.createElement('option'); option.value = item.id; option.textContent = revisionLabel(item); revisions.appendChild(option);
- });
- history.hidden = false;
- status.textContent = 'Current server revision: ' + st.currentRevision + '. ' + (revisions.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
- }); };
- revisions.onchange = clearPreview;
- buttons.view.onclick = function() { run(async function() {
- var data = await promptRequest(base() + '/revisions/' + encodeURIComponent(revisions.value));
- viewedRevision = data.revision;
- preview.value = viewedRevision.value;
- meta.textContent = revisionLabel(viewedRevision);
- status.textContent = 'Viewing revision ' + viewedRevision.id + '. Your draft is unchanged.';
- }); };
- buttons.restore.onclick = function() {
- var id = viewedRevision && viewedRevision.id;
- if (!id) return;
- showConfirm('Restore revision ' + id + ' for only this prompt? This replaces this editor’s draft and creates a new revision.', function() {
- run(function() { return mutate('restore', id); });
- });
- };
- buttons.baseline.onclick = function() {
- var st = state();
- if (!viewedRevision || !st || viewedRevision.id !== st.currentRevision) return;
- st.revision = st.currentRevision; controls();
- status.textContent = 'Draft kept. The next save will use revision ' + st.revision + ' as its baseline; review your edits before saving.';
- };
+ });
controls();
return editor;
}
diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js
index ad087e26..60ce5711 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -79,7 +79,7 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default');
assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0');
assert.equal(draftOf(scribe).value, unsafe);
- for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(scribe, name));
+ for (const name of ['save', 'reset']) assert.ok(actionOf(scribe, name)); assert.equal(scribe.querySelector('.prompt-history'), null, 'no revision history UI');
for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
const editor = sectionForPrompt(ui, p);
await choosePrompt(editor, p.dbKey);
@@ -90,82 +90,59 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
assert.equal(call.url, '/api/admin/config/' + p.dbKey);
assert.equal(call.options.method, 'PUT');
assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision });
- assert.match(statusOf(editor).textContent, /Saved revision 11/);
+ assert.match(statusOf(editor).textContent, /Saved/);
}
await choosePrompt(scribe, catalogue[2].dbKey);
assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged');
});
-test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => {
- const historical = { id: 5, value: unsafe, createdAt: '2026-01-02T03:04:05Z', createdBy: '
', restoredFrom: 2, wasDefault: true };
+test('restore original resets only the selected prompt and never replaces other drafts', async t => {
const ui = await browser(t, 'admin', (url, options) => {
- if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 10, revisions: [historical] });
- if (url.endsWith('/revisions/5')) return json({ success: true, revision: historical });
- if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 });
if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 });
});
const scribe = sectionOf(ui, 'scribe');
+ const clinical = sectionOf(ui, 'clinical');
+ await choosePrompt(clinical, 'clinical_assistant.system_behavior');
+ draftOf(clinical).value = 'Unsaved clinical edits';
await choosePrompt(scribe, catalogue[1].dbKey);
draftOf(scribe).value = 'Unrelated unsaved Scribe draft';
await choosePrompt(scribe, catalogue[0].dbKey);
- for (const p of [catalogue[0], ...catalogue.slice(-2)]) {
- const f = sectionForPrompt(ui, p);
- await choosePrompt(f, p.dbKey);
- const base = '/api/admin/config/prompts/' + p.dbKey;
- draftOf(f).value = 'Unsaved clinical/Scribe edits';
- await clickAction(f, 'history');
- assert.equal(ui.calls.at(-1).url, base + '/history?limit=100');
- assert.match(f.querySelector('.prompt-revisions option').textContent, /default snapshot.*restored from #2/);
- await clickAction(f, 'view');
- assert.equal(ui.calls.at(-1).url, base + '/revisions/5');
- assert.equal(f.querySelector('.prompt-revision-text').value, unsafe);
- assert.equal(f.querySelector('.prompt-revision-text').readOnly, true);
- assert.equal(draftOf(f).value, 'Unsaved clinical/Scribe edits');
- assert.equal(actionOf(f, 'baseline').disabled, true, 'an old revision cannot rebase a stale save');
- assert.equal(f.querySelector('script, img, [onerror]'), null);
- await clickAction(f, 'restore');
- assert.equal(ui.calls.at(-1).url, base + '/restore');
- assert.equal(ui.calls.at(-1).options.method, 'POST');
- assert.deepEqual(ui.calls.at(-1).body, { revisionId: 5, expectedRevision: 10 });
- assert.equal(draftOf(f).value, unsafe);
- await clickAction(f, 'reset');
- assert.equal(ui.calls.at(-1).url, base + '/reset');
- assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 });
- assert.equal(draftOf(f).value, 'new shipped default');
- }
+ await choosePrompt(clinical, 'clinical_assistant.image_behavior');
+ draftOf(clinical).value = 'Unsaved image edits';
+ await clickAction(clinical, 'reset');
+ assert.equal(ui.calls.at(-1).url, '/api/admin/config/prompts/clinical_assistant.image_behavior/reset');
+ assert.equal(ui.calls.at(-1).options.method, 'POST');
+ assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 10 });
+ assert.equal(draftOf(clinical).value, 'new shipped default');
await choosePrompt(scribe, catalogue[1].dbKey);
- assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft');
+ assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'reset never touches another prompt');
+ await choosePrompt(clinical, 'clinical_assistant.system_behavior');
+ assert.equal(draftOf(clinical).value, 'Unsaved clinical edits', 'reset never touches another prompt in the same section');
});
-test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
+test('409s preserve drafts; revisiting the section refreshes the baseline for a clean save', async t => {
let conflicting = true;
const ui = await browser(t, 'admin', (url, options) => {
- if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 14, revisions: [{ id: 14, createdAt: 'now' }] });
- if (url.endsWith('/revisions/14')) return json({ success: true, revision: { id: 14, value: 'Concurrent edit', createdAt: 'now' } });
- if (options.method === 'PUT' || url.endsWith('/reset') || url.endsWith('/restore')) {
+ if (options.method === 'PUT' || url.endsWith('/reset')) {
if (conflicting) return json({ error: 'Stale revision' }, 409);
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
}
});
- const f = sectionOf(ui, 'clinical');
- await choosePrompt(f, 'clinical_assistant.system_behavior');
- draftOf(f).value = 'Keep this draft';
- await clickAction(f, 'save');
- assert.match(statusOf(f).textContent, /Conflict.*draft is unchanged/);
- await clickAction(f, 'reset');
- assert.equal(draftOf(f).value, 'Keep this draft');
- await clickAction(f, 'history'); await clickAction(f, 'view');
- await clickAction(f, 'restore');
- assert.match(statusOf(f).textContent, /Conflict/);
- assert.equal(draftOf(f).value, 'Keep this draft');
- assert.match(f.querySelector('.prompt-baseline').textContent, /revision 10/);
- await clickAction(f, 'baseline');
- assert.equal(draftOf(f).value, 'Keep this draft');
- assert.match(f.querySelector('.prompt-baseline').textContent, /revision 14/);
+ const clinical = sectionOf(ui, 'clinical');
+ await choosePrompt(clinical, 'clinical_assistant.system_behavior');
+ draftOf(clinical).value = 'Keep this draft';
+ await clickAction(clinical, 'save');
+ assert.match(statusOf(clinical).textContent, /Conflict.*draft is unchanged/);
+ assert.equal(draftOf(clinical).value, 'Keep this draft');
conflicting = false;
- await clickAction(f, 'save');
- assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 14 });
- assert.match(statusOf(f).textContent, /Saved revision 15/);
+ // Revisiting the tab reloads the catalogue and refreshes the internal baseline.
+ ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
+ await tick();
+ await choosePrompt(clinical, 'clinical_assistant.system_behavior');
+ draftOf(clinical).value = 'Keep this draft';
+ await clickAction(clinical, 'save');
+ assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 10 });
+ assert.match(statusOf(clinical).textContent, /Saved/);
});
test('failed loads can retry; transport failures and in-flight saves preserve newer and unrelated drafts', async t => {
@@ -186,8 +163,6 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
const f = sectionOf(ui, 'clinical');
await choosePrompt(f, 'clinical_assistant.image_behavior');
draftOf(f).value = 'Preserved draft';
- await clickAction(f, 'history');
- assert.match(statusOf(f).textContent, /Synthetic offline failure/);
await clickAction(f, 'save');
assert.equal(draftOf(f).value, 'Preserved draft');
assert.match(statusOf(f).textContent, /Migration unavailable/);
@@ -198,32 +173,12 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
draftOf(f).value = 'Newer draft typed during save';
release(); await tick();
assert.equal(draftOf(f).value, 'Newer draft typed during save');
- assert.match(statusOf(f).textContent, /Newer draft edits are still unsaved/);
+ assert.match(statusOf(f).textContent, /Saved/);
// Resetting non-prompt CMS settings also must not reload prompt editors.
ui.document.getElementById('btn-reset-all-defaults').click(); await tick();
assert.equal(draftOf(f).value, 'Newer draft typed during save');
});
-test('empty history and failed revision viewing leave drafts intact and restore unavailable', async t => {
- let empty = true;
- const ui = await browser(t, 'admin', url => {
- if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] });
- if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404);
- });
- const f = sectionOf(ui, 'scribe');
- await choosePrompt(f, catalogue[1].dbKey);
- draftOf(f).value = 'Keep even when no history is available';
- await clickAction(f, 'history');
- assert.match(statusOf(f).textContent, /No saved revisions yet/);
- assert.equal(actionOf(f, 'view').disabled, true);
- assert.equal(actionOf(f, 'restore').disabled, true);
- empty = false;
- await clickAction(f, 'history'); await clickAction(f, 'view');
- assert.match(statusOf(f).textContent, /Revision unavailable/);
- assert.equal(actionOf(f, 'restore').disabled, true);
- assert.equal(draftOf(f).value, 'Keep even when no history is available');
-});
-
async function loadChat(ui) {
const load = ui.document.querySelector('[data-assistant-load-chat="1"]');
assert.ok(load); load.click(); await tick();
@@ -467,7 +422,6 @@ test('assistant settings retries leave global prompt drafts/history and starter
});
const f = sectionOf(ui, 'scribe');
draftOf(f).value = 'Unsaved global prompt';
- f.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
setting(ui, 'prompt-pool-snapshots').innerHTML = '';
const before = f.outerHTML;
const promptCalls = ui.calls.filter(c => c.url.includes('/config/prompts')).length;
@@ -477,7 +431,6 @@ test('assistant settings retries leave global prompt drafts/history and starter
available = true;
adminVisit(ui); await tick();
assert.equal(draftOf(f).value, 'Unsaved global prompt');
- assert.equal(f.querySelector('.prompt-revision-text').value, 'Previously viewed revision');
assert.equal(ui.calls.filter(c => c.url.includes('/config/prompts')).length, promptCalls);
assert.equal(ui.calls.some(c => c.options.method === 'POST' || c.options.method === 'PUT'), false);
});