← Commit history

Merge PR #121: Show larger interactive previews when Hydrogen expands the widget

John Lauer ·c468232943 ·19d ago ·parent 9e8fd3d
3 files changed +29−6
tests/widget-workflow.py+15
@@ -12,3 +12,18 @@ with tempfile.TemporaryDirectory() as td:  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')
tools/aiflow-widget.py+8−6
@@ -15,6 +15,8 @@ def thumbnail(run,src):  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):@@ -60,8 +62,8 @@ def data():  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'],'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}}</style><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={};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;i.title=t.label+' · '+t.at;return i}));document.querySelector('#root').title=d.label+' · '+(d.updatedAt||'no artifact yet')+' · '+(d.mode||'')+' · '+(d.progressBasis||'')+' · '+Math.round((d.staleSeconds||0)/60)+' min since thumbnail';window.parent.postMessage({hdWidget:{width:innerHeight>=34?265:250}},'*')}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('resize',()=>draw(last));tick()</script>'''+ 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())@@ -74,8 +76,7 @@ elif a.action=='event':   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:-   src=a.file.resolve();assert src.is_relative_to(run) and src.is_file(),'artifact must exist inside this run';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)+   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))@@ -91,10 +92,11 @@ else:    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/'):+   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-thumbs'/name+    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
widgets/README.md+6
@@ -19,3 +19,9 @@ The percentage is completed planned steps, not a prediction of elapsed or remain 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.