diff --git a/TESTING.md b/TESTING.md index 787bbcc..d9bf971 100644 --- a/TESTING.md +++ b/TESTING.md @@ -786,6 +786,54 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \ workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit ``` +### Prompt history page and recorded deviations (#249 PR F-a) + +The history page (`#/library/`) takes the concept's heading, summary, negative +variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304, +style.css 1059–1179). The summary is `article.surface` with the category chip, the +positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images +· N negative variants" (tracked runs from the recorded window's `recorded_run_count`). +The panel is `section.surface.timeline-panel` (region "Saved negative variants") with +Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane` +rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, " · N runs" and a +`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and +All images for this positive. Recorded executions, frozen fields, send and archived +lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and +`workspace-library-editor.spec.mjs` cover them on Chromium and touch. + +Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the +selected pair's roles load), **D25** (Compare negatives compares a baseline with the +selected red dot, and lists the selected side's added comma terms) and **D26** (All +images for this positive is a dialog with its own bounded gallery; the run dot opens +Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the +pair's newest linked image, reading the archived gallery for an archived pair). + +Recorded deviations on this page: +- **D45**: a variant dot is named "Select negative " when no other loaded lane + shares that title (compared trimmed), else "Select negative variant N"; a title that + itself reads "variant N" always uses the lane's own number, so no two names repeat. + The bounded "N of M saved negative variants" count stays a visually hidden status, + and production's "Saved combinations, not an execution timeline." is removed. + Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6 + marker emphasis (their text stays at full contrast) with their Restore button. Under + `pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one + (`::after` overlays). While a run dot shows, the lane text wraps within 139px before + it. The panel heading, the legend row and All images wrap at 390px and 200% zoom. +- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's + baseline is the oldest (lowest-id) active variant, not the first lane; while the + variant page has more, the oldest is not loaded and the section reads "Load more + negative variants to reach the baseline." instead of guessing. The two blocks are + named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats. + Compare negatives is disabled while the positive has no active variant. The page + opens on the newest lane (existing behavior), while the concept opens on + `negatives[0]`, its baseline: the first comparison reads "Baseline compared with + <newest>" where the concept compares the baseline with itself. The history concept + comparison (PR F-b, F5) maps this. + +The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` +(see "Chronological Library history"), and each lane's "N runs" is the pair's +`run_count` (see "Workspace data fields"). + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no @@ -884,7 +932,9 @@ null. The edit token ignores both. `variant_total` has two meanings. On the list response it counts the active variants over all filtered parents (the same snapshot as `total`, the same on every cursor page). On the detail response it counts one positive's variants in the chosen archive scope. The Library caption -reads it from the list state. +reads it from the list state. Each detail variant (active and archived) also carries +its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes +show as "N runs" (#249 F2). Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with `{root_index, path}`. Both are idempotent and answer @@ -1109,10 +1159,20 @@ The versioned public reads are: | Route | Meaning | | --- | --- | | `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows | +| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` | | `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID | | `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations | | `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL | +The newest-pair read takes no query. It answers 404 when the positive is not an +eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact +raw-text scope newest first and follows only a row whose `reference_epoch` is the +current epoch and whose saved-pair hint still resolves to that pair (the +`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints +report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside +the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a +storage failure 503 `storage-unavailable`. + Run and output reads accept the returned `epoch`; current-file routes require the returned epoch, current scope and occurrence coordinates. Run integers are decimal strings, preserving SQLite's full signed 64-bit values. Windows and diff --git a/database/history_reads.py b/database/history_reads.py index 9d84f24..a96227f 100644 --- a/database/history_reads.py +++ b/database/history_reads.py @@ -10,7 +10,7 @@ import re import sqlite3 from typing import Iterator -from .execution_contexts import content_digest +from .execution_contexts import _hints, _values, content_digest from .execution_history import HistoryEpochChanged from .execution_outputs import _capture, _REFERENCE_COLUMNS from .library import _ELIGIBLE @@ -98,6 +98,8 @@ _ORDER = ( "r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC," "r.queue_item_id DESC,r.run_id COLLATE BINARY DESC" ) +# Stale-hint rows a newest-pair lookup inspects before it reports none. +_PAIR_RUN_CANDIDATES = 16 _MATCHING = ( "SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s " "JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id " @@ -410,6 +412,62 @@ class HistoryReadOperations: result["context_summaries"] = list(ordered.values()) return result + def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None: + """The newest matching run whose current saved-pair hint names `pair_id`. + + None when the positive is not an eligible saved prompt or the pair is + not one of its pairs; ``{"run": None}`` when no recorded run names it. + Only a hint captured in this epoch and still resolving to the same pair + counts, as in ``get_history_run``. + """ + conn = self._history_connection() + saved = conn.execute( + "SELECT CASE WHEN typeof(p.text)='text' AND " + "length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text " + f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})", + (positive_id,), + ).fetchone() + if saved is None or ( + conn.execute( + "SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?", + (pair_id, positive_id), + ).fetchone() + is None + ): + return None + if saved["text"] is None: + return {"run": None} + epoch = self.get_execution_epoch() + candidates = conn.execute( + "SELECT r.run_id,rc.anchor_id,rc.context_id," + "c.positive_snapshot_id,c.negative_snapshot_id " + "FROM execution_runs r JOIN execution_run_contexts rc " + "ON rc.run_id=r.run_id JOIN execution_contexts c " + "ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s " + "ON s.snapshot_id=c.positive_snapshot_id " + "WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? " + f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?", + ( + content_digest("text", (saved["text"],)), + pair_id, + epoch, + _PAIR_RUN_CANDIDATES, + ), + ).fetchall() + for row in candidates: + positive = _values(conn, row["positive_snapshot_id"]) + negative = _values(conn, row["negative_snapshot_id"]) + if positive is None or positive["raw"] != saved["text"]: + continue + hints = _hints(conn, positive["raw"], negative["raw"] if negative else None) + if hints[2] == pair_id: + return { + "run": { + key: row[key] for key in ("run_id", "anchor_id", "context_id") + } + } + return {"run": None} + def get_history_run(self, run_id: str) -> dict | None: conn = self._history_connection() run = self.get_execution_run(run_id) diff --git a/database/library.py b/database/library.py index ce562df..4e532b9 100644 --- a/database/library.py +++ b/database/library.py @@ -388,7 +388,7 @@ class LibraryOperations: for row in conn.execute( "SELECT pp.id,COALESCE(n.text,'') AS negative_text," "pp.category,pp.title,pp.archived," - "pp.negative_prompt_id IS NULL AS explicit_empty," + "pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count," "(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip " "WHERE ip.pair_id=pp.id) AS image_count " "FROM prompt_pairs pp " @@ -404,6 +404,8 @@ class LibraryOperations: row["tags"] = [] row["archived"] = bool(row["archived"]) row["explicit_empty"] = bool(row["explicit_empty"]) + # The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset. + row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0 if by_id: marks = ",".join("?" for _ in by_id) for row in conn.execute( diff --git a/py/api/history.py b/py/api/history.py index 9282ac7..14bbdfe 100644 --- a/py/api/history.py +++ b/py/api/history.py @@ -388,6 +388,9 @@ class HistoryRoutesMixin: routes.get("/prompt_manager/library/{positive_id}/history")( self.get_library_history ) + routes.get( + "/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest" + )(self.get_newest_pair_run) routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run) routes.get("/prompt_manager/history/runs/{run_id}/outputs")( self.get_history_outputs @@ -473,6 +476,40 @@ class HistoryRoutesMixin: return _success_response(await self._run_in_executor(project)) + @_history_errors + async def get_newest_pair_run(self, request: web.Request) -> web.Response: + """The pair's newest recorded run for the history run dot (#249 D26).""" + _query(request) + if request.query: + raise ValueError("Invalid newest pair run query") + positive_id = _decimal(request.match_info["positive_id"], minimum=1) + pair_id = _decimal(request.match_info["pair_id"], minimum=1) + fence = self._history_fence() + db = self.db + + def project() -> bytes: + generation = fence.begin() + try: + with db.recorded_history_snapshot() as epoch: + result = db.get_newest_pair_run(positive_id, pair_id) + finally: + db.model.close() + if result is None: + fence.validate(epoch, generation) + raise HistoryReadError(404, "not-found") + encoded = _serialize_history( + { + "success": True, + "history_version": 1, + "history_epoch": epoch, + **result, + } + ) + fence.validate(epoch, generation) + return encoded + + return _success_response(await self._run_in_executor(project)) + @_history_errors async def get_history_run(self, request: web.Request) -> web.Response: _query(request) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 3630d45..bca64e5 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -621,11 +621,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play'); await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id; - await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); + await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); await expect(position(page)).toContainText('of 1 loaded: complete.png'); await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); + // #249 D26: the whole positive's images are in the All images dialog's own embedded gallery. await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); - await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:'); + const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true}); + await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click(); + await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); + await expect(position(page)).toContainText('of 2 loaded:'); }); test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{ diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index 3e5978c..8dadadb 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -10,7 +10,7 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m export const canonicalDifferences = { data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'], semantics: [ - 'Saved combinations are distinct from the additional chronological executions', + 'Negative variants are saved combinations; Recorded runs list the chronological executions', 'Recorded run exposes exact frozen evidence; Repeat run is illustrative', 'Current-file candidates disclose unknown identity and context membership', ], @@ -35,13 +35,13 @@ export async function captureCanonicalState(page, testInfo, name, selector) { export async function historyGeometry(page, production) { const selectors = production ? { - surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane', - branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]', - positiveLabel: '.library-positive-lane span', + surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane', + branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]', + positiveLabel: '.library-history .positive-lane span', role: '[aria-label="Saved prompt texts"] .prompt-section', prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery', - heading: '.library-variants h2', intro: '.library-variants > p.muted', - footer: '.library-variant-footer', all: '.library-variants button:last-child', + heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small', + footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button', recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas', recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label', diff --git a/tests/browser/library-editor-visual.spec.mjs b/tests/browser/library-editor-visual.spec.mjs index 0822469..e277f8e 100644 --- a/tests/browser/library-editor-visual.spec.mjs +++ b/tests/browser/library-editor-visual.spec.mjs @@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e 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:'Theme',exact:true}).selectOption(theme); - const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor'); + const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'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(); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index febf0b8..bbb4ba5 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true}); const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true}); const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();}; -// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery. -const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');}; +// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the +// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter). +const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');}; const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`); const exact=async locator=>locator.evaluate(node=>node.textContent); @@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{ const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`; - await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media; await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`); try { @@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;}); await open(page,fixture.positive_id); const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json(); - const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1; - await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click(); + const selected=detail.variants.find(v=>v.negative_text==='A negative').title; + await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click(); await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey)); await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read'); @@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se await expect(marker(page,id)).toBeFocused(); } await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative'); - await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true'); expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys); }); @@ -282,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me }); test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{ - const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); + const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click(); const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey)); let writes=0,galleryReads=0,outputReads=0; page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;}); @@ -301,7 +302,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape'); - await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true'); expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0); }); @@ -399,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true; await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible(); - await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click(); + await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click(); await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click(); @@ -564,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run }); test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{ - const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;}); await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue()); 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'}}})); @@ -575,7 +576,7 @@ test('history epoch recovery reports saved-parent read failures and retries with }); test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{ - const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text')); const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0; page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;}); @@ -595,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified 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 expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();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();await expect(choices.last()).toHaveAttribute('aria-pressed','true'); + const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true'); 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'); @@ -608,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr 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 choices=page.getByRole('button',{name:/^Select negative /});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'); @@ -617,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr 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); + await expect(choices.first()).toBeEnabled();await expect(page.locator('.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); }); @@ -625,14 +626,14 @@ test('an authoritative save after a stale revalidation makes the saved view curr 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(); - const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair); - await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click(); + const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title; + await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click(); await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')); await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;}); let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;}); return async()=>{ - await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true'); expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative); expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0); }; @@ -721,3 +722,240 @@ test('completed page one stays bounded across mode changes until explicit output await page.keyboard.press('Escape');await retained(); } finally {releaseNext();} }); + +// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24). +const historyHeading=page=>page.locator('#workspace-main > .heading'); +const summary=page=>page.locator('#workspace-main .library-history article.surface'); +test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{ + await page.setViewportSize({width:1600,height:1100});const f=page.fixture; + let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;}); + await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});}); + try { + await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`); + const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send'); + await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery'); + // D24: disabled only while the selected pair's roles are loading. + await expect(send).toBeDisabled();releaseDetail(); + await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled(); + } finally {releaseDetail();} + const heading=historyHeading(page),title=await heading.locator('h1').textContent(); + const actions=heading.locator('.heading-actions > button'); + await expect(actions).toHaveCount(3); + expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']); + await expect(actions.nth(2)).toHaveClass('send'); + await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`); + for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled(); + const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};})); + expect(new Set(boxes.map(box=>box.y)).size).toBe(1); + const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height); + expect(row).toBe(boxes[0].height); +}); +test('summary meta uses recorded runs',async({page,request})=>{ + const f=page.fixture;await open(page,f.positive_id); + const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count; + const surface=summary(page),title=await historyHeading(page).locator('h1').textContent(); + await expect(surface).toHaveCount(1); + await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized'); + const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`; + await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]); + // The concept order: category chip, positive block, selected negative block, then the meta line. + expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']); + for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI']) + await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible(); + await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px'); +}); + +// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26). +const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); +const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true}); +test('variants panel matches the concept',async({page})=>{ + const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); + await expect(panel).toHaveClass('surface timeline-panel'); + const heading=panel.locator(':scope > .panel-heading'); + await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants'); + await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.'); + const actions=heading.locator(':scope > .row-actions > button'); + expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']); + const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false'); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false'); + const timeline=panel.locator(':scope > .timeline'); + await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged'); + const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3); + // Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i. + const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs']; + for(const [index,name] of names.entries()) { + const lane=lanes.nth(index); + await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`); + const dot=lane.locator(':scope > button.variant-dot'); + await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`); + await expect(dot).toHaveAttribute('aria-pressed',String(index===0)); + await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`); + expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']); + } + await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false'); + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2); + const lower=panel.locator(':scope > .timeline-lower'); + await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run'); + await expect(lower.locator(':scope > button')).toHaveText('All images for this positive'); + await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/); + await expect(panel).not.toContainText('Saved combinations, not an execution timeline.'); + // The concept tokens reach the lanes (style.css 1096-1137). + await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px'); + await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%'); + await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px'); +}); +test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{ + const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); + await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2); + await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0); + // D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target. + for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) { + const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]); + expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x); + } + if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) { + const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded(); + const reach=await dot.evaluate(node=>{ + const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2; + return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;}); + }); + expect(reach).toEqual(['dot','dot','dot','dot']); + } + // The baseline has a recorded execution: the dot opens Recorded run for it. + const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click(); + await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run); + await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0); + // Clean geometry was counted but never recorded: Repeat run shows its newest linked image only. + const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}); + const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);}); + await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible(); + await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.'); + await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1); + expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1'); + await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused(); +}); +test('all images dialog lists every image for the positive',async({page,request})=>{ + const f=page.fixture;await open(page,f.detail_id); + const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); + const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}); + // Opening on the first lane, the shared gallery shows that lane only. + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0); + await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible(); + await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused(); + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0); + // Leaving the route closes an open dialog and its gallery. + await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count); + await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0); +}); +test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{ + const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); + const compare=panel.getByRole('button',{name:'Compare negatives',exact:true}); + const section=page.locator('.library-history .timeline-panel + section.surface'); + await expect(section).toHaveCount(0); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true'); + // The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25). + await expect(section).toHaveCount(1); + expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']); + await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants'); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.'); + const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2); + await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark'); + await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)'); + await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0); + // D46: the two blocks are named apart, so no region or action name repeats on the page. + await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible(); + await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible(); + await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms'); + await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.'); + await expect(section.locator('.diff-added')).toHaveCount(0); + // Another dot updates the same section in place. + const node=await section.elementHandle(); + await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click(); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.'); + await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light'); + await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']); + await expect(section.locator(':scope > p.small')).toHaveCount(1); + expect(await node.evaluate(element=>element.isConnected)).toBe(true); + await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block'); + await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px'); + await page.setViewportSize({width:1600,height:1100}); + let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON())); + expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1); + await page.setViewportSize({width:390,height:844}); + boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON())); + expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click(); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.'); + await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.'); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0); +}); +test('compare waits for the page holding the oldest active negative',async({page})=>{ + const f=page.fixture; + // A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46). + await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{ + const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue(); + const response=await route.fetch(),body=await response.json(); + await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}}); + }); + await open(page,f.canonical_id);const panel=variantsPanel(page); + await panel.getByRole('button',{name:'Compare negatives',exact:true}).click(); + const section=page.locator('.library-history .timeline-panel + section.surface'); + await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants'); + await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.'); + await expect(section.locator('.compare-grid')).toHaveCount(0); + await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`)); +}); +test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{ + const f=page.fixture; + await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}})); + await open(page,f.canonical_id);const panel=variantsPanel(page); + const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}); + await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible(); + await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.'); + await expect(dialog(page)).toHaveCount(0); + await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused(); + await page.unroute(/\/history\/pairs\/\d+\/newest/); + // The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast. + await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}})); + await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible(); + await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';}); + const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url())); + await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click(); + expect((await(await newest).json()).run.run_id).toBe(f.canonical_run); + await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0); + await expect(page.locator('#workspace-toast')).toHaveText(''); +}); +test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{ + const f=page.fixture; + await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK(); + await open(page,f.canonical_id);const panel=variantsPanel(page); + await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click(); + const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);}); + await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click(); + const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1); + expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived'); + // Each open replaces the previous dialog and its gallery. + await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click(); + await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1); +}); +test('Compare negatives is disabled without an active variant',async({page,request})=>{ + const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json(); + for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK(); + await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true}); + await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false'); + await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0); +}); +test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{ + const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json(); + const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id])); + await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK(); + await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK(); + await open(page,f.canonical_id); + const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label'))); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']); +}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index d534a35..97cc644 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants 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"]'); +const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]'); async function openHistory(page,id) { await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible(); } @@ -450,7 +450,7 @@ async function leaveAndReturn(page,id) { await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1); } -const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text}); +const laneLabel=(page,text)=>variantsPanel(page).locator('.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(); @@ -497,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy 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)']); + expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false'); // The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled. await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled(); @@ -509,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy expect(page.writes).toHaveLength(0); }); -test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{ +test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await selectPair(page,'Select negative variant 2'); await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); + // #249 D26: All images opens the concept dialog and leaves the selected negative as it was. const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}); - await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0); + await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible(); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await leaveAndReturn(page,f.positive_id); - await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false'); + await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0); }); @@ -532,7 +534,7 @@ test('add negative re-renders history with the saved or reused pair selected',as 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('p.prompt-text')).toHaveText(f.selected_negative); - await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1); + await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3'); // The saved selection applies to that save only; returning opens on the first negative, as in the concept. await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); @@ -552,7 +554,7 @@ test('reused pair outside first forty remains selected without loading every pag 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('p.prompt-text')).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); + await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41); expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1); }); @@ -565,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag 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(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.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'); @@ -639,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false); }); +// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis. +test('show archived and restore still work',async({page,request})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const timeline=variantsPanel(page).locator(':scope > .timeline'); + expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']); + await expect(timeline.locator('.library-variant-archived')).toHaveCount(0); + const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true'); + const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1); + await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/); + const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−'); + expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1); + expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1); + await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1'); + await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); + const writes=page.writes.length; + await lane.getByRole('button',{name:/^Restore /}).click(); + await expect(timeline.locator('.library-variant-archived')).toHaveCount(0); + await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible(); + expect(page.writes.slice(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); + await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1); +}); + 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;}); @@ -668,16 +693,16 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected 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(variantsPanel(page).locator('.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 image · 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 lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1); + const opacity=await lane.locator('.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 lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); // #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar. @@ -728,7 +753,7 @@ test('export downloads the full Library JSON without a toast and reports a failu 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);}; +const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.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); @@ -739,7 +764,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on 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(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).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); @@ -748,7 +773,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on 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'); + await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true'); expect(page.writes.every(write=>write.method==='POST')).toBe(true); }); @@ -809,7 +834,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch 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 image · Archived')).toHaveCount(1);await uniqueDotNames(page); + await expect(laneLabel(page,'· 2 runs · 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})=>{ diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 1e33cdd..0fd10b4 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -7,6 +7,7 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true}); const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true}); const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const media=page=>gallery(page).locator('article[data-image-key]'); +const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true}); const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); /** The Library's Tags filter lives in "Refine this view" (#249 D14). */ @@ -28,8 +29,10 @@ async function openHistory(page,text='Cat portrait') { await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); await expect(history(page)).toBeVisible(); } +// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position. +const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot'); async function selectVariant(page,index) { - const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}); + const choice=variantDot(page,index); await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true'); } @@ -57,9 +60,10 @@ test('history displays the selected full negative beneath its positive and filte const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); // The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0). await page.goto(route);await openHistory(page); - await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true'); await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0); - await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false'); + // #249 D26: All images for this positive opens a dialog; it is not a gallery filter. + await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/); for(const negative of ['blurry, watermark','low quality','low contrast','']) { const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index); const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true}); @@ -70,8 +74,10 @@ test('history displays the selected full negative beneath its positive and filte const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]'))); expect(order&4).toBeTruthy(); } - await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); - expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png'])); + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click(); + const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3); + expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png'])); + await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0); }); test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{ @@ -200,8 +206,9 @@ test('the caption count is announced once per change and kept while the shelf re test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{ await page.goto(route);await openHistory(page); - await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); - const card=gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]'); + // The unassigned file is reached through the All images dialog (#249 D26). + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3); + const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]'); const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true}); await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0); await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0); @@ -210,7 +217,7 @@ test('a Library-embedded media card stars its file and the star survives a reloa await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★'); await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible(); await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click(); - await expect(gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true'); }); test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{ @@ -327,10 +334,10 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK(); await page.goto(route);await openHistory(page); - const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40); + const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40); await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent(); await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45); - await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true'); await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative); }); @@ -553,7 +560,7 @@ test('saved choices retain curved staggered lanes and the full negative immediat await page.goto(route);await openHistory(page);await selectVariant(page,1); const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true}); expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt'); - const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius}))); + const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius}))); expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]); const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label'))); expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index e30f304..3db1696 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -94,14 +94,15 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await page.goto(`${workspace}#/library/${fixture.canonical_id}`); await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json(); - const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair); - const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true }); + const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title; + const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true }); await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true'); const actual = await historyGeometry(page, true); 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 to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible(); + const title = await page.locator('#workspace-main > .heading h1').textContent(); + for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, 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-main > .heading button.send')).toBeEnabled(); @@ -110,7 +111,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png'); await page.locator('.library-shared-gallery .media-card.selected .image-open').click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); - for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { + for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector); } const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`); @@ -541,7 +542,7 @@ test('Library visual evidence keeps natural cards and returns from real history await noOverflow(page); await page.getByRole('button',{name:'Explore history',exact:true}).first().click(); await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); - await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.'); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery'); 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-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.'); @@ -637,15 +638,18 @@ test('embedded history gallery keeps the history heading and disposes its layout await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); await expect(page.locator('h1')).not.toHaveText('Prompt library'); const title=await page.locator('h1').textContent();expect(title).not.toBe('Images'); - // History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery. + // History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked + // to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26). await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); - await expect(page.locator('article[data-image-key]').first()).toBeVisible(); + const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true}); + await expect(listing.locator('article[data-image-key]').first()).toBeVisible(); for (const layout of ['List','Masonry','Grid']) { - await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); + await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); await expect(page.locator('h1')).toHaveText(title); - await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.'); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery'); } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0); } diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py index 34c569e..96c0051 100644 --- a/tests/browser/workspace_history_fixture.py +++ b/tests/browser/workspace_history_fixture.py @@ -479,8 +479,14 @@ def seed_workspace_history( canonical_positive, canonical_negative, title="Quality baseline" ) canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"] - api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry") + clean_pair = api.db.save_prompt_pair( + canonical_positive, "flat light", title="Clean geometry" + ) api.db.save_prompt_pair(canonical_positive, "", title="No negative") + # Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only + # the baseline has a recorded execution, so Clean geometry opens Repeat run. + api.db.record_prompt_pair_use(canonical_pair, 2) + api.db.record_prompt_pair_use(clean_pair, 2) for number, name in enumerate(("canonical.png", "canonical-alt.png")): path = folder / name _write_art(path, number, canonical_positive, canonical_negative) @@ -500,6 +506,8 @@ def seed_workspace_history( os.utime(path, (stamp, stamp)) image_id = api.db.link_image_to_prompt(canonical_id, str(path)) api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"}) + if number: + api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"}) canonical_run = run( ( RecipeContext( @@ -529,6 +537,7 @@ def seed_workspace_history( "detail_image_id": current, "canonical_id": canonical_id, "canonical_pair": canonical_pair, + "clean_pair": clean_pair, "canonical_run": canonical_run, "canonical_positive": canonical_positive, "canonical_negative": canonical_negative, diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index c760618..5e7fcd9 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -25,7 +25,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive); await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); const history=workspace.getByRole('region',{name:'Prompt history',exact:true}); - await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); + await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click(); await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); // The pair send lives in the history heading, as in the approved concept. await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click(); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index 15f353b..c90a492 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -166,6 +166,21 @@ test('a title names each action after its prompt (concept "Copy positive prompt: assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']); }finally{scope.dispose();f.restore();} }); +test('setTitle renames the actions in place and keeps the last prompts and state',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}}); + view.setState({prompts:{positive:record('kept')},actionsEnabled:false}); + const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']); + const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render'); + view.setTitle('New name'); + assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']); + assert.ok(f.walk(container,node=>node.textContent==='kept').length); + assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true)); + view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']); + scope.dispose();view.setTitle('After dispose'); + }finally{scope.dispose();f.restore();} +}); const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}), ...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})}); test('prompt block emits concept markup without a source line',()=>{ diff --git a/tests/js/workspace-history-state.test.js b/tests/js/workspace-history-state.test.js index bb6c446..b5da246 100644 --- a/tests/js/workspace-history-state.test.js +++ b/tests/js/workspace-history-state.test.js @@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async() await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose(); } }); + +test('added terms split, trim and exact-diff like the concept',()=>{ + // The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly. + assert.equal(typeof m.addedTerms,'function','addedTerms is exported'); + assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']); + assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']); + assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']); + // Duplicates in the selected negative stay, as the concept lists every added term in order. + assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']); + // Case and inner whitespace are exact. + assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']); + assert.deepEqual(m.addedTerms('same, text','text, same'),[]); + assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index a2a925e..0e7a4a0 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -565,6 +565,47 @@ test('an action may open the next dialog without the late close event closing it } }); +test('a content dialog shows page nodes and releases them once on close or replacement', async () => { + const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; + const dom = fakeDOM(); + const scope = (await coreModule).createScope(); + try { + const trigger = new dom.Element('button'); + dom.body.append(trigger); + let closed = 0; + const grid = new dom.Element('div'); + openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ }); + assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive'); + assert.deepEqual(byId(dom, 'modal-body').children, [grid]); + assert.equal(modal(dom).open, true); + modal(dom).dispatchEvent(new Event('cancel', { cancelable: true })); + assert.equal(closed, 1); + assert.equal(document.activeElement, trigger); + await tick(); + assert.equal(closed, 1); + // Another dialog replacing the body releases it too, exactly once. + openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ }); + openCopyFallback('swap', { scope }); + assert.equal(closed, 2); + closeWorkspaceDialog(); + assert.equal(closed, 2); + // Disposing the page scope closes it. + openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ }); + scope.dispose(); + assert.equal(closed, 3); + assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/); + const other = (await coreModule).createScope(); + assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError); + assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError); + assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError); + other.dispose(); + } finally { + closeWorkspaceDialog(); + scope.dispose(); + dom.restore(); + } +}); + test('dialogs need the page scope and close with it', async () => { const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; const { createScope } = await coreModule; diff --git a/tests/test_api_history.py b/tests/test_api_history.py index 3e96ef0..67476d7 100644 --- a/tests/test_api_history.py +++ b/tests/test_api_history.py @@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase): response, body, 409, "history-changed", {"action": "refresh-history"} ) + async def test_newest_pair_run_route_names_the_run_or_none(self): + pair = self.api.db.save_prompt_pair("positive", "negative") + quiet = self.api.db.save_prompt_pair("positive", "quiet") + foreign = self.api.db.save_prompt_pair("elsewhere", "negative") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + self.capture(0, 100) + newest = self.capture(1, 200) + base = f"/prompt_manager/library/{positive}/history/pairs" + response = await self.client.get(f"{base}/{pair}/newest") + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertEqual(body["history_version"], 1) + self.assertEqual(body["history_epoch"], newest.storage_epoch) + self.assertEqual(body["run"]["run_id"], newest.run_id) + self.assertEqual(body["run"]["anchor_id"], "4") + response = await self.client.get(f"{base}/{quiet}/newest") + self.assertEqual((await response.json())["run"], None) + response = await self.client.get(f"{base}/{foreign}/newest") + self.assert_error(response, await response.json(), 404, "not-found") + response = await self.client.get( + f"/prompt_manager/library/999999/history/pairs/{pair}/newest" + ) + self.assert_error(response, await response.json(), 404, "not-found") + for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"): + response = await self.client.get(path) + self.assert_error(response, await response.json(), 400, "invalid-request") + response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"}) + self.assert_error(response, await response.json(), 400, "invalid-request") + + async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self): + pair = self.api.db.save_prompt_pair("positive", "negative") + saved = self.api.db.get_prompt_pair(pair) + self.capture(0, 100) + url = ( + f"/prompt_manager/library/{saved['positive_prompt_id']}" + f"/history/pairs/{pair}/newest" + ) + # A prompt saved only as a negative is not a Library positive. + response = await self.client.get( + f"/prompt_manager/library/{saved['negative_prompt_id']}" + f"/history/pairs/{pair}/newest" + ) + self.assert_error(response, await response.json(), 404, "not-found") + private = "SECRET_PROMPT /private/data/secret.db" + for exc in (sqlite3.OperationalError("busy " + private), OSError(private)): + with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc): + with self.assertLogs("prompt_manager.api", "WARNING") as logs: + response = await self.client.get(url) + body = await response.json() + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + self.assertNotIn(private, json.dumps(body) + " ".join(logs.output)) + source = str(self.comfy_dir / "source.db") + self.assertTrue(self.api.db.model.backup_database(source)) + writer = PromptDatabase(self.api.db.model.db_path) + original = self.api.db.get_newest_pair_run + + def during_snapshot(*args, **kwargs): + result = original(*args, **kwargs) + with patch("database.execution_history.notify_history_restore"): + writer.model.restore_from_file(source) + return result + + try: + with patch.object( + self.api.db, "get_newest_pair_run", side_effect=during_snapshot + ): + response = await self.client.get(url) + self.assert_error( + response, + await response.json(), + 409, + "history-changed", + {"action": "refresh-history"}, + ) + finally: + writer.close_all() + async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self): start = make_start( SESSION, diff --git a/tests/test_history_reads.py b/tests/test_history_reads.py index 734c1f8..e9e6a40 100644 --- a/tests/test_history_reads.py +++ b/tests/test_history_reads.py @@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase): db.list_history_outputs( start.run_id, after=OutputPosition("0" * 64, (node, 0)) ) + + def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self): + db = self.database() + pair_a = db.save_prompt_pair("positive", "A") + pair_b = db.save_prompt_pair("positive", "B") + quiet = db.save_prompt_pair("positive", "C") + other = db.save_prompt_pair("elsewhere", "A") + positive = db.get_prompt_pair(pair_a)["positive_prompt_id"] + self.capture(db, 0, bundle("A"), time=100) + newest = self.capture(db, 1, bundle("A"), time=200) + b = self.capture(db, 2, bundle("B"), time=300) + with db.recorded_history_snapshot(): + found = db.get_newest_pair_run(positive, pair_a) + found_b = db.get_newest_pair_run(positive, pair_b) + none = db.get_newest_pair_run(positive, quiet) + foreign = db.get_newest_pair_run(positive, other) + missing = db.get_newest_pair_run(9999, pair_a) + self.assertEqual(found["run"]["run_id"], newest.run_id) + self.assertEqual(found["run"]["anchor_id"], "4") + self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$") + self.assertEqual(found_b["run"]["run_id"], b.run_id) + self.assertEqual(none, {"run": None}) + self.assertIsNone(foreign) + self.assertIsNone(missing) + # A hint that no longer names the current pair is not followed. + db.delete_prompt_pair(pair_b) + recreated = db.save_prompt_pair("positive", "B") + with db.recorded_history_snapshot(): + self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None}) + with self.assertRaises(sqlite3.ProgrammingError): + db.get_newest_pair_run(positive, pair_a) + + def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self): + db = self.database() + pair_a = db.save_prompt_pair("positive", "A") + pair_b = db.save_prompt_pair("positive", "B") + positive = db.get_prompt_pair(pair_a)["positive_prompt_id"] + older = self.capture(db, 0, bundle("A"), time=100) + stale_epoch = self.capture(db, 1, bundle("A"), time=200) + conn = db.model.get_connection() + # A row whose reference epoch is not the current one is never followed. + conn.execute( + "UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?", + ("00000000-0000-4000-8000-000000000000", stale_epoch.run_id), + ) + conn.commit() + with db.recorded_history_snapshot(): + self.assertEqual( + db.get_newest_pair_run(positive, pair_a)["run"]["run_id"], + older.run_id, + ) + # Seventeen newer rows whose hint names pair A but whose texts resolve to + # pair B fill the candidate bound, so the older valid run is not reached. + for item in range(2, 19): + start = self.capture(db, item, bundle("B"), time=300 + item) + conn.execute( + "UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?", + (pair_a, start.run_id), + ) + conn.commit() + with db.recorded_history_snapshot(): + self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None}) + self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b)) + + def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self): + db = self.database() + pair = db.save_prompt_pair("positive", "negative") + saved = db.get_prompt_pair(pair) + self.capture(db, 0, bundle("negative"), time=100) + with db.recorded_history_snapshot(): + # A prompt saved only as a pair's negative is not a Library positive. + self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair)) + db.delete_prompt_pair(pair) + db.delete_prompt(saved["positive_prompt_id"]) + with db.recorded_history_snapshot(): + self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair)) diff --git a/tests/test_library.py b/tests/test_library.py index 088706c..fd83cb7 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase): self.db.list_library_images(positive, pair_id=unrelated) self.assertIsNone(self.db.get_library_positive(9999)) + def test_detail_variants_carry_their_pair_run_count(self): + counted = self.db.save_prompt_pair("Cat", "Blur") + fresh = self.db.save_prompt_pair("Cat", "Noise") + self.db.record_prompt_pair_use(counted, 3) + positive = self.db.get_prompt_pair(counted)["positive_prompt_id"] + variants = { + row["id"]: row for row in self.db.get_library_positive(positive)["variants"] + } + self.assertEqual(variants[counted]["run_count"], 3) + self.assertEqual(variants[fresh]["run_count"], 0) + def test_options_include_active_pair_categories_not_negative_only_or_archived(self): self.save("Legacy", category="Legacy category") pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category") diff --git a/web/js/workspace/history-state.mjs b/web/js/workspace/history-state.mjs index 4d4fda0..2b72bd6 100644 --- a/web/js/workspace/history-state.mjs +++ b/web/js/workspace/history-state.mjs @@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) { value.contexts.forEach(validateContext); requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length); } +/** + * Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614): + * split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats. + */ +export function addedTerms(base, selected) { + if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings'); + const terms = text => text.split(',').map(term => term.trim()); + const baseline = new Set(terms(base)); + return terms(selected).filter(term => term && !baseline.has(term)); +} export function frozenPromptRoles(context, mode) { requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context); return Object.fromEntries(['positive', 'negative'].map(role => { diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs index 7015c9f..d7e65c0 100644 --- a/web/js/workspace/history.mjs +++ b/web/js/workspace/history.mjs @@ -29,9 +29,10 @@ function place(parent, node, before) { else parent.insertBefore(node, before); } -export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) { +export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) { const scope = createScope(), dispose = () => scope.dispose(); - if (parentScope.signal.aborted) {dispose();return {dispose};} + // openRun is a hoisted declaration: on an aborted parent it answers false before reading any later state. + if (parentScope.signal.aborted) {dispose();return {dispose, openRun};} parentScope.signal.addEventListener('abort', dispose, {once:true}); scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); const section = el('section', undefined, 'library-surface recorded-history'); @@ -61,6 +62,9 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => { render(next);runView?.setState(next); if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);} + // The history summary's "N tracked runs" (#249 D24): a completed window read's count; a read in flight keeps the last. + const window = next.window; + if (!window.loading) onRecordedCount?.(window.history_epoch && !window.error ? window.recorded_run_count : null); }}); state = controller.state; runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker}); @@ -218,5 +222,11 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s }); render(state); void controller.load(); - return {dispose}; + /** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */ + function openRun({runId, anchorId = null, contextId = null, trigger}) { + if (scope.signal.aborted || !state.actionsEnabled) return false; + runView.open({runId, anchorId, contextId, trigger}); + return true; + } + return {dispose, openRun}; } diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 3376e63..08813b2 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -2,18 +2,10 @@ main.library-main { --library-positive: #73dda9; } .library-main :where(.library-shelf, .library-history, .library-linked-images) { min-inline-size: 0; } .library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } .library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } -.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } .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-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; } -.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; } -.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); } -.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; } -.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; } -.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); } .library-main .library-shared-gallery { margin-block-start: 24px; } .library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @@ -25,24 +17,12 @@ main.library-main { --library-positive: #73dda9; } main.library-main { --library-negative: #ff8c98; } [data-ca-theme="light"] main.library-main { --library-negative: #b71931; } .library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; } -.library-main .library-branches { overflow-x: auto; position: relative; } -.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; } -.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; } -.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; } -.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; } -.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; } -.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); } -.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; } -.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; } -.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; } +.history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; } +.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); } +.history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; } .history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; } -.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; } +/* D45: the panel's paging buttons wrap like the recorded toolbar's. */ +.ca-tools .library-main :where(.timeline-panel, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; } .recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; } .library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; } .library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; } @@ -90,7 +70,10 @@ main.library-main { --library-negative: #ff8c98; } .ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; } .ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); } .ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); } - .ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; } + /* D45: a coarse pointer reaches the concept dot through a 47px target. */ + .ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; } + /* D45: the 12px repeat-run dot (8px inside its border) gets a 44px coarse target the same way. */ + .ca-tools .library-history .run-dot::after { content: ""; position: absolute; inset: -18px; } .ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; } } @media (forced-colors: active) { @@ -103,9 +86,14 @@ main.library-main { --library-negative: #ff8c98; } keeps the concept's) and are retired in PR F (history). */ .library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .ca-tools .library-main .library-history .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } -.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; } @media (pointer: coarse) { .ca-tools .library-main .library-history .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; } +/* D45: archived pairs (#235) are concept lanes at reduced emphasis (the .6 disabled-marker opacity); text stays at full contrast. */ +.library-history .variant-lane.library-variant-archived::before, .ca-tools .library-history .library-variant-archived .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; } } +/* D45: at 390px and 200% zoom the panel heading and the legend row wrap, and the All images button wraps its label, + so the page never scrolls sideways (the retired production footer did the same). */ +.library-history .timeline-panel > .panel-heading, .library-history .timeline-lower { flex-wrap: wrap; } +/* D45: while the repeat-run dot shows (left: 190px), the lane text wraps inside the 57px lane before it (43px + 139px). */ +.library-history .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; } +.ca-tools .library-history .timeline-lower > button { /* D45 */ max-inline-size: 100%; white-space: normal; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 20abda1..84294d7 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -10,9 +10,10 @@ import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './libr import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; +import {addedTerms} from './history-state.mjs'; import {pageHeading} from './heading.mjs'; import {mountToolbar} from './toolbar.mjs'; -import {openRefineDialog} from './workspace-dialogs.mjs'; +import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -416,8 +417,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { function mountHistory() { const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history); const body=el('div');history.append(body); - // Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI. - headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.'); + // Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI. + headingDescription(main,history,'Prompt history · one positive, one shared gallery'); 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); @@ -460,21 +461,61 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return; } detail=state.detail;const content=contentScope; - const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip); + // The concept summary (app.js a598): category chip, positive and selected negative blocks, then the counts (D24). + const summary=el('article',undefined,'surface');const categoryChip=el('span','','chip');summary.append(categoryChip); const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer); + const meta=el('div',undefined,'card-meta'),runsMeta=el('span'),imagesMeta=el('span'),variantsMeta=el('span');runsMeta.hidden=true; + meta.append(runsMeta,imagesMeta,variantsMeta);summary.append(meta); 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; 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'); - 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); + // The concept's negative variants panel (app.js a598, style.css 1059-1154): heading, the lanes, then the legend. + const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants'); + const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions'); + panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small')); + // Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition. + const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton(''); + compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false'); + rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading); + content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();}); + // The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest + // active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place. + const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null; + const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];}; + content.cleanup(disposeCompareBlocks); + function compareBlock(text,label) { + const item=el('div',undefined,'compare-item'); + // D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page. + compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)})); + compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'}); + return item; + } + function updateCompare() { + if(detail.variant_total===0) compare.setAttribute('aria-pressed','false'); + compare.disabled=detail.variant_total===0; + if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;} + if(variants.nextSibling!==compareSection) variants.after(compareSection); + // D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes. + const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.id<oldest.id?variant:oldest,null); + const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id); + const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline; + const name=lane?laneName(lane.entry):''; + const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]); + if(key===compareKey) return; + compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants')); + if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;} + const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected')); + compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small')); + const terms=addedTerms(baseline.negative_text,selected.negative_text); + if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added')); + else compareSection.append(el('p','No added terms.','small')); + } + const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane); 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'); + // D45: the bounded page count stays a status for assistive technology; the concept shows no count line. + const variantCount=el('p','','ca-visually-hidden');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; @@ -511,13 +552,24 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1}); return entries; } + /** D45: a dot names its pair by a title no other loaded lane shares, else by its number, so every name is unique. */ + // Titles compare trimmed (accessible names are whitespace-normalized), and a title that reads like a fallback + // "variant N" is never used, so it cannot collide with an untitled lane's name. + const trimmedTitle=variant=>typeof variant.title==='string'?variant.title.trim():''; + function dotName(entry,entries) { + const title=trimmedTitle(entry.variant); + return title && !/^variant\s+\d+$/i.test(title) && entries.filter(other=>trimmedTitle(other.variant)===title).length===1?title:`variant ${entry.number}`; + } + const runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0; 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); + // Concept lane (app.js a598): the "−" dot, "<name> · N runs", then the repeat-run dot (D26). + const node=el('div',undefined,`variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','variant-dot');const label=el('span'); + const runDot=createButton('','run-dot');runDot.hidden=true; + node.append(button,label,runDot);lane={node,button,label,runDot,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); + content.listen(button,'click',()=>select(lane.entry.variant.id));content.listen(runDot,'click',()=>openRunDot(lane));lanes.set(entry.key,lane); } lane.entry=entry;return lane; } @@ -528,9 +580,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { 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}`); + const named=entry.kind==='separate'?'the saved negative variant outside the loaded list':dotName(entry,entries); + lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative ${named}`); lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current'; - lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`; + lane.text.textContent=`${name} · ${countOf(runCount(entry.variant),'run')}${entry.kind==='archived'?' · Archived':''}`; + // D26: the small dot appears only for a pair counted more than once. + lane.runDot.hidden=runCount(entry.variant)<=1;lane.runDot.setAttribute('aria-label',`Show repeat run for ${named}`);lane.runDot.disabled=savedReadState!=='current'; 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; @@ -545,17 +600,19 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { 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)); + updateCompare(); }; + const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');}; 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)}`); + const name=displayTitle(detail.positive); + main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta(); + send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name); }; 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,restoreStatus,restoreAction,more,retry,variantFooter); + const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all); + variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower); 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); @@ -589,7 +646,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;} return {...variant,...current}; }); - detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized'; + detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';updateMeta(); savedReadState=changed || unobserved || additional?'stale':'current'; parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.'; reloadSaved.hidden=savedReadState==='current'; @@ -608,23 +665,23 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);}); if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{}); - mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ + const recordedView=mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent); - }}); + },onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}}); // 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}); + blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false,title:displayTitle(detail.positive)}); function select(pairId,{force=false}={}) { if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return; if(pairId!==null && !recordFor(pairId)) pairId=null; activePairId=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)); + for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId)); 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(); // 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(); + updateSend();updateCompare(); } /** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */ async function rereadSelectedPair() { @@ -688,7 +745,49 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { 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)); + /** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */ + // One slot: each open replaces the previous dialog's scope, so repeated opens never pile up cleanups. + let imagesDialogScope=null;content.cleanup(()=>{imagesDialogScope?.dispose();imagesDialogScope=null;}); + function openImagesDialog({title,intro,className,fetchPage,trigger}) { + imagesDialogScope?.dispose(); + const dialogScope=createScope();imagesDialogScope=dialogScope; + const grid=el('div',undefined,className); + openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}}); + mountImages(grid,api,dialogScope,{embedded:true,fetchPage}); + } + const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal}); + /** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */ + function openAllImagesDialog(id,trigger) { + openImagesDialog({title:'All images for this positive',trigger,fetchPage:({parameters,signal})=>imagesPage(id,new URLSearchParams(parameters),signal)}); + } + /** Concept "Repeat run" (a1291-1304): the pair's newest linked image only. */ + function openRepeatRun(pair,trigger) { + openImagesDialog({title:'Repeat run',intro:'Same positive and negative, another execution.',className:'spaced-15',trigger,fetchPage:async({parameters,signal})=>{ + const query=new URLSearchParams(parameters);query.set('pair_id',String(pair.id));query.set('limit','1');query.delete('cursor'); + if(pair.archived===true) query.set('archive','archived'); + const page=await imagesPage(positiveId,query,signal),images=Array.isArray(page?.images)?page.images.slice(0,1):page?.images; + return {...page,images,total:Array.isArray(images)?images.length:page?.total,has_more:false,next_cursor:null}; + }}); + } + /** D26: the run dot opens Recorded run for the pair's newest recorded execution, else Repeat run. */ + const runLookups=new Set(); + async function openRunDot(lane) { + const pair=lane.entry.variant,trigger=lane.runDot; + if(savedReadState!=='current' || runLookups.has(pair.id)) return; + runLookups.add(pair.id);let run=null,failed=false; + try { + const value=await api.get(`/prompt_manager/library/${positiveId}/history/pairs/${pair.id}/newest`,{signal:content.signal,cache:'no-store'}); + run=value?.run && typeof value.run.run_id==='string'?value.run:null; + } catch { + failed=true; + } finally {runLookups.delete(pair.id);} + // The view may have gone stale while the lookup was in flight; a stale view opens nothing. + if(content.signal.aborted || savedReadState!=='current') return; + if(run && recordedView.openRun({runId:run.run_id,anchorId:run.anchor_id ?? null,contextId:run.context_id ?? null,trigger})) return; + if(failed) showWorkspaceToast('Recorded runs could not be read. Showing the newest linked image.'); + openRepeatRun(pair,trigger); + } + content.listen(all,'click',()=>openAllImagesDialog(positiveId,all)); const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null; if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);} if(archivedShown) archivedLoader.load(positiveId); diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 7f748f5..08f827a 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -38,8 +38,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`; const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`; // A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>"). - const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${title ? `: ${title}` : ''}`; - if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; + // The title is mutable so a saved title edited in place (setTitle below, #249 F1) renames the actions. + let currentTitle = title || ''; + const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${currentTitle ? `: ${currentTitle}` : ''}`; + // An aborted parent gets one inert handle; every method is the same no-op. + const inert = () => {}; + if (parentScope.signal.aborted) return {update:inert, setState:inert, setTitle:inert, dispose:inert}; const scope = childScope(parentScope); container.classList.add('prompt-inspector'); scope.listen(container, 'click', async event => { @@ -66,9 +70,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role } if (event.target.closest('[data-retry-prompts]')) onRetry?.(); }); - let roles = {}, actionsEnabled = true; + let roles = {}, actionsEnabled = true, current = {prompts}; function render(state) { if (scope.signal.aborted) return; + current = state; roles = {}; actionsEnabled = state.actionsEnabled !== false; container.replaceChildren(); @@ -119,5 +124,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role } } render({prompts}); - return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; + // A saved title edited in place renames the actions without a remount (the history summary, #249 F1). + const setTitle = next => { + const value = next || ''; + if (value !== currentTitle) {currentTitle = value;render(current);} + }; + return {update:prompts => render({prompts}), setState:render, setTitle, dispose:() => scope.dispose()}; } diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 606e9e2..7985922 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -12,6 +12,8 @@ * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22). * - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary * action; used by the Images selection bar's "Set category" (#249 D8). + * - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this + * positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell. * * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page. */ @@ -69,9 +71,10 @@ function createShell() { /** Close the open dialog, if any, and focus its trigger while that is still on the page. */ function finish() { if (!session) return; - const { trigger } = session; + const { trigger, onClose } = session; session = null; if (shell?.dialog.open) shell.dialog.close(); + onClose?.(); if (trigger?.isConnected) trigger.focus(); } @@ -97,11 +100,14 @@ function bindScope(scope) { }); } -function show(heading, nodes, { scope, trigger, focusTarget }) { +function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) { shell ??= createShell(); if (!shell.dialog.isConnected) document.body.append(shell.dialog); bindScope(scope); - session = { scope, trigger: trigger ?? session?.trigger ?? null }; + // A swapped-out body is gone from the shell, so its owner releases it. + const replaced = session?.onClose; + session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose }; + replaced?.(); shell.title.textContent = heading; shell.body.replaceChildren(...nodes); if (!shell.dialog.open) shell.dialog.showModal(); @@ -287,3 +293,15 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe actions.append(cancel, apply); show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); } + +/** + * The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or + * another dialog replaces its body, so the page can dispose what it mounted there. + */ +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) { + requireScope(scope); + if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); + if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); + if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); + show(title, nodes, { scope, trigger, onClose }); +} diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 8668271..d51b036 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -216,6 +216,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space .prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; /* D23 */ } /* #249 D23: the prompt blocks' mount point is transparent, so the section sits in `.card-content` as the concept's does. */ .card-content > .prompt-inspector { display: contents; /* D23 */ } +/* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount + point keeps its "Saved prompt texts" region name. */ +.library-history article.surface > .prompt-inspector { display: contents; /* D24 */ } /* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */ .card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }