diff --git a/TESTING.md b/TESTING.md index 4950592..ea33428 100644 --- a/TESTING.md +++ b/TESTING.md @@ -258,7 +258,8 @@ loading a workflow or queueing generation. `npm run --prefix tools/vendor test:executions` runs 46 real execution cases (23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases), -alongside the existing 78 stock host cases and six pack compatibility cases. Run +alongside the existing 80 stock host cases (the original 78 plus the native +editor host case) and six pack compatibility cases. Run these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`, `PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`: @@ -310,7 +311,8 @@ both ways and workflow reload. PM usage counts retain their existing per-job behavior. References identify reported locators only: file identity is unverified, new generation and context membership are unknown, and temp references are transient. The stock suite excludes both execution files; discovery retains its -78 cases and the six pack cases unchanged. +80 cases (the original 78 plus the native editor host case) and the six pack +cases unchanged. For a meaningful historical failure, create a separate disposable detached checkout at `fdba6fd0126a8a40cbd6f3e50ed4c5de8991efd4` and pass its path to @@ -501,3 +503,65 @@ every previous metric floor and each new file's 80% line requirement pass; rerun the coverage gate using the committed baseline. Python retains its 85% combined statement/branch gate. Deploying the history Python changes requires a maintainer ComfyUI restart; automated acceptance never restarts the personal server. + +## Native recipe editor (#234) + +The Library page edits recipes in one native modal (`dialog.library-editor`) +instead of sending users to the legacy dashboard. It is frontend only: it consumes +the Library/pair routes, epoch tokens, null-clears and conflict codes defined by +the backend change (#233) and does not add or change a route, so this section does +not repeat them. The editor reads fresh, `no-store` state before it opens. + +The four dialog modes match the approved concept's shell: **New prompt**, +**Edit recipe metadata**, **Create text revision** and **Add a negative variant**. +Pair **Archive…** and **Restore** are one reviewed action with **Show archived** +in the saved-variants list; archiving a pair never touches its positive prompt, its +siblings, history or images. **Export** downloads the full v1 recipe export. A +failed Save keeps focus inside the dialog and keeps every entry. + +Recovery is exercised against real failure responses, not mocks of the controller: + +- a **conflict** response keeps the draft and explains the newer saved state; a + **storage-changed** response writes nothing, and only New prompt re-reads in + place: every other dialog offers only **Reload Library**, because an old numeric + owner is never resolved after a restore; +- an **unknown outcome** (no response) keeps the draft and offers an explicit + **Retry** that resends the identical request once, which the server + deduplicates; an Edit metadata retry instead reloads the latest saved values for + review and resends nothing; +- a **confirmed receipt** whose follow-up refresh fails offers a read-only + refresh retry and never re-sends the write. + +The concept comparison (`library-editor-canonical-comparison.mjs`) inventories the +concept dialog and the production dialog in the same browser: geometry, CSS tokens, +colors and a control inventory (labels, field types, options, actions and their +danger/primary classes). Only the documented differences (the prototype's Save +label and demo note) are mapped. The mutation check proves the comparison fails when +a token, label or control is changed, so it cannot pass vacuously. + +PNG baselines are intentionally absent: `toHaveScreenshot` is not used for the +editor. Paired concept/production captures and the artifact-only states +(`library-editor-visual.spec.mjs`) are attached as test artifacts for maintainer +acceptance; a baseline is added only after the maintainer accepts them. + +Focused commands: + +```sh +PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \ + workspace-library-editor.spec.mjs library-editor-visual.spec.mjs \ + --project chromium --project touch --project firefox --project webkit +npm run --prefix tools/vendor test:comfyui # library-sender.spec.mjs host regression +``` + +`native editor preview sends exact saved roles across renderer transitions` opens +the real workspace, creates a prompt with an explicitly empty negative, and sends the +selected pair through the acknowledged host bridge in Classic and Nodes 2.0, across +both switches and a workflow reload. It asserts the actual widgets and serialized +workflow, no queue or workflow load, unchanged history and pair metadata, and that +a connected destination rejects the preview without changing either role. It needs +the pinned disposable ComfyUI, so it runs in CI's "real ComfyUI compatibility" job. + +Deploying this change needs no Python change and no ComfyUI restart: the diff for +the editor UI touches only HTML, CSS and JavaScript (plus test fixtures). A browser +refresh is enough. New editor modules have additive coverage floors in +`tools/vendor/frontend-coverage-baseline.json`; none were lowered. diff --git a/tests/browser/library-editor-canonical-comparison.mjs b/tests/browser/library-editor-canonical-comparison.mjs new file mode 100644 index 0000000..d202e18 --- /dev/null +++ b/tests/browser/library-editor-canonical-comparison.mjs @@ -0,0 +1,44 @@ +// Concept-vs-production control inventory for the native editor dialog. inventoryOf runs in the page. +export function inventoryOf(dialog) { + const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; + const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button'); + const actions=dialog.querySelector('.dialog-actions'),twoCol=dialog.querySelector('.two-col'); + return { + dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','borderTopWidth','borderTopStyle','maxHeight','overflowY','backgroundColor','color','borderTopColor','fontFamily','fontSize','lineHeight'), + width:dialog.getBoundingClientRect().width,modal:dialog.matches(':modal'),backdrop:getComputedStyle(dialog,'::backdrop').backgroundColor, + labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title}, + header:css(header,'display','justifyContent','alignItems','columnGap','marginBottom'), + title:{text:title.textContent,...css(title,'fontSize','fontWeight','marginTop','marginBottom','lineHeight')}, + close:{label:close.getAttribute('aria-label'),text:close.textContent.trim(),focused:document.activeElement===close,...css(close,'fontSize','minWidth','minHeight')}, + fields:[...dialog.querySelectorAll('.field')].map(field=>{ + const label=field.querySelector('label'),control=field.querySelector('input,textarea,select'); + return {label:label.textContent,labelled:label.htmlFor===control.id,tag:control.tagName.toLowerCase(),readOnly:Boolean(control.readOnly),required:control.required, + placeholder:control.getAttribute('placeholder'),listed:Boolean(control.list),commaOptions:control.list?[...control.list.options].every(option=>option.value.endsWith(',')):null, + options:control.tagName==='SELECT'?[...control.options].map(option=>[option.value,option.textContent]):null, + field:css(field,'display','rowGap','fontSize','marginTop','marginBottom'),control:{...css(control,'fontSize','minHeight','resize','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'),height:control.getBoundingClientRect().height}}; + }), + twoCol:twoCol?{columns:getComputedStyle(twoCol).gridTemplateColumns.split(' ').length,...css(twoCol,'display','columnGap')}:null, + small:[...dialog.querySelectorAll('p.small')].map(node=>({text:node.textContent,...css(node,'fontSize','color')})), + actions:{...css(actions,'display','justifyContent','columnGap','flexWrap','marginTop'), + buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),type:node.type,danger:node.classList.contains('danger'),primary:node.classList.contains('ca-primary'), + ...css(node,'minHeight','fontSize','color','borderTopColor','backgroundColor'),height:node.getBoundingClientRect().height}))}, + demo:Boolean(dialog.querySelector('.demo-note')), + }; +} +// Documented differences only: prototype Save label, prototype demo note, and data-dependent values. +export function mapConcept(reference) { + return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}}; +} + +// Flatten an inventory to path->value so a comparison reports exactly which leaves differ. +function flatten(value,path,out) { + if(Array.isArray(value)) {out.set(`${path}.length`,value.length);value.forEach((item,index)=>flatten(item,`${path}.${index}`,out));} + else if(value&&typeof value==='object')for(const [key,item] of Object.entries(value))flatten(item,path?`${path}.${key}`:key,out); + else out.set(path,value); + return out; +} +export function diffInventory(reference,actual) { + const left=flatten(reference,'',new Map()),right=flatten(actual,'',new Map()); + return [...new Set([...left.keys(),...right.keys()])].filter(path=>left.get(path)!==right.get(path)) + .map(path=>({path,reference:left.get(path),actual:right.get(path)})); +} diff --git a/tests/browser/library-editor-visual.spec.mjs b/tests/browser/library-editor-visual.spec.mjs new file mode 100644 index 0000000..47cd71c --- /dev/null +++ b/tests/browser/library-editor-visual.spec.mjs @@ -0,0 +1,49 @@ +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; + +// Artifact-only captures of the editor states that have no concept counterpart (archive confirmation, Show archived, +// busy and error). The four concept-referenced modes are captured as concept/production pairs by +// workspace-library-editor.spec.mjs. There are deliberately no pixel assertions or baselines: the maintainer accepts +// these captures visually before any baseline is added. +test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); +const workspace='/prompt_manager/workspace'; +const editor=page=>page.locator('dialog.library-editor'); +const variants=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); +const note=page=>editor(page).locator('.warning-note'); + +test.beforeEach(async({page,request})=>{ + const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json(); +}); + +for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height});const f=page.fixture; + await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible(); + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor'); + + await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click(); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible(); + await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled(); + await editor(page).getByRole('button',{name:'Archive…',exact:true}).click(); + await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive'); + + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();}); + await editor(page).getByRole('button',{name:'Archive',exact:true}).click(); + await expect(note(page)).toBeVisible();await shot('editor-busy'); + release();await expect(editor(page)).toHaveCount(0);await page.unroute(`**/prompt_manager/pairs/${f.pair_id}`); + + await variants(page).getByRole('button',{name:/^Show archived/}).click(); + await expect(variants(page).locator('.library-variant-archived').first()).toBeVisible();await shot('editor-archived-library'); + + await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click(); + await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled(); + await page.route('**/prompt_manager/library',route=>route.request().method()==='POST' + ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1}); + await editor(page).getByLabel('Title',{exact:true}).fill(`Long title ${'W'.repeat(160)} with a very long tail`); + await editor(page).getByLabel('Positive prompt',{exact:true}).fill('A quiet harbor at dawn'); + await editor(page).getByLabel('Notes',{exact:true}).fill(Array.from({length:40},(_,index)=>`Long note line ${index}`).join('\n')); + await editor(page).getByRole('button',{name:'Save',exact:true}).click(); + await expect(note(page)).toHaveText('Not saved: Title is too long. Your entries are kept.'); + await shot('editor-long-error'); +}); diff --git a/tests/browser/server.py b/tests/browser/server.py index df17e50..6533622 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -21,6 +21,9 @@ from tests.browser.workspace_history_fixture import ( register_workspace_history_commands, seed_workspace_history, ) +from tests.browser.workspace_library_editor_fixture import ( + register_library_editor_commands, +) CANONICAL_ROOT = Path(__file__).resolve().parents[2] CANONICAL_ASSETS = { @@ -100,6 +103,7 @@ def create_app(root: Path) -> web.Application: api = PromptManagerAPI() api.add_routes(routes) history_commands = register_workspace_history_commands(routes, api, root) + library_editor_commands = register_library_editor_commands(routes, api, root) async def canonical_asset(request: web.Request) -> web.Response: filename, content_type = CANONICAL_ASSETS[request.path] @@ -274,6 +278,7 @@ def create_app(root: Path) -> web.Application: @routes.post("/__test__/reset") async def reset(request: web.Request) -> web.Response: nonlocal history_fixture + library_editor_commands.reset() data = await request.json() if request.can_read_body else {} if not isinstance(data, dict): raise web.HTTPBadRequest() diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index e14378c..dd15012 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -333,7 +333,7 @@ test('route reentry restores older timeline range and both scroll axes after 61 await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape'); const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));}); const scroll=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(scroll.left).toBeGreaterThan(0);expect(scroll.top).toBeGreaterThan(0); - await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'}); + await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'}); let windowReads=0;page.on('request',request=>{if(/\/library\/\d+\/history$/.test(new URL(request.url()).pathname))windowReads++;}); let release,entered;const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;}); await page.route(new RegExp(`/history/runs/${oldRun}\\?`),async route=>{const response=await route.fetch();entered();await held;await route.fulfill({response}).catch(()=>{});}); @@ -366,7 +366,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark',' await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape'); const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));}); const before=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));await reviewCapture(page,testInfo,`range-before-${width}-${theme}`,timeline); - await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'}); + await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'}); await page.evaluate(id=>{location.hash=`#/library/${id}`;},range.positive_id);await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true'); const after=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(after.left-before.left)).toBeLessThanOrEqual(1);expect(Math.abs(after.top-before.top)).toBeLessThanOrEqual(1); expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(ids); @@ -421,7 +421,7 @@ test('more than 1000 real runs retain sparse columns, focused identity and pixel await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound); expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound); expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true); - await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0); + await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0); }); test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{ @@ -560,6 +560,24 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled(); }); +test('an authoritative save after a stale revalidation makes the saved view current again',async({page,request})=>{ + const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); + for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK(); + await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click(); + const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click(); + const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent(); + await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}})); + await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape'); + await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.first()).toBeDisabled(); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const editor=page.locator('dialog.library-editor'); + await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation'); + await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0); + await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden(); + await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1); + expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative); + const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length); +}); + async function retainSavedSelection(page,request,fixture) { await open(page,fixture.detail_id); const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json(); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs new file mode 100644 index 0000000..8193f09 --- /dev/null +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -0,0 +1,786 @@ +import {writeFile} from 'node:fs/promises'; +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs'; + +// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions. +test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); +const workspace='/prompt_manager/workspace'; +const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']}; +const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes']; +const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']]; +const editorDialog=page=>page.locator('dialog.library-editor'); + +test.beforeEach(async({page,request,browserName})=>{ + await startCoverage(page,browserName); + const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json(); + page.editorErrors=[];page.on('pageerror',error=>page.editorErrors.push(error.message)); + page.writes=[];page.on('request',item=>{if(item.method()!=='GET')page.writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});}); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.editorErrors).toEqual([]);}); + +async function mountEditor(page,theme) { + await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await page.evaluate(async()=>{ + const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger); + window.editorEvents={committed:[],reconciled:[]}; + try { + const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs'); + const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); + const scope=createScope(); + const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope, + onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)}); + window.editorHarness={scope,editor,showWorkspaceToast}; + } catch(error) {window.editorHarness={missing:String(error)};} + }); +} +async function openEditor(page,options) { + await page.evaluate(options=>{const trigger=document.getElementById('editor-trigger');trigger.focus();window.editorHarness.editor?.open({...options,trigger});},options); + const dialog=editorDialog(page);await expect(dialog).toBeVisible();return dialog; +} +const field=(dialog,label)=>dialog.getByLabel(label,{exact:true}); +const button=(dialog,name)=>dialog.getByRole('button',{name,exact:true}); +const note=dialog=>dialog.locator('.warning-note'); +async function ready(dialog) {await expect(button(dialog,'Save')).toBeEnabled();} +async function actionNames(dialog) {return dialog.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));} +const detail=async(request,id,archive='all')=>(await(await request.get(`/prompt_manager/library/${id}?archive=${archive}`)).json()); +const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pairs/${id}`)).json()).pair; + +test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{ + const f=page.fixture;await mountEditor(page); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id}); + await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible(); + expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true); + await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog); + expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(LABELS); + await expect(field(dialog,'Title')).toHaveValue(f.metadata.title); + await expect(field(dialog,'Positive prompt')).toHaveValue(f.positive_text); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); + for(const label of ['Positive prompt','Negative prompt (optional)'])await expect(field(dialog,label)).toHaveJSProperty('readOnly',true); + expect(await dialog.locator('textarea[readonly]').evaluateAll(nodes=>nodes.map(node=>node.tagName))).toEqual(['TEXTAREA','TEXTAREA']); + await expect(dialog.getByRole('button',{name:/copy|send/i})).toHaveCount(0); + await expect(dialog.getByRole('region')).toHaveCount(0); + await expect(field(dialog,'Category')).toHaveValue('Portraits'); + await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light'); + await expect(field(dialog,'Rating')).toHaveValue('4'); + await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes); + expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS); + expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']); + expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']); + await expect(dialog.locator('[placeholder]')).toHaveCount(0); + await expect(dialog.locator('.demo-note')).toHaveCount(0); + expect(await actionNames(dialog)).toEqual(ACTIONS.metadata); + await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/); + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); +}); + +test('editor modes persist through reload with selected saved negative',async({page,request})=>{ + const f=page.fixture;await mountEditor(page); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill(''); + await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0'); + await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click(); + await expect(dialog).toHaveCount(0); + expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]); + expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'}); + await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); + const saved=(await detail(request,f.positive_id)).positive; + expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']); + + await page.reload();await mountEditor(page); + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue(''); + await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); + await button(dialog,'Create text revision').click(); + await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible(); + await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true); + await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog); + await expect(field(dialog,'Positive prompt')).toHaveJSProperty('readOnly',false); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); + expect(await actionNames(dialog)).toEqual(ACTIONS.revision); + await field(dialog,'Positive prompt').fill('Editor portrait, revised dusk light');await field(dialog,'Category').fill(''); + await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library'); + expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative, + parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}}); + expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']); + await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); + const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); + expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id); + expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'}); + + dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); + expect(await actionNames(dialog)).toEqual(ACTIONS.new); + for(const label of ['Title','Positive prompt'])await expect(field(dialog,label)).toHaveJSProperty('required',true); + const before=page.writes.length;await button(dialog,'Save').click(); + await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true); + expect(page.writes).toHaveLength(before); + await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn'); + await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'', + parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}}); + + dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id}); + await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog); + expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Negative prompt']); + await expect(dialog.locator('p.small')).toHaveText('Leave empty for an explicitly empty negative. The positive stays unchanged.'); + expect(await actionNames(dialog)).toEqual(ACTIONS.negative); + await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + expect(page.writes.at(-1)).toMatchObject({method:'POST',url:`/prompt_manager/library/${f.positive_id}/variants`,body:{negative_text:'grain, dust'}}); + const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); + expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}}); + await expect(page.locator('#editor-trigger')).toBeFocused(); +}); + +test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{ + const f=page.fixture;await mountEditor(page); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog); + const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled(); + await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.'); + await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.'); + await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused(); + + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Archive…').click(); + await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible(); + await expect(button(dialog,'Close dialog')).toBeFocused(); + const block=dialog.getByRole('region',{name:'Negative prompt',exact:true}); + await expect(block).toHaveClass(/\bprompt-negative\b/);await expect(block.locator('pre.prompt-text')).toHaveText(f.selected_negative); + await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await expect(dialog).toContainText('You can restore it from Show archived in Negative variants.'); + expect(await actionNames(dialog)).toEqual(['Cancel','Archive']);await expect(button(dialog,'Archive')).toHaveClass(/\bdanger\b/); + await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); + await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); + expect((await pairRead(request,f.pair_id)).archived).toBe(false); + + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]); + expect((await pairRead(request,f.pair_id)).archived).toBe(true); + await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.at(-1)?.commit.kind)).toBe('archive-pair'); +}); + +test('a failed archive keeps focus on the Archive confirmation inside the dialog',async({page})=>{ + const f=page.fixture;await mountEditor(page); + let failed=false; + await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{ + if(route.request().method()!=='PATCH'||failed)return route.continue(); + failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'}); + }); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive').click(); + await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1); + await expect(button(dialog,'Archive')).toBeFocused(); + expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); +}); + +test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{ + const f=page.fixture;await mountEditor(page); + let conflicted=false; + await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{ + if(route.request().method()!=='PATCH'||conflicted)return route.continue(); + conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'}); + }); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive').click(); + await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.'); + await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1); + expect(await actionNames(dialog)).toEqual(['Reload','Cancel','Archive']); + await button(dialog,'Reload').click(); + await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review them, then archive.'); + await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText(f.selected_negative); + expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.pair_id)).archived).toBe(false); + await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/pairs/${f.pair_id}`,`PATCH /prompt_manager/pairs/${f.pair_id}`]); + expect((await pairRead(request,f.pair_id)).archived).toBe(true); +}); + +test('cancel and busy submit have honest write counts',async({page})=>{ + const f=page.fixture;await mountEditor(page); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(0); + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Notes').fill('changed then cancelled');await button(dialog,'Close dialog').click(); + await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); + + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();}); + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.'); + for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); + await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape'); + await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1); + release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1); + await expect(page.locator('#editor-trigger')).toBeFocused(); +}); + +test('recovery states render inline and never replay a write',async({page,request})=>{ + const f=page.fixture;await mountEditor(page); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null}); + await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.'); + await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeVisible(); + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + const current=await detail(request,f.positive_id,'active'); + await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{notes:'Changed elsewhere'}}})).toBeOK(); + await field(dialog,'Title').fill('Conflicting title');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('This prompt changed since it was loaded. Reload it and review your entries before saving.'); + await expect(button(dialog,'Save')).toBeDisabled();await button(dialog,'Reload').click(); + await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); + await expect(field(dialog,'Title')).toHaveValue('Conflicting title');await expect(field(dialog,'Notes')).toHaveValue('Changed elsewhere'); + await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + expect(page.writes.filter(write=>write.method==='PATCH')).toHaveLength(2); + expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Conflicting title',notes:'Changed elsewhere'}); + + page.writes.length=0; + dialog=await openEditor(page,{mode:'new'});await ready(dialog); + const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK(); + const restore=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:await snapshot.body()}}}); + await expect(restore).toBeOK();page.writes.length=0; + await field(dialog,'Title').fill('After restore');await field(dialog,'Positive prompt').fill('Restored storage prompt');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); + await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1); + await button(dialog,'Reload Library state').click(); + await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); + await expect(field(dialog,'Title')).toHaveValue('After restore');expect(page.writes).toHaveLength(1); + await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); + + page.writes.length=0;let dropped=false; + await page.route('**/prompt_manager/library',async route=>{ + if(route.request().method()==='POST'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} + await route.continue(); + }); + dialog=await openEditor(page,{mode:'new'});await ready(dialog); + await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.'); + await expect(button(dialog,'Save')).toBeDisabled(); + await page.unroute('**/prompt_manager/library'); + await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1}); + await button(dialog,'Retry').click(); + await expect(note(dialog)).toHaveText('Saved; Library refresh failed.'); + expect(await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt)).toMatchObject({positive_created:false,pair_created:false}); + expect(page.writes.map(write=>write.body.positive_text)).toEqual(['Lost response prompt','Lost response prompt']); + await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(2); + const rows=(await(await request.get('/prompt_manager/library?q=Lost%20response')).json()).items.filter(item=>item.text==='Lost response prompt'); + expect(rows).toHaveLength(1); +}); + +test('edge states keep drafts, explain disabled archive and clean up on dispose',async({page,request})=>{ + const f=page.fixture; + await page.route('**/prompt_manager/tags',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'})); + await mountEditor(page); + let dialog=await openEditor(page,{mode:'new',draft:{title:'Drafted title',notes:'Drafted notes'}});await ready(dialog); + await expect(field(dialog,'Title')).toHaveValue('Drafted title');await expect(field(dialog,'Notes')).toHaveValue('Drafted notes'); + expect(await field(dialog,'Tags, separated by commas').evaluate(node=>node.list.options.length)).toBe(0); + await field(dialog,'Title').fill(' ');await field(dialog,'Positive prompt').fill('Spaces only title');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Title is required');expect(page.writes).toHaveLength(0); + await page.route('**/prompt_manager/library',route=>route.request().method()==='POST' + ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1}); + await field(dialog,'Title').fill('Rejected title');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.'); + await expect(button(dialog,'Save')).toBeEnabled();await expect(field(dialog,'Title')).toHaveValue('Rejected title'); + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + + dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null}); + await expect(button(dialog,'Retry')).toBeVisible();await button(dialog,'Retry').click(); + await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');await button(dialog,'Cancel').click(); + + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative); + await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.'); + await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.'); + await button(dialog,'Cancel').click(); + + let failed=true; + await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();}); + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(''); + failed=false;await button(dialog,'Archive…').click(); + await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.'); + await expect(button(dialog,'Archive')).toBeDisabled(); + await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); + await button(dialog,'Retry').click();await expect(dialog).toContainText('0 images and 0 recorded runs stay linked.'); + await expect(button(dialog,'Archive')).toBeEnabled();expect(page.writes).toHaveLength(1);// only the rejected create + + await page.evaluate(()=>window.editorHarness.scope.dispose()); + await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused(); + await page.evaluate(()=>window.editorHarness.editor.open({mode:'new'}));await expect(editorDialog(page)).toHaveCount(0); + expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.empty_pair_id)).archived).toBe(false); +}); + +test('the archive confirmation does not inherit a failed or invalid Edit save message',async({page})=>{ + const f=page.fixture;await mountEditor(page); + await page.route(`**/prompt_manager/library/${f.positive_id}`,route=>route.request().method()==='PATCH' + ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Notes are too long.","code":null}'}):route.continue()); + let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.'); + await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); + dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required'); + await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); + await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); + expect(page.writes).toHaveLength(1); +}); + +test('an unconfirmed Edit save says Retry reloads for review and resends nothing',async({page})=>{ + const f=page.fixture;await mountEditor(page);let dropped=false; + await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{ + if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} + await route.continue(); + }); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'); + await button(dialog,'Retry').click(); + await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); + await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1); +}); + +function toastStyle(node) { + const style=getComputedStyle(node); + return Object.fromEntries(['position','bottom','left','transform','backgroundColor','borderTopWidth','borderTopColor','paddingTop','paddingLeft', + 'borderTopLeftRadius','boxShadow','zIndex','maxWidth','fontSize','display'].map(key=>[key,style[key]])); +} +for(const width of [1600,390])test(`the shared workspace toast is one polite status matching the concept ${width}`,async({page})=>{ + await page.setViewportSize({width,height:844}); + await page.goto('/__test__/canonical/workspace#library');await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('dark'); + await page.locator('#toast').evaluate(node=>{node.textContent='Negative saved without adding another positive or gallery.';});const reference=await page.locator('#toast').evaluate(toastStyle); + await mountEditor(page,'dark'); + const toast=page.locator('#workspace-toast');await expect(toast).toHaveCount(1); + await expect(toast).toHaveAttribute('role','status');await expect(toast).toHaveAttribute('aria-live','polite');await expect(toast).toBeHidden(); + await page.evaluate(()=>window.editorHarness.showWorkspaceToast('Negative saved without adding another positive or gallery.')); + await expect(page.getByRole('status').filter({hasText:'Negative saved without adding another positive or gallery.'})).toBeVisible(); + expect(await toast.evaluate(toastStyle)).toEqual(reference); + expect(reference).toMatchObject({position:'fixed',bottom:width<=700?'12px':'22px',borderTopWidth:'1px',paddingTop:'12px',paddingLeft:'18px',borderTopLeftRadius:'6px',fontSize:'12px'}); +}); + +const conceptModes={ + new:['library',async page=>page.getByRole('button',{name:'+ New prompt',exact:true}).click()], + metadata:['prompt/alpine',async page=>page.getByRole('button',{name:'Edit metadata',exact:true}).click()], + revision:['prompt/alpine',async page=>{await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await page.locator('#modal').getByRole('button',{name:'Create text revision',exact:true}).click();}], + negative:['prompt/alpine',async page=>page.getByRole('button',{name:'+ Add negative',exact:true}).click()], +}; + +for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`dialog geometry, tokens and controls match the concept ${width} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height});const f=page.fixture;const result={}; + for(const mode of Object.keys(conceptModes)) { + const [hash,openMode]=conceptModes[mode]; + await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`); + await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);const modal=page.locator('#modal');await expect(modal).toBeVisible(); + if(mode==='revision')await page.locator('#modal-close').focus(); + const reference=await modal.evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`concept-editor-${mode}-${width}-${theme}`,'#modal'); + result[mode]={reference}; + } + await mountEditor(page,theme); + const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}}; + for(const mode of Object.keys(conceptModes)) { + const dialog=await openEditor(page,options[mode]);await ready(dialog); + result[mode].actual=await dialog.evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`production-editor-${mode}-${width}-${theme}`,'dialog.library-editor'); + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + } + const path=testInfo.outputPath('editor-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach('editor-geometry',{path,contentType:'application/json'}); + for(const [mode,{reference,actual}] of Object.entries(result)) { + const expected=mapConcept(reference); + expect.soft(reference.demo,`${mode} concept demo note`).toBe(true); + expect.soft(expected.actions.buttons.map(item=>item.text),`${mode} actions`).toEqual(ACTIONS[mode]); + expect.soft(actual.dialog,`${mode} dialog`).toEqual(expected.dialog); + for(const key of ['header','title','close','fields','twoCol','small','actions','demo'])expect.soft(actual[key],`${mode} ${key}`).toEqual(expected[key]); + // The UA modal max-inline-size (100% - 6px - 2em) also applies to the concept at 390px. + expect.soft(actual.dialog).toMatchObject({paddingTop:'24px',borderTopLeftRadius:'9px',borderTopWidth:'1px',width:Math.min(650,width-24,width-34),modal:true}); + expect.soft(actual.title.fontSize).toBe('18px'); + expect.soft(actual.actions).toMatchObject({justifyContent:'flex-end',columnGap:'8px',flexWrap:'wrap',marginTop:'23px'}); + for(const item of actual.fields)expect.soft(item.field).toMatchObject({rowGap:'7px',fontSize:'12px'}); + for(const item of actual.fields.filter(item=>item.tag==='textarea'))expect.soft(item.control.minHeight).toBe('100px'); + if(actual.twoCol)expect.soft(actual.twoCol.columns,`${mode} two columns`).toBe(width<=700?1:2); + if(testInfo.project.name==='chromium')expect.soft(actual.dialog.backdrop).toBe('rgba(8, 11, 18, 0.72)'); + } + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); +}); + +// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel. +const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true}); +const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions'); +const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); +const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true}); +const toast=page=>page.locator('#workspace-toast'); +const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim())); +const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]'); +async function openHistory(page,id) { + await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible(); +} +async function selectPair(page,name) { + const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); +} +const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text}); + +test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{ + await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); + const actions=headingActions(page).locator('button'); + expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']); + await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); + await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/); + for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32); + await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); + await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise'); + await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0]; + await expect(page).toHaveURL(new RegExp(`#/library/${created.id}$`)); + await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(toast(page)).toHaveText('Saved.'); + await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise'); + expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']); +}); + +test('new from Images cancel preserves browse',async({page})=>{ + await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']); + const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1); + await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); + await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0); + expect(page.writes).toHaveLength(0); + await trigger.click();dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill('From images');await field(dialog,'Positive prompt').fill('A prompt saved from Images');await button(dialog,'Save').click(); + await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('From images');await expect(page.locator('#workspace-main h1')).toBeFocused(); +}); + +test('history heading follows the concept and sends the selected saved pair',async({page})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const actions=headingActions(page).locator('button'); + expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']); + await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0); + const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1); + await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`); + expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']); + await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false'); + await expect(send).toBeDisabled(); + await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled(); + await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0); + await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible(); + await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused(); + await actions.nth(0).click();await expect(page).toHaveURL(/#\/library$/);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); + expect(page.writes).toHaveLength(0); +}); + +test('add negative re-renders history with the saved or reused pair selected',async({page})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}); + await add.click();let dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click(); + await expect(dialog).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); + await expect(toast(page)).toHaveText('Negative saved without adding another positive or gallery.');await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('grain, dust'); + await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); + await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click(); + await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.'); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + await expect(laneLabel(page,'· 1 linked images')).toHaveCount(1); + await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3'); + expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]); +}); + +test('reused pair outside first forty remains selected without loading every page',async({page,request})=>{ + test.setTimeout(90000); + const f=page.fixture;let current=await detail(request,f.positive_id,'active'); + for(let index=0;index<41;index++) { + const response=await request.post(`/prompt_manager/library/${f.positive_id}/variants`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,negative_text:`bulk negative ${index}`}}); + await expect(response).toBeOK();current={...current,positive:(await response.json()).positive}; + } + const variantCursorRequests=[];page.on('request',item=>{if(item.url().includes('variant_cursor'))variantCursorRequests.push(item.url());}); + await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0; + await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click(); + const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative); + await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants'); + await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41); + expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1); +}); + +test('edit metadata stays on the page and re-reads the selected pair',async({page,request})=>{ + const f=page.fixture;await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2'); + const pairReads=[];page.on('request',item=>{if(item.method()==='GET'&&new URL(item.url()).pathname===`/prompt_manager/pairs/${f.pair_id}`)pairReads.push(item.url());}); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); + const before=pairReads.length; + await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(toast(page)).toHaveText('Saved.'); + await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work'); + await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2'); + await expect.poll(()=>pairReads.length).toBeGreaterThan(before); + await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place'); + expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Edited in place',category:'Studio work'}); +}); + +test('saved refresh failure retries only reads',async({page})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + let failed=false; + await page.route(`**/prompt_manager/library/${f.positive_id}?variant_limit=40`,route=>{if(page.writes.length&&!failed){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'});}return route.continue();}); + await field(dialog,'Notes').fill('Refresh after failure');await button(dialog,'Save').click(); + await expect(page.getByText('Saved; Library refresh failed.',{exact:true})).toBeVisible(); + const writeRequests=page.writes;expect(writeRequests).toHaveLength(1); + await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(writeRequests).toHaveLength(1); + await expect(page.locator('#workspace-main h1')).toBeFocused(); +}); + +test('restored storage after a confirmed save or before an archive offers an explicit Library reload',async({page,request})=>{ + const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body(); + const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}); + await openHistory(page,f.positive_id); + await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{ + if(route.request().method()!=='PATCH')return route.continue(); + const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response}); + }); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.'); + expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); + await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0); + await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); + await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1); + await selectPair(page,'Select negative variant 2'); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await expect(await restore()).toBeOK();await button(dialog,'Archive').click(); + await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.'); + expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1); + await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); + expect((await pairRead(request,f.pair_id)).archived).toBe(false); +}); + +test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{ + const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body(); + const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}); + await openHistory(page,f.positive_id); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); + await expect(await restore()).toBeOK(); + await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); + expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); + await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled(); + expect(page.writes).toHaveLength(1);// the refused save + await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); + expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes); + + await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await expect(await restore()).toBeOK(); + await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); + expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']); + await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2); + await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); + expect(page.writes).toHaveLength(2); + expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false); +}); + +test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route(`**/prompt_manager/pairs/${f.archived_pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();}); + const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/}); + const restoreButton=()=>variantsPanel(page).locator('.library-variant-archived').getByRole('button',{name:/^Restore/}); + await toggle().click();await expect(restoreButton()).toBeEnabled(); + await restoreButton().click();await expect.poll(()=>page.writes.length).toBe(1);await expect(restoreButton()).toBeDisabled(); + // Hiding and showing archived variants rebuilds the lane while the restore is in flight. + await toggle().click();await toggle().click(); + await expect(restoreButton()).toBeDisabled();await restoreButton().evaluate(node=>node.click()); + release(); + await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0); + expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]); + expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false); +}); + +test('archive hides a pair, Show archived finds it and Restore keeps it selected with links and counts unchanged',async({page,request})=>{ + const f=page.fixture; + const evidence=async()=>{ + const pair=await pairRead(request,f.pair_id);const gallery=await(await request.get(`/prompt_manager/library/${f.positive_id}/images?limit=50`)).json(); + const history=await(await request.get(`/prompt_manager/library/${f.positive_id}/history`)).json();const parent=(await detail(request,f.positive_id,'all')).positive; + return {counts:{images:pair.image_count,runs:pair.run_count},gallery:gallery.images.map(item=>item.id),history:history.recorded_run_count,parentImages:parent.image_count}; + }; + const before=await evidence();expect(before.counts).toEqual({images:1,runs:2}); + await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2'); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative); + expect(await evidence()).toEqual(before); + const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false'); + await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true'); + const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2); + const lane=archived.filter({hasText:'· 1 linked images · Archived'});await expect(lane).toHaveCount(1); + const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1); + const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); + await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); + expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id)); + await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled(); + const writes=page.writes.length; + await lane.getByRole('button',{name:/^Restore/}).click(); + await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible(); + await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1); + await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]); + expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before); + await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); + expect(galleryReads.at(-1).get('archive')).toBeNull(); +}); + +test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{ + await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone'); + await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); + const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);}); + await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click(); + await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter'); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); + await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone'); + await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); + await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible(); + expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true); + expect(shelfReads.at(-1).get('text')).toBe('Standalone'); +}); + +test('export downloads the full Library JSON without a toast and reports a failure before any download',async({page,request})=>{ + await page.addInitScript(()=>{window.revokedUrls=[];const revoke=URL.revokeObjectURL.bind(URL);URL.revokeObjectURL=url=>{window.revokedUrls.push(url);revoke(url);};}); + await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); + const exportButton=page.getByRole('button',{name:'Export full Library JSON',exact:true}); + const [download]=await Promise.all([page.waitForEvent('download'),exportButton.click()]); + expect(download.suggestedFilename()).toBe('prompt-manager-library-v1.json'); + const saved=JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(),'utf8')); + expect(saved).toEqual(await(await request.get('/prompt_manager/library/export')).json()); + expect(saved).toMatchObject({schema_version:1,kind:'prompt-manager-library'}); + await expect.poll(()=>page.evaluate(()=>window.revokedUrls.length)).toBe(1);await expect(toast(page)).toBeHidden(); + await page.route('**/prompt_manager/library/export',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'})); + let downloads=0;page.on('download',()=>downloads++);await exportButton.click(); + await expect(page.getByRole('alert').filter({hasText:'The Library export could not be downloaded.'})).toBeVisible(); + expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0); +}); + +const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);}; + +test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); + await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Negative prompt').fill(f.archived_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + const message='This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.'; + await expect(toast(page)).toHaveText(message); + await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true'); + const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1); + await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative); + await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id)); + await uniqueDotNames(page); + await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative); + await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); + await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); + await expect(toast(page)).toHaveText(message); + await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true'); + expect(page.writes.every(write=>write.method==='POST')).toBe(true); +}); + +test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{ + const f=page.fixture;await page.goto(`${workspace}#/library`); + await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative'); + await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click(); + await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); + await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); + await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes'); + await button(dialog,'Cancel').click(); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click(); + expect(page.writes).toHaveLength(1); +}); + +test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/}); + await toggle().click();await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1); + await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0); + await selectPair(page,'Select negative variant 2'); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); + await expect(toggle()).toHaveText('Show archived (2)');await toggle().click(); + await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2); + await expect(laneLabel(page,'· 1 linked images · Archived')).toHaveCount(1);await uniqueDotNames(page); +}); + +test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{ + const f=page.fixture;await mountEditor(page,'dark'); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + // Work on a copy of the production dialog so the live editor is untouched; ids are renamed to keep label/list wiring valid. + await dialog.evaluate(node=>{ + const copy=node.cloneNode(true);copy.removeAttribute('open');copy.id='mutant';copy.classList.add('mutant'); + const ids=new Set([...copy.querySelectorAll('[id]')].map(item=>item.id)); + for(const item of copy.querySelectorAll('[id]'))item.id=`${item.id}-copy`; + for(const item of copy.querySelectorAll('[for],[list],[aria-labelledby],[aria-describedby]'))for(const attr of ['for','list','aria-labelledby','aria-describedby']) { + const value=item.getAttribute(attr);if(value&&ids.has(value))item.setAttribute(attr,`${value}-copy`); + } + copy.setAttribute('open','');node.parentElement.append(copy); + }); + const mutant=page.locator('dialog.mutant');await expect(mutant).toBeVisible(); + const baseline=await mutant.evaluate(inventoryOf); + expect(diffInventory(baseline,await mutant.evaluate(inventoryOf))).toEqual([]); + await page.addStyleTag({content:'dialog.mutant .danger{color:rgb(1,2,3)!important}dialog.mutant .field input{color:rgb(4,5,6)!important;background-color:rgb(7,8,9)!important}'}); + await mutant.getByRole('button',{name:'Save',exact:true}).evaluate(node=>node.remove()); + const paths=diffInventory(baseline,await mutant.evaluate(inventoryOf)).map(item=>item.path); + expect(paths).toContain('actions.buttons.length'); + expect(paths).toContain('actions.buttons.1.color'); + expect(paths).toContain('fields.0.control.color'); + expect(paths).toContain('fields.0.control.backgroundColor'); + await page.keyboard.press('Escape'); +}); + +test('long and error states retain footer focus and reflow',async({page})=>{ + await page.setViewportSize({width:390,height:844}); + await mountEditor(page,'light'); + const dialog=await openEditor(page,{mode:'new'});await ready(dialog); + const unbroken='W'.repeat(240); + await field(dialog,'Title').fill(`A very long title that keeps going ${unbroken} and going`); + await field(dialog,'Positive prompt').fill('A quiet harbor at dawn'); + await field(dialog,'Notes').fill(Array.from({length:60},(_,index)=>`Long note line ${index} ${unbroken.slice(0,40)}`).join('\n')); + await page.route('**/prompt_manager/library',route=>route.request().method()==='POST' + ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1}); + await button(dialog,'Save').click(); + await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.'); + // Save had focus when the write started and keeps it after the failure; focus never falls out of the modal. + await expect(button(dialog,'Save')).toBeFocused(); + expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); + const state=await dialog.evaluate(node=>({scrolls:node.scrollHeight>node.clientHeight,height:node.getBoundingClientRect().height,innerHeight,overflowY:getComputedStyle(node).overflowY, + pageOverflow:document.documentElement.scrollWidth-document.documentElement.clientWidth,dialogOverflow:node.scrollWidth-node.clientWidth, + left:node.getBoundingClientRect().left,right:node.getBoundingClientRect().right,viewport:innerWidth})); + expect(state.scrolls).toBe(true);expect(state.height).toBeLessThanOrEqual(state.innerHeight*.9+1);expect(['auto','scroll']).toContain(state.overflowY); + expect(state.pageOverflow).toBeLessThanOrEqual(1);expect(state.dialogOverflow).toBeLessThanOrEqual(1);expect(state.left).toBeGreaterThanOrEqual(0);expect(state.right).toBeLessThanOrEqual(state.viewport); + for(const name of ['Cancel','Save']) { + const item=button(dialog,name);await item.scrollIntoViewIfNeeded();await expect(item).toBeVisible();await expect(item).toBeEnabled(); + await item.focus();await expect(item).toBeFocused(); + const box=await item.boundingBox();expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390); + } + // Keyboard: Tab from Cancel reaches Save inside the dialog; the draft survives the error. + await button(dialog,'Cancel').focus();await page.keyboard.press('Tab');await expect(button(dialog,'Save')).toBeFocused(); + await expect(field(dialog,'Positive prompt')).toHaveValue('A quiet harbor at dawn'); + expect(page.writes.filter(write=>write.method==='POST')).toHaveLength(1); + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 6440722..800073c 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -123,7 +123,7 @@ test('returning from history restores library context and mobile content stays i await openHistory(page);await selectVariant(page,0); await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); - await history(page).getByRole('link',{name:'Back to library',exact:true}).click(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click(); await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); @@ -148,7 +148,7 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor' expect(await page.evaluate(()=>scrollY)).toBe(0); const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); - await page.setViewportSize({width:390,height:844});await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape'); }); @@ -162,7 +162,7 @@ test('history detail failures retry and returning to the list ignores late detai await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible(); fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click(); - await page.getByRole('link',{name:'Back to library',exact:true}).click();release(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release(); await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0); } finally {release();} }); @@ -184,7 +184,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json(); await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark')); const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); - await saved(page).getByRole('button',{name:'Send prompt pair',exact:true}).click(); + await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click(); const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'}); @@ -331,7 +331,7 @@ test('row measurement preserves anchor after width invalidation and append',asyn const before=await firstVisibleParent(page); await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); if(changedWidth)await page.setViewportSize({width:1600,height:1100}); - await page.getByRole('link',{name:'Back to library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before); expect(await cards(page).count()).toBeLessThan(80); } expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 9aa5374..4975a81 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -101,7 +101,10 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true }); const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true }); expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt'); - for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible(); + for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible(); + // As in the concept, the selected pair's send sits in the history heading, not among the saved texts. + await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0); + await expect(page.locator('.workspace-heading button.send')).toBeEnabled(); await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2); await page.locator('.library-shared-gallery .image-open').first().click(); await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png'); @@ -517,7 +520,7 @@ test('Library visual evidence keeps natural cards and returns from real history await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); await expect(page.locator('.library-history > div > .intro')).toHaveCount(1); - await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); expect(errors).toEqual([]); @@ -616,7 +619,7 @@ test('embedded history gallery keeps the history heading and disposes its layout await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0); } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); - await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0); } }); diff --git a/tests/browser/workspace_library_editor_fixture.py b/tests/browser/workspace_library_editor_fixture.py new file mode 100644 index 0000000..270c42d --- /dev/null +++ b/tests/browser/workspace_library_editor_fixture.py @@ -0,0 +1,136 @@ +"""Owned, deterministic Library data for the native recipe editor workflows.""" + +from __future__ import annotations + +from pathlib import Path +import shutil +from typing import TYPE_CHECKING + +from aiohttp import web +from PIL import Image + +if TYPE_CHECKING: + from py.api import PromptManagerAPI + + +EDITOR_POSITIVE = "Editor portrait, soft window light" +FIRST_NEGATIVE = "blurry, watermark" +SELECTED_NEGATIVE = "low contrast, muddy shadows" +ARCHIVED_NEGATIVE = "oversaturated, harsh flash" +STANDALONE = "Standalone sketch without saved negatives" +EDITOR_METADATA = { + "title": "Editor portrait", + "category": "Portraits", + "tags": ["studio", "window light"], + "rating": 4, + "notes": "Keep the soft light.", +} +_TABLES = ( + "image_prompt_pairs", + "prompt_pair_tags", + "prompt_pairs", + "generated_images", + "prompt_tags", + "tags", + "prompt_library_state", + "prompts", +) + + +def seed_library_editor(api: PromptManagerAPI, root: Path) -> dict: + """Replace this server's disposable rows with one fixed editor Library.""" + db = api.db + with db.model.get_connection() as conn: + for table in _TABLES: + conn.execute(f"DELETE FROM {table}") + for name in ("output", "input"): + shutil.rmtree(root / name) + (root / name).mkdir() + epoch = db.get_execution_epoch() + receipt = db.create_library_recipe( + EDITOR_POSITIVE, + FIRST_NEGATIVE, + expected_epoch=epoch, + parent_metadata=dict(EDITOR_METADATA), + ) + positive_id = receipt["positive"]["id"] + first_pair_id = receipt["pair"]["id"] + pairs = {} + for name, text in ( + ("pair_id", SELECTED_NEGATIVE), + ("empty_pair_id", ""), + ("archived_pair_id", ARCHIVED_NEGATIVE), + ): + receipt = db.add_library_negative( + positive_id, + text, + expected_epoch=epoch, + if_match=receipt["positive"]["edit_token"], + ) + pairs[name] = receipt["pair"]["id"] + db.archive_prompt_pair(pairs["archived_pair_id"], True) + image_path = root / "output" / "editor" / "portrait.png" + image_path.parent.mkdir() + with Image.new("RGB", (64, 64), (40, 90, 120)) as image: + image.save(image_path) + image_id = db.link_image_to_prompt(positive_id, str(image_path)) + db.link_image_to_pair(pairs["pair_id"], image_id, {"source": "editor fixture"}) + db.record_prompt_pair_use(pairs["pair_id"], times=2) + standalone_id = db.save_prompt(STANDALONE, category="Sketches", tags=["draft"]) + with db.model.get_connection() as conn: + negative_only_id = conn.execute( + "SELECT negative_prompt_id FROM prompt_pairs WHERE id=?", + (first_pair_id,), + ).fetchone()[0] + api.invalidate_gallery_cache() + return { + "positive_id": positive_id, + "first_pair_id": first_pair_id, + **pairs, + "standalone_id": standalone_id, + "negative_only_id": negative_only_id, + "positive_text": EDITOR_POSITIVE, + "first_negative": FIRST_NEGATIVE, + "selected_negative": SELECTED_NEGATIVE, + "archived_negative": ARCHIVED_NEGATIVE, + "metadata": dict(EDITOR_METADATA), + } + + +class _LibraryEditorCommands: + """One disposable fixture lifetime; an ordinary reset expires it.""" + + def __init__(self) -> None: + self.manifest: dict | None = None + + def reset(self) -> None: + self.manifest = None + + +def register_library_editor_commands( + routes: web.RouteTableDef, api: PromptManagerAPI, root: Path +) -> _LibraryEditorCommands: + """Register only fixed seed and snapshot commands; no paths or SQL input.""" + state = _LibraryEditorCommands() + + @routes.post("/__test__/library-editor/reset") + async def reset(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else {} + if data != {} or request.query: + raise web.HTTPBadRequest() + state.manifest = await api._run_in_executor(seed_library_editor, api, root) + return web.json_response({"success": True, **state.manifest}) + + @routes.get("/__test__/library-editor/snapshot") + async def snapshot(request: web.Request) -> web.Response: + if state.manifest is None or request.query: + raise web.HTTPConflict() + path = root / "owned-library-editor-snapshot.db" + created = await api._run_in_executor(api.db.model.backup_database, str(path)) + if not created: + raise web.HTTPInternalServerError() + return web.Response( + body=path.read_bytes(), content_type="application/octet-stream" + ) + + return state diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index 9d8285f..92f2775 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -27,7 +27,8 @@ test('native library sends the selected saved pair into a real ComfyUI destinati const history=workspace.getByRole('region',{name:'Prompt history',exact:true}); await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); - await history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('button',{name:'Send prompt pair',exact:true}).click(); + // The pair send lives in the history heading, as in the approved concept. + await workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click(); const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1}); await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1}); @@ -44,3 +45,95 @@ test('native library sends the selected saved pair into a real ComfyUI destinati expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]); } finally {await workspace.close();} }); + +test('native editor preview sends exact saved roles across renderer transitions',async({page,context,request},testInfo)=>{ + test.setTimeout(150000); + const title=`Native editor host ${testInfo.project.name}`;const positive=`${title} positive ${Date.now()}`; + const id=await page.evaluate(async()=>{ + const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node); + node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id; + }); + const editor=page.locator('.pm-pair'); + await editor.getByText('Composition · prepend / append',{exact:true}).click(); + await editor.getByRole('textbox',{name:'Prepend',exact:true}).fill('Keep trigger');await editor.getByRole('textbox',{name:'Append',exact:true}).fill('Keep detail'); + const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message)); + try { + // Created through the native editor entry action, with an explicitly empty negative. + await workspace.goto('/prompt_manager/workspace#/library'); + await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click(); + const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled(); + await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive); + await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0); + await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1)); + await expect(workspace.locator('#workspace-main h1')).toHaveText(title); + const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true}); + await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); + const evidence=async()=>{ + const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json(); + const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json(); + return {history:{count:history.recorded_run_count,runs:history.items.map(item=>item.run_id)}, + pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))}; + }; + const before=await evidence();expect(before.pairMetadata).toHaveLength(1); + const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true}); + for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) { + await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer); + await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer); + await page.evaluate(async id=>{ + const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id); + node.widgets.find(widget=>widget.name==='text').value='Before positive';node.widgets.find(widget=>widget.name==='negative_text').value='Before negative'; + window.editorEffects={queue:0,load:0};window.editorOriginals={}; + for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.editorOriginals[method]=original;app[method]=function(...args){window.editorEffects[key]++;return original.apply(this,args);};} + },id); + try { + await expect(send).toBeEnabled();await send.click(); + const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + for(const text of ['Before positive','Before negative',positive,'(empty)',`Keep trigger ${positive} Keep detail`])await expect(sender).toContainText(text); + await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + const state=await page.evaluate(async id=>{ + const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize(); + return {inputs:(await app.graphToPrompt()).output[String(id)].inputs,serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.editorEffects}; + },id); + expect(state.effects).toEqual({queue:0,load:0}); + for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:positive,negative_text:'',prepend_text:'Keep trigger',append_text:'Keep detail'}); + await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused(); + } finally {await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.editorOriginals);});} + } + // A connected target rejects the whole preview: neither role changes, nothing queues or loads. + const link=await page.evaluate(async id=>{ + const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js'); + const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text'); + if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget); + node.widgets.find(item=>item.name==='text').value='Before positive';widget.value='Before negative'; + const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(item=>item.name==='value').value='Upstream negative'; + source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text')); + window.editorEffects={queue:0,load:0};window.editorOriginals={}; + for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.editorOriginals[method]=original;app[method]=function(...args){window.editorEffects[key]++;return original.apply(this,args);};} + return [String(source.id),0]; + },id); + try { + await send.click(); + const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await expect(sender.getByRole('alert')).toContainText(/connected/i); + await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled(); + const rejected=await page.evaluate(async id=>{ + const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize(); + return {inputs:(await app.graphToPrompt()).output[String(id)].inputs,widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])), + serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.editorEffects}; + },id); + expect(rejected.effects).toEqual({queue:0,load:0}); + expect(rejected.inputs.negative_text).toEqual(link); + for(const field of ['widgets','serialized'])expect(rejected[field]).toMatchObject({text:'Before positive',negative_text:'Before negative',prepend_text:'Keep trigger',append_text:'Keep detail'}); + await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0); + } finally {await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.editorOriginals);});} + const after=await evidence(); + expect(after.history).toEqual(before.history);expect(after.pairMetadata).toEqual(before.pairMetadata); + expect(errors).toEqual([]); + } finally {await workspace.close();} +}); diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 211f064..bde3ba0 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -253,3 +253,33 @@ test("ApiError exposes only validated history recovery actions on all error path } } }); + +test("patch sends a JSON body with the PATCH method and forwards an AbortSignal", async () => { + const fetch = fakeFetch(); + const api = createApiClient(fetch); + assert.equal(typeof api.patch, "function"); + const controller = new AbortController(); + const pending = api.patch("/prompt_manager/library/7", { metadata: { rating: null } }, { signal: controller.signal }); + assert.equal(fetch.calls[0].init.method, "PATCH"); + assert.equal(fetch.calls[0].init.body, JSON.stringify({ metadata: { rating: null } })); + assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json"); + assert.equal(fetch.calls[0].init.signal, controller.signal); + fetch.calls[0].resolve(jsonResponse(409, { success: false, code: "metadata-conflict", error: "changed" })); + await assert.rejects(pending, error => error instanceof ApiError && error.status === 409 && error.code === "metadata-conflict"); +}); + +test("get forwards an explicit cache mode and keeps the default request otherwise", async () => { + const fetch = fakeFetch(); + const api = createApiClient(fetch); + const fresh = api.get("/prompt_manager/library/options", { cache: "no-store" }); + const plain = api.get("/prompt_manager/library/options"); + assert.equal(fetch.calls[0].init.cache, "no-store"); + assert.equal("cache" in fetch.calls[1].init, false); + assert.deepEqual(fetch.calls[1].init, { method: "GET", headers: {} }); + for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true })); + await Promise.all([fresh, plain]); + const writes = api.post("/x", {}, { cache: "no-store" }); + assert.equal("cache" in fetch.calls[2].init, false, "only reads take a cache mode"); + fetch.calls[2].resolve(jsonResponse(200, { success: true })); + await writes; +}); diff --git a/tests/js/workspace-library-editor-state.test.js b/tests/js/workspace-library-editor-state.test.js new file mode 100644 index 0000000..5d60501 --- /dev/null +++ b/tests/js/workspace-library-editor-state.test.js @@ -0,0 +1,790 @@ +// Run with: node --test tests/js/workspace-library-editor-state.test.js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApiClient } = require('../../web/js/api-client.js'); + +// Only an absent module is tolerated, so RED is an assertion failure, not a crash. +const source = import('../../web/js/workspace/library-editor-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); + +const EPOCH = '11111111-1111-4111-8111-111111111111'; +const RESTORED = '22222222-2222-4222-8222-222222222222'; +const TOKEN = 'a'.repeat(64); +const TOKEN2 = 'b'.repeat(64); + +const flush = () => new Promise(resolve => setImmediate(resolve)); +async function settle(times = 6) { for (let i = 0; i < times; i++) await flush(); } +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function response(status, body) { + return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body }; +} + +/** Real createApiClient over a fetch whose answers come from handler(call). */ +function harness(handler) { + const calls = []; + const fetch = (url, init = {}) => { + const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false }; + calls.push(call); + return new Promise((resolve, reject) => { + init.signal?.addEventListener('abort', () => { + call.aborted = true; + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + Promise.resolve().then(() => handler(call)).then(resolve, reject); + }); + }; + return { api: createApiClient(fetch), calls, writes: () => calls.filter(call => call.method !== 'GET') }; +} + +const positive = (overrides = {}) => ({ + id: 7, text: 'First line\nsecond line', title: null, category: 'Studio', tags: ['soft', 'warm'], + rating: 3, notes: null, edit_token: TOKEN, created_at: '2026-01-01 00:00:00', variant_count: 1, image_count: 2, + ...overrides, +}); +const variant = (id, negative = 'blurry') => ({ + id, negative_text: negative, category: null, title: null, archived: false, + explicit_empty: negative === '', image_count: 1, tags: [], +}); +const pair = (overrides = {}) => ({ + id: 31, positive_prompt_id: 7, negative_prompt_id: 12, title: null, category: null, rating: null, notes: null, + created_at: '', updated_at: '', last_used_at: null, run_count: 4, archived: false, + positive_text: 'First line\nsecond line', negative_text: 'blurry', image_count: 2, tags: [], + ...overrides, +}); +const detail = (epoch = EPOCH, owner = positive(), variants = [variant(31)]) => ({ + success: true, library_epoch: epoch, positive: { ...owner, archived_variant_count: 0, previews: [] }, + variants, unassigned_image_count: 0, variant_total: variants.length, variants_has_more: false, variants_next_cursor: null, +}); +const options = (epoch = EPOCH) => ({ success: true, library_epoch: epoch, categories: ['Studio', 'Portrait'] }); +const recipe = (overrides = {}) => ({ + success: true, library_epoch: EPOCH, positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' }), + pair: pair({ id: 40, positive_prompt_id: 9, positive_text: 'a portrait', negative_text: '', negative_prompt_id: null }), + positive_created: true, pair_created: true, parent_metadata_applied: true, ...overrides, +}); + +/** Answer the standard Library reads; `write` answers anything else. */ +function library({ epoch = EPOCH, owner = positive(), variants = [variant(31)], selected = pair(), write } = {}) { + return call => { + const path = call.url.split('?')[0]; + if (call.method === 'GET' && path === '/prompt_manager/library/options') return response(200, options(epoch)); + if (call.method === 'GET' && path === `/prompt_manager/library/${owner.id}`) return response(200, detail(epoch, owner, variants)); + if (call.method === 'GET' && path === `/prompt_manager/pairs/${selected.id}`) return response(200, { success: true, pair: selected }); + if (write) return write(call); + throw new Error(`Unexpected ${call.method} ${call.url}`); + }; +} +/** Reads for the owner a create receipt returns. */ +const created = (write, epoch = EPOCH) => library({ epoch, owner: recipe().positive, selected: recipe().pair, write }); + +test('new prompt from Images waits for a fresh options epoch', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const read = deferred(); + const committed = [], reconciled = []; + const server = harness(created(() => response(200, recipe()))); + const original = server.api.get; + let first = true; + server.api.get = (url, init) => { + if (first) { first = false; return read.promise.then(() => original(url, init)); } + return original(url, init); + }; + const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onReconciled: r => reconciled.push(r) }); + const initialization = controller.initialize(); + assert.equal(controller.state.phase, 'initializing'); + controller.setField('title', ' Portrait '); + controller.setField('positive_text', 'a portrait'); + await controller.submit(); + assert.equal(server.writes().length, 0, 'pending initialization cannot save'); + read.resolve(); + await initialization; + const request = server.calls[0]; + assert.equal(request.url, '/prompt_manager/library/options'); + assert.equal(request.init.cache, 'no-store'); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.draft.title, ' Portrait ', 'typing survives the pending read'); + assert.deepEqual(controller.state.categories, ['Studio', 'Portrait']); + await controller.submit(); + const [write] = server.writes(); + assert.equal(write.method, 'POST'); + assert.equal(write.url, '/prompt_manager/library'); + const sentBody = write.body; + assert.equal(sentBody.expected_epoch, EPOCH); + assert.deepEqual(sentBody, { + expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '', + parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' }, + }); + assert.equal(controller.state.phase, 'complete'); + assert.equal(committed.length, 1); + assert.equal(committed[0].kind, 'create'); + assert.equal(reconciled.length, 1); + assert.equal(reconciled[0].pair.id, 40); + assert.equal(reconciled[0].detail.positive.id, 9); + assert.equal(controller.state.receipt.pair.id, 40); + assert.equal(controller.state.outcome.metadataNotApplied, false); + await controller.submit(); + assert.equal(server.writes().length, 1, 'submit once'); + controller.dispose(); +}); + +test('failed initialization retries only the read and retains draft', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let fail = 2; + const server = harness(call => { + if (fail-- > 0) return fail === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, library_epoch: 'not-a-uuid', categories: [] }); + return library()(call); + }); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + const pending = controller.initialize(); + controller.setField('notes', 'kept\n'); + await pending; + assert.equal(controller.state.phase, 'initialization-error'); + await controller.submit(); + await controller.retryInitialization(); + assert.equal(controller.state.phase, 'initialization-error', 'invalid epoch is not ready'); + await controller.retryInitialization(); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.draft.notes, 'kept\n'); + assert.equal(controller.state.error, null); + assert.equal(server.writes().length, 0); + assert.ok(server.calls.every(call => call.method === 'GET' && call.url === '/prompt_manager/library/options')); + assert.equal(server.calls.length, 3); + controller.dispose(); +}); + +test('late initialization cannot enable a replacement dialog', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const answers = [deferred(), deferred(), deferred()]; + let index = 0; + const server = harness(() => answers[index++].promise); + const old = createLibraryEditorController({ api: server.api, mode: 'new' }); + const oldInit = old.initialize(); + old.dispose(); + assert.equal(server.calls[0].aborted, true, 'disposal aborts the read'); + assert.equal(old.state.phase, 'disposed'); + const replacement = createLibraryEditorController({ api: server.api, mode: 'new' }); + const replacementInit = replacement.initialize(); + await settle(); + answers[0].resolve(response(200, options(RESTORED))); + await oldInit; + await settle(); + assert.equal(replacement.state.phase, 'initializing', 'an older dialog cannot enable this one'); + assert.equal(replacement.state.epoch, null); + // A retried read supersedes the earlier attempt inside the same dialog too. + answers[1].reject(new TypeError('offline')); + await replacementInit; + assert.equal(replacement.state.phase, 'initialization-error'); + const retry = replacement.retryInitialization(); + await settle(); + answers[2].resolve(response(200, options(EPOCH))); + await retry; + assert.equal(replacement.state.epoch, EPOCH); + assert.equal(replacement.state.phase, 'ready'); + replacement.dispose(); +}); + +test('restore after initialization rejects the old New submission', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let epoch = EPOCH; + const server = harness(call => created(write => write.body.expected_epoch === RESTORED + ? response(200, recipe({ library_epoch: RESTORED })) + : response(409, { success: false, code: 'storage-changed', error: 'Prompt storage was restored; refresh the library', library_epoch: RESTORED }), epoch)(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + await controller.initialize(); + controller.setField('title', 'Portrait'); + controller.setField('positive_text', 'a portrait'); + epoch = RESTORED; + await controller.submit(); + assert.equal(controller.state.phase, 'storage-changed'); + assert.equal(controller.state.error.code, 'storage-changed'); + assert.equal(controller.state.draft.title, 'Portrait'); + assert.equal(controller.state.epoch, EPOCH, 'the error response epoch is never borrowed'); + await controller.submit(); + assert.equal(server.writes().length, 1, 'Save is disabled after storage changed'); + await controller.reloadOwner({ keepDraft: true }); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.epoch, RESTORED); + assert.equal(controller.state.reviewRequired, true); + assert.equal(controller.state.draft.positive_text, 'a portrait'); + assert.equal(server.writes().length, 1, 'reload never replays the write'); + await controller.submit(); + assert.equal(server.writes().length, 2); + assert.equal(server.writes()[1].body.expected_epoch, RESTORED); + assert.equal(controller.state.phase, 'complete'); + controller.dispose(); +}); + +test('editor recovery preserves hyphenated ApiError codes', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const errors = [], phases = []; + for (const [code, extra] of [['metadata-conflict', { positive: positive({ edit_token: TOKEN2 }) }], ['storage-changed', {}], ['other-conflict', {}], [null, {}]]) { + const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) })); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + controller.setField('rating', 5); + await controller.submit(); + phases.push(controller.state.phase); + if (controller.state.phase !== 'write-error') errors.push(controller.state.error); + assert.equal(controller.state.draft.rating, 5, 'draft preserved'); + assert.equal(controller.state.receipt, null); + controller.dispose(); + } + assert.deepEqual(errors.map(e => e.code), ['metadata-conflict', 'storage-changed']); + assert.equal(errors[0].status, 409); + assert.deepEqual(phases, ['conflict', 'storage-changed', 'write-error', 'write-error'], 'an unrelated 409 is generic'); +}); + +test('lost save response never manufactures a receipt', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const committed = []; + let lose = true; + const reused = recipe({ positive_created: false, pair_created: false, parent_metadata_applied: false }); + const server = harness(created(() => { + if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); } + return response(200, reused); + })); + const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: commit => committed.push(commit) }); + await controller.initialize(); + controller.setField('title', 'Portrait'); + controller.setField('positive_text', 'a portrait'); + controller.setField('category', 'Uncategorized'); + await controller.submit(); + assert.equal(controller.state.receipt, null); + assert.equal(controller.state.phase, 'unknown-outcome'); + assert.equal(committed.length, 0); + assert.equal(server.writes().length, 1, 'no automatic retry'); + await settle(); + assert.equal(server.writes().length, 1); + controller.setField('title', 'Changed after the loss'); + await controller.submit(); + assert.equal(server.writes().length, 1, 'Save cannot replace the unconfirmed write'); + await controller.retryUnconfirmed(); + assert.equal(server.writes().length, 2); + assert.deepEqual(server.writes()[1].body, server.writes()[0].body, 'identical payload'); + assert.equal(controller.state.phase, 'complete'); + assert.equal(controller.state.receipt.positive_created, false); + assert.equal(controller.state.outcome.metadataNotApplied, false, 'stored metadata equals the submitted draft'); + assert.equal(committed.length, 1); + controller.dispose(); +}); + +test('reused retry discloses unapplied metadata only when it differs', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let lose = true; + const other = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false, + positive: positive({ id: 9, text: 'a portrait', title: 'Someone else', tags: ['x'] }) }); + const server = harness(created(() => { + if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); } + return response(200, other); + })); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + await controller.initialize(); + controller.setField('title', 'Portrait'); + controller.setField('positive_text', 'a portrait'); + await controller.submit(); + await controller.retryUnconfirmed(); + assert.equal(controller.state.phase, 'complete'); + assert.equal(controller.state.outcome.metadataNotApplied, true); + assert.equal(controller.state.outcome.pairCreated, true); + assert.equal(controller.state.outcome.positiveCreated, false); + controller.dispose(); +}); + +test('confirmed save refresh retry is read only', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let failReads = 1; + const committed = [], reconciled = []; + const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) }; + const base = library({ write: () => response(200, receipt) }); + const server = harness(call => { + if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline')); + return base(call); + }); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31, + onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) }); + await controller.initialize(); + controller.setField('rating', null); + controller.setField('notes', 'keep\ntext'); + await controller.submit(); + assert.equal(controller.state.phase, 'saved-refresh-error'); + assert.equal(controller.state.receipt.positive.edit_token, TOKEN2); + assert.equal(committed.length, 1); + assert.equal(committed[0].kind, 'metadata'); + const writeCalls = server.writes(); + await controller.submit(); + await controller.retryReconciliation(); + assert.equal(writeCalls.length, 1); // retryReconciliation only GETs + assert.equal(server.writes().length, 1); + assert.equal(controller.state.phase, 'complete'); + assert.equal(reconciled.length, 1); + assert.equal(reconciled[0].commit, committed[0]); + assert.equal(reconciled[0].pair, null); + assert.equal(reconciled[0].options.library_epoch, EPOCH); + controller.dispose(); +}); + +test('legacy title fallback is not an edit', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) }; + const server = harness(library({ write: () => response(200, receipt) })); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + assert.equal(controller.state.draft.title, 'First line', 'displayed fallback seeds the input'); + assert.equal(controller.state.baseline.title, null, 'stored title stays distinct'); + assert.equal(controller.state.draft.tags, 'soft, warm'); + assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative'); + controller.setField('rating', null); + controller.setField('notes', 'keep\ntext'); + controller.setField('tags', 'warm, soft,'); + controller.setField('category', ' Studio '); + await controller.submit(); + const [write] = server.writes(); + assert.equal(write.method, 'PATCH'); + assert.equal(write.url, '/prompt_manager/library/7'); + const sentBody = write.body; + assert.equal(sentBody.expected_epoch, EPOCH); + assert.equal(sentBody.if_match, TOKEN); + assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' }); + + const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await unchanged.initialize(); + unchanged.setField('notes', ''); + await unchanged.submit(); + assert.equal(server.writes().length, 1, 'unchanged edit submits nothing'); + assert.equal(unchanged.state.phase, 'complete'); + assert.equal(unchanged.state.receipt, null); + + const edited = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await edited.initialize(); + edited.setField('title', ' Studio portrait '); + edited.setField('category', ''); + await edited.submit(); + assert.deepEqual(server.writes()[1].body.metadata, { title: 'Studio portrait', category: '' }, 'Edit keeps a blank category blank'); + for (const item of [controller, unchanged, edited]) item.dispose(); +}); + +test('existing-parent forms read detail, options and the exact selected pair fresh', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const many = Array.from({ length: 40 }, (_, i) => variant(100 - i)); + const outside = pair({ id: 31, run_count: 9 }); + const server = harness(library({ variants: many, selected: outside })); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.pair.id, 31); + assert.equal(controller.state.pair.run_count, 9); + assert.ok(server.calls.every(call => call.init.cache === 'no-store')); + assert.deepEqual(server.calls.map(call => call.url.split('?')[0]).sort(), + ['/prompt_manager/library/7', '/prompt_manager/library/options', '/prompt_manager/pairs/31']); + controller.dispose(); + + for (const broken of [ + library({ selected: pair({ positive_prompt_id: 8 }) }), + library({ selected: pair({ positive_text: 'other text' }) }), + call => call.url.startsWith('/prompt_manager/library/options') ? response(200, options(RESTORED)) : library()(call), + library({ owner: positive({ id: 7, edit_token: 'short' }) }), + ]) { + const reads = harness(broken); + const rejected = createLibraryEditorController({ api: reads.api, mode: 'revision', positiveId: 7, pairId: 31 }); + await rejected.initialize(); + assert.equal(rejected.state.phase, 'initialization-error'); + await rejected.submit(); + assert.equal(reads.writes().length, 0); + rejected.dispose(); + } + const wrongOwner = harness(call => call.url.startsWith('/prompt_manager/library/7') ? response(200, detail(EPOCH, positive({ id: 8 }))) : library()(call)); + const mismatch = createLibraryEditorController({ api: wrongOwner.api, mode: 'negative', positiveId: 7 }); + await mismatch.initialize(); + assert.equal(mismatch.state.phase, 'initialization-error'); + mismatch.dispose(); + assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'metadata' }), TypeError); + assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'new', positiveId: 7 }), TypeError); + assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'archive', positiveId: 7 }), TypeError); +}); + +test('metadata conflict reload keeps changed fields for review and never replays', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let owner = positive(); + const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2 + ? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } }) + : response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + controller.setField('rating', '5'); + owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 }); + await controller.submit(); + assert.equal(controller.state.phase, 'conflict'); + await controller.submit(); + assert.equal(server.writes().length, 1); + await controller.reloadOwner({ keepDraft: true }); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.reviewRequired, true); + assert.equal(controller.state.draft.rating, 5, 'changed field kept'); + assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner'); + assert.equal(controller.state.draft.notes, 'other writer'); + assert.equal(server.writes().length, 1); + await controller.submit(); + assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } }); + assert.equal(controller.state.phase, 'complete'); + controller.dispose(); + + const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await discard.initialize(); + discard.setField('rating', 1); + await discard.reloadOwner({ keepDraft: false }); + assert.equal(discard.state.draft.rating, 3); + assert.equal(discard.state.reviewRequired, false); + discard.dispose(); +}); + +test('metadata unknown outcome retry reloads the owner instead of replaying', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let owner = positive(); + const server = harness(call => library({ owner, write: () => { + owner = positive({ rating: 5, edit_token: TOKEN2 }); + return Promise.reject(new TypeError('Failed to fetch')); + } })(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + controller.setField('rating', 5); + await controller.submit(); + assert.equal(controller.state.phase, 'unknown-outcome'); + await controller.retryUnconfirmed(); + assert.equal(server.writes().length, 1, 'a metadata retry requires review, not a replay'); + assert.equal(controller.state.phase, 'ready'); + assert.equal(controller.state.reviewRequired, true); + assert.equal(controller.state.owner.edit_token, TOKEN2); + await controller.submit(); + assert.equal(server.writes().length, 1, 'the applied edit is now unchanged'); + assert.equal(controller.state.phase, 'complete'); + controller.dispose(); +}); + +test('pair archive and restore patch the selected pair and reconcile', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let selected = pair(); + const committed = [], reconciled = []; + const server = harness(call => library({ selected, write: write => { + selected = pair({ archived: write.body.archived }); + return response(200, { success: true, pair: selected }); + } })(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31, + onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) }); + await controller.initialize(); + controller.setField('notes', 'unsaved'); + await controller.setArchive(true); + const [write] = server.writes(); + assert.equal(write.method, 'PATCH'); + assert.equal(write.url, '/prompt_manager/pairs/31'); + assert.deepEqual(write.body, { archived: true }); + assert.equal(committed[0].kind, 'archive-pair'); + assert.equal(controller.state.phase, 'complete'); + assert.equal(reconciled[0].pair.archived, true); + controller.dispose(); + + const restore = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31, + onCommitted: commit => committed.push(commit) }); + await restore.initialize(); + assert.equal(restore.state.pair.archived, true); + await restore.setArchive(false); + assert.equal(committed[1].kind, 'restore-pair'); + assert.deepEqual(server.writes()[1].body, { archived: false }); + restore.dispose(); + + let optionReads = 0; + const restored = harness(call => call.url === '/prompt_manager/library/options' && ++optionReads > 1 + ? response(200, options(RESTORED)) : library()(call)); + const guarded = createLibraryEditorController({ api: restored.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await guarded.initialize(); + await guarded.setArchive(true); + assert.equal(guarded.state.phase, 'storage-changed', 'a restored database cannot archive a reused pair ID'); + assert.equal(restored.writes().length, 0); + guarded.dispose(); + + const none = harness(library()); + const noPair = createLibraryEditorController({ api: none.api, mode: 'metadata', positiveId: 7 }); + await noPair.initialize(); + assert.equal(noPair.state.pair, null); + assert.equal(noPair.state.draft.negative_text, ''); + await noPair.setArchive(true); + assert.equal(none.writes().length, 0); + assert.match(noPair.state.error.message, /saved negative/); + noPair.dispose(); +}); + +test('add negative sends only the negative under the loaded token', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const archived = recipe({ positive: positive(), pair: pair({ id: 55, archived: true, negative_text: '' }), + positive_created: false, pair_created: false, parent_metadata_applied: false }); + const server = harness(library({ selected: pair({ id: 55, archived: true, negative_text: '' }), write: () => response(200, archived) })); + const committed = []; + const controller = createLibraryEditorController({ api: server.api, mode: 'negative', positiveId: 7, onCommitted: c => committed.push(c) }); + await controller.initialize(); + assert.ok(server.calls.every(call => !call.url.startsWith('/prompt_manager/library/options')), 'Add negative needs only the owner read'); + await controller.submit(); + const [write] = server.writes(); + assert.equal(write.url, '/prompt_manager/library/7/variants'); + assert.deepEqual(write.body, { expected_epoch: EPOCH, if_match: TOKEN, negative_text: '' }); + assert.equal(committed[0].kind, 'add-negative'); + assert.equal(controller.state.outcome.pairArchived, true); + assert.equal(controller.state.outcome.metadataNotApplied, false); + controller.dispose(); + + const forged = harness(library({ write: () => response(200, { ...archived, positive: positive({ id: 8 }) }) })); + const unconfirmed = createLibraryEditorController({ api: forged.api, mode: 'negative', positiveId: 7 }); + await unconfirmed.initialize(); + await unconfirmed.submit(); + assert.equal(unconfirmed.state.phase, 'unknown-outcome', 'an invalid receipt is not confirmation'); + assert.equal(unconfirmed.state.receipt, null); + unconfirmed.dispose(); +}); + +test('revision seeds text from the selected pair and sends Uncategorized for a blank category', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const server = harness(library({ owner: positive({ title: 'Portrait', category: null, notes: 'n' }), variants: [variant(30, 'first'), variant(31)], write: () => response(200, recipe()) })); + const controller = createLibraryEditorController({ api: server.api, mode: 'revision', positiveId: 7, pairId: 31 }); + await controller.initialize(); + assert.equal(controller.state.draft.negative_text, 'blurry', 'never the first arbitrary negative'); + assert.equal(controller.state.draft.positive_text, 'First line\nsecond line'); + controller.setField('positive_text', 'First line\nrevised'); + controller.setField('tags', ' a, b ,a,, '); + await controller.submit(); + assert.deepEqual(server.writes()[0].body, { + expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry', + parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' }, + }); + controller.dispose(); +}); + +test('invalid drafts and server failures keep the draft without a receipt', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let status = 500; + const server = harness(library({ write: () => response(status, { success: false, code: null, error: 'Library command failed' }) })); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + await controller.initialize(); + controller.setField('title', ' '); + controller.setField('positive_text', 'x'); + await controller.submit(); + assert.equal(server.writes().length, 0); + assert.equal(controller.state.phase, 'ready'); + assert.match(controller.state.error.message, /Title/); + assert.throws(() => controller.setField('unknown', 'x'), TypeError); + assert.throws(() => controller.setField('rating', 7), TypeError); + controller.setField('title', 'T'); + await controller.submit(); + assert.equal(controller.state.phase, 'write-error'); + assert.equal(controller.state.draft.title, 'T'); + status = 504; + await controller.submit(); + assert.equal(controller.state.phase, 'unknown-outcome', 'a gateway timeout cannot confirm either outcome'); + controller.dispose(); + controller.setField('title', 'ignored'); + assert.equal(controller.state.phase, 'disposed'); +}); + +test('disposal suppresses late write results without aborting the write but still reports the commit', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + const answer = deferred(); + const committed = [], changes = []; + const server = harness(library({ write: () => answer.promise })); + const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onChange: s => changes.push(s) }); + await controller.initialize(); + controller.setField('title', 'T'); + controller.setField('positive_text', 'x'); + const saving = controller.submit(); + await settle(); + assert.equal(controller.state.phase, 'saving'); + assert.ok(Object.isFrozen(controller.state)); + await controller.submit(); + assert.equal(server.writes().length, 1, 'duplicate submit is disabled'); + controller.dispose(); + assert.equal(server.writes()[0].aborted, false, 'abort cannot undo a write'); + const count = changes.length, calls = server.calls.length; + answer.resolve(response(200, recipe())); + await saving; + await settle(); + // A committed receipt still invalidates cached Library views after the dialog went away. + assert.equal(committed.length, 1); + assert.equal(committed[0].kind, 'create'); + assert.equal(committed[0].receipt.positive.id, 9); + assert.equal(server.calls.length, calls, 'no reconciliation read after dispose'); + assert.equal(changes.length, count); + assert.equal(controller.state.phase, 'disposed'); +}); + +test('disposal during an unconfirmed save reports no commit', async () => { + const { createLibraryEditorController } = await source; + const answer = deferred(); + const committed = []; + const server = harness(library({ write: () => answer.promise })); + const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c) }); + await controller.initialize(); + controller.setField('title', 'T'); + controller.setField('positive_text', 'x'); + const saving = controller.submit(); + await settle(); + controller.dispose(); + answer.resolve(response(200, { success: true })); + await saving; + await settle(); + assert.equal(committed.length, 0, 'an invalid receipt is not a commit'); +}); + +test('reconcileLibraryReceipt reads detail, options and the verified pair', async () => { + const { reconcileLibraryReceipt } = await source; + assert.equal(typeof reconcileLibraryReceipt, 'function', 'reconcile export'); + const receipt = recipe({ positive: positive(), pair: pair() }); + const server = harness(library()); + const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {}); + assert.equal(result.detail.positive.id, 7); + assert.equal(result.pair.id, 31); + assert.ok(!result.detail.variants.some(item => item === result.pair), 'the verified pair stays separate'); + assert.equal(server.writes().length, 0); + const restored = harness(library({ epoch: RESTORED })); + await assert.rejects(reconcileLibraryReceipt(restored.api, { kind: 'add-negative', receipt }, {}), error => error.code === 'storage-changed'); + const moved = harness(library({ selected: pair({ positive_prompt_id: 8 }) })); + await assert.rejects(reconcileLibraryReceipt(moved.api, { kind: 'add-negative', receipt }, {}), TypeError); + await assert.rejects(reconcileLibraryReceipt(server.api, { kind: 'archive', receipt }, {}), TypeError); +}); + +test('unconfirmed pair archive retry is fenced against restored storage', async () => { + const { createLibraryEditorController } = await source; + assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); + let epoch = EPOCH; + const server = harness(call => library({ epoch, write: () => { + epoch = RESTORED; + return Promise.reject(new TypeError('Failed to fetch')); + } })(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + await controller.setArchive(true); + assert.equal(controller.state.phase, 'unknown-outcome'); + assert.equal(server.writes().length, 1); + await controller.retryUnconfirmed(); + assert.equal(server.writes().length, 1, 'a restored database cannot take the retried pair write'); + assert.equal(controller.state.phase, 'storage-changed'); + assert.equal(controller.state.error.code, 'storage-changed'); + controller.dispose(); +}); + +test('refreshPair re-reads the selected pair through the owner check and reports failures without changing it', async () => { + const { createLibraryEditorController } = await source; + let selected = pair(); + let fail = null; + const server = harness(call => { + if (fail && call.method === 'GET' && call.url.startsWith('/prompt_manager/pairs/31')) return fail(); + return library({ selected })(call); + }); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + assert.equal(typeof controller.refreshPair, 'function', 'refreshPair export'); + await controller.initialize(); + assert.equal(controller.state.pairRead, 'fresh'); + + selected = pair({ image_count: 5, run_count: 9 }); + const pending = controller.refreshPair(); + assert.equal(controller.state.pairRead, 'reading'); + await pending; + assert.equal(controller.state.pairRead, 'fresh'); + assert.equal(controller.state.pair.image_count, 5); + assert.equal(controller.state.pair.run_count, 9); + assert.equal(controller.state.pairReadError, null); + const pairReads = server.calls.filter(call => call.url.startsWith('/prompt_manager/pairs/31')); + assert.equal(pairReads.at(-1).init.cache, 'no-store'); + + for (const [name, answer] of [ + ['other owner text', () => response(200, { success: true, pair: pair({ image_count: 77, positive_text: 'someone else' }) })], + ['other owner id', () => response(200, { success: true, pair: pair({ image_count: 77, positive_prompt_id: 8 }) })], + ['failed read', () => response(500, { success: false, error: 'down' })], + ['success false', () => response(200, { success: false, error: 'gone' })], + ]) { + fail = answer; + await controller.refreshPair(); + assert.equal(controller.state.pairRead, 'error', name); + assert.ok(controller.state.pairReadError instanceof Error, name); + assert.equal(controller.state.pair.image_count, 5, name); + assert.equal(controller.state.phase, 'ready', name); + } + assert.equal(server.writes().length, 0); + controller.dispose(); +}); + +test('refreshPair is inert without a selected pair, while busy and after dispose', async () => { + const { createLibraryEditorController } = await source; + const server = harness(library()); + const none = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7 }); + await none.initialize(); + const before = server.calls.length; + await none.refreshPair(); + assert.equal(server.calls.length, before); + assert.equal(none.state.pairRead, 'none'); + none.dispose(); + const disposed = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await disposed.initialize(); + disposed.dispose(); + const count = server.calls.length; + await disposed.refreshPair(); + assert.equal(server.calls.length, count); +}); + +test('reused pair outside first forty remains selected without loading every page', async () => { + const { reconcileLibraryReceipt } = await source; + const { libraryPromptRoles } = await import('../../web/js/workspace/library-state.mjs'); + const page = Array.from({ length: 40 }, (_, index) => variant(200 - index, `negative ${200 - index}`)); + const oldPair = pair({ id: 3, negative_text: 'the oldest saved negative', negative_prompt_id: 99 }); + const server = harness(call => { + const path = call.url.split('?')[0]; + if (call.method === 'GET' && path === '/prompt_manager/library/7') { + return response(200, { ...detail(EPOCH, positive(), page), variant_total: 85, variants_has_more: true, variants_next_cursor: 'c'.repeat(16) }); + } + return library({ selected: oldPair })(call); + }); + const receipt = recipe({ positive: positive(), pair: oldPair, positive_created: false, pair_created: false, parent_metadata_applied: false }); + const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {}); + const detailResult = result.detail; + assert.equal(detailResult.variants.length, 40); + assert.equal(detailResult.variant_total, 85); + assert.ok(!detailResult.variants.some(item => item.id === oldPair.id), 'the reused pair is outside the bounded page'); + assert.equal(libraryPromptRoles(detailResult, oldPair.id, result.pair).negative.text, oldPair.negative_text); + const variantCursorRequests = server.calls.filter(call => call.url.includes('variant_cursor')); + assert.equal(variantCursorRequests.length, 0); + assert.equal(server.writes().length, 0); +}); + +test('a refresh failed by restored storage cannot recover through retryReconciliation', async () => { + const { createLibraryEditorController } = await source; + let restored = false; + const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'saved', edit_token: TOKEN2 }) }; + const server = harness(call => library({ epoch: restored ? RESTORED : EPOCH, write: () => {restored = true; return response(200, receipt);} })(call)); + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); + await controller.initialize(); + controller.setField('notes', 'saved'); + await controller.submit(); + assert.equal(controller.state.phase, 'saved-refresh-error'); + assert.equal(controller.state.error.code, 'storage-changed'); + await controller.retryReconciliation(); + assert.equal(controller.state.phase, 'saved-refresh-error', 'only an explicit Library reload can leave this state'); + assert.equal(controller.state.error.code, 'storage-changed'); + assert.equal(server.writes().length, 1); + controller.dispose(); +}); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 219db80..98724dc 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -53,3 +53,29 @@ test('negative variants append continuously without losing selected identity, an const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}}); await loader.load(1);assert.equal(loader.state.detail.variants.length,1);await loader.loadMore();assert.match(loader.state.variantsError.message,/offline/);assert.equal(loader.state.detail.variants[0].id,9);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[9,10]);assert.match(calls[1],/variant_cursor=next/);assert.equal(calls[1],calls[2]);assert.equal(await loader.loadMore(),0);loader.dispose(); }); +test('a separately verified pair outside the loaded page supplies roles only when it belongs to the parent',async()=>{ + const {libraryPromptRoles}=await source;const owner=item(1);const detail={positive:owner,variants:[{id:9,negative_text:'',explicit_empty:true}]}; + const outside={id:70,positive_prompt_id:1,positive_text:owner.text,negative_text:'old, kept',archived:false}; + assert.equal(libraryPromptRoles(detail,70,outside).negative.text,'old, kept');assert.equal(libraryPromptRoles(detail,9,outside).negative.text,''); + assert.throws(()=>libraryPromptRoles(detail,70,{...outside,positive_prompt_id:2}));assert.throws(()=>libraryPromptRoles(detail,70,{...outside,positive_text:'other'})); + assert.throws(()=>libraryPromptRoles(detail,71,outside));assert.throws(()=>libraryPromptRoles(detail,70,null)); +}); +test('accept replaces the detail with validated reconciled data and aborts the request it supersedes',async()=>{ + const {createLibraryDetail}=await source;const pending=[],states=[];const loader=createLibraryDetail({api:{get:(url,{signal})=>new Promise((resolve,reject)=>pending.push({url,signal,resolve,reject}))},onChange:state=>states.push(state)}); + assert.equal(typeof loader.accept,'function'); + const first=loader.load(1);pending[0].resolve({positive:item(1),variants:[{id:9,negative_text:'a',explicit_empty:false}],variant_total:1,variants_has_more:false,variants_next_cursor:null});await first; + const more=loader.load(1); + loader.accept({positive:{...item(1),category:'Edited'},variants:[{id:10,negative_text:'b',explicit_empty:false}],variant_total:85,variants_has_more:true,variants_next_cursor:'next'}); + assert.equal(pending[1].signal.aborted,true);pending[1].resolve({positive:item(1),variants:[],variant_total:0,variants_has_more:false,variants_next_cursor:null});await more; + assert.equal(loader.state.detail.positive.category,'Edited');assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[10]);assert.equal(loader.state.loading,false);assert.equal(loader.state.error,null); + assert.throws(()=>loader.accept({positive:item(2),variants:[]}));assert.throws(()=>loader.accept({positive:item(1),variants:[{id:-1}]}));assert.equal(loader.state.detail.positive.category,'Edited'); + assert.equal(pending.length,2);loader.dispose();assert.throws(()=>loader.accept({positive:item(1),variants:[]})); +}); +test('archived variant reads use their own scope and cursor without changing the active default',async()=>{ + const {createLibraryDetail}=await source;const urls=[];const v=id=>({id,negative_text:`n${id}`,explicit_empty:false,archived:true});let call=0; + const loader=createLibraryDetail({api:{get:async url=>{urls.push(url);call++;return {positive:item(1),variants:[v(call)],variant_total:2,variants_has_more:call===1,variants_next_cursor:call===1?'arch':null};}},onChange:()=>{},archive:'archived'}); + await loader.load(1);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[1,2]); + for(const url of urls)assert.equal(new URL(url,'https://x').searchParams.get('archive'),'archived');assert.equal(new URL(urls[1],'https://x').searchParams.get('variant_cursor'),'arch'); + const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false); + assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'})); +}); diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 0c97b09..4713167 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -15,14 +15,14 @@ test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () = assert.match(output, /\[nodes2-start\]/); }); -test('execution script discovers only the new matrix and preserves all 78 stock cases', () => { +test('execution script discovers only the new matrix and preserves all 78 original stock cases plus the native editor host case', () => { const manifest = JSON.parse(readFileSync(new URL('../../tools/vendor/package.json', import.meta.url), 'utf8')); assert.equal(manifest.scripts['test:executions'], 'playwright test --config playwright.execution.config.mjs'); const list = config => execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js', 'test', '--config', config, '--list'], { cwd: root, encoding: 'utf8', env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } }); const stock = list('tools/vendor/comfyui.config.mjs'); - assert.match(stock, /Total: 78 tests in 7 files/); + assert.match(stock, /Total: 80 tests in 7 files/); assert.doesNotMatch(stock, /execution-history/); assert.doesNotMatch(stock, /execution-outputs/); const executions = list('tools/vendor/playwright.execution.config.mjs'); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ce0a193..0719181 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -215,6 +215,18 @@ "functions": 98, "statements": 98 }, + "workspace/library-editor-state.mjs": { + "lines": 96, + "branches": 91, + "functions": 98, + "statements": 96 + }, + "workspace/library-editor.mjs": { + "lines": 98, + "branches": 88, + "functions": 92, + "statements": 98 + }, "workspace/library-layout.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/api-client.js b/web/js/api-client.js index 23d65fa..8c07992 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -56,8 +56,9 @@ function createApiClient(fetchImpl) { const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args)); - async function request(method, url, { body, signal } = {}) { + async function request(method, url, { body, signal, cache } = {}) { const init = { method, headers: {} }; + if (cache !== undefined) init.cache = cache; if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); @@ -85,9 +86,11 @@ } return { - get: (url, { signal } = {}) => request("GET", url, { signal }), + // cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default. + get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }), post: (url, body, { signal } = {}) => request("POST", url, { body, signal }), put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }), + patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }), del: (url, { signal } = {}) => request("DELETE", url, { signal }), }; } diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index 2d87210..2862606 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -11,6 +11,8 @@ const paths = Object.freeze({ "search": "M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm5.5 13 5 5", "info": "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z", "copy": "M8 8h13v13H8z M16 8V3H3v13h5", + "send": "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13", + "plus": "M12 4v16M4 12h16", "branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0" }); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 596cfec..235e2a9 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -3,7 +3,7 @@ import { createButton, createField, createScope, createSelect } from '../shared/ import { routes, mountRouter, summarizeStats } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { mountImages } from './images.mjs'; -import { mountLibrary } from './library.mjs'; +import { mountLibrary, mountNewPromptAction } from './library.mjs'; import { createWorkspaceIcon } from './icons.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -67,6 +67,8 @@ function mountWorkspace() { } document.title = `${route.label} · Prompt Manager`; const heading = element('h1', route.label); + // Focus target after a save, as the concept's page title. + heading.tabIndex = -1; const headingCopy = element('div', undefined, 'workspace-heading-copy'); headingCopy.append(heading); const headingRegion = element('header', undefined, 'workspace-heading'); @@ -76,6 +78,7 @@ function mountWorkspace() { firstRender = false; document.getElementById('workspace-breadcrumb').textContent = route.label; if (route.id === 'images') { + mountNewPromptAction(main, api, view); mountImages(main, api, view); return; } diff --git a/web/js/workspace/library-editor-state.mjs b/web/js/workspace/library-editor-state.mjs new file mode 100644 index 0000000..9c09469 --- /dev/null +++ b/web/js/workspace/library-editor-state.mjs @@ -0,0 +1,394 @@ +/** + * Native Library editor state: fresh initialization, guarded saves and honest recovery. + * + * UI-agnostic. A dialog renders `controller.state` and calls its methods. Every + * controller owns its reads: a newer read, a retry or dispose() supersedes older + * ones, so a late response can never enable Save or supply an epoch. Writes are + * sent once, without an abort signal (aborting a request cannot undo a write), + * and are never replayed automatically. + */ +const MODES = new Set(['new', 'metadata', 'revision', 'negative']); +const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']); +const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating']; +const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'}; +// A proxy timeout or bad gateway cannot tell whether the server committed the write. +const UNCONFIRMED_STATUS = new Set([502, 503, 504]); +const SUBMITTABLE = new Set(['ready', 'write-error']); +const RELOADABLE = new Set(['ready', 'write-error', 'conflict', 'storage-changed', 'unknown-outcome', 'initialization-error']); +const UNCATEGORIZED = 'Uncategorized'; +const VARIANT_LIMIT = 40; +const OPTIONS_URL = '/prompt_manager/library/options'; + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value); +const token = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +const id = value => Number.isSafeInteger(value) && value > 0; +const count = value => Number.isSafeInteger(value) && value >= 0; +const nullableString = value => value === null || typeof value === 'string'; +const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string'); +const rating = value => value === null || (Number.isInteger(value) && value >= 1 && value <= 5); + +function requireValue(valid, message = 'Invalid Library response') {if (!valid) throw new TypeError(message);} +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);} + return value; +} +function storageChanged() { + return Object.assign(new Error('Prompt storage was restored; refresh the library'), {code: 'storage-changed'}); +} +// Duck-typed by name: this ES module cannot import the UMD api-client.js, and a +// browser page may load a different ApiError class instance than the Node tests. +function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);} + +function validateParent(value, positiveId) { + requireValue(object(value) && id(value.id) && typeof value.text === 'string' && nullableString(value.title) && + nullableString(value.category) && strings(value.tags) && rating(value.rating) && nullableString(value.notes) && + token(value.edit_token) && (positiveId === undefined || value.id === positiveId), 'Invalid prompt owner'); +} +function validatePair(value, owner) { + requireValue(object(value) && id(value.id) && id(value.positive_prompt_id) && typeof value.positive_text === 'string' && + typeof value.negative_text === 'string' && typeof value.archived === 'boolean' && count(value.image_count) && + count(value.run_count), 'Invalid saved negative variant'); + requireValue(value.positive_prompt_id === owner.id && value.positive_text === owner.text, + 'Saved negative variant belongs to another prompt'); +} + +const fresh = (api, url, signal) => api.get(url, {signal, cache: 'no-store'}); +async function readOptions(api, signal) { + const value = await fresh(api, OPTIONS_URL, signal); + requireValue(object(value) && value.success === true && uuid(value.library_epoch) && strings(value.categories), 'Invalid Library options'); + return value; +} +async function readDetail(api, positiveId, signal) { + const value = await fresh(api, `/prompt_manager/library/${positiveId}?variant_limit=${VARIANT_LIMIT}`, signal); + requireValue(object(value) && value.success === true && uuid(value.library_epoch) && Array.isArray(value.variants), 'Invalid prompt history'); + validateParent(value.positive, positiveId); + return value; +} +/** The exact pair by ID; ownership is checked against the parent read by the caller. */ +async function readPair(api, pairId, signal) { + const value = await fresh(api, `/prompt_manager/pairs/${pairId}`, signal); + requireValue(object(value) && value.success === true && object(value.pair) && value.pair.id === pairId, 'Invalid saved negative variant'); + return value.pair; +} + +/** Detail, options and pair read together; snapshots from different epochs are rejected. */ +async function readOwner(api, {positiveId, pairId, withOptions}, signal) { + const [detail, options, pair] = await Promise.all([readDetail(api, positiveId, signal), + withOptions ? readOptions(api, signal) : null, pairId === null ? null : readPair(api, pairId, signal)]); + if (options && options.library_epoch !== detail.library_epoch) throw storageChanged(); + if (pair) validatePair(pair, detail.positive); + return {epoch: detail.library_epoch, owner: detail.positive, pair, categories: options ? options.categories : [], detail, options}; +} + +/** New prompt has no owner: only a fresh options epoch makes it ready. */ +async function readNewSnapshot(api, signal) { + const options = await readOptions(api, signal); + return {epoch: options.library_epoch, owner: null, pair: null, categories: options.categories}; +} + +/** + * Re-read the saved owner after a confirmed receipt. Reads only: detail, options + * and (when the commit names one) the exact pair, kept apart from detail.variants. + */ +export async function reconcileLibraryReceipt(api, commit, {signal} = {}) { + requireValue(object(commit) && KINDS.has(commit.kind) && object(commit.receipt), 'Invalid editor commit'); + const {receipt} = commit; + const positiveId = receipt.positive ? receipt.positive.id : receipt.pair?.positive_prompt_id; + requireValue(id(positiveId), 'Invalid editor commit'); + const pairId = receipt.pair ? receipt.pair.id : null; + const {detail, options, pair} = await readOwner(api, {positiveId, pairId, withOptions: true}, signal); + if (receipt.library_epoch !== detail.library_epoch) throw storageChanged(); + return freeze({commit, detail, pair, options}); +} + +export function fallbackTitle(text) {return text.split('\n')[0].trim();} +export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];} +const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length; +const stored = value => (value ?? '').trim(); +function normalizeRating(value) { + if (value === null || value === '') return null; + const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value; + if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null'); + return number; +} + +const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null}); +function seedDraft(mode, {owner, pair}) { + if (!owner) return blankDraft(); + if (mode === 'negative') return {...blankDraft(), positive_text: owner.text}; + return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text, + negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '), + notes: owner.notes ?? '', rating: owner.rating}; +} +/** Keep fields the user changed from the previous seed; the rest follow the new seed. */ +function overlay(next, draft, previous) { + return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]])); +} +function baselineOf(owner) { + return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes}; +} + +/** Only changed fields; an untouched fallback title is not a title edit. */ +function metadataChanges(draft, seed, baseline) { + const changes = {}; + const title = draft.title.trim(), category = draft.category.trim(), tags = parseTags(draft.tags); + if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title; + if (category !== stored(baseline.category)) changes.category = category; + if (!sameTags(tags, baseline.tags)) changes.tags = tags; + if (draft.rating !== baseline.rating) changes.rating = draft.rating; + if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes; + return changes; +} +function createMetadata(draft) { + return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags), + rating: draft.rating, notes: draft.notes}; +} +function sameMetadata(sent, owner) { + return sent.title === stored(owner.title) && sent.category === stored(owner.category) && sameTags(sent.tags, owner.tags) && + sent.rating === owner.rating && sent.notes === (owner.notes ?? ''); +} +function draftProblem(mode, draft) { + if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'}); + if ((mode === 'new' || mode === 'revision') && !draft.positive_text.trim()) { + return Object.assign(new Error('Positive prompt is required'), {code: 'invalid-draft'}); + } + return null; +} + +/** Phase for a failed write: only an exact 409 code drives recovery. */ +function failedPhase(error) { + if (!isApiError(error)) return 'unknown-outcome'; + if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code]; + if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome'; + return 'write-error'; +} + +function validateReceipt(command, value, {positiveId, owner, pairId}) { + requireValue(object(value) && value.success === true, 'Invalid save receipt'); + if (command.kind === 'archive-pair' || command.kind === 'restore-pair') { + validatePair(value.pair, owner); + requireValue(value.pair.id === pairId && value.pair.archived === (command.kind === 'archive-pair'), 'Invalid save receipt'); + return {success: true, library_epoch: command.epoch, pair: value.pair}; + } + requireValue(value.library_epoch === command.body.expected_epoch, 'Invalid save receipt'); + validateParent(value.positive, command.kind === 'create' ? undefined : positiveId); + if (command.kind === 'metadata') return value; + validatePair(value.pair, value.positive); + requireValue(typeof value.positive_created === 'boolean' && typeof value.pair_created === 'boolean' && + value.parent_metadata_applied === value.positive_created && (value.pair_created || !value.positive_created) && + (command.kind === 'create' || !value.positive_created), 'Invalid save receipt'); + return value; +} +function outcomeOf(command, receipt) { + const recipe = command.kind === 'create' || command.kind === 'add-negative'; + return {kind: command.kind, positiveCreated: recipe && receipt.positive_created, pairCreated: recipe && receipt.pair_created, + metadataNotApplied: command.kind === 'create' && !receipt.parent_metadata_applied && + !sameMetadata(command.body.parent_metadata, receipt.positive), + pairArchived: receipt.pair ? receipt.pair.archived : null}; +} + +export function createLibraryEditorController({api, mode, positiveId = null, pairId = null, onChange = () => {}, + onCommitted = () => {}, onReconciled = () => {}} = {}) { + requireValue(MODES.has(mode), 'Unknown editor mode'); + const existing = mode !== 'new'; + requireValue(existing ? id(positiveId) : positiveId === null && pairId === null, 'Invalid editor owner'); + requireValue(pairId === null || id(pairId), 'Invalid editor pair'); + const withOptions = mode !== 'negative'; + let disposed = false, generation = 0, reads = null, started = null, review = false; + let seed = blankDraft(), pending = null, commit = null; + let state = freeze({phase: 'initializing', draft: blankDraft(), baseline: null, receipt: null, error: null, + reviewRequired: false, epoch: null, owner: null, pair: null, pairRead: 'none', pairReadError: null, categories: [], outcome: null, + reconciled: null, unchanged: false}); + const emit = patch => {if (disposed) return; state = freeze({...state, ...patch}); onChange(state);}; + + /** Start a read that supersedes every older one; current() is false once stale. */ + function beginRead() { + reads?.abort(); + const controller = reads = new AbortController(), attempt = ++generation; + return {signal: controller.signal, current() { + if (disposed || attempt !== generation) return false; + reads = null; return true; + }}; + } + + async function load(keepDraft) { + const read = beginRead(); + emit({phase: 'initializing', error: null}); + try { + const snapshot = existing ? await readOwner(api, {positiveId, pairId, withOptions}, read.signal) + : await readNewSnapshot(api, read.signal); + if (!read.current()) return; + const next = seedDraft(mode, snapshot); + const draft = keepDraft ? overlay(next, state.draft, seed) : next; + seed = next; + emit({phase: 'ready', draft, baseline: baselineOf(snapshot.owner), epoch: snapshot.epoch, owner: snapshot.owner, + pair: snapshot.pair, pairRead: snapshot.pair ? 'fresh' : 'none', pairReadError: null, categories: snapshot.categories, + error: null, reviewRequired: review && keepDraft}); + } catch (error) { + if (read.current()) emit({phase: 'initialization-error', error}); + } + } + + function command() { + const {draft, epoch, owner} = state; + if (mode === 'metadata') { + const metadata = metadataChanges(draft, seed, state.baseline); + return Object.keys(metadata).length ? {kind: 'metadata', method: 'patch', url: `/prompt_manager/library/${positiveId}`, + body: {expected_epoch: epoch, if_match: owner.edit_token, metadata}} : null; + } + if (mode === 'negative') { + return {kind: 'add-negative', method: 'post', url: `/prompt_manager/library/${positiveId}/variants`, + body: {expected_epoch: epoch, if_match: owner.edit_token, negative_text: draft.negative_text}}; + } + return {kind: 'create', method: 'post', url: '/prompt_manager/library', body: {expected_epoch: epoch, + positive_text: draft.positive_text, negative_text: draft.negative_text, parent_metadata: createMetadata(draft)}}; + } + + async function send(next) { + pending = next; + emit({phase: 'saving', error: null}); + let value; + try { + value = await api[next.method](next.url, next.body); + } catch (error) { + if (disposed) return; + const phase = failedPhase(error); + if (phase !== 'unknown-outcome') pending = null; + emit({phase, error}); + return; + } + let receipt; + try { + receipt = freeze(structuredClone(validateReceipt(next, value, {positiveId, owner: state.owner, pairId: state.pair?.id}))); + } catch (error) { + emit({phase: 'unknown-outcome', error}); + return; + } + pending = null; + commit = freeze({kind: next.kind, receipt}); + // A confirmed write still invalidates cached Library views after the dialog went away; nothing else runs then. + if (disposed) {onCommitted(commit); return;} + emit({receipt, outcome: outcomeOf(next, receipt)}); + onCommitted(commit); + await reconcile(); + } + + async function reconcile() { + const read = beginRead(); + try { + const result = await reconcileLibraryReceipt(api, commit, {signal: read.signal}); + if (!read.current()) return; + emit({phase: 'complete', reconciled: result, error: null}); + onReconciled(result); + } catch (error) { + if (read.current()) emit({phase: 'saved-refresh-error', error}); + } + } + + /** + * The pair route has no epoch guard, so every pair write, first or retried, + * first confirms with a fresh read that storage was not restored since + * `next.epoch`. A failed check read keeps `failurePhase` and writes nothing. + */ + async function sendFencedPair(next, failurePhase) { + emit({phase: 'saving', error: null}); + const read = beginRead(); + let current; + try { + current = await readOptions(api, read.signal); + } catch (error) { + if (read.current()) emit({phase: failurePhase, error}); + return; + } + if (!read.current()) return; + if (current.library_epoch !== next.epoch) { + pending = null; + emit({phase: 'storage-changed', error: storageChanged()}); + return; + } + await send(next); + } + + async function setArchive(value) { + if (typeof value !== 'boolean') throw new TypeError('Archive state must be a boolean'); + if (disposed || !existing || !SUBMITTABLE.has(state.phase)) return; + if (!state.pair) {emit({error: new Error('Choose a saved negative variant first.')}); return;} + await sendFencedPair(freeze({kind: value ? 'archive-pair' : 'restore-pair', method: 'patch', + url: `/prompt_manager/pairs/${state.pair.id}`, body: {archived: value}, epoch: state.epoch}), 'write-error'); + } + + return { + get state() {return state;}, + initialize() { + if (!started) started = disposed ? Promise.resolve() : load(true); + return started; + }, + setField(name, value) { + if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`); + const next = name === 'rating' ? normalizeRating(value) : value; + if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`); + emit({draft: {...state.draft, [name]: next}}); + }, + async submit() { + if (disposed || !SUBMITTABLE.has(state.phase)) return; + const problem = draftProblem(mode, state.draft); + if (problem) {emit({error: problem}); return;} + const next = command(); + if (!next) {emit({phase: 'complete', error: null, unchanged: true}); return;} + await send(freeze(next)); + }, + setArchive, + /** + * Re-read the selected pair (for example its linked counts) through the same + * exact-ID read and owner check as initialization. A failure keeps the + * previous pair and is reported as `pairRead: 'error'` with `pairReadError`. + */ + async refreshPair() { + if (disposed || !existing || !state.pair || !SUBMITTABLE.has(state.phase)) return; + const read = beginRead(); + emit({pairRead: 'reading', pairReadError: null}); + try { + const pair = await readPair(api, state.pair.id, read.signal); + validatePair(pair, state.owner); + if (read.current()) emit({pair, pairRead: 'fresh'}); + } catch (error) { + if (read.current()) emit({pairRead: 'error', pairReadError: error}); + } + }, + async reloadOwner({keepDraft} = {}) { + if (typeof keepDraft !== 'boolean') throw new TypeError('reloadOwner needs keepDraft'); + if (disposed || !RELOADABLE.has(state.phase)) return; + pending = null; + review = keepDraft; + await load(keepDraft); + }, + async retryInitialization() { + if (!disposed && state.phase === 'initialization-error') await load(true); + }, + async retryReconciliation() { + if (!disposed && state.phase === 'saved-refresh-error') await reconcile(); + }, + /** Explicit user action only: resend the identical unconfirmed payload once. */ + async retryUnconfirmed() { + if (disposed || state.phase !== 'unknown-outcome' || !pending) return; + if (pending.kind === 'archive-pair' || pending.kind === 'restore-pair') { + // The outcome stays unknown if the fence read itself fails. + await sendFencedPair(pending, 'unknown-outcome'); + return; + } + if (pending.kind !== 'metadata') {await send(pending); return;} + // A metadata retry needs the current owner reviewed, never a replayed token. + pending = null; + review = true; + await load(true); + }, + dispose() { + if (disposed) return; + disposed = true; + reads?.abort(); + reads = null; + state = freeze({...state, phase: 'disposed'}); + }, + }; +} diff --git a/web/js/workspace/library-editor.css b/web/js/workspace/library-editor.css new file mode 100644 index 0000000..050e016 --- /dev/null +++ b/web/js/workspace/library-editor.css @@ -0,0 +1,27 @@ +/* Native recipe editor and shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */ +.library-editor, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; } +[data-ca-theme="light"] :where(.library-editor, .toast) { --editor-red: #b71931; --editor-warning: #815514; } +.ca-tools .library-editor { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; } +.library-editor::backdrop { background: #080b12b8; } +.library-editor .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; } +.library-editor .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; } +.library-editor :where(form, p) { margin: 0; line-height: inherit; } +.library-editor p + p { margin-top: 10px; } +.ca-tools .library-editor button { min-height: 32px; font-size: 12px; } +.ca-tools .library-editor .dialog-header > button { font-size: 20px; min-width: 32px; } +.library-editor .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; } +.library-editor .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; } +.library-editor .field :where(input, textarea, select) { width: 100%; font-size: 12px; } +.library-editor .field textarea { min-height: 100px; resize: vertical; } +.library-editor .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.library-editor .small { font-size: 12px; color: var(--ca-muted); } +.library-editor .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; } +.ca-tools .library-editor .danger { color: var(--editor-red); border-color: var(--editor-red); } +.library-editor :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } +.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--ca-surface); border: 1px solid var(--ca-accent); padding: 12px 18px; border-radius: 6px; box-shadow: 0 8px 35px #0004; z-index: 10; max-width: min(650px, 90vw); font-size: 12px; } +.toast:empty { display: none; } +@media (max-width: 700px) { + .library-editor .two-col { grid-template-columns: 1fr; } + .toast { bottom: 12px; } +} +@media (pointer: coarse) { .ca-tools .library-editor button { min-height: 40px; } } diff --git a/web/js/workspace/library-editor.mjs b/web/js/workspace/library-editor.mjs new file mode 100644 index 0000000..d964d57 --- /dev/null +++ b/web/js/workspace/library-editor.mjs @@ -0,0 +1,362 @@ +/** + * Native recipe editor dialogs: New prompt, Edit recipe metadata, Create text + * revision, Add a negative variant, and the pair archive confirmation. + * + * Markup, labels, order and native datalists follow the approved concept. All + * reads, writes, receipts and recovery live in library-editor-state.mjs; this + * module only renders `controller.state` and forwards user actions. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { createLibraryEditorController } from './library-editor-state.mjs'; + +const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'}; +const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']]; +const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category', + tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'}; +const SUBMITTABLE = new Set(['ready', 'write-error']); +const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.'; +const NO_PAIR = 'Archive needs a selected saved negative variant.'; +const ALREADY_ARCHIVED = 'This negative variant is already archived.'; +const REVIEW = 'Loaded the latest saved values. Review your entries, then save.'; +const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.'; +const TOAST_MS = 6500; +const REFRESH = ['Reload Library', 'refresh']; +let toastTimer; + +/** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */ +export function showWorkspaceToast(message) { + const toast = document.getElementById('workspace-toast'); + if (!toast) return; + toast.textContent = message; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS); +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Inline state copy and its one recovery action; `archive` selects the pair-archive + * wording. After a restore only New prompt may re-read: every other mode names an + * owner by its old numeric ID, so it offers only the Library refresh exit. + */ +function statusOf(state, archive, mode = null) { + const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.'; + const message = state.error?.message; + switch (state.phase) { + case 'initializing': return {text: 'Loading the latest saved values…'}; + case 'initialization-error': return {text: `The latest saved values could not be loaded. ${nothing}`, action: ['Retry', 'retryInitialization']}; + case 'saving': return {text: archive ? 'Archiving… The dialog stays open until the server answers.' : 'Saving… The dialog stays open until the server answers.'}; + case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`}; + case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.' + : 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']}; + case 'storage-changed': return {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: mode === 'new' ? ['Reload Library state', 'reload'] : REFRESH}; + case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.' + : mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.' + : 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']}; + case 'saved-refresh-error': + // A refresh that found restored storage cannot recover by re-reading; only reloading the Library view can. + if (state.error?.code === 'storage-changed') { + return {text: `${archive ? 'Archived' : 'Saved'}; Library refresh failed because prompt storage was restored. Reload the Library to see current data.`, action: REFRESH}; + } + return {text: archive ? 'Archived; Library refresh failed.' : 'Saved; Library refresh failed.', action: ['Retry refresh', 'retryReconciliation']}; + case 'ready': return message ? {text: message} : state.reviewRequired ? {text: archive ? REVIEW_ARCHIVE : REVIEW} : null; + default: return null; + } +} + +/** One persistent inline status in the concept's .warning-note style, so live updates are announced. */ +function statusLine() { + const note = el('p', '', 'warning-note'); + note.id = 'library-editor-status'; + note.setAttribute('role', 'status'); + note.hidden = true; + return {note, set(text) { + if (note.textContent !== (text || '')) note.textContent = text || ''; + note.hidden = !text; + }}; +} +/** Re-parent only on a real change: moving a focused button would drop its focus. */ +function setChildren(parent, nodes) { + const current = [...parent.children]; + if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes); +} + +/** A button that held focus when saving began gets it back after a failed or conflicting write (disabling drops focus to the body). */ +function restoreFocus(button, state) { + if (!button || state.phase === 'saving') return button; + if (state.phase !== 'complete' && button.isConnected && !button.disabled && (!document.activeElement || document.activeElement === document.body)) button.focus(); + return null; +} + +function field(label, control, id) { + const wrapper = el('div', undefined, 'field'); + const caption = el('label', label); + caption.htmlFor = id; + control.id = id; + wrapper.append(caption, control); + return wrapper; +} +function datalist(id, values) { + const list = el('datalist'); + list.id = id; + list.append(...values.map(value => Object.assign(document.createElement('option'), {value}))); + return list; +} + +/** + * `onReconciled(result, {outcome, draft})` receives the reconciled result with the + * controller's outcome and submitted draft. `onRefreshRequired()` is the explicit + * exit when prompt storage was restored; the dialog closes first. + */ +export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {}, + onRefreshRequired = () => window.location.reload()}) { + let session = null, disposed = false; + const dialog = el('dialog', undefined, 'library-editor'); + dialog.setAttribute('aria-labelledby', 'library-editor-title'); + const header = el('div', undefined, 'dialog-header'); + const title = el('h2'); + title.id = 'library-editor-title'; + const close = createButton('×'); + close.setAttribute('aria-label', 'Close dialog'); + const body = el('div'); + header.append(title, close); + dialog.append(header, body); + + const busy = () => session?.controller.state.phase === 'saving'; + close.addEventListener('click', () => finish()); + dialog.addEventListener('cancel', event => { + event.preventDefault(); + if (!busy()) finish(); + }); + // A browser may still force-close a modal; treat that as a dismissal without claiming rollback. + dialog.addEventListener('close', () => {if (!dialog.open) finish();}); + + function finish() { + if (!session) return; + const {controller, trigger, views} = session; + session = null; + views.abort(); + controller.dispose(); + if (dialog.open) dialog.close(); + dialog.remove(); + if (trigger?.isConnected) trigger.focus(); + } + + function show(heading) { + title.textContent = heading; + close.disabled = false; + if (!dialog.isConnected) document.body.append(dialog); + if (!dialog.open) dialog.showModal(); + close.focus(); + } + + /** The one recovery action for a phase, created once per view and reused across renders. */ + function recoveryButton(cache, action, signal) { + if (!action) return null; + const [label, method] = action; + if (!cache.has(label)) { + const control = createButton(label); + control.addEventListener('click', () => { + const {controller} = session; + if (method === 'refresh') {finish(); onRefreshRequired(); return;} + if (method === 'reload') controller.reloadOwner({keepDraft: true}); + else controller[method](); + }, {signal}); + cache.set(label, control); + } + return cache.get(label); + } + + function controllerFor(mode, positiveId, pairId) { + const controller = createLibraryEditorController({api, mode, positiveId, pairId, onCommitted, + onReconciled: result => onReconciled(result, {outcome: controller.state.outcome, draft: controller.state.draft}), + onChange: state => session?.render(state)}); + return controller; + } + + function editorView({mode, positiveId, pairId, draft}) { + const views = new AbortController(); + const controller = controllerFor(mode, positiveId, pairId); + const negativeOnly = mode === 'negative'; + const readOnly = mode === 'metadata'; + const form = el('form'); + const controls = {}; + const textarea = (name, required = false) => { + const node = el('textarea'); + node.required = required; + node.readOnly = readOnly && (name === 'positive_text' || name === 'negative_text'); + return controls[name] = node; + }; + const input = (name, required = false) => { + const node = el('input'); + node.required = required; + return controls[name] = node; + }; + if (!negativeOnly) { + form.append(field('Title', input('title', true), FIELD_IDS.title), + field('Positive prompt', textarea('positive_text', true), FIELD_IDS.positive_text)); + } + form.append(field(negativeOnly ? 'Negative prompt' : 'Negative prompt (optional)', textarea('negative_text'), FIELD_IDS.negative_text)); + const categories = datalist('categories', []), tags = datalist('tag-options', []); + if (!negativeOnly) { + const columns = el('div', undefined, 'two-col'); + const category = input('category'), tagInput = input('tags'); + category.setAttribute('list', 'categories'); + tagInput.setAttribute('list', 'tag-options'); + columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags)); + const rating = controls.rating = el('select'); + for (const [value, text] of RATINGS) rating.add(new Option(text, value)); + form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes)); + } else form.append(el('p', NEGATIVE_HELP, 'small')); + const line = statusLine(), recovery = new Map(); + const actions = el('div', undefined, 'dialog-actions'); + const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger'); + const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary'); + save.type = 'submit'; + form.append(line.note, actions); + + for (const [name, node] of Object.entries(controls)) { + node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => { + controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value); + }, {signal: views.signal}); + } + form.addEventListener('submit', event => { + event.preventDefault(); + controller.submit(); + }, {signal: views.signal}); + cancel.addEventListener('click', () => finish(), {signal: views.signal}); + revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal}); + archive.addEventListener('click', () => archiveView(), {signal: views.signal}); + if (!negativeOnly) loadVocabulary(tags, views.signal); + + let categoryKey = null, refocus = null; + function render(state) { + for (const [name, node] of Object.entries(controls)) { + const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name]; + if (node.value !== value) node.value = value; + } + const key = state.categories.join('\n'); + if (key !== categoryKey) { + categoryKey = key; + categories.replaceChildren(...datalist('', state.categories).children); + } + const saving = state.phase === 'saving'; + if (saving && document.activeElement === save) refocus = save; + save.disabled = !SUBMITTABLE.has(state.phase); + cancel.disabled = close.disabled = revise.disabled = saving; + const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null; + archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived; + const status = statusOf(state, false, mode); + line.set(status?.text ?? pairProblem); + if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id); + else archive.removeAttribute('aria-describedby'); + const retry = recoveryButton(recovery, status?.action, views.signal); + setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]); + refocus = restoreFocus(refocus, state); + if (state.phase === 'complete') finish(); + } + + body.replaceChildren(form); + return {controller, views, render, start() { + if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value); + render(controller.state); + controller.initialize(); + }}; + } + + async function loadVocabulary(list, signal) { + try { + const result = await api.get('/prompt_manager/tags', {signal, cache: 'no-store'}); + if (signal.aborted || !Array.isArray(result.tags)) return; + list.replaceChildren(...datalist('', result.tags.filter(tag => typeof tag === 'string').map(tag => `${tag},`)).children); + } catch { + // Tag suggestions are auxiliary vocabulary; the field stays fully usable without them. + } + } + + /** Swap the open Edit dialog to the pair archive confirmation, reusing its controller. */ + function archiveView() { + const {controller, trigger} = session; + // An Edit save's failure or validation message is not about the pair; the confirmation starts clean. + const inherited = controller.state.error; + session.views.abort(); + const views = new AbortController(); + const block = el('section', undefined, 'prompt-block prompt-negative'); + block.setAttribute('aria-label', 'Negative prompt'); + const blockHeading = el('div', undefined, 'prompt-block-heading'); + blockHeading.append(el('h3', 'Negative prompt')); + const negative = el('div'); + block.append(blockHeading, negative); + const links = el('p', undefined, 'small'); + const restore = el('p', 'You can restore it from Show archived in Negative variants.', 'small'); + const line = statusLine(), recovery = new Map(); + let refocus = null; + const actions = el('div', undefined, 'dialog-actions'); + const reread = createButton('Retry'); + reread.addEventListener('click', () => controller.refreshPair(), {signal: views.signal}); + const cancel = createButton('Cancel'), confirm = createButton('Archive', 'danger'); + cancel.addEventListener('click', () => finish(), {signal: views.signal}); + confirm.addEventListener('click', () => controller.setArchive(true), {signal: views.signal}); + let shownNegative = null; + + function render(state) { + const {pair} = state; + if (pair && pair.negative_text !== shownNegative) { + shownNegative = pair.negative_text; + negative.replaceChildren(shownNegative === '' ? el('p', 'Empty negative prompt', 'prompt-empty') : el('pre', shownNegative, 'prompt-text')); + } + // Counts come only from the controller's fresh exact-ID pair read. + const counted = state.pairRead === 'fresh' && Boolean(pair), readFailed = state.pairRead === 'error'; + if (counted) { + const images = pair.image_count, runs = pair.run_count; + links.textContent = `${images} image${images === 1 ? '' : 's'} and ${runs} recorded run${runs === 1 ? '' : 's'} stay linked.`; + } + const status = state.error && state.error === inherited ? null : statusOf(state, true); + line.set(status?.text ?? (readFailed ? 'Linked images and recorded runs could not be read. Nothing was archived.' + : counted ? null : 'Checking linked images and recorded runs…')); + const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null); + links.hidden = !counted; + const saving = state.phase === 'saving'; + if (saving && document.activeElement === confirm) refocus = confirm; + confirm.disabled = !counted || !SUBMITTABLE.has(state.phase); + cancel.disabled = close.disabled = saving; + setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]); + refocus = restoreFocus(refocus, state); + if (state.phase === 'complete') finish(); + } + + body.replaceChildren(block, links, restore, line.note, actions); + session = {controller, trigger, views, render}; + show('Archive negative variant'); + controller.refreshPair(); + render(controller.state); + } + + function open({mode, positiveId = null, pairId = null, trigger = null, draft = null}) { + if (disposed) return; + const previous = session; + if (previous) { + // Swap the same open dialog's content, as the concept does; the old controller stops here. + previous.views.abort(); + previous.controller.dispose(); + } + const view = editorView({mode, positiveId, pairId, draft}); + session = {controller: view.controller, trigger: trigger ?? previous?.trigger ?? null, views: view.views, render: view.render}; + show(TITLES[mode]); + view.start(); + } + + function dispose() { + if (disposed) return; + finish(); + disposed = true; + } + if (parentScope.signal.aborted) dispose(); + else parentScope.signal.addEventListener('abort', dispose, {once: true}); + return {open, dispose}; +} diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index fd2e8ed..f8e21ab 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -44,12 +44,23 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); },dispose(){disposed=true;request?.abort();request=null;}}; } -export function libraryPromptRoles(detail,pairId=null) { - const variant=pairId===null?null:detail.variants.find(item=>item.id===pairId); +/** + * Saved roles for one selection. A pair outside the bounded variant page is + * passed separately as `selectedPair` (an exact-ID read) and must belong to the + * same positive; it never enters `detail.variants`. + */ +export function libraryPromptRoles(detail,pairId=null,selectedPair=null) { + let variant=pairId===null?null:detail.variants.find(item=>item.id===pairId); + if(pairId!==null && !variant && selectedPair?.id===pairId) { + if(selectedPair.positive_prompt_id!==detail.positive.id || selectedPair.positive_text!==detail.positive.text || typeof selectedPair.negative_text!=='string') throw new TypeError('Saved negative variant belongs to another prompt'); + variant=selectedPair; + } if(pairId!==null && !variant) throw new TypeError('Saved negative variant is unavailable'); return {positive:{status:'resolved',text:detail.positive.text,source:'library'},negative:variant?{status:'resolved',text:variant.negative_text,source:'library'}:{status:'unresolved',text:null,source:null,message:'Choose a saved negative variant to inspect or send it.'}}; } -export function createLibraryDetail({api,onChange}) { +const ARCHIVE_SCOPES=new Set(['active','archived','all']); +export function createLibraryDetail({api,onChange,archive='active'}) { + if(!ARCHIVE_SCOPES.has(archive)) throw new TypeError('Invalid archive scope'); let request,disposed=false,id; let state=Object.freeze({detail:null,loading:false,error:null,loadingVariants:false,variantsError:null}); const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);}; @@ -65,7 +76,7 @@ export function createLibraryDetail({api,onChange}) { emit(append?{loadingVariants:true,variantsError:null}:{detail:null,loading:true,error:null,loadingVariants:false,variantsError:null}); try { if(!validId(id)) throw new TypeError('Invalid positive prompt'); - const query=new URLSearchParams({variant_limit:'40'});if(cursor) query.set('variant_cursor',cursor); + const query=new URLSearchParams({variant_limit:'40'});if(archive!=='active') query.set('archive',archive);if(cursor) query.set('variant_cursor',cursor); const raw=await api.get(`/prompt_manager/library/${id}?${query}`,{signal:current.signal}); if(disposed || request!==current) return 0; const detail=validate(raw); @@ -76,7 +87,14 @@ export function createLibraryDetail({api,onChange}) { request=null;emit({detail,loading:false,error:null,loadingVariants:false,variantsError:null});return detail.variants.length-(append?previous.variants.length:0); } catch(error) {if(!disposed && request===current) {request=null;emit(append?{loadingVariants:false,variantsError:error}:{loading:false,error});}return 0;} } - return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},dispose(){disposed=true;request?.abort();}}; + /** Same-parent reconciliation: a validated authoritative detail replaces the current one and supersedes any read. */ + function accept(detail) { + if(disposed) throw new TypeError('Prompt history is disposed'); + const next=validate(detail); + request?.abort();request=null; + emit({detail:next,loading:false,error:null,loadingVariants:false,variantsError:null}); + } + return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},accept,dispose(){disposed=true;request?.abort();}}; } export function libraryWindow(count,columns,top,height,focused=-1) { diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 7a23238..4b7b482 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -31,7 +31,6 @@ main.library-main { --library-positive: #73dda9; } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } .library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); } -.library-main .library-back { display: inline-block; margin-block: 0 12px; color: var(--ca-accent); } .library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; } .library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; } .library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; } @@ -57,6 +56,7 @@ main.library-main { --library-negative: #ff8c98; } .library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); } .library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; } .library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; } +.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; } .library-main .library-variants .library-branches { margin-block: 0; } .library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; } .library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; } @@ -122,3 +122,18 @@ main.library-main { --library-negative: #ff8c98; } .history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; } .history-run-marker[aria-pressed="true"] { outline-color: Highlight; } } + +/* Heading and panel actions follow the concept (style.css 283-300, 540-549, 1408-1414). */ +.workspace-heading .heading-actions, .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } +.workspace-heading .heading-actions { min-inline-size: 0; max-inline-size: 100%; } +.ca-tools .workspace-heading button.send { color: var(--ca-focus); border-color: var(--ca-line); display: inline-flex; align-items: center; gap: 6px; } +.workspace-heading h1:focus { outline: none; } +.workspace-heading h1:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } +.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; } +@media (max-width: 700px) { .workspace-heading .heading-actions { inline-size: 100%; } } +@media (pointer: coarse) { .ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 40px; } } +/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */ +.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; } +.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; } +@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 02ed149..57bc4cf 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -3,18 +3,89 @@ import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs'; +import {promptRoles} from './prompt-blocks.mjs'; +import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; +import {createLibraryEditorController} from './library-editor-state.mjs'; import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; let shelfVisit=null; +// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. +let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null; const variantSelections=new Map(); const historyVisits=new Map(); +// Exact-ID pair reads for selections outside the bounded variant page; never merged into it. +const selectedPairs=new Map(); +// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action. +const metadataDrafts=new Map(); +const archivedVisible=new Map(); +const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.'; function el(tag,text,className) { const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; } function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;} +/** The full stored title; the first-line fallback only when it is blank. */ +function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);} +function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();} +function headingActions(main,fallback) { + const actions=el('div',undefined,'heading-actions');(main.querySelector('.workspace-heading') || fallback).append(actions);return actions; +} + +/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */ +export function invalidateLibraryAfterWrite(commit) { + if(!commit || typeof commit.kind!=='string' || !commit.receipt || typeof commit.receipt!=='object') throw new TypeError('Invalid editor commit'); + writeGeneration++; + if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null}; +} +/** Remember the selection a reconciled save names, so the destination history opens on it. */ +export function rememberLibraryResult(result) { + const positive=result?.detail?.positive, kind=result?.commit?.kind; + if(!positive || !Number.isSafeInteger(positive.id) || !Array.isArray(result.detail.variants) || typeof kind!=='string') throw new TypeError('Invalid reconciled Library result'); + if(kind==='create') savedParentId=positive.id; + if(!result.pair || kind==='archive-pair') return; + variantSelections.set(positive.id,result.pair.id); + if(result.detail.variants.some(variant=>variant.id===result.pair.id)) selectedPairs.delete(positive.id); + else selectedPairs.set(positive.id,result.pair); + if(result.pair.archived) archivedVisible.set(positive.id,true); +} +/** + * Toast copy from the receipt: the concept's save toast for New, Revision and Edit and its + * Add-negative line (app.js 989, 1028); a reuse discloses what was not saved. + */ +function outcomeMessage(result) { + const {kind,receipt}=result.commit; + if(kind==='metadata') return 'Saved.'; + if(kind==='archive-pair') return ARCHIVED_MESSAGE; + if(kind!=='create' && kind!=='add-negative') return null; + if(receipt.positive_created) return 'Saved.'; + if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.' + :'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'; + if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.'; + return 'This prompt pair already exists. Showing the saved version.'; +} +function rememberUnappliedMetadata(result,{outcome,draft}={}) { + if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return; + const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes}); +} +/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */ +function createNewPromptEditor(api,scope,main) { + return createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:(result,context)=>{ + rememberLibraryResult(result);rememberUnappliedMetadata(result,context); + const message=outcomeMessage(result);if(message) showWorkspaceToast(message); + const hash=`#/library/${result.detail.positive.id}`; + if(window.location.hash===hash) focusHeading(main);else {focusHeadingOnMount=true;window.location.hash=hash;} + }}); +} +/** The Images heading's New prompt: opens the editor without mounting or navigating to the Library. */ +export function mountNewPromptAction(main,api,parentScope) { + const actions=headingActions(main,main); + const button=createButton('','ca-primary');button.append(createWorkspaceIcon('plus'),el('span','New prompt'));actions.append(button); + const editor=createNewPromptEditor(api,parentScope,main); + parentScope.listen(button,'click',()=>editor.open({mode:'new',trigger:button})); + return button; +} export function mountLibrary(main,api,parentScope,{positiveId}={}) { const scope=createScope();parentScope.cleanup(()=>scope.dispose()); @@ -22,11 +93,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const sender=createPromptSender({scope}); if(positiveId!==undefined) mountHistory(); else mountShelf(); + if(focusHeadingOnMount) {focusHeadingOnMount=false;focusHeading(main);} // Save the visit before removing the class that defines the shelf's scroll range. scope.cleanup(()=>main.classList.remove('library-main')); function mountShelf() { - const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; + const visit=shelfVisit, mountedGeneration=writeGeneration; + // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. + const restorable=visit?.state?visit:null; + const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro'); const toolbar=el('search',undefined,'library-toolbar'); @@ -43,16 +118,42 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); + const actions=headingActions(main,section); + const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON'); + const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt); + const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert'); + const editor=createNewPromptEditor(api,scope,main); + scope.listen(newPrompt,'click',()=>editor.open({mode:'new',trigger:newPrompt})); + const exportUrls=new Set();let exporting=false; + scope.cleanup(()=>{for(const url of exportUrls)URL.revokeObjectURL(url);exportUrls.clear();}); + // The exact full-Library document; a failure is reported before anything is downloaded. + async function exportLibrary() { + if(exporting) return;exporting=true;exportStatus.hidden=true; + try { + const response=await fetch(window.ApiClient.resolveUrl('/prompt_manager/library/export'),{cache:'no-store',credentials:'same-origin',signal:scope.signal}); + if(!response.ok) throw new Error(`Export failed with status ${response.status}`); + const text=await response.text();const parsed=JSON.parse(text); + if(parsed?.kind!=='prompt-manager-library' || parsed.schema_version!==1 || !Array.isArray(parsed.positives)) throw new TypeError('Invalid Library export'); + if(scope.signal.aborted) return; + const url=URL.createObjectURL(new Blob([text],{type:'application/json'}));exportUrls.add(url); + const anchor=el('a');anchor.href=url;anchor.download='prompt-manager-library-v1.json';anchor.hidden=true;section.append(anchor);anchor.click();anchor.remove(); + setTimeout(()=>{if(exportUrls.delete(url))URL.revokeObjectURL(url);},0); + } catch { + if(!scope.signal.aborted) {exportStatus.textContent='The Library export could not be downloaded. Nothing was saved to this device.';exportStatus.hidden=false;} + } finally {exporting=false;} + } + scope.listen(exportButton,'click',exportLibrary); (main.querySelector('.workspace-heading-copy') || section).append(introduction); - continuation.append(message,retry,more);section.append(toolbar,optionsStatus,caption,grid,continuation,legacy);main.append(section); + continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); - let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY; - let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null; - if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height); + let anchor=restorable?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY, retainedAnchor=restorable?null:visit?.anchor || null; + let restoreScrollY=Number.isFinite(restorable?.scrollY) && restorable.scrollY>=0?restorable.scrollY:null; + if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height); + const hiddenSave=restorable?null:savedParentId;savedParentId=null; function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} function cardFor(item,index) { const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); @@ -67,7 +168,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(!previews.length) art.append(el('span','No available image previews','library-art-empty')); const content=el('div',undefined,'library-card-content'); const heading=el('header',undefined,'library-card-title'); - const name=link(title(item.text),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name); + const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name); heading.append(h2,el('span',item.category || 'Uncategorized','library-chip')); const prompts=el('div',undefined,'library-card-prompts'); const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']}); @@ -193,15 +294,16 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0}; for(const card of cards.values())removeCard(card); cards.clear();rowNodes.clear();spacers.clear();grid.replaceChildren(); - } + } else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;} } state=next; caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; - message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':''; + const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; + message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); schedule();scheduleLoad(); } - const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined}); + const stream=createLibraryStream({api,onChange:render,restore:restorable?{filters,state:restorable.state}:undefined}); function maybeLoad() { if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return; const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore(); @@ -223,72 +325,170 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { scope.listen(window,'scroll',()=>{reconcileScroll();schedule();}); // Mobile viewport resizing can adjust scroll before delivering its resize event. scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();}); - scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); - loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset(); + scope.cleanup(()=>{ + const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor(); + // A write while mounted makes this stream stale: keep only what still means something. + shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null; + stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset(); } function mountHistory() { const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history); - const back=link('Back to library','#/library','library-back'); - const body=el('div');history.append(back,body); - let blocks,galleryScope,detail,contentScope,updateBranches; + const body=el('div');history.append(body); + // Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI. + const actions=headingActions(main,history); + const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send'); + send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send); + let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null; scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();}); const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose()); + const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView}); + scope.listen(back,'click',()=>{window.location.hash='#/library';}); + scope.listen(edit,'click',()=>{ + const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId); + editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null}); + }); + scope.listen(send,'click',()=>{const roles=current?.sendRoles();if(roles)sender.open(roles,send);}); + function reconciled(result,context) { + rememberLibraryResult(result);rememberUnappliedMetadata(result,context); + const message=outcomeMessage(result),id=result.detail.positive.id; + if(message) showWorkspaceToast(message); + if(id!==positiveId) {focusHeadingOnMount=true;window.location.hash=`#/library/${id}`;return;} + if(current) current.apply(result);else loader.load(positiveId); + focusHeading(main); + } + /** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */ + function reloadView() { + writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null}; + variantSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId); + current=null;contentScope?.dispose();contentScope=null;historyVisits.delete(positiveId); + loader.load(positiveId);focusHeading(main); + } function render(state) { - if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateBranches(state);return;} - contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null; + if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;} + contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true; blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren(); if(state.loading || state.error) { const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message); if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return; } - detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text); + detail=state.detail;const content=contentScope; const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro'); - const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip); + const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip); const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer); const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status'); const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved); const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0; - contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();}); + content.cleanup(()=>{parentGeneration++;parentRequest?.abort();}); const all=createButton('All images for this positive'); const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants'); - variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted')); + const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions'); + panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted')); + const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false'); + rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading); const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane); - const choices=[]; + const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted'); const variantCount=el('p','','muted');variantCount.setAttribute('role','status'); const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true; + const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true; + const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null; + content.listen(restoreAction,'click',()=>restoreHandler?.()); const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true; - contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();}); + const archivedLoader=createLibraryDetail({api,onChange:()=>{if(!content.signal.aborted)updateBranches(loader.state);},archive:'archived'});content.cleanup(()=>archivedLoader.dispose()); + const restoringPairs=new Set(); + let archivedShown=archivedVisible.get(positiveId)===true,archivedStale=false; + const archivedList=()=>archivedShown?archivedLoader.state.detail?.variants || []:[]; + const separate=()=>{const pair=selectedPairs.get(positiveId);return pair && !detail.variants.some(variant=>variant.id===pair.id) && !archivedList().some(variant=>variant.id===pair.id)?pair:null;}; + const moreTarget=()=>detail.variants_has_more?loader:archivedShown && archivedLoader.state.detail?.variants_has_more?archivedLoader:null; + content.listen(more,'click',()=>{if(savedReadState==='current')moreTarget()?.loadMore();}); + content.listen(retry,'click',()=>{ + if(savedReadState!=='current') return; + if(archivedLoader.state.error) archivedLoader.load(positiveId);else if(archivedLoader.state.variantsError) archivedLoader.loadMore();else loader.loadMore(); + }); + content.listen(addNegative,'click',()=>editor.open({mode:'negative',positiveId,trigger:addNegative})); + content.listen(showArchived,'click',()=>{ + archivedShown=!archivedShown;archivedVisible.set(positiveId,archivedShown); + // A write while hidden makes the loaded archived list stale; reread it when it is shown again. + if(archivedShown && (archivedStale || (!archivedLoader.state.detail && !archivedLoader.state.loading))) {archivedStale=false;archivedLoader.load(positiveId);} + if(!archivedShown && archivedList().every(variant=>variant.id!==activePairId) && detail.variants.every(variant=>variant.id!==activePairId) && separate()?.id!==activePairId) select(null); + updateBranches(loader.state); + }); + function laneName(entry) { + const {variant}=entry; + return variant.title || (variant.negative_text===''?'Explicitly empty negative':entry.kind==='separate'?'Selected saved negative variant':`Negative variant ${entry.number}`); + } + function laneEntries() { + const entries=detail.variants.map((variant,index)=>({key:`active:${variant.id}`,kind:'active',variant,number:index+1})); + const loadedArchived=archivedList(),outside=separate(); + // Archived numbers follow the active total; a pair outside the loaded archived page follows them, so names stay unique. + if(outside) entries.push({key:`${outside.archived?'archived':'separate'}:${outside.id}`,kind:outside.archived?'archived':'separate',variant:outside,number:detail.variant_total+loadedArchived.length+1}); + for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1}); + return entries; + } + function laneFor(entry) { + let lane=lanes.get(entry.key); + if(!lane) { + const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span'); + node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text); + if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));} + content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane); + } + lane.entry=entry;return lane; + } updateBranches=state=>{ - const known=new Set(choices.map(choice=>choice.variant.id)); - for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) { - const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`); - button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id)); - const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label}); - contentScope.listen(button,'click',()=>select(variant.id)); - } - for(const [index,choice] of choices.entries()) { - choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant; - const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`; - choice.button.disabled=savedReadState!=='current'; + const entries=laneEntries(),keep=new Set(entries.map(entry=>entry.key)),active=document.activeElement; + for(const [key,lane] of lanes) if(!keep.has(key)) {lane.node.remove();lanes.delete(key);} + let previous=positiveLane; + for(const [index,entry] of entries.entries()) { + const lane=laneFor(entry),name=laneName(entry); + lane.node.style.setProperty('--lane-offset',`${38+index*47}px`); + lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`); + lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current'; + lane.text.textContent=`${name} · ${entry.variant.image_count} linked images${entry.kind==='archived'?' · Archived':''}`; + if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);} + if(previous.nextSibling!==lane.node) previous.after(lane.node); + previous=lane.node; } + if(active?.isConnected && document.activeElement!==active) active.focus({preventScroll:true}); + emptyNote.hidden=entries.length>0; variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`; - more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError; - more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current'; - variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':''; + const archivedState=archivedLoader.state,target=moreTarget(); + const loadError=state.variantsError || (archivedShown && (archivedState.error || archivedState.variantsError)); + more.hidden=!target || !!loadError;more.setAttribute('aria-disabled',String(state.loadingVariants || archivedState.loading || archivedState.loadingVariants || savedReadState!=='current'));retry.hidden=!loadError; + more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';addNegative.disabled=savedReadState!=='current'; + variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.'; + const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0; + showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown)); }; - updateBranches(state); - if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted')); + updateParent=()=>{ + main.querySelector('h1').textContent=displayTitle(detail.positive);categoryChip.textContent=detail.positive.category || 'Uncategorized'; + send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${displayTitle(detail.positive)}`); + }; + let activePairId; + updateParent();updateBranches(state); + branches.append(emptyNote); const note=el('p','Saved combinations, not an execution timeline.','muted'); const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all); - variants.append(branches,variantError,more,retry,variantFooter); + variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter); const context=el('p','','library-variant-context');context.setAttribute('role','status'); const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context); - const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId; + const images=el('div',undefined,'library-linked-images');gallery.append(images); const recorded=el('div',undefined,'library-recorded-container'); body.append(subtitle,summary,variants,recorded,gallery); + const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null); + const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true); + function rolesFor(pairId) { + if(pairId===null || detail.variants.some(variant=>variant.id===pairId)) return libraryPromptRoles(detail,pairId); + const outside=separate();if(outside?.id===pairId) return libraryPromptRoles(detail,pairId,outside); + return libraryPromptRoles({...detail,variants:[...detail.variants,...archivedList()]},pairId); + } + function updateSend() { + const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId)); + send.disabled=savedReadState!=='current' || !Object.hasOwn(roles,'positive') || !Object.hasOwn(roles,'negative'); + } function setParentState(parent) { - if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return; + if(content.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return; parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true; if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';} else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;} @@ -310,32 +510,107 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { reloadSaved.hidden=savedReadState==='current'; } } - updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'}); + updateBranches(loader.state);blocks?.setState({prompts:rolesFor(variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});updateSend(); } async function retrySavedParent() { parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true}); try { const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal}); - if(contentScope.signal.aborted || generation!==parentGeneration) return; + if(content.signal.aborted || generation!==parentGeneration) return; if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details'); setParentState({detail:fresh}); - } catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});} + } catch(error) {if(!content.signal.aborted && generation===parentGeneration)setParentState({error});} } - contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);}); + content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);}); if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{}); - mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ - if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent); + mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ + if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent); }}); - blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope}); - function select(pairId) { - if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId; - variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true}); - for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null)); - context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`; + // The heading carries the pair send, as in the concept; the saved texts keep their per-role actions. + blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false}); + function select(pairId,{force=false}={}) { + if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return; + if(pairId!==null && !recordFor(pairId)) pairId=null; + activePairId=pairId;variantSelections.set(positiveId,pairId);blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true}); + for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null)); + const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId); + const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId); + context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`; galleryScope?.dispose();galleryScope=createScope();images.replaceChildren(); - mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); + // An archived pair's images are read in the archived scope; the gallery stays authoritative. + mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); + updateSend(); } - contentScope.listen(all,'click',()=>select(null));const remembered=variantSelections.get(positiveId);select(detail.variants.some(variant=>variant.id===remembered)?remembered:null); + /** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */ + async function rereadSelectedPair() { + const pairId=activePairId;if(pairId===null || pairId===undefined) return; + try { + const value=await api.get(`/prompt_manager/pairs/${pairId}`,{signal:content.signal,cache:'no-store'}); + const pair=value?.pair; + if(content.signal.aborted || activePairId!==pairId || pair?.id!==pairId || pair.positive_prompt_id!==positiveId || pair.positive_text!==detail.positive.text || typeof pair.negative_text!=='string') return; + if(!detail.variants.some(variant=>variant.id===pairId) && !archivedList().some(variant=>variant.id===pairId)) {selectedPairs.set(positiveId,pair);updateBranches(loader.state);} + blocks.setState({prompts:rolesFor(pairId),actionsEnabled:savedReadState==='current'});updateSend(); + } catch { + // The previous verified selection stays; a later explicit action reads it again. + } + } + function showRestoreProblem(text,label,handler) { + restoreStatus.textContent=text;restoreStatus.hidden=!text;restoreHandler=handler;restoreAction.textContent=label || '';restoreAction.hidden=!label; + } + /** Restore through the editor controller's fenced pair path; nothing restores silently. */ + async function restorePair(pairId,button) { + // A rebuilt lane must not offer a second Restore while this one is in flight. + if(restoringPairs.has(pairId)) return; + restoringPairs.add(pairId);showRestoreProblem('');button.disabled=true; + const controller=createLibraryEditorController({api,mode:'metadata',positiveId,pairId,onCommitted:invalidateLibraryAfterWrite,onReconciled:result=>{ + rememberLibraryResult(result);apply(result); + const dot=[...lanes.values()].find(lane=>lane.entry.kind!=='archived' && lane.entry.variant.id===pairId)?.button;dot?.focus(); + }}); + content.cleanup(()=>controller.dispose()); + const settle=()=>{ + const {phase,error}=controller.state; + if(phase==='complete' || phase==='disposed' || content.signal.aborted) return; + if(button.isConnected) button.disabled=savedReadState!=='current'; + if(phase==='storage-changed' || (phase==='saved-refresh-error' && error?.code==='storage-changed')) showRestoreProblem(phase==='storage-changed'?'Prompt storage was restored since this view loaded. Nothing was restored.':'Restored; Library refresh failed because prompt storage was restored. Reload the Library to see current data.','Reload Library',reloadView); + else if(phase==='saved-refresh-error') showRestoreProblem('Restored; Library refresh failed.','Retry refresh',async()=>{await controller.retryReconciliation();settle();}); + else if(phase==='unknown-outcome') showRestoreProblem('Restore could not be confirmed. Retry sends the same restore once.','Retry',async()=>{await controller.retryUnconfirmed();settle();}); + else showRestoreProblem(`Not restored: ${error?.message || 'The saved negative variant could not be read.'}`); + }; + try { + await controller.initialize(); + if(controller.state.phase==='ready') await controller.setArchive(false); + } finally { + restoringPairs.delete(pairId);if(!content.signal.aborted) updateBranches(loader.state); + } + settle(); + } + /** Same-parent reconciliation from one authoritative result; the separately verified pair stays outside the page. */ + function apply(result) { + const kind=result.commit.kind,pair=result.pair,previous=detail.variants.map(variant=>variant.id); + // An authoritative reconciled read supersedes any stale or pending revalidation. + parentGeneration++;parentRequest?.abort();savedReadState='current'; + parentStatus.hidden=true;parentStatus.textContent='';retryParent.hidden=reloadSaved.hidden=true; + loader.accept(result.detail); + // A reused archived pair is shown where it lives, under Show archived; archiving itself never turns the toggle on. + if(pair?.archived && kind!=='archive-pair') {archivedShown=true;archivedVisible.set(positiveId,true);} + if(archivedShown) archivedLoader.load(positiveId);else archivedStale=true; + if(kind==='metadata') {rereadSelectedPair();return;} + if(kind==='archive-pair') { + if(selectedPairs.get(positiveId)?.id===pair.id) selectedPairs.delete(positiveId); + const index=previous.indexOf(pair.id),next=detail.variants[Math.min(Math.max(index,0),detail.variants.length-1)]; + select(next?.id ?? null,{force:true});return; + } + if(pair) select(pair.id,{force:true}); + } + current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply}; + content.listen(all,'click',()=>select(null)); + const remembered=variantSelections.get(positiveId),outside=selectedPairs.get(positiveId); + if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);} + if(archivedShown) archivedLoader.load(positiveId); + let initial=null; + if(detail.variants.some(variant=>variant.id===remembered)) initial=remembered; + else if(outside?.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}} + updateBranches(state);select(initial); } loader.load(positiveId); } diff --git a/web/workspace.html b/web/workspace.html index 8ff41bd..6cbedc7 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -11,6 +11,7 @@ + @@ -30,6 +31,7 @@ +