Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61dcf6305a | ||
|
|
0037921e0b | ||
|
|
59e2b224d3 | ||
|
|
76eab9de4f | ||
|
|
c09df3da12 | ||
|
|
4ac9df6006 | ||
|
|
bfda248db0 |
+61
-1
@@ -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/<id>`) 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`, "<name> · 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 <title>" 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})=>{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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})=>{
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',()=>{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+127
-28
@@ -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);
|
||||
|
||||
@@ -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()};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user