← All Pull Requests

Show larger interactive previews when Hydrogen expands the widget #7

Merged opened by John Lauer 2026-09-16

AI Flow now adapts its existing widget frame to Hydrogen's optional larger hover surface. Below 100px it keeps the current title-bar view; above that it shows a large saved image, caption, timestamp and thumbnail selector. The selected image remains when it shrinks because the document stays alive. It forwards Escape to the host, and disabled widgets withdraw the expansion request. Native title tooltips were removed so they do not obscure the richer host card.

The helper caches a larger preview from each existing source image, serves only hash-named images inside the selected run, and falls back to old small thumbnails where a larger cache does not yet exist. It does not capture the desktop or call an AI. Disabled state still prevents new cache work and event writes. Existing Hydrogen versions ignore the additional postMessage field and retain the small widget.

Tested: existing widget tests plus larger-preview creation, URL mapping, outside-run rejection and disabled-cache avoidance pass. The live helper rendered in the actual modified Hydrogen Svelte component in WebView2. Hover/out/reopen kept iframe identity, performance.timeOrigin and selected image; pinning, child Escape, resize alignment, provider opt-out and compact layout passed.

Requires the core expansion opt-in in https://wiki.adom.inc/adom/hydrogen-dev/prs/1 for the larger surface. This is an additive follow-up to AI Flow PR #6; keep that PR's command/workflow/install changes. This helper is running locally for John's ESC; shared package release and Hydrogen's native build are not claimed complete.

Diff Skip to comments (1)

--- a/tools/aiflow-widget.py+++ b/tools/aiflow-widget.py@@ -0,0 +1,106 @@+#!/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">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='';function expanded(d){let rows=[...new Map((d.thumbs||[]).map(t=>[t.preview||t.url,t])).values()];let selected=rows.find(t=>(t.preview||t.url)===chosen)||rows[rows.length-1];document.querySelector('#expanded-step').textContent=d.step;let large=document.querySelector('#large');if(selected){let src=selected.preview||selected.url;chosen=src;if(large.getAttribute('src')!==src)large.src=src;large.alt=selected.label;document.querySelector('#caption').textContent=selected.label;document.querySelector('#stamp').textContent=selected.at||''}else{large.removeAttribute('src');document.querySelector('#caption').textContent=d.label;document.querySelector('#stamp').textContent=''}document.querySelector('#choices').replaceChildren(...rows.map(t=>{let b=document.createElement('button');b.setAttribute('aria-label',t.label);b.setAttribute('aria-pressed',String((t.preview||t.url)===chosen));let i=document.createElement('img');i.src=t.url;i.alt='';b.appendChild(i);b.onclick=()=>{chosen=t.preview||t.url;expanded(last)};return b}))}function draw(d){last=d;document.querySelector('#step').textContent=d.step;document.querySelector('#label').textContent=d.label;document.querySelector('#bar').style.width=d.progress==null?'0%':d.progress+'%';document.querySelector('#mark').textContent=d.progress==null?'…':Math.round(d.progress)+'%';document.querySelector('#pictures').replaceChildren(...(d.thumbs||[]).map(t=>{let i=document.createElement('img');i.src=t.url;i.alt=t.label;return i}));if(innerHeight>=100)expanded(d);window.parent.postMessage({hdWidget:{width:innerHeight>=34?265:250,expand:d.enabled?{width:560,height:400}:null}},'*')}async function tick(){try{let r=await fetch('/data.json',{cache:'no-store'});draw(await r.json())}catch(e){document.querySelector('#label').textContent='Feed unavailable'}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));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','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,28 @@+# 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.+

Comments

John Lauer 2026-09-16

Superseded client file set: PR #8 includes these hover-preview changes plus stable polling and permanent Adom AI Flow identification. Please integrate #8 with the broader PR #6.

Log in to comment.