From d2f449efc44f80d5df665f69d4d545fe825ad1af Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 12:33:30 -0700 Subject: [PATCH] feat(history): render recorded executions and frozen run details (#209) --- py/api/history.py | 1 + tests/browser/server.py | 22 ++ tests/browser/workspace-history.spec.mjs | 261 +++++++++++++++++++++ tests/browser/workspace-library.spec.mjs | 14 ++ tests/browser/workspace_history_fixture.py | 238 +++++++++++++++++++ tests/js/prompt-blocks.test.js | 20 ++ tests/js/workspace-history-layout.test.js | 23 ++ tests/test_api_history.py | 52 ++++ web/js/workspace/history-state.mjs | 15 ++ web/js/workspace/history.mjs | 197 ++++++++++++++++ web/js/workspace/library.css | 64 +++++ web/js/workspace/library.mjs | 70 +++++- web/js/workspace/prompt-blocks.mjs | 16 +- web/js/workspace/recorded-run.mjs | 211 +++++++++++++++++ 14 files changed, 1193 insertions(+), 11 deletions(-) create mode 100644 tests/browser/workspace-history.spec.mjs create mode 100644 tests/browser/workspace_history_fixture.py create mode 100644 tests/js/workspace-history-layout.test.js create mode 100644 web/js/workspace/history.mjs create mode 100644 web/js/workspace/recorded-run.mjs diff --git a/py/api/history.py b/py/api/history.py index 32d8270..651057a 100644 --- a/py/api/history.py +++ b/py/api/history.py @@ -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]) diff --git a/tests/browser/server.py b/tests/browser/server.py index 419fa3a..6c5afb5 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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) diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs new file mode 100644 index 0000000..131f309 --- /dev/null +++ b/tests/browser/workspace-history.spec.mjs @@ -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(); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 2b29e30..6440722 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py new file mode 100644 index 0000000..1b74aec --- /dev/null +++ b/tests/browser/workspace_history_fixture.py @@ -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, + } diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index 566871a..9805a61 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -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();} +}); diff --git a/tests/js/workspace-history-layout.test.js b/tests/js/workspace-history-layout.test.js new file mode 100644 index 0000000..11124bb --- /dev/null +++ b/tests/js/workspace-history-layout.test.js @@ -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); +}); diff --git a/tests/test_api_history.py b/tests/test_api_history.py index 7bc84b9..586497d 100644 --- a/tests/test_api_history.py +++ b/tests/test_api_history.py @@ -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") diff --git a/web/js/workspace/history-state.mjs b/web/js/workspace/history-state.mjs index cfc562f..07961fb 100644 --- a/web/js/workspace/history-state.mjs +++ b/web/js/workspace/history-state.mjs @@ -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}; diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs new file mode 100644 index 0000000..42e0e47 --- /dev/null +++ b/web/js/workspace/history.mjs @@ -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}; +} diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index b0c8405..0dc126f 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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; } +} diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index fb3cd8a..33cc3ee 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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(); diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 113a791..fc53cff 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -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); } } diff --git a/web/js/workspace/recorded-run.mjs b/web/js/workspace/recorded-run.mjs new file mode 100644 index 0000000..6d1ee7f --- /dev/null +++ b/web/js/workspace/recorded-run.mjs @@ -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}; +}