feat(history): render recorded executions and frozen run details (#209)
This commit is contained in:
@@ -354,6 +354,7 @@ def _close_prepared_result(task: asyncio.Task) -> None:
|
||||
|
||||
|
||||
def _wire_run(run: dict) -> None:
|
||||
run["diagnostics_omitted"] = bool(run["diagnostics_omitted"])
|
||||
for key in _RUN_DECIMALS:
|
||||
if run[key] is not None:
|
||||
run[key] = str(run[key])
|
||||
|
||||
@@ -16,6 +16,7 @@ from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
|
||||
from tests.browser.workspace_history_fixture import seed_workspace_history
|
||||
|
||||
|
||||
class TestModel:
|
||||
@@ -226,6 +227,27 @@ def create_app(root: Path) -> web.Application:
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
chronological_history = data.get("chronological_history", False)
|
||||
history_runs = data.get("history_runs", 0)
|
||||
if (
|
||||
not isinstance(chronological_history, bool)
|
||||
or isinstance(history_runs, bool)
|
||||
or not isinstance(history_runs, int)
|
||||
or not 0 <= history_runs <= 1200
|
||||
or (not chronological_history and "history_runs" in data)
|
||||
or (
|
||||
chronological_history
|
||||
and set(data) - {"chronological_history", "history_runs"}
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
if chronological_history:
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_history(api, root, history_runs=history_runs)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
workspace_visual = data.get("workspace_visual", False)
|
||||
full_inspector = data.get("full_inspector", False)
|
||||
extra = data.get("extra_images", 0)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const recorded=page=>page.getByRole('region',{name:'Recorded executions',exact:true});
|
||||
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();};
|
||||
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);
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});await expect(reset).toBeOK();page.fixture=await reset.json();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.historyErrors).toEqual([]);});
|
||||
|
||||
test('strict reset seeds real A,A,B positions, two contexts and independently selected saved gallery',async({page,request})=>{
|
||||
for(const data of [{chronological_history:'true'},{chronological_history:true,native_library:true},{chronological_history:true,full_inspector:false},{chronological_history:true,extra_images:0},{chronological_history:true,history_runs:true},{chronological_history:true,unknown:1},{history_runs:1}])
|
||||
expect((await request.post('/__test__/reset',{data})).status()).toBe(400);
|
||||
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();
|
||||
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');
|
||||
await expect(recorded(page)).toContainText('same positive raw text');
|
||||
await expect(recorded(page).locator('[data-context-branch]')).toHaveCount(2);
|
||||
const buttons=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button');
|
||||
await expect(buttons).toHaveCount(3);
|
||||
expect(await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.runId))).toEqual(fixture.aab);
|
||||
const contexts=await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.contextId));expect(contexts[0]).toBe(contexts[1]);expect(contexts[2]).not.toBe(contexts[0]);
|
||||
for(const [index,id] of fixture.aab.entries()) {
|
||||
await marker(page,id).focus();await page.keyboard.press(index===1?' ':'Enter');
|
||||
await expect(dialog(page)).toContainText(id);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
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');
|
||||
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
|
||||
});
|
||||
|
||||
test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode and equal-looking snapshots',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
|
||||
const same=window.context_summaries.filter(c=>c.negative_raw.preview?.startsWith('same visible negative'));
|
||||
expect(same.length).toBe(2);expect(same[0].context_id).not.toBe(same[1].context_id);
|
||||
await expect(recorded(page).locator(`[data-context-branch="${same[0].context_id}"]`)).toHaveCount(1);
|
||||
const shared=window.items.find(run=>run.run_id===f.runs.shared);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page)).toBeVisible();
|
||||
const contexts=dialog(page).getByRole('group',{name:'Recorded anchors',exact:true});
|
||||
await expect(contexts.getByRole('button')).toHaveCount(3);await expect(contexts.locator('[aria-pressed="true"]')).toContainText('z');
|
||||
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
|
||||
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
|
||||
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
|
||||
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
|
||||
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
|
||||
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
|
||||
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
|
||||
});
|
||||
|
||||
test('every ordered receipt remains selected through distinct current locations, Viewer and nested Escape',async({page,request})=>{
|
||||
const f=page.fixture;let outputReads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))outputReads++;});await open(page,f.detail_id);await marker(page,f.runs.shared).click();
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const receipts=dialog(page).locator('[data-receipt]');
|
||||
expect(await receipts.evaluateAll(nodes=>nodes.map(node=>[node.dataset.outputNodeId,Number(node.dataset.imageOrdinal)]))).toEqual([['10',0],['10',1],['2',0],['2',1],['z',0]]);
|
||||
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
|
||||
await expect(dialog(page)).toContainText('Context membership unknown');
|
||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
|
||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
|
||||
await receipts.nth(1).getByRole('button',{name:'View current file',exact:true}).click();await page.keyboard.press('Escape');
|
||||
await expect(receipts.nth(1)).toHaveAttribute('aria-current','true');
|
||||
expect(outputReads).toBe(loadedReads);
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);await expect(marker(page,f.runs.shared)).toBeFocused();
|
||||
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
|
||||
const outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(outputs.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('output capture distinguishes unknown, complete zero, partial and conflicts with exact out-of-range time',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
for(const [name,expected] of [['equal_looking','Reported outputs not captured'],['empty','0 reported outputs · complete'],['whitespace','1 retained reported output · incomplete'],['conflicting','First captured context · conflicting evidence']]) {
|
||||
await marker(page,f.runs[name]).click();await expect(dialog(page)).toContainText(expected);
|
||||
if(name==='conflicting') {await expect(dialog(page)).toContainText('Recorded time outside display range');await expect(dialog(page)).toContainText('9223372036854775807');}
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
});
|
||||
|
||||
test('history, run and output failures retry their own reads and route exit ignores late callbacks',async({page})=>{
|
||||
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 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();
|
||||
await expect(dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true})).toBeVisible();outputFailure=false;await dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true}).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await page.keyboard.press('Escape');let release;const held=new Promise(resolve=>{release=resolve;});await page.route(/\/history\/runs\/[^/]+\?/,async route=>{await held;await route.continue().catch(()=>{});});
|
||||
await marker(page,f.runs.empty).click();await page.evaluate(()=>{location.hash='#/overview';});release();await expect(page.locator('#workspace-main h1')).toHaveText('Overview');await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('more than 1000 real runs retain sparse columns, focused identity and pixel anchors on prepend with moveBefore absent',async({page,request})=>{
|
||||
test.setTimeout(120000);const response=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:1001}});await expect(response).toBeOK();const f=await response.json();
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;Element.prototype.moveBefore=undefined;});await open(page,f.positive_id);
|
||||
const more=recorded(page).getByRole('button',{name:'Load earlier runs',exact:true,includeHidden:true});
|
||||
await expect(recorded(page).locator('[data-history-run]')).toHaveCount(40);
|
||||
const first=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button').first();
|
||||
await first.focus();await first.evaluate(node=>{window.retainedHistoryButton=node;window.retainedHistoryRun=node.dataset.runId;window.retainedHistoryX=node.getBoundingClientRect().left;window.retainedHistoryY=node.getBoundingClientRect().top;});
|
||||
await more.evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const retained=await page.evaluate(()=>({same:window.retainedHistoryButton===document.querySelector(`[data-run-id="${window.retainedHistoryRun}"]`),focus:document.activeElement===window.retainedHistoryButton,delta:Math.abs(window.retainedHistoryX-window.retainedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.retainedHistoryY-window.retainedHistoryButton.getBoundingClientRect().top)}));
|
||||
expect(retained.same&&retained.focus).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
|
||||
while(await more.isVisible()) {await more.evaluate(node=>node.click());await expect(more).not.toHaveAttribute('aria-disabled','true');}
|
||||
await expect(recorded(page)).toContainText('1004 loaded positions');
|
||||
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=node.scrollWidth/2;node.dispatchEvent(new Event('scroll'));});
|
||||
const bound=await timeline.evaluate(node=>Math.ceil(node.clientWidth/64)+8);
|
||||
await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound);
|
||||
expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound);
|
||||
expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await open(page,page.fixture.detail_id);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.locator('#workspace-main').evaluate(node=>{node.style.zoom='2';});
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await marker(page,page.fixture.runs.shared).click();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);await page.keyboard.press('Escape');
|
||||
await page.locator('#workspace-main').evaluate(node=>node.style.removeProperty('zoom'));
|
||||
await page.emulateMedia({forcedColors:'active'});await expect(marker(page,page.fixture.runs.shared)).toBeVisible();
|
||||
});
|
||||
|
||||
test('frozen Effective pair uses the shared acknowledged sender and Escape closes only its top dialog',async({page,context})=>{
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'history-pair',label:'History destination',type:'PromptManagerPair',roles:['positive','negative']};window.historyApplied=[];
|
||||
window.historyHost=createBridgeHost({origin:location.origin,namespace:'/',label:'History ComfyUI',adapter:{listTargets:()=>[target],
|
||||
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
|
||||
apply:roles=>{window.historyApplied.push(roles);return{targetId:target.id,values:roles};}}});
|
||||
});
|
||||
try {
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
|
||||
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(recorded(page)).toBeVisible();
|
||||
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','true');
|
||||
await marker(page,page.fixture.runs.shared).evaluate(node=>node.click());await expect(dialog(page)).toHaveCount(0);release();
|
||||
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','false');expect(checks).toBeGreaterThan(0);await expect(dialog(page)).toHaveCount(0);
|
||||
await marker(page,page.fixture.runs.shared).click();await expect(dialog(page)).toContainText(page.fixture.runs.shared);await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('native connected moves and keyboard navigation preserve marker identity through earlier branch reorder',async({page,request})=>{
|
||||
const result=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:140}});await expect(result).toBeOK();const f=await result.json();
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;window.historyMoves=0;const move=Element.prototype.moveBefore;window.hasHistoryMove=typeof move==='function';if(move)Element.prototype.moveBefore=function(...args){window.historyMoves++;return move.apply(this,args);};});
|
||||
await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(40);await buttons.first().focus();
|
||||
await buttons.first().evaluate(node=>{window.keyedHistoryButton=node;window.keyedHistoryX=node.getBoundingClientRect().left;window.keyedHistoryY=node.getBoundingClientRect().top;});
|
||||
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const retained=await page.evaluate(()=>({same:window.keyedHistoryButton.isConnected,focused:document.activeElement===window.keyedHistoryButton,delta:Math.abs(window.keyedHistoryX-window.keyedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.keyedHistoryY-window.keyedHistoryButton.getBoundingClientRect().top)}));
|
||||
expect(retained.same&&retained.focused).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
|
||||
if(await page.evaluate(()=>window.hasHistoryMove)) expect(await page.evaluate(()=>window.historyMoves)).toBeGreaterThan(0);
|
||||
await page.keyboard.press('End');await expect(recorded(page).locator('[data-history-run]').last()).toHaveAttribute('aria-posinset','80');
|
||||
expect(await page.evaluate(()=>document.activeElement.closest('[data-history-run]')?.getAttribute('aria-posinset'))).toBe('80');
|
||||
});
|
||||
|
||||
test('coarse-pointer markers expose 44px targets while retaining the small repeat visual',async({browser,request})=>{
|
||||
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true});const page=await context.newPage();
|
||||
try {
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(3);
|
||||
expect(await buttons.evaluateAll(nodes=>nodes.every(node=>node.getBoundingClientRect().width>=44&&node.getBoundingClientRect().height>=44))).toBe(true);
|
||||
const repeat=marker(page,f.aab[1]);expect(await repeat.evaluate(node=>({width:getComputedStyle(node,'::before').width,height:getComputedStyle(node,'::before').height}))).toEqual({width:'12px',height:'12px'});
|
||||
await repeat.tap();await expect(dialog(page)).toContainText(f.aab[1]);await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).tap();await expect(dialog(page)).toHaveCount(0);
|
||||
} finally {await context.close();}
|
||||
});
|
||||
|
||||
test('capture recovery reloads only reported outputs while retaining frozen run and saved gallery',async({page})=>{
|
||||
const f=page.fixture;let failures=2,outputReads=0,runReads=0,galleryReads=0;
|
||||
page.on('request',request=>{const path=new URL(request.url()).pathname;if(path===`/prompt_manager/history/runs/${f.runs.shared}`)runReads++;if(path===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>{
|
||||
outputReads++;if(failures-->0)return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'refresh-outputs'}}});
|
||||
await route.continue();
|
||||
});
|
||||
await open(page,f.detail_id);await marker(page,f.runs.shared).click();
|
||||
const reload=dialog(page).getByRole('button',{name:'Reload reported outputs',exact:true});await expect(reload).toBeVisible();expect(outputReads).toBe(2);
|
||||
const runCount=runReads,galleryCount=galleryReads;await reload.click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
expect(runReads).toBe(runCount);expect(galleryReads).toBe(galleryCount);expect(outputReads).toBe(3);await expect(dialog(page)).toContainText(f.runs.shared);
|
||||
});
|
||||
|
||||
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 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:'reload-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
const retry=page.getByRole('button',{name:'Retry saved Library details',exact:true});await expect(retry).toBeVisible();await expect(page.locator('.library-parent-status')).toContainText('could not be revalidated');
|
||||
parentFailure=false;await retry.click();await expect(page.locator('.library-parent-status')).toContainText('revalidated');await expect(retry).toBeHidden();
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();expect(galleryReads).toBe(0);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
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 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('pre'));
|
||||
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++;});
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
|
||||
for(const variant of details.variants) await expect(await request.delete(`/prompt_manager/pairs/${variant.id}`)).toBeOK();
|
||||
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
|
||||
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:'reload-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
|
||||
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
|
||||
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
|
||||
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
|
||||
});
|
||||
|
||||
test('a bounded saved-parent prefix leaves previously loaded variants unverified without claiming deletion',async({page,request})=>{
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await 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 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:'reload-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('not observed in this bounded revalidation page');await expect(page.locator('.library-parent-status')).not.toContainText(/deleted|removed/i);
|
||||
await expect(choices.last()).toHaveAttribute('aria-pressed','true');expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
|
||||
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled();
|
||||
});
|
||||
@@ -427,3 +427,17 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
|
||||
} finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();}
|
||||
});
|
||||
|
||||
test('saved choices retain curved staggered lanes and the full negative immediately follows its positive',async({page,request})=>{
|
||||
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})));
|
||||
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']);
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
const selected=details.variants[1].negative_text;
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Real disposable ledger commands and physical files for history workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
HISTORY_POSITIVE = (
|
||||
"Chronology A,A,B\nExact positive 猫 😀 " + "fine light " * 35
|
||||
).rstrip()
|
||||
DETAIL_POSITIVE = "Frozen evidence\n" + "unbroken-" * 90
|
||||
LONG_NEGATIVE = "same visible negative\n" + "exact detail 猫 😀 " * 70
|
||||
SESSION = "69f04366-6384-4182-b84b-117597479127"
|
||||
|
||||
|
||||
def _known(value: str, source: str = "runtime_return") -> TextEvidence:
|
||||
provenance = "inferred" if source.startswith("graph_") else "recorded"
|
||||
return TextEvidence(value, provenance, source, None)
|
||||
|
||||
|
||||
def _snapshot(raw: str, suffix: str = "") -> PromptSnapshot:
|
||||
return PromptSnapshot(_known(raw), _known(raw + suffix), _known(""), _known(suffix))
|
||||
|
||||
|
||||
def seed_workspace_history(
|
||||
api: PromptManagerAPI, root: Path, *, history_runs: int = 0
|
||||
) -> dict:
|
||||
"""Write actual captures; reset removes only this server's disposable rows."""
|
||||
seed_workspace_visual(api, root)
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
epoch = api.db.get_execution_epoch()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
folder = root / "output" / "history"
|
||||
folder.mkdir(exist_ok=True)
|
||||
for index, name in enumerate(("current.png", "other.png")):
|
||||
with Image.new("RGB", (160, 100), (50, 80 + index * 50, 110)) as image:
|
||||
image.save(folder / name)
|
||||
|
||||
def reference(
|
||||
node: str, ordinal: int, name: str, kind: str = "output"
|
||||
) -> OutputReference:
|
||||
return OutputReference(
|
||||
node,
|
||||
ordinal,
|
||||
kind,
|
||||
"history",
|
||||
name,
|
||||
"posix",
|
||||
roots.output_key if kind == "output" else roots.temp_key,
|
||||
)
|
||||
|
||||
item = 0
|
||||
|
||||
def run(
|
||||
contexts: tuple[RecipeContext, ...],
|
||||
outputs: OutputBundle | None = None,
|
||||
*,
|
||||
observed: int | None = None,
|
||||
conflicting: bool = False,
|
||||
) -> str:
|
||||
nonlocal item
|
||||
job = str(uuid5(UUID(SESSION), f"job-{item}"))
|
||||
start = make_start(
|
||||
SESSION,
|
||||
((item, job, {}, {}), item),
|
||||
epoch,
|
||||
1_780_000_000_000 + item if observed is None else observed,
|
||||
)
|
||||
api.db.write_execution_start(start)
|
||||
messages = [("execution_success", {"prompt_id": job, "timestamp": item + 1})]
|
||||
finish = make_finish(
|
||||
SESSION, item, epoch, SimpleNamespace(messages=messages), item + 10
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
partial = any(
|
||||
getattr(side, field).value is None
|
||||
for context in contexts
|
||||
for side in (context.positive, context.negative)
|
||||
if side is not None
|
||||
for field in ("raw", "effective", "prepend", "append")
|
||||
)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
contexts,
|
||||
"partial" if partial else "complete",
|
||||
"partial" if partial else "captured",
|
||||
),
|
||||
)
|
||||
if outputs is not None:
|
||||
api.db.write_execution_outputs_for_finish(finish, outputs)
|
||||
if conflicting:
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
(replace(contexts[0], negative=_snapshot("later")),),
|
||||
"complete",
|
||||
"captured",
|
||||
),
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((), "complete", "none_reported")
|
||||
)
|
||||
item += 1
|
||||
return start.run_id
|
||||
|
||||
pair_a = api.db.save_prompt_pair(
|
||||
HISTORY_POSITIVE, "A negative", title="Saved A", category="History"
|
||||
)
|
||||
api.db.save_prompt_pair(HISTORY_POSITIVE, "B negative", title="Saved B")
|
||||
positive_id = api.db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
image = api.db.link_image_to_prompt(positive_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(pair_a, image, {"source": "history browser fixture"})
|
||||
a = RecipeContext(
|
||||
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
|
||||
)
|
||||
b = replace(a, negative=_snapshot("B negative"))
|
||||
aab = [run((context,)) for context in (a, a, b)]
|
||||
for number in range(history_runs):
|
||||
context = (
|
||||
a
|
||||
if number % 3 == 0
|
||||
else (
|
||||
b
|
||||
if number % 3 == 1
|
||||
else replace(b, negative=_snapshot(f"Distinct context {number:04}"))
|
||||
)
|
||||
)
|
||||
run((context,))
|
||||
|
||||
saved_pair = api.db.save_prompt_pair(
|
||||
DETAIL_POSITIVE, LONG_NEGATIVE, title="Saved exact negative"
|
||||
)
|
||||
detail_id = api.db.get_prompt_pair(saved_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(DETAIL_POSITIVE, "", title="Saved empty")
|
||||
current = api.db.link_image_to_prompt(detail_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(saved_pair, current, {"source": "fixture"})
|
||||
x = RecipeContext(
|
||||
"10",
|
||||
_snapshot(DETAIL_POSITIVE, " + composition A"),
|
||||
_snapshot(LONG_NEGATIVE, " A"),
|
||||
"inferred",
|
||||
)
|
||||
y = replace(x, negative=_snapshot(LONG_NEGATIVE, " B"))
|
||||
receipts = OutputBundle(
|
||||
(
|
||||
reference("10", 0, "current.png"),
|
||||
reference("10", 1, "current.png"),
|
||||
reference("2", 0, "other.png"),
|
||||
reference("2", 1, "missing.png"),
|
||||
reference("z", 0, "transient.png", "temp"),
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
)
|
||||
detail_runs = {
|
||||
"shared": run(
|
||||
(x, replace(x, anchor_id="2"), replace(y, anchor_id="z")), receipts
|
||||
),
|
||||
"equal_looking": run((y,)),
|
||||
"empty": run(
|
||||
(replace(x, negative=_snapshot("")),),
|
||||
OutputBundle((), "complete", "none_reported"),
|
||||
),
|
||||
"unknown": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
TextEvidence(None, "unknown", "unknown", "missing"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
"whitespace": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
_known(" \n\t ", "graph_literal"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
),
|
||||
OutputBundle(
|
||||
(reference("1", 0, "current.png"),), "partial", "limit_reached"
|
||||
),
|
||||
),
|
||||
"conflicting": run(
|
||||
(x,), receipts, conflicting=True, observed=9_223_372_036_854_775_807
|
||||
),
|
||||
}
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
"detail_id": detail_id,
|
||||
"aab": aab,
|
||||
"runs": detail_runs,
|
||||
"saved_pair": saved_pair,
|
||||
"detail_image_id": current,
|
||||
}
|
||||
@@ -50,3 +50,23 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
|
||||
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
|
||||
assert.deepEqual(sent[1],{negative:' \n\t '});
|
||||
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/history-state.mjs');
|
||||
|
||||
test('chronological columns keep the visible runs, three-column overscan and distant focus in ascending order', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function', 'history must expose a bounded column window');
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 640, 128, 999), [7,8,9,10,11,12,13,14,999]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 0, 64), [0,1,2,3]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 64000, 390, 0), [0,997,998,999,1000]);
|
||||
assert.deepEqual(visibleHistoryColumns(3, 100, 1), [0,1,2]);
|
||||
assert.deepEqual(visibleHistoryColumns(0, 0, 500), []);
|
||||
});
|
||||
|
||||
test('column geometry handles fractional edges, invalid scroll and focus without scanning all loaded runs', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function');
|
||||
assert.deepEqual(visibleHistoryColumns(50000, 640.5, 64, 0), [0,7,8,9,10,11,12,13,14]);
|
||||
assert.deepEqual(visibleHistoryColumns(121, NaN, Infinity), [0,1,2,3]);
|
||||
assert.equal(visibleHistoryColumns(120, 900, 1).length, 120);
|
||||
assert.ok(visibleHistoryColumns(10000000, 40000, 390).length <= 14);
|
||||
for(const count of [-1, 1.1, NaN, '4']) assert.throws(()=>visibleHistoryColumns(count,0,100), TypeError);
|
||||
});
|
||||
@@ -191,6 +191,58 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
self.assertEqual(uncaptured["contexts"], [])
|
||||
self.assertIsNone(uncaptured["output_summary"]["retained_occurrence_count"])
|
||||
|
||||
async def test_window_and_exact_run_publish_boolean_diagnostics_from_sqlite(
|
||||
self,
|
||||
) -> None:
|
||||
positive = self._save_prompt("positive")
|
||||
starts = []
|
||||
for item, omitted in enumerate((False, True)):
|
||||
start = make_start(
|
||||
SESSION,
|
||||
((0, JOB, {}, {}), item),
|
||||
self.api.db.get_execution_epoch(),
|
||||
item,
|
||||
)
|
||||
self.api.db.write_execution_start(start)
|
||||
finish = make_finish(
|
||||
SESSION,
|
||||
item,
|
||||
start.storage_epoch,
|
||||
SimpleNamespace(
|
||||
messages=[
|
||||
(
|
||||
"execution_success",
|
||||
{
|
||||
"prompt_id": JOB,
|
||||
"timestamp": item,
|
||||
"node_type": object() if omitted else None,
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
item,
|
||||
)
|
||||
self.api.db.write_execution_finish(finish)
|
||||
self.api.db.write_execution_contexts(
|
||||
start.run_id, start.storage_epoch, bundle()
|
||||
)
|
||||
self.assertEqual(
|
||||
self.api.db.get_execution_run(start.run_id)["diagnostics_omitted"],
|
||||
int(omitted),
|
||||
)
|
||||
starts.append(start)
|
||||
response, window = await self.window(positive)
|
||||
self.assertEqual(response.status, 200, window)
|
||||
for start, omitted in zip(starts, (False, True)):
|
||||
with self.subTest(omitted=omitted):
|
||||
summary = next(
|
||||
row for row in window["items"] if row["run_id"] == start.run_id
|
||||
)
|
||||
self.assertIs(summary["diagnostics_omitted"], omitted)
|
||||
response, detail = await self.detail(start.run_id)
|
||||
self.assertEqual(response.status, 200, detail)
|
||||
self.assertIs(detail["run"]["diagnostics_omitted"], omitted)
|
||||
|
||||
async def test_missing_and_invalid_ids_epochs_and_duplicate_queries(self):
|
||||
response, body = await self.window(999999)
|
||||
self.assert_error(response, body, 404, "not-found")
|
||||
|
||||
@@ -157,6 +157,21 @@ const emptyRun = () => ({detail:null, loading:false, error:null});
|
||||
const emptyOutputs = () => ({items:[], capture:null, capture_fingerprint:null, retained_occurrence_count:null, list_status:null, next_cursor:null, has_more:false, loading:false, error:null});
|
||||
const emptySelection = mode => ({run_id:null, anchor_id:null, context_id:null, mode, receipt:null});
|
||||
|
||||
/** Fixed run positions; an off-screen focused column remains in semantic order. */
|
||||
export function visibleHistoryColumns(count, left, width, focusedIndex = -1) {
|
||||
requireValue(Number.isSafeInteger(count) && count >= 0, 'Invalid history column count');
|
||||
if (count <= 120) return Array.from({length:count}, (_, index) => index);
|
||||
const start = Math.max(0, Math.floor((Number.isFinite(left) ? Math.max(0, left) : 0) / 64) - 3);
|
||||
const right = (Number.isFinite(left) ? Math.max(0, left) : 0) + (Number.isFinite(width) ? Math.max(1, width) : 1);
|
||||
const end = Math.min(count - 1, Math.ceil(right / 64) - 1 + 3);
|
||||
const result = [];
|
||||
for (let index = start; index <= end; index++) result.push(index);
|
||||
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0 && focusedIndex < count && !result.includes(focusedIndex)) {
|
||||
result.push(focusedIndex);result.sort((a, b) => a - b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createHistoryController({api, positiveId, onChange = () => {}, restore}) {
|
||||
requireValue(id(positiveId), 'Invalid saved positive ID');
|
||||
const intent = {search:typeof restore?.search === 'string' ? restore.search : '', scrollTop:typeof restore?.scrollTop === 'number' && Number.isFinite(restore.scrollTop) ? Math.max(0, restore.scrollTop) : 0};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
|
||||
import {mountRecordedRun} from './recorded-run.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function preview(summary) {
|
||||
const raw = summary.negative_raw;
|
||||
return raw.state === 'unknown' ? 'Negative unknown' : raw.state === 'explicit_empty' ? 'Explicitly empty negative' : `${raw.preview}${raw.truncated ? '…' : ''}`;
|
||||
}
|
||||
function distinction(summary) {
|
||||
const sources = [...new Set(summary.negative_raw.evidence.map(e => e.source))];
|
||||
return `Frozen composition · ${sources.join(', ') || 'unknown evidence'}`;
|
||||
}
|
||||
|
||||
// New nodes use ordinary insertion; connected moves preserve browser state where supported.
|
||||
function place(parent, node, before) {
|
||||
if (node === before || (node.parentNode === parent && node.nextSibling === before)) return;
|
||||
if (typeof parent.moveBefore === 'function' && parent.isConnected && node.isConnected && parent.ownerDocument === node.ownerDocument) parent.moveBefore(node, before);
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
const section = el('section', undefined, 'library-surface recorded-history');
|
||||
section.setAttribute('aria-label', 'Recorded executions');
|
||||
section.append(el('h2', 'Recorded executions'), el('p', 'same positive raw text · frozen contexts can differ in composition and evidence.', 'muted'));
|
||||
const count = el('p', '', 'history-count muted');count.setAttribute('role', 'status');
|
||||
const disclosure = el('details', undefined, 'history-scope');disclosure.append(el('summary', 'Recorded history scope'));
|
||||
const scopeText = el('p', '', 'muted');disclosure.append(scopeText);
|
||||
const toolbar = el('div', undefined, 'history-toolbar');
|
||||
const earlier = createButton('Load earlier runs'), refresh = createButton('Refresh history'), retry = createButton('Retry recorded history');
|
||||
const reload = createButton('Reload recorded history');
|
||||
toolbar.append(earlier, refresh, retry, reload);
|
||||
const status = el('p', '', 'history-status muted');status.setAttribute('role', 'status');
|
||||
const timeline = el('div', undefined, 'history-timeline');timeline.setAttribute('aria-label', 'Chronological branch timeline');
|
||||
const canvas = el('div', undefined, 'history-canvas');
|
||||
const edge = el('span', undefined, 'history-earlier-edge');edge.setAttribute('aria-hidden', 'true');
|
||||
const positive = el('div', 'Positive raw text · unchanged', 'history-positive-lane');positive.setAttribute('aria-hidden', 'true');
|
||||
const branches = el('div', undefined, 'history-context-branches');branches.setAttribute('aria-hidden', 'true');
|
||||
const positions = el('ol', undefined, 'history-run-list');positions.setAttribute('aria-label', 'Recorded run positions');
|
||||
canvas.append(edge, positive, branches, positions);timeline.append(canvas);
|
||||
section.append(count, disclosure, toolbar, status, timeline, el('p', 'Green + positive · red − negative · small dot repeated frozen context. Colors identify prompt roles.', 'history-legend muted'));
|
||||
container.append(section);
|
||||
let state, runView, frame = 0, observer, items = null, summaries = [], indexes = new Map(), contexts = new Map();
|
||||
let initialized = false, focusedRun = null, activeParent = null;
|
||||
const columns = new Map(), branchNodes = new Map();
|
||||
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
|
||||
render(next);runView?.setState(next);
|
||||
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
|
||||
}});
|
||||
state = controller.state;
|
||||
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
|
||||
|
||||
function restoreMarker(selection) {
|
||||
const index = indexes.get(selection?.runId || state.selection.run_id);
|
||||
if (index === undefined) {refresh.focus({preventScroll:true});return;}
|
||||
focusedRun = items[index].run_id;renderColumns();
|
||||
const column = columns.get(focusedRun);
|
||||
const button = [...column.node.querySelectorAll('button')].find(node => node.dataset.contextId === (selection?.contextId || state.selection.context_id)) || column.node.querySelector('button');
|
||||
button?.focus({preventScroll:true});
|
||||
}
|
||||
function captureAnchor() {
|
||||
const bounds = timeline.getBoundingClientRect(), active = document.activeElement;
|
||||
let button = timeline.contains(active) && active.matches('[data-run-id]') ? active : null;
|
||||
if (!button) for (const column of columns.values()) {
|
||||
const box = column.node.getBoundingClientRect();
|
||||
if (box.right > bounds.left + 1 && box.left < bounds.right) {button = column.node.querySelector('button');break;}
|
||||
}
|
||||
if (!button) return null;
|
||||
const box = button.getBoundingClientRect();
|
||||
return {run:button.dataset.runId, context:button.dataset.contextId, x:box.left - bounds.left, y:box.top - bounds.top};
|
||||
}
|
||||
function restoreAnchor(anchor) {
|
||||
const column = columns.get(anchor?.run);
|
||||
const button = column && [...column.buttons.values()].find(node => node.dataset.contextId === anchor.context);
|
||||
if (!button) return;
|
||||
const box = button.getBoundingClientRect(), bounds = timeline.getBoundingClientRect();
|
||||
timeline.scrollLeft += box.left - bounds.left - anchor.x;
|
||||
timeline.scrollTop += box.top - bounds.top - anchor.y;
|
||||
}
|
||||
function columnFor(run) {
|
||||
const node = el('li', undefined, 'history-run-column');node.dataset.historyRun = run.run_id;
|
||||
const position = el('span', '', 'history-position');position.setAttribute('aria-hidden', 'true');node.append(position);
|
||||
return {node, position, buttons:new Map()};
|
||||
}
|
||||
function renderColumns() {
|
||||
if (scope.signal.aborted || !items) return;
|
||||
const active = document.activeElement;
|
||||
const focused = indexes.get(timeline.contains(active) ? active.closest('[data-history-run]')?.dataset.historyRun : focusedRun || state.selection.run_id) ?? -1;
|
||||
const visible = visibleHistoryColumns(items.length, Math.max(0, timeline.scrollLeft - 38), timeline.clientWidth, focused);
|
||||
const keep = new Set(visible.map(index => items[index].run_id));
|
||||
for (const [id, column] of columns) if (!keep.has(id)) {column.node.remove();columns.delete(id);}
|
||||
let before = positions.firstChild;
|
||||
for (const index of visible) {
|
||||
const run = items[index];let column = columns.get(run.run_id);
|
||||
if (!column) {column = columnFor(run);columns.set(run.run_id, column);}
|
||||
const {node, buttons} = column;
|
||||
node.style.insetInlineStart = `${38 + index * 64}px`;node.value = index + 1;
|
||||
node.setAttribute('aria-posinset', String(index + 1));node.setAttribute('aria-setsize', String(items.length));
|
||||
column.position.textContent = String(index + 1);
|
||||
const anchors = new Map();for (const anchor of run.matching_anchors) if (!anchors.has(anchor.context_id)) anchors.set(anchor.context_id, anchor);
|
||||
for (const anchor of anchors.values()) {
|
||||
let button = buttons.get(anchor.context_id);
|
||||
if (!button) {
|
||||
button = createButton('', 'history-run-marker');button.dataset.runId = run.run_id;button.dataset.anchorId = anchor.anchor_id;button.dataset.contextId = anchor.context_id;
|
||||
buttons.set(anchor.context_id, button);node.append(button);
|
||||
}
|
||||
const context = contexts.get(anchor.context_id), first = context.first === index;
|
||||
button.classList.toggle('history-context-anchor', first);
|
||||
button.style.insetBlockStart = `${80 + context.row * 57}px`;
|
||||
button.textContent = first ? '−' : '';
|
||||
button.setAttribute('aria-label', `Recorded run ${index + 1} of ${items.length} loaded · ${preview(context.summary)} · context ${anchor.context_id.slice(0, 8)} · anchor ${anchor.anchor_id}`);
|
||||
button.setAttribute('aria-pressed', String(state.selection.run_id === run.run_id && state.selection.context_id === anchor.context_id));
|
||||
// Disabling a focused native button drops focus during a prepend read.
|
||||
button.setAttribute('aria-disabled', String(!state.actionsEnabled));
|
||||
}
|
||||
place(positions, node, before);before = node.nextSibling;
|
||||
}
|
||||
if (timeline.contains(active) && active.isConnected && document.activeElement !== active) active.focus({preventScroll:true});
|
||||
}
|
||||
function render(next) {
|
||||
if (scope.signal.aborted) return;
|
||||
const anchor = items !== next.window.items ? captureAnchor() : null;
|
||||
state = next;
|
||||
const window = next.window;
|
||||
if (items !== window.items) {
|
||||
items = window.items;indexes = new Map(items.map((run, index) => [run.run_id, index]));
|
||||
const firsts = new Map();
|
||||
for (const [index, run] of items.entries()) for (const anchor of run.matching_anchors) if (!firsts.has(anchor.context_id)) firsts.set(anchor.context_id, index);
|
||||
summaries = [...window.context_summaries].sort((a, b) => firsts.get(a.context_id) - firsts.get(b.context_id) || (a.context_id < b.context_id ? -1 : 1));
|
||||
contexts = new Map(summaries.map((summary, row) => [summary.context_id, {summary, row, first:firsts.get(summary.context_id)}]));
|
||||
canvas.style.inlineSize = `${Math.max(520, 76 + items.length * 64)}px`;
|
||||
canvas.style.blockSize = `${Math.max(150, 115 + summaries.length * 57)}px`;
|
||||
const keep = new Set(contexts.keys());
|
||||
for (const [id, node] of branchNodes) if (!keep.has(id)) {node.remove();branchNodes.delete(id);}
|
||||
let before = branches.firstChild;
|
||||
for (const [id, context] of contexts) {
|
||||
let branch = branchNodes.get(id);
|
||||
if (!branch) {branch = el('div', undefined, 'history-context-branch');branch.dataset.contextBranch = id;branch.append(el('span', '', 'history-context-label'));branchNodes.set(id, branch);}
|
||||
branch.style.insetBlockStart = `${80 + context.row * 57}px`;branch.style.insetInlineStart = `${38 + context.first * 64}px`;
|
||||
branch.firstChild.textContent = `${preview(context.summary)} · ${id.slice(0, 8)} · ${distinction(context.summary)}`;
|
||||
place(branches, branch, before);before = branch.nextSibling;
|
||||
}
|
||||
if (!initialized && items.length) {timeline.scrollLeft = canvas.offsetWidth;timeline.scrollTop = state.intent.scrollTop;initialized = true;}
|
||||
else if (anchor) {
|
||||
const index = indexes.get(anchor.run);
|
||||
if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x);
|
||||
}
|
||||
}
|
||||
count.textContent = window.group ? `${window.recorded_run_count} distinct recorded runs as of this read · ${window.items.length} loaded positions` : '';
|
||||
const group = window.group;
|
||||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||||
status.setAttribute('role', window.error ? 'alert' : 'status');
|
||||
status.textContent = next.validating ? 'Revalidating recorded history…' : window.loading ? 'Loading recorded history…' : window.error ? 'Recorded history could not be loaded.' : next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : !items.length ? 'No matching recorded executions.' : window.has_more ? 'Newest bounded window · displayed in chronological order.' : 'All matching recorded runs in this window are loaded.';
|
||||
earlier.hidden = !window.has_more;earlier.disabled = window.loading || !!next.refreshRequired;earlier.setAttribute('aria-disabled', String(earlier.disabled));
|
||||
refresh.disabled = window.loading || next.validating;retry.hidden = !window.error || !!next.refreshRequired;
|
||||
reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||||
timeline.hidden = !items.length;renderColumns();if (anchor) restoreAnchor(anchor);
|
||||
}
|
||||
const schedule = () => {if (!frame) frame = requestAnimationFrame(() => {frame = 0;renderColumns();});};
|
||||
scope.listen(timeline, 'scroll', schedule);scope.listen(window, 'resize', schedule);
|
||||
scope.listen(positions, 'focusin', event => {focusedRun = event.target.closest('[data-history-run]')?.dataset.historyRun || focusedRun;});
|
||||
scope.listen(positions, 'click', event => {
|
||||
const button = event.target.closest('button[data-run-id]');if (!button || !state.actionsEnabled) return;
|
||||
runView.open({runId:button.dataset.runId, anchorId:button.dataset.anchorId, contextId:button.dataset.contextId, trigger:button});
|
||||
});
|
||||
scope.listen(positions, 'keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !state.actionsEnabled) return;
|
||||
const current = event.target.closest('[data-history-run]');if (!current) return;
|
||||
const index = indexes.get(current.dataset.historyRun), next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : index + (event.key === 'ArrowLeft' ? -1 : 1);
|
||||
if (next < 0 || next >= items.length) return;
|
||||
event.preventDefault();focusedRun = items[next].run_id;renderColumns();
|
||||
const button = columns.get(focusedRun).node.querySelector('button');button.focus({preventScroll:true});button.scrollIntoView({block:'nearest', inline:'nearest'});renderColumns();
|
||||
});
|
||||
scope.listen(earlier, 'click', () => controller.loadEarlier());scope.listen(refresh, 'click', () => {initialized = false;controller.refresh();});
|
||||
scope.listen(retry, 'click', () => controller.load());scope.listen(reload, 'click', () => state.refreshRequired === 'history-changed' || state.refreshRequired === 'revalidation-failed' ? controller.refresh() : controller.loadOutputs());
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
observer = new IntersectionObserver(entries => {if (entries.some(entry => entry.isIntersecting) && state.actionsEnabled && state.window.has_more) controller.loadEarlier();}, {root:timeline});observer.observe(edge);
|
||||
}
|
||||
scope.listen(window, 'pagehide', dispose);
|
||||
scope.cleanup(() => {
|
||||
Object.assign(visit, {mode:state.selection.mode, runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, scrollTop:timeline.scrollTop});
|
||||
observer?.disconnect();cancelAnimationFrame(frame);runView.dispose();controller.dispose();columns.clear();branchNodes.clear();section.remove();
|
||||
});
|
||||
render(state);
|
||||
const reentry = Object.hasOwn(visit, 'mode');
|
||||
void controller.load().then(() => {if (reentry && !scope.signal.aborted) return controller.revalidate();});
|
||||
return {dispose};
|
||||
}
|
||||
@@ -46,3 +46,67 @@ main.library-main { --library-positive: #73dda9; }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
|
||||
|
||||
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
|
||||
|
||||
/* Role colors and branch geometry follow the approved history surface. */
|
||||
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: var(--ca-field); text-align: center; font-size: 16px; line-height: 25px; }
|
||||
.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: var(--ca-field); }
|
||||
.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; }
|
||||
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.history-scope summary { cursor: pointer; }
|
||||
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
|
||||
.history-canvas { position: relative; margin: 18px 20px; }
|
||||
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
|
||||
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
|
||||
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
|
||||
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
|
||||
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
|
||||
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); }
|
||||
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
|
||||
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-line); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
|
||||
.recorded-run-dialog::backdrop { background: #0009; }
|
||||
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.recorded-run-header h2 { margin: 0; font-size: 20px; }
|
||||
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
|
||||
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
|
||||
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
|
||||
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
|
||||
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
|
||||
.recorded-provenance summary { cursor: pointer; }
|
||||
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
|
||||
.recorded-outputs { margin-block: 24px; }
|
||||
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
|
||||
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
|
||||
.recorded-receipt > div { margin-block-end: 10px; }
|
||||
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
|
||||
@media (max-width: 700px) {
|
||||
.recorded-run-dialog { padding: 16px; }
|
||||
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.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: var(--ca-field); background: var(--library-negative); }
|
||||
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import {mountImages} from './images.mjs';
|
||||
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
const variantSelections=new Map();
|
||||
const historyVisits=new Map();
|
||||
function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
@@ -242,27 +244,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text);
|
||||
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
|
||||
const summary=el('article',undefined,'library-surface');summary.append(el('span',detail.positive.category || 'Uncategorized','library-chip'));
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
|
||||
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
|
||||
contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
|
||||
const all=createButton('All images for this positive');
|
||||
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
|
||||
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
|
||||
const branches=el('div',undefined,'library-branches');branches.append(el('p','+ Positive prompt · unchanged','library-positive-lane'));
|
||||
const branches=el('div',undefined,'library-branches');branches.append(el('p','Positive prompt · unchanged','library-positive-lane'));
|
||||
const choices=[];
|
||||
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
|
||||
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
|
||||
const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true;
|
||||
contentScope.listen(more,'click',()=>loader.loadMore());contentScope.listen(retry,'click',()=>loader.loadMore());
|
||||
contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();});
|
||||
updateBranches=state=>{
|
||||
const known=new Set(choices.map(choice=>choice.variant.id));
|
||||
for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) {
|
||||
const lane=el('div',undefined,'library-variant-lane');const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id));
|
||||
lane.append(button,el('span',`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`));branches.append(lane);choices.push({button,variant});
|
||||
const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label});
|
||||
contentScope.listen(button,'click',()=>select(variant.id));
|
||||
}
|
||||
for(const [index,choice] of choices.entries()) {
|
||||
choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant;
|
||||
const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`;
|
||||
choice.button.disabled=savedReadState!=='current';
|
||||
}
|
||||
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants));retry.hidden=!state.variantsError;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError;
|
||||
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';
|
||||
variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':'';
|
||||
};
|
||||
updateBranches(state);
|
||||
@@ -271,11 +283,51 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const context=el('p','','library-variant-context');context.setAttribute('role','status');
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;
|
||||
body.append(subtitle,summary,variants,gallery);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(subtitle,summary,variants,recorded,gallery);
|
||||
function setParentState(parent) {
|
||||
if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
|
||||
parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true;
|
||||
if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';}
|
||||
else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;}
|
||||
else {
|
||||
const fresh=parent.detail;
|
||||
if(fresh.positive.id!==positiveId || fresh.positive.text!==ownerText) {
|
||||
savedReadState='stale';parentStatus.textContent='Saved Library owner changed. This view retains the previous prompt and gallery. Reload the saved Library view to inspect current data.';reloadSaved.hidden=false;
|
||||
} else {
|
||||
const observed=new Map(fresh.variants.map(variant=>[variant.id,variant]));const loaded=new Set(detail.variants.map(variant=>variant.id));const additional=fresh.variants.some(variant=>!loaded.has(variant.id));let changed=false,unobserved=false;
|
||||
const variants=detail.variants.map(variant=>{
|
||||
const current=observed.get(variant.id);
|
||||
if(!current) {unobserved=true;return variant;}
|
||||
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';
|
||||
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';
|
||||
}
|
||||
}
|
||||
updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});
|
||||
}
|
||||
async function retrySavedParent() {
|
||||
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
|
||||
try {
|
||||
const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal});
|
||||
if(contentScope.signal.aborted || generation!==parentGeneration) return;
|
||||
if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details');
|
||||
setParentState({detail:fresh});
|
||||
} catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});}
|
||||
}
|
||||
contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
}});
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope});
|
||||
function select(pairId) {
|
||||
if(galleryScope && activePairId===pairId) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.update(libraryPromptRoles(detail,pairId));
|
||||
if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true});
|
||||
for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`;
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
|
||||
@@ -18,6 +18,12 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
const fieldSources = {
|
||||
runtime_return: 'Runtime return',
|
||||
host_cache_output: 'Host cache output',
|
||||
graph_literal: 'Inferred graph literal',
|
||||
graph_semantics: 'Inferred graph semantics',
|
||||
};
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -39,6 +45,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
scope.listen(container, 'click', async event => {
|
||||
if (!actionsEnabled) return;
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
const role = send.dataset.sendRole;
|
||||
@@ -57,10 +64,11 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||||
});
|
||||
let roles = {};
|
||||
let roles = {}, actionsEnabled = true;
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
roles = {};
|
||||
actionsEnabled = state.actionsEnabled !== false;
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
|
||||
@@ -83,7 +91,8 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
heading.append(el('h3', title));
|
||||
block.append(heading);
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
const proof = record.evidence;
|
||||
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
@@ -93,11 +102,13 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${role} prompt`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${role} prompt`);
|
||||
copy.title = `Copy ${role} prompt`;
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
actions.append(copy, send);
|
||||
heading.append(actions);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
@@ -107,6 +118,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
pair.disabled = !actionsEnabled;
|
||||
container.append(pair);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {frozenPromptRoles} from './history-state.mjs';
|
||||
import {mountPromptBlocks} from './prompt-blocks.mjs';
|
||||
import {createInspectionController} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {createMediaViewer} from './media-viewer.mjs';
|
||||
import {distinctCurrentMedia, occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function facts(entries) {
|
||||
const list = el('dl', undefined, 'recorded-run-facts');
|
||||
for (const [label, value] of entries) {const row = el('div');row.append(el('dt', label), el('dd', value ?? 'Not recorded'));list.append(row);}
|
||||
return list;
|
||||
}
|
||||
function observed(value) {
|
||||
const time = Number(value);
|
||||
return Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'Recorded time outside display range';
|
||||
}
|
||||
function outputSummary(summary) {
|
||||
if (summary.state === 'not_captured') return 'Reported outputs not captured';
|
||||
const count = summary.retained_occurrence_count;
|
||||
return summary.list_status === 'complete' ? `${count} reported outputs · complete` : `${count} retained reported output${count === 1 ? '' : 's'} · incomplete · ${summary.state}`;
|
||||
}
|
||||
|
||||
export function mountRecordedRun(container, {api, controller, sender, parentScope, onClose, onRestoreFocus}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
let active = null, latest = controller.state;
|
||||
if (parentScope.signal.aborted) {dispose();return {open() {}, setState() {}, dispose};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
scope.cleanup(() => active?.close(false));
|
||||
|
||||
function open({runId, anchorId = null, contextId = null, trigger = document.activeElement}) {
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const local = createScope(), dialog = el('dialog', undefined, 'recorded-run-dialog');dialog.setAttribute('aria-label', 'Recorded run');
|
||||
const header = el('header', undefined, 'recorded-run-header');
|
||||
const close = createButton('×');close.setAttribute('aria-label', 'Close recorded run');close.autofocus = true;
|
||||
header.append(el('h2', 'Recorded run'), close);
|
||||
const message = el('p', '', 'recorded-run-status muted');message.setAttribute('role', 'status');
|
||||
const retry = createButton('Retry run details'), reload = createButton('Reload recorded history');
|
||||
const body = el('div', undefined, 'recorded-run-body');
|
||||
const metadata = el('section');metadata.setAttribute('aria-label', 'Run evidence');
|
||||
const anchors = el('div', undefined, 'recorded-run-anchors');anchors.setAttribute('role', 'group');anchors.setAttribute('aria-label', 'Recorded anchors');
|
||||
const mode = el('div', undefined, 'recorded-run-mode');mode.setAttribute('role', 'group');mode.setAttribute('aria-label', 'Frozen text field');
|
||||
const raw = createButton('Raw'), effective = createButton('Effective');mode.append(raw, effective);
|
||||
const promptSection = el('section');promptSection.setAttribute('aria-label', 'Frozen run prompt texts');
|
||||
promptSection.append(el('h3', 'Frozen prompt fields'), el('p', 'Only the selected stored field is shown. Role assignment: inferred. Field evidence is disclosed separately.', 'muted'));
|
||||
const prompts = el('div');promptSection.append(prompts);
|
||||
const provenance = el('details', undefined, 'recorded-provenance');provenance.append(el('summary', 'Frozen field provenance'));
|
||||
const proof = el('div');provenance.append(proof);promptSection.append(provenance);
|
||||
const outputs = el('section', undefined, 'recorded-outputs');outputs.setAttribute('aria-label', 'Reported outputs');
|
||||
const outputState = el('p', '', 'recorded-output-status muted');outputState.setAttribute('role', 'status');
|
||||
const receipts = el('ol', undefined, 'recorded-receipts');receipts.setAttribute('aria-label', 'Ordered reported output receipts');
|
||||
const more = createButton('Load more reported outputs'), retryOutputs = createButton('Retry reported outputs');
|
||||
outputs.append(el('h3', 'Reported outputs'), el('p', 'Host-reported locations · file identity unverified · newly generated unknown · Context membership unknown.', 'muted'), outputState, receipts, more, retryOutputs);
|
||||
const current = el('section', undefined, 'recorded-current-file');current.setAttribute('aria-label', 'Current reported file');
|
||||
current.append(el('h3', 'Current file at reported location'), el('p', 'Current file prompts and facts are separate from frozen run fields.', 'muted'));
|
||||
const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true;
|
||||
body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog);
|
||||
const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter});
|
||||
let closed = false, renderedDetail = null, renderedRoles = '', outputsRequested = false, inspected = null, viewerOpen = false;
|
||||
const receiptNodes = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
}
|
||||
function restoreReceiptFocus() {
|
||||
const key = latest.selection.receipt && occurrenceKey(latest.selection.receipt);
|
||||
receiptNodes.get(key)?.view?.focus({preventScroll:true});
|
||||
}
|
||||
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
|
||||
onSelect:entry => {
|
||||
if (closed || !latest.actionsEnabled) return;
|
||||
controller.selectReceipt(entry.occurrence);inspected = occurrenceKey(entry.occurrence);inspection.select(entry);
|
||||
}, onRefresh:refreshCurrent, onRestoreFocus:restoreReceiptFocus,
|
||||
onClose:() => {viewerOpen = false;restoreReceiptFocus();},
|
||||
});
|
||||
let viewer = makeViewer();
|
||||
function resetViewer() {viewer.dispose();viewerOpen = false;viewer = makeViewer();}
|
||||
function refreshCurrent() {
|
||||
if (closed) return;
|
||||
resetViewer();inspection.clear();inspected = null;
|
||||
if (latest.refreshRequired === 'history-changed') controller.refresh();
|
||||
else controller.revalidate();
|
||||
}
|
||||
function showViewer(triggerButton) {
|
||||
if (!latest.actionsEnabled || inspection.state.refreshRequired || closed) return;
|
||||
const receipt = selectedReceipt();if (receipt?.current_candidate.status !== 'available') return;
|
||||
const items = distinctCurrentMedia(latest.outputs.items, latest.selection.receipt);
|
||||
viewerOpen = true;viewer.open({items, selectedKey:reportedLocationAdapter.key(receipt.current_candidate.media), trigger:triggerButton || receiptNodes.get(occurrenceKey(receipt.occurrence))?.view});
|
||||
}
|
||||
function chooseReceipt(receipt, triggerButton) {
|
||||
if (!latest.actionsEnabled || closed) return;
|
||||
controller.selectReceipt(receipt.occurrence);inspected = occurrenceKey(receipt.occurrence);
|
||||
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
|
||||
}
|
||||
function closeRun(restore = true) {
|
||||
if (closed) return;closed = true;active = null;local.dispose();
|
||||
if (restore && !scope.signal.aborted) {
|
||||
if (trigger?.isConnected) trigger.focus({preventScroll:true});
|
||||
else onRestoreFocus?.({runId, anchorId, contextId});
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
local.cleanup(() => {viewer.dispose();inspection.dispose();inspector.dispose();blocks.dispose();receiptNodes.clear();if (dialog.open) dialog.close();dialog.remove();});
|
||||
local.listen(close, 'click', () => closeRun());local.listen(dialog, 'close', () => closeRun());
|
||||
local.listen(dialog, 'cancel', event => {event.preventDefault();closeRun();});
|
||||
local.listen(retry, 'click', () => controller.retryRun());
|
||||
local.listen(reload, 'click', () => {
|
||||
if (['capture-changed', 'candidate-stale'].includes(latest.refreshRequired)) controller.loadOutputs();
|
||||
else {closeRun();controller.refresh();}
|
||||
});
|
||||
local.listen(raw, 'click', () => {if (latest.actionsEnabled) controller.setMode('raw');});
|
||||
local.listen(effective, 'click', () => {if (latest.actionsEnabled) controller.setMode('effective');});
|
||||
local.listen(anchors, 'click', event => {const button = event.target.closest('[data-select-anchor]');if (button && latest.actionsEnabled) controller.selectContext(button.dataset.selectAnchor);});
|
||||
local.listen(more, 'click', () => controller.loadOutputs());local.listen(retryOutputs, 'click', () => controller.retryOutputs());
|
||||
local.listen(receipts, 'click', event => {
|
||||
const button = event.target.closest('[data-view-receipt]');if (!button || button.disabled) return;
|
||||
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === button.dataset.viewReceipt);
|
||||
if (receipt) chooseReceipt(receipt, button);
|
||||
});
|
||||
local.cleanup(inspection.subscribe(state => {
|
||||
if (closed) return;inspector.setState(state);
|
||||
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || state.refreshRequired;
|
||||
}));
|
||||
function render(next) {
|
||||
if (closed || local.signal.aborted) return;
|
||||
const selectedRun = next.selection.run_id === runId, detail = selectedRun ? next.run.detail : null;
|
||||
const enabled = next.actionsEnabled && selectedRun;
|
||||
message.setAttribute('role', next.run.error || next.refreshRequired ? 'alert' : 'status');
|
||||
message.textContent = next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : next.validating ? 'Revalidating recorded run…' : next.run.error && selectedRun ? 'Recorded run details could not be loaded.' : !detail ? 'Loading recorded run…' : '';
|
||||
retry.hidden = !selectedRun || !next.run.error || !!next.refreshRequired;reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||||
body.hidden = !detail;raw.disabled = effective.disabled = !enabled;
|
||||
if (!detail) {if (renderedDetail) {inspection.clear();renderedDetail = null;renderedRoles = '';metadata.replaceChildren();anchors.replaceChildren();receipts.replaceChildren();receiptNodes.clear();}return;}
|
||||
raw.setAttribute('aria-pressed', String(next.selection.mode === 'raw'));effective.setAttribute('aria-pressed', String(next.selection.mode === 'effective'));
|
||||
if (detail !== renderedDetail) {
|
||||
renderedDetail = detail;const run = detail.run;
|
||||
metadata.replaceChildren(facts([
|
||||
['Run ID', run.run_id], ['Session ID', run.session_id], ['Queue item ID', run.queue_item_id], ['Job / correlation ID', run.job_id],
|
||||
['Observed dequeue', observed(run.observed_dequeue_at)], ['Observed dequeue · exact decimal milliseconds', run.observed_dequeue_at],
|
||||
['Outcome', run.outcome], ['Outcome reason', run.reason], ['Context capture', `${detail.context_capture.state} · ${detail.context_capture.reason ?? 'not captured'}`],
|
||||
['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`],
|
||||
['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type],
|
||||
['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'],
|
||||
]));
|
||||
if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted'));
|
||||
anchors.replaceChildren();
|
||||
for (const context of detail.contexts) {
|
||||
const button = createButton(`Anchor ${context.anchor_id} · context ${context.context_id.slice(0, 8)}`);button.dataset.selectAnchor = context.anchor_id;anchors.append(button);
|
||||
}
|
||||
}
|
||||
for (const button of anchors.querySelectorAll('button')) {button.disabled = !enabled;button.setAttribute('aria-pressed', String(button.dataset.selectAnchor === next.selection.anchor_id));}
|
||||
const context = detail.contexts.find(item => item.anchor_id === next.selection.anchor_id);
|
||||
const roleKey = `${next.selection.anchor_id}:${next.selection.mode}:${enabled}:${detail.context_capture.state}`;
|
||||
if (roleKey !== renderedRoles) {
|
||||
renderedRoles = roleKey;
|
||||
blocks.setState({prompts:context ? frozenPromptRoles(context, next.selection.mode) : undefined, actionsEnabled:enabled});
|
||||
proof.replaceChildren(el('p', 'Role assignment: inferred. Sources describe each field, not blanket execution proof.', 'muted'));
|
||||
if (context) for (const role of ['positive', 'negative']) {
|
||||
const section = el('section');section.append(el('h4', `${role[0].toUpperCase() + role.slice(1)} fields`));
|
||||
for (const field of ['raw', 'effective', 'prepend', 'append']) {
|
||||
const value = context[role]?.[field] ?? null, evidence = context.evidence[role]?.[field];
|
||||
section.append(el('p', `${field[0].toUpperCase() + field.slice(1)} · ${evidence?.provenance || 'unknown'} · ${evidence?.source || 'unknown'}${evidence?.reason ? ` · reason: ${evidence.reason}` : ''}`, 'muted'));
|
||||
section.append(el('pre', value === null ? 'Unknown · not recorded' : value === '' ? '(explicitly empty)' : value, 'prompt-text'));
|
||||
}
|
||||
proof.append(section);
|
||||
}
|
||||
}
|
||||
const output = next.outputs;
|
||||
outputState.setAttribute('role', output.error ? 'alert' : 'status');
|
||||
outputState.textContent = `${outputSummary(detail.output_summary)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`;
|
||||
more.hidden = !output.has_more;more.disabled = output.loading || !enabled;
|
||||
retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired;
|
||||
const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence)));
|
||||
for (const [key, record] of receiptNodes) if (!keep.has(key)) {record.node.remove();receiptNodes.delete(key);}
|
||||
for (const receipt of output.items) {
|
||||
const key = occurrenceKey(receipt.occurrence), candidate = receipt.current_candidate;
|
||||
let record = receiptNodes.get(key);
|
||||
if (!record) {
|
||||
const node = el('li', undefined, 'recorded-receipt');node.dataset.receipt = key;node.dataset.outputNodeId = receipt.occurrence.output_node_id;node.dataset.imageOrdinal = String(receipt.occurrence.image_ordinal);
|
||||
const description = el('div'), view = createButton('View current file');view.dataset.viewReceipt = key;node.append(description, view);receipts.append(node);record = {node, description, view};receiptNodes.set(key, record);
|
||||
}
|
||||
const reference = receipt.reference;
|
||||
record.description.textContent = `Output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal} · ${reference.folder_type}/${[reference.subfolder, reference.filename].filter(Boolean).join('/')} · ${reference.path_style} · root namespace ${reference.root_key ?? 'not recorded'} · ${receipt.transient ? 'Transient' : 'Non-transient locator'} · capture ${output.capture?.state || detail.output_summary.state} · Current candidate: ${candidate.status}${candidate.reason ? ` · ${candidate.reason}` : ''}`;
|
||||
record.node.setAttribute('aria-current', String(next.selection.receipt !== null && key === occurrenceKey(next.selection.receipt)));
|
||||
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || inspection.state.refreshRequired;
|
||||
}
|
||||
const selected = selectedReceipt();current.hidden = !selected;
|
||||
if (!selected && inspected !== null) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
|
||||
if (!enabled && viewerOpen) resetViewer();
|
||||
if (!outputsRequested && !output.capture && !output.loading && !output.error) {
|
||||
outputsRequested = true;
|
||||
queueMicrotask(() => {if (!closed && latest.selection.run_id === runId && !local.signal.aborted) controller.loadOutputs();});
|
||||
}
|
||||
}
|
||||
active = {close:closeRun, render};
|
||||
dialog.showModal();
|
||||
render(latest);
|
||||
void controller.selectRun(runId, {anchorId, contextId});
|
||||
}
|
||||
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
|
||||
return {open, setState, dispose};
|
||||
}
|
||||
Reference in New Issue
Block a user