← All Pull Requests

Keep widget identity visible and eliminate unchanged-poll flicker #8

Merged opened by John Lauer 2026-09-16

The widget rebuilt its thumbnails every four seconds and repeatedly sent identical host sizing messages, causing distracting blips. It now reuses existing DOM nodes, skips unchanged renders, decodes replacements before display, and sends sizing messages only when changed. Hash-addressed preview assets are privately cached; live state stays uncached.

The compact widget permanently displays “Adom AI Flow” with its current activity underneath; the expanded heading also preserves the product name.

Verified in the live Hydrogen WebView2 titlebar: over 13 seconds spanning multiple polls, both mounted copies had zero DOM mutations, zero image load events, identical thumbnail nodes and loaded images. Screenshot checked for readable product identity. Existing helper tests pass. Hover expansion was retested using the actual modified Svelte component fixture, including same-document retention, pinning, Escape, resize and opt-out.

This file set includes and supersedes the client files in PR #7. Keep PR #6's broader workflow/install changes and Hydrogen core PR https://wiki.adom.inc/adom/hydrogen-dev/prs/1. The local helper is deployed; shared package publication and native Hydrogen integration remain with the maintainers.

Diff Skip to comments (1)

--- a/tools/aiflow-widget.py+++ b/tools/aiflow-widget.py@@ -0,0 +1,123 @@+#!/usr/bin/env python3+"""Opt-in Hydrogen progress widget. Reuses artifacts; never calls an AI/provider."""+import argparse,pathlib,json,os,time,datetime,hashlib,subprocess,http.server,urllib.request,signal,re+CONFIG=pathlib.Path.home()/'.adom/aiflow-widget.json'+p=argparse.ArgumentParser();p.add_argument('action',choices=['enable','disable','serve','stop','event','status']);p.add_argument('--ai-thread');p.add_argument('--run',type=pathlib.Path);p.add_argument('--port',type=int,default=int(os.environ.get('HYDROGEN_WIDGET_PORT','27020')));p.add_argument('--file',type=pathlib.Path);p.add_argument('--label');p.add_argument('--step');p.add_argument('--progress',type=float);a=p.parse_args()+def read(p,default):+ try:return json.loads(p.read_text())+ except (OSError,ValueError):return default++def write(p,data):p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix('.tmp');q.write_text(json.dumps(data,indent=2));q.replace(p)+def cfg():return read(CONFIG,{'enabled':False})+_ledger_cache={}+def thumbnail(run,src):+ src=src.resolve()+ if not src.is_relative_to(run.resolve()) or not src.is_file():return None+ name=hashlib.sha256(src.read_bytes()).hexdigest()+'.png';dest=run/'widget-thumbs'/name;dest.parent.mkdir(exist_ok=True)+ if not dest.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=256:192:force_original_aspect_ratio=decrease','-frames:v','1',str(dest)],check=True,timeout=15)+ preview=run/'widget-previews'/name;preview.parent.mkdir(exist_ok=True)+ if not preview.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=1024:768:force_original_aspect_ratio=decrease','-frames:v','1',str(preview)],check=True,timeout=15)+ return name++def ledger_events(run):+ path=run/'run.jsonl'+ if not path.exists():return []+ stat=path.stat();key=(str(path),stat.st_mtime_ns,stat.st_size)+ if key in _ledger_cache:return _ledger_cache[key]+ # Read only the tail of the append-only ledger; never write it.+ with path.open('rb') as f:+  start=max(0,stat.st_size-2*1024*1024);f.seek(start)+  if start:f.readline()+  lines=f.readlines()+ events=[]+ candidates=[]+ for line in lines:+  try:e=json.loads(line)+  except ValueError:continue+  if e.get('event')!='artifact':continue+  file=e.get('file')+  if not file or pathlib.Path(file).suffix.lower() not in ['.png','.jpg','.jpeg']:continue+  candidates.append(e)+ for e in candidates[-12:]:+  file=e['file'];src=pathlib.Path(file);src=src if src.is_absolute() else run/src+  try:+   name=thumbnail(run,src)+   if not name:continue+   stamp=e.get('t') or e.get('at');unix=datetime.datetime.fromisoformat(stamp.replace('Z','+00:00')).timestamp()+   events.append({'step':e.get('step'),'label':e.get('caption') or e.get('kind','Saved artifact'),'thumb':name,'at':stamp,'unix':unix})+  except (OSError,ValueError,subprocess.SubprocessError):continue+ _ledger_cache.clear();_ledger_cache[key]=events[-12:];return events[-12:]++def data():+ c=cfg()+ if not c.get('enabled'):return {'enabled':False,'step':'Widget off','label':'Updates disabled','thumbs':[],'progress':None}+ run=pathlib.Path(c['run']);r=read(run/'run.json',{});w=read(run/'widget-state.json',{});step=r.get('currentStep','Starting')+ events=sorted(ledger_events(run)+w.get('events',[]),key=lambda e:e.get('unix',0));latest=events[-1] if events else {}+ visits=[v for group in r.get('steps',{}).values() for v in group.get('visits',[])]+ visit=max(visits,key=lambda v:v.get('at',''),default={});step_at=visit.get('at','');event_at=latest.get('at','')+ activity_step=latest.get('step') if event_at>step_at else step+ names=[s['name'] for s in r.get('flow',{}).get('steps',[]) if s.get('name')]+ completed=sum(bool(r.get('steps',{}).get(n,{}).get('visits')) and all(v.get('end') for v in r['steps'][n]['visits']) for n in names)+ progress=latest.get('progress') if event_at>step_at else None+ basis='activity progress' if progress is not None else 'completed planned steps; not time remaining'+ if progress is None and names and activity_step==step:progress=round(100*completed/len(names))+ age=max(0,time.time()-latest.get('unix',time.time()))+ return {'enabled':True,'step':activity_step or step,'ledgerStep':step,'label':latest.get('label') or 'Waiting for a saved artifact','progress':progress,'progressBasis':basis,'completedSteps':completed,'plannedSteps':len(names),'updatedAt':latest.get('at'),'staleSeconds':round(age),'thumbs':[{'url':'/thumb/'+e['thumb'],'preview':'/preview-image/'+e['thumb'],'label':e.get('label',''),'at':e.get('at'),'step':e.get('step')} for e in events[-3:] if e.get('thumb')],'run':str(run),'mode':'Reuses saved artifacts; no AI calls; extra capture disabled','delivered':bool(r.get('delivery'))}+PAGE='''<!doctype html><meta charset="utf-8"><style>*{box-sizing:border-box}html,body{margin:0;background:transparent;color:#dce7f4;font:11px system-ui;overflow:hidden;height:100%}#root{height:100%;display:flex;align-items:center;gap:5px;padding:0 3px}#pictures{display:flex;gap:2px;height:calc(100vh - 2px)}img{height:100%;width:auto;max-width:46px;object-fit:contain;border-radius:3px}#info{min-width:70px;max-width:160px;flex:1}#step,#label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#step{font-weight:650}#label{font-size:10px;color:#a9bbcf}#track{height:3px;background:#354455;margin-top:2px;border-radius:2px}#bar{height:100%;background:#59c6ee;max-width:100%;border-radius:2px}#mark{font-size:9px;color:#a9bbcf;white-space:nowrap}@media(max-height:33px){#info{display:flex;align-items:center;gap:5px;max-width:230px}#label{display:none}#track{width:35px;margin:0}#step{max-width:145px}#pictures img:not(:last-child){display:none}}#expanded{display:none}@media(min-height:100px){#root{display:none}#expanded{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr) auto auto;gap:6px;padding:10px;background:#171e26;font:12px system-ui}#large{max-width:none;width:100%;height:100%;object-fit:contain;min-height:0;background:#27333f}#caption{font-size:12px;line-height:1.35;overflow-wrap:anywhere}#choices{display:flex;gap:6px}#choices button{height:48px;width:74px;background:#27333f;border:2px solid transparent;border-radius:4px;cursor:pointer;padding:2px}#choices button[aria-pressed=true]{border-color:#00b8b1}#choices img{max-width:none;width:100%;height:100%;object-fit:contain}#stamp{color:#a9bbcf;font-size:10px}}</style><section id="expanded" aria-label="Activity previews"><b id="expanded-step"></b><img id="large" alt=""><div><div id="caption"></div><div id="stamp"></div></div><div id="choices"></div></section><div id="root"><div id="pictures"></div><div id="info"><div id="step">Adom AI Flow</div><div id="label">Waiting</div><div id="track"><div id="bar"></div></div></div><span id="mark"></span></div><script>let last={},chosen='',lastView='',lastHostMessage='',imageGeneration=0;+function text(id,value){let node=document.querySelector(id),next=value==null?'':String(value);if(node.textContent!==next)node.textContent=next}+function attr(node,name,value){if(node.getAttribute(name)!==value)node.setAttribute(name,value)}+function rowsFor(d){let seen=new Set(),rows=[];for(let t of [...(d.thumbs||[])].reverse()){let key=t.preview||t.url;if(!seen.has(key)){seen.add(key);rows.push(t)}}return rows.reverse()}+function publish(d,force=false){let message={hdWidget:{width:innerHeight>=34?265:250,expand:d.enabled?{width:560,height:400}:null}},key=JSON.stringify(message);if(force||key!==lastHostMessage){lastHostMessage=key;window.parent.postMessage(message,'*')}}+function syncChildren(parent,nodes){nodes.forEach((node,i)=>{if(parent.children[i]!==node)parent.insertBefore(node,parent.children[i]||null)});for(let child of [...parent.children])if(!nodes.includes(child))child.remove()}+const imageLoads=new Map();+function preparedImage(src){if(!imageLoads.has(src)){let image=new Image();image.src=src;imageLoads.set(src,image.decode().then(()=>image).catch(()=>{imageLoads.delete(src);return null}))}return imageLoads.get(src)}+async function smallImages(rows){let generation=++imageGeneration,parent=document.querySelector('#pictures'),existing=new Map([...parent.children].map(i=>[i.getAttribute('src'),i]));let nodes=await Promise.all(rows.map(async t=>{let i=existing.get(t.url)||await preparedImage(t.url);if(i)attr(i,'alt',t.label||'');return i}));if(generation===imageGeneration)syncChildren(parent,nodes.filter(Boolean))}+function expanded(d){let rows=rowsFor(d),selected=rows.find(t=>(t.preview||t.url)===chosen)||rows[rows.length-1];text('#expanded-step','Adom AI Flow · '+(d.step||'Waiting'));let large=document.querySelector('#large');if(selected){let src=selected.preview||selected.url;chosen=src;attr(large,'alt',selected.label||'');if(large.getAttribute('src')!==src)preparedImage(src).then(image=>{if(image&&chosen===src)attr(large,'src',src)});text('#caption',selected.label);text('#stamp',selected.at)}else{if(large.hasAttribute('src'))large.removeAttribute('src');text('#caption',d.label);text('#stamp','')}+let parent=document.querySelector('#choices'),existing=new Map([...parent.children].map(b=>[b.dataset.key,b]));let nodes=rows.map(t=>{let key=t.preview||t.url,b=existing.get(key);if(!b){b=document.createElement('button');b.dataset.key=key;let i=document.createElement('img');i.src=t.url;i.alt='';b.appendChild(i);b.onclick=()=>{chosen=key;expanded(last)}}attr(b,'aria-label',t.label||'Preview');attr(b,'aria-pressed',String(key===chosen));return b});syncChildren(parent,nodes)}+function draw(d){last=d;publish(d);let rows=rowsFor(d),view=JSON.stringify([d.step,d.label,d.progress,d.enabled,rows,innerHeight>=100]);if(view===lastView)return;lastView=view;text('#label',[d.step,d.label].filter(Boolean).join(' · '));let bar=document.querySelector('#bar'),width=d.progress==null?'0%':d.progress+'%';if(bar.style.width!==width)bar.style.width=width;text('#mark',d.progress==null?'…':Math.round(d.progress)+'%');smallImages(rows);if(innerHeight>=100)expanded(d)}+async function tick(){try{let r=await fetch('/data.json',{cache:'no-store'});if(!r.ok)throw Error('feed');draw(await r.json())}catch(e){text('#label','Feed unavailable');lastView=''}setTimeout(tick,document.hidden?30000:4000)}+addEventListener('keydown',e=>{if(e.key==='Escape'&&innerHeight>=100)window.parent.postMessage({hdWidget:{collapse:true}},'*')});+addEventListener('resize',()=>draw(last));+addEventListener('load',()=>publish(last,true));+tick();+</script>'''+if a.action in ['enable','disable']:+ c=cfg();c['enabled']=a.action=='enable'+ if a.run:c['run']=str(a.run.resolve())+ if c['enabled']:assert c.get('run'),'--run required to enable';assert (pathlib.Path(c['run'])/'run.json').exists(),'run.json missing'+ c['port']=a.port;c['captureMode']='reuse-only';write(CONFIG,c);print(json.dumps(c))+elif a.action=='event':+ c=cfg();run=(a.run or pathlib.Path(c.get('run','.'))).resolve()+ if not c.get('enabled') or str(run)!=c.get('run'):print('Widget updates disabled for this run');sys_exit=0+ else:+  r=read(run/'run.json',{});e={'step':a.step or r.get('currentStep','working'),'label':a.label or 'Saved artifact','at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'unix':time.time(),'progress':a.progress}+  if a.progress is not None:assert 0<=a.progress<=100,'progress must be 0..100'+  if a.file:+   name=thumbnail(run,a.file);assert name,'artifact must exist inside this run'+   e['thumb']=name+  state=read(run/'widget-state.json',{'events':[]});state['events']=(state['events']+[e])[-12:];write(run/'widget-state.json',state);print(json.dumps(e))+elif a.action=='status':print(json.dumps(data(),indent=2))+elif a.action=='stop':+ pidfile=pathlib.Path.home()/'.adom/aiflow-widget.pid'+ if pidfile.exists():+  pid=int(pidfile.read_text());cmd=pathlib.Path(f'/proc/{pid}/cmdline')+  if cmd.exists() and str(pathlib.Path(__file__).resolve()).encode() in cmd.read_bytes() and b'serve' in cmd.read_bytes():os.kill(pid,signal.SIGTERM)+  pidfile.unlink(missing_ok=True)+else:+ class H(http.server.BaseHTTPRequestHandler):+  def do_GET(self):+   if self.path in ['/','/index.html']:body=PAGE.encode();kind='text/html'+   elif self.path=='/health':body=b'OK';kind='text/plain'+   elif self.path=='/data.json':body=json.dumps(data()).encode();kind='application/json'+   elif self.path.startswith(('/thumb/','/preview-image/')):+    c=cfg();name=self.path.split('/')[-1]+    if not c.get('enabled') or not re.fullmatch(r'[0-9a-f]{64}\.png',name):self.send_error(404);return+    file=pathlib.Path(c['run'])/('widget-previews' if self.path.startswith('/preview-image/') else 'widget-thumbs')/name+    if not file.is_file() and self.path.startswith('/preview-image/'):file=pathlib.Path(c['run'])/'widget-thumbs'/name+    if not file.is_file():self.send_error(404);return+    body=file.read_bytes();kind='image/png'+   else:self.send_error(404);return+   self.send_response(200);self.send_header('Content-Type',kind);self.send_header('Cache-Control','private, max-age=31536000, immutable' if kind=='image/png' and ('/thumb/' in self.path or file.parent.name=='widget-previews') else 'no-store');self.end_headers();self.wfile.write(body)+  def log_message(self,*args):pass+ server=http.server.ThreadingHTTPServer(('127.0.0.1',a.port),H);(pathlib.Path.home()/'.adom/aiflow-widget.pid').write_text(str(os.getpid()));print('AI Flow widget listening',a.port,flush=True);server.serve_forever()+--- a/tests/widget-workflow.py+++ b/tests/widget-workflow.py@@ -0,0 +1,30 @@+#!/usr/bin/env python3+import tempfile,pathlib,json,subprocess,os,hashlib+root=pathlib.Path(__file__).resolve().parents[1];helper=root/'tools/aiflow-widget.py'+with tempfile.TemporaryDirectory() as td:+ home=pathlib.Path(td);run=home/'run';run.mkdir();env=dict(os.environ,HOME=td);env['PATH']=str(home/'bin')+':'+env['PATH'];(home/'bin').mkdir();(home/'bin/adom-aiflow-widget').symlink_to(helper)+ def call(*args):return subprocess.check_output(['python3',str(helper),*args,'--run',str(run)],env=env,text=True)+ r={'currentStep':'placement','flow':{'steps':[{'name':'components'},{'name':'placement'}]},'steps':{'components':{'visits':[{'at':'2026-01-01T00:00:00Z','end':'2026-01-01T00:01:00Z'}]},'placement':{'visits':[{'at':'2026-01-01T00:01:00Z','end':None}]}}}+ (run/'run.json').write_text(json.dumps(r));ledger=run/'run.jsonl';ledger.write_text('');before=hashlib.sha256((run/'run.json').read_bytes()).hexdigest()+ assert 'disabled' in call('event','--label','ignored');assert not (run/'widget-state.json').exists()+ call('enable');d=json.loads(call('status'));assert d['progress']==50 and d['step']=='placement'+ call('event','--step','components','--label','5 of 40 reviewed','--progress','12.5');d=json.loads(call('status'));assert d['progress']==12.5 and d['step']=='components'+ old=(run/'widget-state.json').read_bytes();call('disable');call('event','--label','must not write');assert (run/'widget-state.json').read_bytes()==old;assert json.loads(call('status'))['thumbs']==[]+ assert hashlib.sha256((run/'run.json').read_bytes()).hexdigest()==before and ledger.read_text()==''+ print('PASS: opt-out prevents writes; enable selects run; planned-step progress; milestone override; ledger and run untouched')+# Expanded previews reuse the source image; opting out still prevents all new cache work.+with tempfile.TemporaryDirectory() as td:+ home=pathlib.Path(td);run=home/'run';run.mkdir();env=dict(os.environ,HOME=td)+ (run/'run.json').write_text(json.dumps({'currentStep':'components'}))+ def call(*args):return subprocess.run(['python3',str(helper),*args,'--run',str(run)],env=env,text=True,capture_output=True)+ src=run/'image.png';subprocess.run(['ffmpeg','-y','-v','error','-f','lavfi','-i','color=c=teal:s=800x600','-frames:v','1',str(src)],check=True)+ assert call('event','--file',str(src)).returncode==0 and not (run/'widget-previews').exists()+ assert call('enable').returncode==0+ result=call('event','--file',str(src),'--label','Generated test image');assert result.returncode==0,result.stderr+ name=json.loads(result.stdout)['thumb'];assert (run/'widget-thumbs'/name).exists() and (run/'widget-previews'/name).exists()+ state=json.loads(call('status').stdout);assert state['thumbs'][-1]['preview']=='/preview-image/'+name+ outside=home/'outside.png';outside.write_bytes(src.read_bytes());assert call('event','--file',str(outside)).returncode!=0+ assert call('disable').returncode==0+ assert json.loads(call('status').stdout)['thumbs']==[]+ print('PASS: large cached preview, named URL, outside-run rejection and disabled cache avoidance')+--- a/widgets/README.md+++ b/widgets/README.md@@ -0,0 +1,34 @@+# AI Flow progress in Hydrogen++![Live Hydrogen widget with cached component thumbnails](https://wiki.adom.inc/api/pages/adom/esc-g431/repo/files/docs/astra/component-quality/widget-tall.png)++An optional title-bar widget shows the selected run's current step, a progress bar, and up to three recent artifact thumbnails. Enable it when the user wants to monitor a long board run while doing other work. Default is off; respect an explicit no-widget preference.++```+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget enable+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget event --step components --file /absolute/run/part.png --label "MPN-marked TPSM preview"+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget disable+```++Open **AI Flow progress** in Hydrogen Widgets after enabling. A local checkout with `hydrogen-widget.json` is discoverable now; global listing needs the curated entry in adom/hydrogen-bootstrap after package publication.++The service reads `run.json` and existing image artifact events in `run.jsonl`. It never changes either file. Explicit milestone events go in `widget-state.json`; thumbnails are cached by content hash in `widget-thumbs/`. Images must be inside the selected run. Publish existing component, marked-model, symbol, placement, pour and Fields images when those milestones produce them. No extra capture or model call is made. The display uses the new step immediately and retains older thumbnails with their timestamps until fresh images arrive.++The percentage is completed planned steps, not a prediction of elapsed or remaining time. An explicit milestone percentage can describe a component batch; label that scope. Returns can reduce progress. If no denominator exists, the widget shows an unknown value. The tooltip identifies the basis and thumbnail age.++The full 38-pixel title bar shows three thumbnails and two text lines; the compact 20-pixel bar shows the latest thumbnail and step. The local JSON feed polls every four seconds while visible, every thirty while hidden. Disabled means no ledger scans, thumbnail production or event writes; the mounted slot says updates are disabled. Closing the widget also stops its own server via a verified PID, without touching another process.++Files: `hydrogen-widget.json`, `tools/aiflow-widget.py`, this page, and the helper symlink installed by `install.sh`. Python 3 serves it; ffmpeg downsizes existing images. No provider access, AI tokens or network analysis is involved. See the [Hydrogen widget SDK](https://wiki.adom.inc/adom/hydrogen-bootstrap).++## Larger live hover surface++With Hydrogen's optional `hdWidget.expand` support, this same widget frame expands into its hover card. At heights above 100px it shows a large saved image, caption, timestamp and selectable thumbnail strip. Leaving the card returns it to the title bar; the selected image survives because the frame never reloads. Pinning keeps it open, and Escape is forwarded from the frame to close it.++The helper caches a larger image in `widget-previews/` from the already-saved source. It never takes a fresh desktop screenshot or calls an AI. Older entries without that cache can still show their small thumbnail; new milestone events and ledger artifacts populate the larger cache. The image endpoint accepts only known hash filenames inside the selected run. Turning the widget off also withdraws its expansion request. On older Hydrogen builds the extra message is ignored and the ordinary small widget continues working.++The compact widget always identifies itself as **Adom AI Flow**, with the current+activity underneath; the expanded heading retains that identity. Polling updates+only changed content. Existing image and selector nodes survive unchanged polls,+new images decode before replacement, and unchanged sizing messages are suppressed.+Hash-addressed cached images use private immutable caching; the live feed does not.+

Comments

John Lauer 2026-09-16

Attached live verification: unchanged-poll stability and visible titlebar branding; expanded fixture retest passed after activating its tab (hidden fixture has zero layout dimensions).

branded-titlebar

stability-test.json

live-test.json

Log in to comment.