diff --git a/README.md b/README.md
index ddcc554..8387d8d 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,18 @@ node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
+**Prompt Manager (Focused)** adds an editor-first layout with collapsible
+composition and metadata fields. Open **Browse library** to search saved prompts,
+filter by saved category and comma-separated tags, preview a selection, then choose
+**Use prompt**; **Load metadata** controls whether
+category and tags come with it. Metadata fields suggest saved categories and tags;
+choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
+next tag; category remains a single value. New values can still be typed freely.
+Prepend and append STRING sockets remain visible
+when the fields are collapsed, so LoRA trigger outputs can connect directly.
+Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
+support ComfyUI Classic and Nodes 2.0.
+
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
diff --git a/__init__.py b/__init__.py
index 6593b25..f4e3076 100644
--- a/__init__.py
+++ b/__init__.py
@@ -3,12 +3,13 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
with persistent prompt storage, advanced search capabilities, and an automatic image
gallery system.
-This module provides two main node types:
+This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
+- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
-Both nodes share the same database backend for persistent prompt storage and include
+The nodes share the same database backend for persistent prompt storage and include
an automatic image gallery system that monitors ComfyUI output directories and links
generated images to their source prompts.
@@ -38,18 +39,20 @@ def get_version():
return "unknown"
-from .prompt_manager import PromptManager # noqa: E402
+from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
+ "PromptManagerFocused": PromptManagerFocused,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
+ "PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png
new file mode 100644
index 0000000..1cf487b
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png
new file mode 100644
index 0000000..8745cc2
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png
new file mode 100644
index 0000000..a0aa1fc
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-light.png
new file mode 100644
index 0000000..0219fe5
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-light.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png
new file mode 100644
index 0000000..af3e81b
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png
new file mode 100644
index 0000000..acf69de
Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png
new file mode 100644
index 0000000..f241258
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png
new file mode 100644
index 0000000..7c9bb38
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png
new file mode 100644
index 0000000..e1a3b97
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png
new file mode 100644
index 0000000..e4ccc0d
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png
new file mode 100644
index 0000000..376fa8a
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png differ
diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png
new file mode 100644
index 0000000..f74f6c0
Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png differ
diff --git a/prompt_manager.py b/prompt_manager.py
index 90904c0..a0d9fce 100644
--- a/prompt_manager.py
+++ b/prompt_manager.py
@@ -281,3 +281,18 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
return hashlib.sha256(combined.encode()).hexdigest()
+
+
+class PromptManagerFocused(PromptManager):
+ """The editor-first UI uses the existing encoding and storage contract."""
+
+ @classmethod
+ def INPUT_TYPES(cls) -> InputTypeDict:
+ inputs = super().INPUT_TYPES()
+ for name in ("prepend_text", "append_text"):
+ kind, options = inputs["optional"][name]
+ inputs["optional"][name] = (
+ kind,
+ {**options, "default": "", "forceInput": True},
+ )
+ return inputs
diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs
new file mode 100644
index 0000000..cf78a3c
--- /dev/null
+++ b/tests/browser/focused-node.spec.mjs
@@ -0,0 +1,411 @@
+import {readFileSync} from 'node:fs';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
+
+test.beforeEach(async({page,browserName})=>{
+ await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
+ await startCoverage(page,browserName);
+ page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
+ await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
+ await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
+ await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
+ await page.goto('/prompt_manager/test');
+ await page.evaluate(async()=>{
+ document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
+ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
+ await import('/extensions/promptmanager/prompt_manager.js');
+ class Node {
+ constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
+ addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
+ addDOMWidget(name,type,element){document.body.append(element);return {element};}
+ setSize(size){this.size=size;}setDirtyCanvas(){}
+ serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
+ configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
+ }
+ await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
+ window.node=new Node();node.onNodeCreated();
+ });
+ await expect(page.locator('.pm-focused')).toBeVisible();
+});
+test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
+
+test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
+ const requests=[];
+ await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
+ await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
+ await page.route('**/prompt_manager/search?**',route=>{
+ requests.push(new URL(route.request().url()));
+ return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
+ });
+ await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ const category=page.getByLabel('Filter category',{exact:true});
+ await category.fill('por');
+ await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
+ await category.press('ArrowDown');
+ await category.press('Enter');
+ expect(requests).toHaveLength(0);
+ const tags=page.getByLabel('Filter tags',{exact:true});
+ await tags.fill('stu');
+ await page.getByRole('option',{name:'studio',exact:true}).click();
+ await expect(tags).toHaveValue('studio, ');
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
+ await page.getByRole('button',{name:'Search',exact:true}).click();
+ await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
+ expect(requests[0].searchParams.get('text')).toBe('portrait');
+ expect(requests[0].searchParams.get('category')).toBe('Portrait');
+ expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
+ expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
+ await page.locator('.pm-editor-result').click();
+ await category.fill('Landscape');
+ await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
+ const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
+ expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
+ await expect(category).toHaveValue('Landscape');
+ await expect(tags).toHaveValue('studio, ');
+ let recentRequest;
+ await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
+ await page.getByRole('button',{name:'Recent',exact:true}).click();
+ await expect.poll(()=>Boolean(recentRequest)).toBe(true);
+ expect(recentRequest.searchParams.has('category')).toBe(false);
+ expect(recentRequest.searchParams.has('tags')).toBe(false);
+ await page.getByRole('button',{name:'Hide library',exact:true}).click();
+ await expect(page.getByRole('listbox')).toBeHidden();
+});
+
+test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
+ const requests=[];
+ await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ await page.getByLabel('Filter category',{exact:true}).fill('New category');
+ await page.getByLabel('Filter category',{exact:true}).press('Enter');
+ await expect.poll(()=>requests.length).toBe(1);
+ expect(requests[0].searchParams.get('category')).toBe('New category');
+ await page.getByLabel('Filter category',{exact:true}).fill('');
+ await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
+ await page.getByLabel('Filter tags',{exact:true}).press('Enter');
+ await expect.poll(()=>requests.length).toBe(2);
+ expect(requests[1].searchParams.get('tags')).toBe('custom');
+});
+
+test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
+ await page.route('**/prompt_manager/tags/suggest?**', route => {
+ const q = new URL(route.request().url()).searchParams.get('q');
+ return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
+ });
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ const tags = page.getByLabel('Tags',{exact:true});
+ await tags.fill('portrait, bl');
+ await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
+ await tags.press('ArrowDown');
+ await tags.press('Enter');
+ await expect(tags).toHaveValue('portrait, blue eyes, ');
+ expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, ');
+ await tags.fill('bl');
+ await page.getByRole('option',{name:'blue sky',exact:true}).click();
+ await expect(tags).toHaveValue('blue sky, ');
+ await tags.fill('portrait, ');
+ await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0);
+ await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
+ await tags.press('Escape');
+ await expect(page.getByRole('listbox')).toBeHidden();
+ await tags.fill('bl');
+ await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
+ await page.locator('.pm-focused').dispatchEvent('scroll');
+ await expect(page.getByRole('listbox')).toBeHidden();
+ await tags.fill('A brand new tag');
+ await tags.press('Tab');
+ await expect(tags).toHaveValue('A brand new tag');
+});
+
+test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
+ let release;
+ let mode='delayed';
+ await page.route('**/prompt_manager/tags/suggest?**', async route => {
+ const q = new URL(route.request().url()).searchParams.get('q');
+ if(q === 'old') await new Promise(resolve=>{release=resolve;});
+ if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'});
+ await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{});
+ });
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ const tags=page.getByLabel('Tags',{exact:true});
+ await tags.fill('old');
+ await expect.poll(()=>Boolean(release)).toBe(true);
+ await tags.fill('new');
+ await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible();
+ release();
+ await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0);
+ mode='error';
+ await tags.fill('unavailable');
+ await expect(page.getByRole('listbox')).toBeHidden();
+ await expect(tags).toHaveValue('unavailable');
+ mode='ok';
+ await tags.fill('retry');
+ await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible();
+ await page.evaluate(()=>node.onRemoved());
+ await expect(page.getByRole('listbox')).toHaveCount(0);
+});
+
+test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
+ await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ const category=page.getByLabel('Category',{exact:true});
+ await category.fill('por');
+ await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible();
+ await category.press('ArrowUp');
+ await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true');
+ await category.press('Enter');
+ await expect(category).toHaveValue('Portrait studio');
+ expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio');
+ await category.fill('por');
+ await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
+ await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true});
+ await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/);
+ await page.getByLabel('Tags',{exact:true}).focus();
+ await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
+ await category.fill('New category');
+ await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
+ await expect(category).toHaveValue('New category');
+});
+
+test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
+ await page.evaluate(async()=>{
+ const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
+ const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
+ const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second');
+ window.completionFixture={scope:createScope(),selected:[],inserted:[]};
+ const one=first.createSuggestionView(completionFixture.scope,null);
+ const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'});
+ completionFixture.views=[one,two];
+ one.hide();
+ const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}];
+ one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name));
+ one.select(-1);
+ one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20});
+ two.render([{name:'Other pack suggestion'}],()=>{},()=>{});
+ two.position({left:0,bottom:5,top:0});
+ });
+ const lists=page.getByRole('listbox');
+ const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id));
+ expect(new Set(ids).size).toBe(ids.length);
+ await page.getByRole('option',{name:'[LORA] model'}).hover();
+ await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true');
+ await page.getByRole('option',{name:'[EMB] folder/nested'}).click();
+ expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']);
+ await page.evaluate(()=>completionFixture.scope.dispose());
+ await expect(lists).toHaveCount(0);
+});
+
+test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
+ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ await page.getByRole('button',{name:'Recent',exact:true}).click();
+ await page.getByRole('button',{name:/Library portrait/}).click();
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
+ await page.getByRole('switch',{name:'Load metadata'}).uncheck();
+ await page.getByRole('button',{name:'Use prompt',exact:true}).click();
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
+ await page.getByText('Composition · prepend / append',{exact:true}).click();
+ await page.getByLabel('Prepend',{exact:true}).fill('trigger');
+ await page.getByLabel('Append',{exact:true}).fill('detailed');
+ await page.getByText('Composed text preview',{exact:true}).click();
+ await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
+ expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
+});
+
+test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ await page.getByLabel('Category',{exact:true}).fill('Saved category');
+ await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
+ await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
+ await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
+ await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
+ await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
+ await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
+});
+
+test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
+ let requested;
+ await page.route('**/prompt_manager/search?**', async route => {
+ requested = new URL(route.request().url());
+ await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
+ });
+ await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ const query=page.getByRole('searchbox',{name:'Search saved prompts'});
+ await query.fill('fox tag:"film grain"');
+ await query.press('Enter');
+ await page.getByRole('button',{name:/Selected fox/}).click();
+ expect(requested.searchParams.get('text')).toBe('fox');
+ expect(requested.searchParams.get('tags')).toBe('film grain');
+ await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
+ await page.getByRole('button',{name:'Use prompt',exact:true}).click();
+ await page.getByText('Metadata · category / tags',{exact:true}).click();
+ await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
+ await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
+ expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
+});
+
+test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
+ let requests=0;
+ let mode='empty';
+ const malicious='
';
+ await page.route('**/prompt_manager/search?**', async route => {
+ requests++;
+ if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
+ await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
+ });
+ await page.getByRole('button',{name:'Browse library',exact:true}).click();
+ const search=page.getByRole('button',{name:'Search',exact:true});
+ await search.click();
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
+ expect(requests).toBe(0);
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
+ await search.click();
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
+ await expect(page.locator('.pm-editor-result')).toHaveCount(0);
+ mode='error';
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
+ await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
+ expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
+ mode='http';
+ await search.click();
+ await expect(page.getByRole('alert')).toContainText('HTTP 503');
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
+ mode='empty';
+ await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
+ await search.click();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
+});
+
+test('Focused treats saved prompt content and metadata as text', async ({page}) => {
+ const malicious='
';
+ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'',tags:['