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); });