← Commit history

Merge PR #122: Keep widget identity visible and eliminate unchanged-poll flicker

John Lauer ·2a5543e9ab ·19d ago ·parent c468232
2 files changed +25−2
tools/aiflow-widget.py+19−2
@@ -63,7 +63,24 @@ def data():  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>'''+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())@@ -100,6 +117,6 @@ else:     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)+   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()
widgets/README.md+6
@@ -25,3 +25,9 @@ Files: `hydrogen-widget.json`, `tools/aiflow-widget.py`, this page, and the help 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.