Global Scribe instructions for the operations listed under each prompt. These are not Clinical Assistant prompts or Markdown formatting controls. Personal Memories and templates are separate and are not managed here.
Loading Scribe prompts...
@@ -336,30 +335,26 @@
-
+
-
+
-
The result limit also bounds selected sources; visual queries separately request up to 8 multimodal candidates before selection. Excerpt size is not the conversation budget.
Conversation input budget — read-only server configuration
Loading server budget metadata...
Set by CLINICAL_ASSISTANT_CONVERSATION_CHARS, or the server default when unset; legacy saved settings are ignored. Counts all prior user/assistant text plus the new question using JavaScript string length (UTF-16 code units), not provider tokens. Over-budget requests stop before inference; no automatic clipping or summarization.
-
Text answer output limits: 2,600 tokens initially and 5,000 for completion retries. Image input is separate: existing image routes trim and clip the user prompt at 5,000 UTF-16 code units before adding poster/layout instructions. This is not a new image budget or output-token limit.
+
Output limits: 2,600 tokens per text answer (5,000 on completion retry); image prompts clip at 5,000 UTF-16 code units.
Clinical Assistant TEXT — system behavior
-
Overrides the default behavior for retrieved text answers (including streaming). Fixed greeting/no-source replies, search rewrites and explicit handoff instructions are separate, not controlled by this editor.
-
Fixed citation safeguards (read-only): Use only retrieved evidence for factual claims and keep the exact provided source numbers. Do not invent, renumber, merge or move citations. Runtime evidence/citation safeguards are appended separately; this editor does not replace them.
Loading clinical text prompt...
Clinical Assistant IMAGE — poster instructions
-
Overrides the default poster instruction appended to the image user prompt by both direct and background-job image generation. Existing automatic portrait/landscape layout suffixes still follow it. This does not change text answers or image-job storage.
Loading clinical image prompt...
diff --git a/public/js/admin.js b/public/js/admin.js
index 2123d27a..cae0bd1a 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -31,6 +31,13 @@ function adminFlashButtonBackground(btn, color) {
setTimeout(function() { if (btn) btn.style.background = ''; }, 2000);
}
+// True when the admin tab is already active AND its component markup is loaded,
+// i.e. tabChanged for admin has already fired (or will never fire again).
+function adminTabActive() {
+ var tab = document.getElementById('admin-tab');
+ return !!tab && tab.classList.contains('active') && tab.dataset.loaded === '1';
+}
+
{
let loaded = false;
@@ -292,14 +299,22 @@ function adminFlashButtonBackground(btn, color) {
{
let cmsLoaded = false;
+ let promptsLoaded = false;
+ let promptsLoading = false;
+
+ function startCms() {
+ if (!cmsLoaded) { loadCms(); loadOidcConfig(); cmsLoaded = true; }
+ }
// Load CMS when admin tab is opened (via tabChanged event or click)
document.addEventListener('tabChanged', function(e) {
- if (e.detail && e.detail.tab === 'admin') {
- if (!cmsLoaded) { loadCms(); loadOidcConfig(); cmsLoaded = true; }
- }
+ if (e.detail && e.detail.tab === 'admin') startCms();
});
+ // Catch-up: when the admin tab is already active and loaded at module init
+ // (e.g. restored tab before this module evaluated), tabChanged may never fire again.
+ if (adminTabActive()) startCms();
+
document.addEventListener('click', function(e) {
// Save buttons
@@ -511,9 +526,6 @@ function adminFlashButtonBackground(btn, color) {
}
});
- let promptsLoaded = false;
- let promptsLoading = false;
-
async function promptRequest(url, method, body) {
var response = await fetch(url, {
headers: getAuthHeaders(), method: method || 'GET',
@@ -539,9 +551,12 @@ function adminFlashButtonBackground(btn, color) {
groups.forEach(function(group) {
if (!group[1]) return;
group[1].replaceChildren();
- (data.prompts || []).filter(function(p) { return p.family === group[0] && p.editable === true; })
- .forEach(function(p) { group[1].appendChild(createPromptEditor(p)); });
- if (!group[1].children.length) group[1].textContent = 'No editable prompts available in this family.';
+ var prompts = (data.prompts || []).filter(function(p) { return p.family === group[0] && p.editable === true; });
+ if (!prompts.length) {
+ group[1].textContent = 'No editable prompts available in this family.';
+ return;
+ }
+ group[1].appendChild(createPromptFamilyEditor(group[0], prompts));
});
promptsLoaded = true;
} catch (error) {
@@ -556,50 +571,69 @@ function adminFlashButtonBackground(btn, color) {
} finally { promptsLoading = false; }
}
- function createPromptEditor(prompt) {
- var revision = prompt.revision;
- var currentRevision = revision;
- var viewedRevision = null;
- var busy = false;
- var base = '/api/admin/config/prompts/' + encodeURIComponent(prompt.dbKey);
- var editor = document.createElement('details');
- editor.dataset.promptKey = prompt.dbKey;
- editor.open = prompt.family !== 'scribe';
- editor.style.cssText = 'border-top:1px solid var(--g200);padding:12px 0;';
+ // 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.
+ 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.
- editor.innerHTML = '' +
- '' +
- '
' +
+ editor.innerHTML =
+ '' +
+ '' +
+ '' +
+ '
' +
'' +
- '' +
+ '' +
'
' +
- '
' +
+ '' +
+ '
' +
'' +
'' +
- '' +
+ '' +
'' +
'' +
'
';
- editor.querySelector('summary').textContent = prompt.key;
- editor.querySelector('.prompt-purpose').textContent = 'Purpose: ' + prompt.purpose;
- editor.querySelector('.prompt-usage').textContent = 'Used by: ' + (prompt.usedBy || []).join('; ');
+ var select = editor.querySelector('.prompt-select');
var text = editor.querySelector('.prompt-draft');
- text.value = prompt.value;
var status = editor.querySelector('.prompt-status');
var history = editor.querySelector('.prompt-history');
- var select = editor.querySelector('.prompt-revisions');
+ 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.
+ 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 });
+ });
+ 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; });
select.disabled = busy;
- buttons.view.disabled = busy || !select.value;
+ revisions.disabled = busy;
+ buttons.view.disabled = busy || !revisions.value;
buttons.restore.disabled = busy || !viewedRevision;
- buttons.baseline.disabled = busy || !viewedRevision || viewedRevision.id !== currentRevision;
- editor.querySelector('.prompt-baseline').textContent = 'Save baseline: revision ' + revision + (revision === 0 ? ' (no history yet).' : '.');
+ 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;
@@ -615,20 +649,40 @@ function adminFlashButtonBackground(btn, color) {
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) {
- var draft = text.value;
- var body = { expectedRevision: revision };
- if (action === 'save') body.value = draft;
+ 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(prompt.dbKey) : base + '/' + action,
+ var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : base() + '/' + action,
action === 'save' ? 'PUT' : 'POST', body);
- revision = data.revision;
- currentRevision = revision;
- // Typing while a request is pending must not be overwritten by its response.
- if (text.value === draft) text.value = data.value;
- history.hidden = true; select.replaceChildren(); clearPreview();
- status.textContent = 'Saved revision ' + revision + (text.value === data.value ? '.' : '. Newer draft edits are still unsaved.');
+ 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.');
}
+ select.onchange = function() {
+ // Stash the outgoing draft before switching: per-key drafts survive switches.
+ 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();
+ 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() {
@@ -636,18 +690,19 @@ function adminFlashButtonBackground(btn, color) {
});
};
buttons.history.onclick = function() { run(async function() {
- var data = await promptRequest(base + '/history?limit=100');
- currentRevision = data.revision;
- select.replaceChildren(); clearPreview();
+ 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); select.appendChild(option);
+ var option = document.createElement('option'); option.value = item.id; option.textContent = revisionLabel(item); revisions.appendChild(option);
});
history.hidden = false;
- status.textContent = 'Current server revision: ' + currentRevision + '. ' + (select.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
+ status.textContent = 'Current server revision: ' + st.currentRevision + '. ' + (revisions.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
}); };
- select.onchange = clearPreview;
+ revisions.onchange = clearPreview;
buttons.view.onclick = function() { run(async function() {
- var data = await promptRequest(base + '/revisions/' + encodeURIComponent(select.value));
+ var data = await promptRequest(base() + '/revisions/' + encodeURIComponent(revisions.value));
viewedRevision = data.revision;
preview.value = viewedRevision.value;
meta.textContent = revisionLabel(viewedRevision);
@@ -661,9 +716,10 @@ function adminFlashButtonBackground(btn, color) {
});
};
buttons.baseline.onclick = function() {
- if (!viewedRevision || viewedRevision.id !== currentRevision) return;
- revision = currentRevision; controls();
- status.textContent = 'Draft kept. The next save will use revision ' + revision + ' as its baseline; review your edits before saving.';
+ 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;
@@ -785,6 +841,9 @@ initClinicalAssistantAdmin(adminEscapeHtml);
}
});
+ // Catch-up for a tab that is already active and loaded at module init.
+ if (adminTabActive()) loadAdminModels();
+
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-discover-models')) discoverModels();
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
@@ -1125,6 +1184,8 @@ initClinicalAssistantAdmin(adminEscapeHtml);
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
});
+ // Catch-up for a tab that is already active and loaded at module init.
+ if (adminTabActive()) loadTTSConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-test-tts')) testTTS();
if (e.target.closest('#btn-discover-tts')) discoverTTS();
@@ -1311,6 +1372,8 @@ initClinicalAssistantAdmin(adminEscapeHtml);
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
});
+ // Catch-up for a tab that is already active and loaded at module init.
+ if (adminTabActive()) loadSTTConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-stt-record')) toggleRecording();
if (e.target.closest('#btn-discover-stt')) discoverSTT();
@@ -1495,6 +1558,8 @@ initClinicalAssistantAdmin(adminEscapeHtml);
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig();
});
+ // Catch-up for a tab that is already active and loaded at module init.
+ if (adminTabActive()) loadEmbeddingConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-test-embedding')) testEmbedding();
if (e.target.closest('#btn-discover-embeddings')) discoverEmbeddings();
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index f10eb7ca..63cbe8e0 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -325,4 +325,10 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
const escAssistant = adminEscapeHtml;
+
+ // Catch-up: when the admin tab is already active and loaded at module init
+ // (e.g. restored tab before this module evaluated), tabChanged may never fire
+ // again. The loader's own state guards make this idempotent with tabChanged.
+ var adminTab = document.getElementById('admin-tab');
+ if (adminTab && adminTab.classList.contains('active') && adminTab.dataset.loaded === '1') loadAssistantAdmin();
}
diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js
index 510530d3..c3312711 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -51,38 +51,49 @@ async function browser(t, module, handler) {
await tick();
return { window, document: window.document, calls, toasts };
}
-const editorFor = (ui, key) => ui.document.querySelector('[data-prompt-key="' + key + '"]');
-const draft = editor => editor.querySelector('.prompt-draft');
-const action = (editor, name) => editor.querySelector('[data-prompt-action="' + name + '"]');
-async function click(editor, name) { action(editor, name).click(); await tick(); }
+const familyOf = (ui, name) => ui.document.getElementById('cms-' + name + '-prompts');
+const selectOf = family => family.querySelector('.prompt-select');
+const draftOf = family => family.querySelector('.prompt-draft');
+const statusOf = family => family.querySelector('.prompt-status');
+const actionOf = (family, name) => family.querySelector('[data-prompt-action="' + name + '"]');
+async function choosePrompt(family, dbKey) {
+ const select = selectOf(family);
+ select.value = dbKey;
+ select.dispatchEvent(new family.ownerDocument.defaultView.Event('change', { bubbles: true }));
+ await tick();
+}
+async function clickAction(family, name) { actionOf(family, name).click(); await tick(); }
-test('native prompt catalogue separates all families, displays inert exact text, and saves canonical dbKey + expectedRevision', async t => {
+test('compact prompt families separate the whole catalogue, display inert exact text, and save canonical dbKey + expectedRevision', async t => {
const ui = await browser(t, 'admin', (url, options) => {
if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 });
});
- assert.equal(ui.document.querySelectorAll('#cms-scribe-prompts details').length, 29);
- assert.equal(ui.document.querySelectorAll('#cms-clinical-text-prompts details').length, 1);
- assert.equal(ui.document.querySelectorAll('#cms-clinical-image-prompts details').length, 1);
+ const scribe = familyOf(ui, 'scribe');
+ const textFamily = familyOf(ui, 'clinical-text');
+ const imageFamily = familyOf(ui, 'clinical-image');
+ assert.equal(selectOf(scribe).options.length, 29);
+ assert.equal(selectOf(textFamily).options.length, 1);
+ assert.equal(selectOf(imageFamily).options.length, 1);
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
- assert.match(ui.document.getElementById('cms-scribe-prompts').parentElement.textContent, /Scribe instructions/);
- for (const p of catalogue) {
- const editor = editorFor(ui, p.dbKey);
- assert.equal(draft(editor).value, unsafe);
- assert.equal(editor.querySelector('.prompt-purpose').textContent, 'Purpose: ' + unsafe);
- assert.match(editor.querySelector('.prompt-usage').textContent, /Synthetic runtime operation/);
- for (const name of ['save', 'history', 'view', 'restore', 'reset']) assert.ok(action(editor, name));
- }
+ assert.match(scribe.closest('.card').textContent, /AI Scribe/);
+ 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 p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
- const editor = editorFor(ui, p.dbKey);
- draft(editor).value = '\n' + unsafe + '\n';
- await click(editor, 'save');
+ const f = p.family === 'scribe' ? scribe : p.family === 'clinical-text' ? textFamily : imageFamily;
+ await choosePrompt(f, p.dbKey);
+ assert.equal(draftOf(f).value, unsafe);
+ draftOf(f).value = '\n' + unsafe + '\n';
+ await clickAction(f, 'save');
const call = ui.calls.at(-1);
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(editor.querySelector('.prompt-status').textContent, /Saved revision 11/);
+ assert.match(statusOf(f).textContent, /Saved revision 11/);
}
- assert.equal(draft(editorFor(ui, catalogue[2].dbKey)).value, unsafe, 'other editors unchanged');
+ 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 => {
@@ -93,33 +104,37 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev
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 other = editorFor(ui, catalogue[1].dbKey);
- draft(other).value = 'Unrelated unsaved Scribe draft';
+ const scribe = familyOf(ui, 'scribe');
+ 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 editor = editorFor(ui, p.dbKey);
+ const f = p.family === 'scribe' ? scribe : p.family === 'clinical-text' ? familyOf(ui, 'clinical-text') : familyOf(ui, 'clinical-image');
+ await choosePrompt(f, p.dbKey);
const base = '/api/admin/config/prompts/' + p.dbKey;
- draft(editor).value = 'Unsaved clinical/Scribe edits';
- await click(editor, 'history');
+ draftOf(f).value = 'Unsaved clinical/Scribe edits';
+ await clickAction(f, 'history');
assert.equal(ui.calls.at(-1).url, base + '/history?limit=100');
- assert.match(editor.querySelector('option').textContent, /default snapshot.*restored from #2/);
- await click(editor, 'view');
+ 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(editor.querySelector('.prompt-revision-text').value, unsafe);
- assert.equal(editor.querySelector('.prompt-revision-text').readOnly, true);
- assert.equal(draft(editor).value, 'Unsaved clinical/Scribe edits');
- assert.equal(action(editor, 'baseline').disabled, true, 'an old revision cannot rebase a stale save');
- assert.equal(editor.querySelector('script, img, [onerror]'), null);
- await click(editor, 'restore');
+ 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(draft(editor).value, unsafe);
- await click(editor, 'reset');
+ 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(draft(editor).value, 'new shipped default');
- assert.equal(draft(other).value, 'Unrelated unsaved Scribe draft');
+ assert.equal(draftOf(f).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');
});
test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
@@ -132,24 +147,25 @@ test('409s preserve drafts; explicit review/rebase resolves conflicts without an
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
}
});
- const editor = editorFor(ui, 'clinical_assistant.system_behavior');
- draft(editor).value = 'Keep this draft';
- await click(editor, 'save');
- assert.match(editor.querySelector('.prompt-status').textContent, /Conflict.*draft is unchanged/);
- await click(editor, 'reset');
- assert.equal(draft(editor).value, 'Keep this draft');
- await click(editor, 'history'); await click(editor, 'view');
- await click(editor, 'restore');
- assert.match(editor.querySelector('.prompt-status').textContent, /Conflict/);
- assert.equal(draft(editor).value, 'Keep this draft');
- assert.match(editor.querySelector('.prompt-baseline').textContent, /revision 10/);
- await click(editor, 'baseline');
- assert.equal(draft(editor).value, 'Keep this draft');
- assert.match(editor.querySelector('.prompt-baseline').textContent, /revision 14/);
+ const f = familyOf(ui, 'clinical-text');
+ 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/);
conflicting = false;
- await click(editor, 'save');
+ await clickAction(f, 'save');
assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 14 });
- assert.match(editor.querySelector('.prompt-status').textContent, /Saved revision 15/);
+ assert.match(statusOf(f).textContent, /Saved revision 15/);
});
test('failed loads can retry; transport failures and in-flight saves preserve newer and unrelated drafts', async t => {
@@ -162,27 +178,30 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); });
}
});
- assert.match(ui.document.getElementById('cms-scribe-prompts').textContent, /Unavailable catalogue/);
+ const scribe = familyOf(ui, 'scribe');
+ assert.match(scribe.textContent, /Unavailable catalogue/);
+ assert.ok(scribe.querySelector('button'), 'retry control stays visible');
catalogueFailure = false;
- ui.document.querySelector('#cms-scribe-prompts button').click(); await tick();
- const editor = editorFor(ui, 'clinical_assistant.image_behavior');
- draft(editor).value = 'Preserved draft';
- await click(editor, 'history');
- assert.match(editor.querySelector('.prompt-status').textContent, /Synthetic offline failure/);
- await click(editor, 'save');
- assert.equal(draft(editor).value, 'Preserved draft');
- assert.match(editor.querySelector('.prompt-status').textContent, /Migration unavailable/);
+ scribe.querySelector('button').click(); await tick();
+ const f = familyOf(ui, 'clinical-image');
+ 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/);
failure = false;
- action(editor, 'save').click(); await tick();
- assert.equal(action(editor, 'save').disabled, true);
- assert.equal(action(editor, 'reset').disabled, true);
- draft(editor).value = 'Newer draft typed during save';
+ actionOf(f, 'save').click(); await tick();
+ assert.equal(actionOf(f, 'save').disabled, true);
+ assert.equal(actionOf(f, 'reset').disabled, true);
+ draftOf(f).value = 'Newer draft typed during save';
release(); await tick();
- assert.equal(draft(editor).value, 'Newer draft typed during save');
- assert.match(editor.querySelector('.prompt-status').textContent, /Newer draft edits are still unsaved/);
+ assert.equal(draftOf(f).value, 'Newer draft typed during save');
+ assert.match(statusOf(f).textContent, /Newer draft edits are still unsaved/);
// Resetting non-prompt CMS settings also must not reload prompt editors.
ui.document.getElementById('btn-reset-all-defaults').click(); await tick();
- assert.equal(draft(editorFor(ui, 'clinical_assistant.image_behavior')).value, 'Newer draft typed during save');
+ 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 => {
@@ -191,17 +210,18 @@ test('empty history and failed revision viewing leave drafts intact and restore
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 editor = editorFor(ui, catalogue[1].dbKey);
- draft(editor).value = 'Keep even when no history is available';
- await click(editor, 'history');
- assert.match(editor.querySelector('.prompt-status').textContent, /No saved revisions yet/);
- assert.equal(action(editor, 'view').disabled, true);
- assert.equal(action(editor, 'restore').disabled, true);
+ const f = familyOf(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 click(editor, 'history'); await click(editor, 'view');
- assert.match(editor.querySelector('.prompt-status').textContent, /Revision unavailable/);
- assert.equal(action(editor, 'restore').disabled, true);
- assert.equal(draft(editor).value, 'Keep even when no history is available');
+ 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) {
@@ -466,19 +486,86 @@ test('assistant settings retries leave global prompt drafts/history and starter
const ui = await browser(t, 'admin', url => {
if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503);
});
- const editor = editorFor(ui, catalogue[0].dbKey);
- draft(editor).value = 'Unsaved global prompt';
- editor.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
+ const f = familyOf(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 = editor.outerHTML;
+ const before = f.outerHTML;
const promptCalls = ui.calls.filter(c => c.url.includes('/config/prompts')).length;
ui.document.getElementById('btn-retry-assistant-config').click(); await tick();
assert.equal(setting(ui, 'prompt-pool-snapshots').value, '7');
- assert.equal(editor.outerHTML, before);
+ assert.equal(f.outerHTML, before);
available = true;
adminVisit(ui); await tick();
- assert.equal(draft(editor).value, 'Unsaved global prompt');
- assert.equal(editor.querySelector('.prompt-revision-text').value, 'Previously viewed revision');
+ 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);
});
+
+test('admin tab already active at module init triggers loaders exactly once; guarded loaders never double-fire', async t => {
+ const dom = new JSDOM('