Chat: render a bounded window and append new messages instead of rebuilding the log; polls back off while hidden

Opening the chat froze the machine: the log rendered all ~800 loaded messages (1.1 M characters, ~10,000 nodes,
a 440,000 px column) and rebuilt the whole log on every new message, receipt change and mark refresh - about
1.8 s of blocked main thread each time. Now:
- the log keeps the newest 150 rows; "Load earlier" adds 150 more (from memory, then the server) and keeps the
  reader's place; switching person or thread starts again at 150
- new messages are appended; a changed receipt, pin, correction or answer repaints only its row
- off-screen rows skip layout and paint (content-visibility); the Intl date formatters are made once
- polls go through every(): no overlapping requests; while the tab is hidden messages poll every 10 s (sound and
  notifications still work), people every 60 s, the overview every 30 s, and the marks refresh and board panels pause
- removed the backdrop-filter blurs on the sidebar, top bar and composer, and the live dot's endless box-shadow
  animation (now opacity)

Measured: forced full render 1,600-1,850 ms -> 16-21 ms (hidden pane, render + layout); load long task 1,806 ms -> 56 ms;
DOM 13,318 -> 5,282 nodes (log 9,870 -> 1,806); a new message appends 2 nodes with no long task; hidden-tab traffic
~37 requests / ~2 MB per 30 s -> 4 requests / 156 KB.
This commit is contained in:
KeelrLeek committed 2026-10-05 18:35:39 -05:00
1 parent 9eee1294e0
commit 580fcfacb4
2 files changed
+97 -49

No files matched your search

+96 -47
View File
@@ -16,7 +16,7 @@ body { overflow: hidden; }
/* sidebar */ /* sidebar */
.side { .side {
display: flex; flex-direction: column; gap: 22px; padding: 18px 12px 14px; overflow-y: auto; z-index: 20; display: flex; flex-direction: column; gap: 22px; padding: 18px 12px 14px; overflow-y: auto; z-index: 20;
border-right: 1px solid var(--line); backdrop-filter: blur(14px); border-right: 1px solid var(--line);
} }
.brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px; } .brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px; }
.logo { .logo {
@@ -47,7 +47,7 @@ body { overflow: hidden; }
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.topbar { .topbar {
display: flex; align-items: center; gap: 14px; padding: 18px 32px 16px; flex-shrink: 0; display: flex; align-items: center; gap: 14px; padding: 18px 32px 16px; flex-shrink: 0;
border-bottom: 1px solid var(--line); background: var(--header); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); background: var(--header);
} }
.topbar h1 { margin: 0; font: 700 21px/1.2 var(--font-display); letter-spacing: -.5px; } .topbar h1 { margin: 0; font: 700 21px/1.2 var(--font-display); letter-spacing: -.5px; }
.topbar .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; } .topbar .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
@@ -349,8 +349,10 @@ dialog .body input[type=text], dialog .body select { width: 100%; }
/* the log */ /* the log */
.log-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; } .log-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
.log { flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; } .log { position: relative; flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; }
.log > * { max-width: 1040px; margin-left: auto; margin-right: auto; } .log > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
/* rows out of view are not laid out or painted (the browser keeps their last size) */
.log > .mrow { content-visibility: auto; contain-intrinsic-size: auto 90px; }
.date { display: flex; align-items: center; gap: 14px; margin-top: 26px; margin-bottom: 8px; font-size: 11px; font-weight: 650; color: var(--muted); } .date { display: flex; align-items: center; gap: 14px; margin-top: 26px; margin-bottom: 8px; font-size: 11px; font-weight: 650; color: var(--muted); }
.date::before, .date::after { content: ""; flex: 1; height: 1px; background: var(--line); } .date::before, .date::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.date span { padding: 3px 11px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); } .date span { padding: 3px 11px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
@@ -414,7 +416,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
} }
/* composer */ /* composer */
.composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); backdrop-filter: blur(12px); } .composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); }
.composer { max-width: 1040px; margin: 0 auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: 18px; box-shadow: var(--shadow-2); transition: border-color .15s, box-shadow .15s; } .composer { max-width: 1040px; margin: 0 auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: 18px; box-shadow: var(--shadow-2); transition: border-color .15s, box-shadow .15s; }
.composer:focus-within { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-2); } .composer:focus-within { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-2); }
.recipient { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px 0 14px; font-size: 12px; color: var(--muted); } .recipient { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px 0 14px; font-size: 12px; color: var(--muted); }
@@ -821,6 +823,15 @@ function put(id,html){const e=typeof id==='string'?el(id):id;if(e._html===html)r
open.forEach(k=>{const d=e.querySelector('details[data-k="'+CSS.escape(k)+'"]');if(d)d.open=true});return true} open.forEach(k=>{const d=e.querySelector('details[data-k="'+CSS.escape(k)+'"]');if(d)d.open=true});return true}
async function post(path,body,again){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Harbor-Token':TOKEN},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.status===403&&/stale page/.test(j.error||'')&&!again&&await freshToken())return post(path,body,true);if(!r.ok||j.ok===false)throw new Error(j.error||('HTTP '+r.status));return j} async function post(path,body,again){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Harbor-Token':TOKEN},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.status===403&&/stale page/.test(j.error||'')&&!again&&await freshToken())return post(path,body,true);if(!r.ok||j.ok===false)throw new Error(j.error||('HTTP '+r.status));return j}
async function get(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json();if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j} async function get(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json();if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
/* Run fn every ms while the page is visible and every hiddenMs while it is hidden (null: not at all). The next run is
timed from the end of the last one, so slow requests never pile up; showing the page again runs everything at once. */
const LOOPS=[];
function every(fn,ms,hiddenMs){const L={busy:false,t:0};
L.run=async()=>{clearTimeout(L.t);if(L.busy)return;
if(!document.hidden||hiddenMs){L.busy=true;try{await fn()}catch(e){}L.busy=false}
L.t=setTimeout(L.run,document.hidden?(hiddenMs||5000):ms)};
L.t=setTimeout(L.run,document.hidden&&hiddenMs?hiddenMs:ms);LOOPS.push(L);return L}
document.addEventListener('visibilitychange',()=>{if(!document.hidden)LOOPS.forEach(L=>{if(!L.busy)L.run()})});
const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'], const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'],
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'], tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
@@ -1186,11 +1197,13 @@ const Chat=(()=>{
const CENTRAL='America/Chicago'; const CENTRAL='America/Chicago';
const log=el('log'); const log=el('log');
const fmt=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>'); const fmt=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>');
const centralParts=d=>Object.fromEntries(new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'}).formatToParts(d).filter(p=>p.type!=='literal').map(p=>[p.type,Number(p.value)])); /* one formatter each, made once: building an Intl formatter per message cost milliseconds per render */
const centralDay=d=>{const p=centralParts(d);return [p.year,p.month,p.day].join('-')}; const DAY_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'});
const centralTime=d=>d.toLocaleTimeString('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'}); const TIME_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
const centralDay=d=>DAY_FMT.format(d);
const centralTime=d=>TIME_FMT.format(d);
const since=s=>{if(!s)return 'never';const m=Math.round((Date.now()-new Date(s))/60000);return m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'}; const since=s=>{if(!s)return 'never';const m=Math.round((Date.now()-new Date(s))/60000);return m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'};
let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,shownSignature='',sending=false,receipts={},overviewData=null,loaded=false; let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,sending=false,receipts={},overviewData=null,loaded=false;
async function getJSON(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j} async function getJSON(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
const face=(p,state,size)=>'<div class="avatar'+(size?' '+size:'')+'" style="--avatar:'+esc(p.color||'#7b8797')+'"'+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(p.initials||String(p.name).slice(0,2))+'</div>'; const face=(p,state,size)=>'<div class="avatar'+(size?' '+size:'')+'" style="--avatar:'+esc(p.color||'#7b8797')+'"'+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(p.initials||String(p.name).slice(0,2))+'</div>';
const display=n=>n===human?'You':n; const display=n=>n===human?'You':n;
@@ -1239,36 +1252,68 @@ function ackButton(m){if(!(m.ack||m.due_at)||m.from===human)return '';const r=re
function visibleMessages(){return messages.filter(m=>{ function visibleMessages(){return messages.filter(m=>{
if(thread)return m.topic===thread; if(thread)return m.topic===thread;
return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})} return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})}
function renderLog(force=false){ /* The log keeps a bounded window of the newest messages in the DOM (WINDOW rows; "Load earlier" adds WINDOW more).
const visible=visibleMessages(); Rendering all of them (800+ long messages, 10,000 nodes, a 440,000 px column) and rebuilding the whole log on every
const signature=JSON.stringify([filter,thread,visible.length,visible.length&&visible[0].id,visible.length&&visible[visible.length-1].id,receipts,visible.filter(m=>m.corrected_by||m.pinned||m.question).map(m=>[m.id,m.corrected_by,m.pinned,m.question&&m.question.answer])]); new message or receipt froze the page for about two seconds each time. Now new messages are appended, a changed
if(!force&&signature===shownSignature)return; receipt or mark repaints only its row, and the whole window is rebuilt only when the view changes. */
const atBottom=nearBottom()||shownSignature==='';const oldTop=log.scrollTop;const frag=document.createDocumentFragment();let prev=null,day=''; const WINDOW=150;
if(!visible.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)} let windowSize=WINDOW,shown={key:null,ids:[],sigs:new Map()};
visible.forEach((m,i)=>{ const dayKey=m=>{const d=parseDate(m.at||m.time);return d?centralDay(d):String(m.time)};
const d=parseDate(m.at||m.time),next=visible[i+1],cday=d?centralDay(d):String(m.time); const prevOf=(vis,i)=>i>0&&dayKey(vis[i-1])===dayKey(vis[i])?vis[i-1]:null; /* a date line starts a new group */
if(cday!==day){const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';frag.appendChild(dv);day=cday;prev=null} function rowSig(m){return JSON.stringify([m.corrected_by||null,!!m.pinned,m.question?[m.question.answer,m.question.answered_by]:null,receipts[m.id]||null])}
const pd=prev?parseDate(prev.at||prev.time):null; function dateLine(m){const d=parseDate(m.at||m.time);const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';return dv}
const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack; function buildRow(m,prev,next){
const nd=next?parseDate(next.at||next.time):null; const d=parseDate(m.at||m.time);
const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack; const pd=prev?parseDate(prev.at||prev.time):null;
const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human; const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':''); const nd=next?parseDate(next.at||next.time):null;
const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', '); const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
if(!Array.isArray(m.attachments))m.attachments=[]; const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':''; const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');row.dataset.id=m.id;
const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>'; const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':''; if(!Array.isArray(m.attachments))m.attachments=[];
row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>'; const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'';
frag.appendChild(row);prev=m}); const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>';
if(!thread&&messages.length&&messages[0].id>1){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)} const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':'';
log.replaceChildren(frag);shownSignature=signature; row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>';
if(atBottom||force)scrollBottom();else{log.scrollTop=oldTop;el('newMessages').hidden=false} return row}
/* the first row in view and its offset, to keep the reader's place across a rebuild */
function topRow(){const st=log.scrollTop;for(const r of log.querySelectorAll('.mrow'))if(r.offsetTop+r.offsetHeight>st)return {id:r.dataset.id,off:r.offsetTop-st};return null}
function renderLog(force=false,preserve=false){
const all=visibleMessages(),key=JSON.stringify([filter,thread]);
if(!force&&!preserve&&shown.key===key&&shown.ids.length){
const start=all.findIndex(m=>m.id===shown.ids[0]),vis=start<0?null:all.slice(start),n=shown.ids.length;
const sameHead=vis&&vis.length>=n&&shown.ids.every((id,i)=>vis[i].id===id);
/* append in place, unless the window has grown far past its size and the reader is at the bottom (then trim) */
if(sameHead&&!(vis.length>windowSize+WINDOW&&nearBottom()))return patchLog(vis)}
const atBottom=!preserve&&(force||nearBottom()||shown.key===null);const anchor=atBottom?null:topRow();
const vis=all.slice(-windowSize),frag=document.createDocumentFragment();let day='';
if(!vis.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)}
vis.forEach((m,i)=>{const k=dayKey(m);if(k!==day){frag.appendChild(dateLine(m));day=k}frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]))});
if(all.length>vis.length||(!thread&&messages.length&&messages[0].id>1)){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)}
log.replaceChildren(frag);
shown={key,ids:vis.map(m=>m.id),sigs:new Map(vis.map(m=>[m.id,rowSig(m)]))};
if(atBottom)scrollBottom();
else if(anchor){const r=log.querySelector('.mrow[data-id="'+anchor.id+'"]');if(r)log.scrollTop=r.offsetTop-anchor.off}
}
function patchLog(vis){
const n=shown.ids.length,added=vis.length-n,redo=new Set();
for(let i=0;i<n;i++)if(shown.sigs.get(vis[i].id)!==rowSig(vis[i]))redo.add(i);
if(!added&&!redo.size)return;
const atBottom=nearBottom();
if(added)redo.add(n-1); /* the old last row may lose its footer or join the new group */
for(const i of redo){const m=vis[i],node=log.querySelector('.mrow[data-id="'+m.id+'"]');
if(node)node.replaceWith(buildRow(m,prevOf(vis,i),vis[i+1]));shown.sigs.set(m.id,rowSig(m))}
if(added){const frag=document.createDocumentFragment();
for(let i=n;i<vis.length;i++){const m=vis[i];if(dayKey(m)!==dayKey(vis[i-1]))frag.appendChild(dateLine(m));
frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]));shown.ids.push(m.id);shown.sigs.set(m.id,rowSig(m))}
log.appendChild(frag);
if(atBottom)scrollBottom();else el('newMessages').hidden=false}
} }
function markRead(){const me=overviewData&&overviewData.agents.find(a=>a.name===human);if(me&&me.unread)post('api/read',{}).then(()=>refresh()).catch(()=>{})} function markRead(){const me=overviewData&&overviewData.agents.find(a=>a.name===human);if(me&&me.unread)post('api/read',{}).then(()=>refresh()).catch(()=>{})}
function view(){mountPeople();if(!active())return;updateHeader();if(loaded)renderLog(true);if(!document.hidden&&document.hasFocus())markRead()} function view(){mountPeople();if(!active())return;updateHeader();if(loaded)renderLog(true);if(!document.hidden&&document.hasFocus())markRead()}
function setFilter(v){filter=v;thread=null;el('shell').classList.remove('open');if(active())view();else show('chat')} function setFilter(v){filter=v;thread=null;windowSize=WINDOW;el('shell').classList.remove('open');if(active())view();else show('chat')}
function setThread(t){thread=t;if(active())view();else show('chat');el('to').value='@thread'} function setThread(t){thread=t;windowSize=WINDOW;if(active())view();else show('chat');el('to').value='@thread'}
function updateHeader(){ function updateHeader(){
el('threadbar').classList.toggle('on',!!thread); el('threadbar').classList.toggle('on',!!thread);
if(thread){const m=messages.find(x=>x.topic===thread);el('threadName').textContent=thread+(m&&m.subject&&m.subject!==thread?' · '+m.subject:'')} if(thread){const m=messages.find(x=>x.topic===thread);el('threadName').textContent=thread+(m&&m.subject&&m.subject!==thread?' · '+m.subject:'')}
@@ -1362,12 +1407,15 @@ document.addEventListener('click',async e=>{
ans.disabled=true;try{await post('api/answer',{question:Number(ans.dataset.answer),answer:value});await refreshMarks();refresh();el('feedback').className='';el('feedback').textContent='Answer sent.'}catch(err){ans.disabled=false;alert(err.message)}return} ans.disabled=true;try{await post('api/answer',{question:Number(ans.dataset.answer),answer:value});await refreshMarks();refresh();el('feedback').className='';el('feedback').textContent='Answer sent.'}catch(err){ans.disabled=false;alert(err.message)}return}
const pb=e.target.closest('[data-pin]');if(pb){try{await post('api/pin',{id:Number(pb.dataset.pin),unpin:!!pb.dataset.unpin});await refreshMarks()}catch(err){alert(err.message)}return} const pb=e.target.closest('[data-pin]');if(pb){try{await post('api/pin',{id:Number(pb.dataset.pin),unpin:!!pb.dataset.unpin});await refreshMarks()}catch(err){alert(err.message)}return}
const a=e.target.closest('[data-ack]');if(a){a.disabled=true;try{await post('api/ack',{ids:[Number(a.dataset.ack)]});await pollPeople();refresh()}catch(err){a.disabled=false;alert(err.message)}}}); const a=e.target.closest('[data-ack]');if(a){a.disabled=true;try{await post('api/ack',{ids:[Number(a.dataset.ack)]});await pollPeople();refresh()}catch(err){a.disabled=false;alert(err.message)}}});
async function loadEarlier(){const first=messages[0];if(!first)return;const before=log.scrollHeight; /* show WINDOW more rows: from memory first, from the server once everything loaded is on screen */
try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return} async function loadEarlier(){const first=messages[0];if(!first)return;
messages=older.concat(messages);shownSignature='x';renderLog();log.scrollTop=log.scrollHeight-before}catch(e){}} if(visibleMessages().length<=shown.ids.length){
async function refreshMarks(){if(!messages.length)return;try{const ids=new Set(messages.slice(-400).map(m=>m.id)); try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=Object.fromEntries(tail.map(m=>[m.id,m])); messages=older.concat(messages)}catch(e){return}}
let changed=false;for(const m of messages){const n=byId[m.id];if(n&&ids.has(m.id)&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}} windowSize=shown.ids.length+WINDOW;renderLog(false,true)}
async function refreshMarks(){if(!messages.length||!active())return;try{
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=new Map(tail.map(m=>[m.id,m]));
let changed=false;for(const m of messages){const n=byId.get(m.id);if(n&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}}
if(changed&&active())renderLog()}catch(e){}} if(changed&&active())renderLog()}catch(e){}}
async function catchUp(sinceWhen,title){ async function catchUp(sinceWhen,title){
let d,o;try{[d,o]=await Promise.all([getJSON('api/digest?since='+encodeURIComponent(sinceWhen)),getJSON('api/overview')])}catch(e){return} let d,o;try{[d,o]=await Promise.all([getJSON('api/digest?since='+encodeURIComponent(sinceWhen)),getJSON('api/overview')])}catch(e){return}
@@ -1412,7 +1460,8 @@ el('notify').onclick=async()=>{if(!('Notification' in window))return alert('This
if(store.get('notify',false)&&Notification.permission==='granted'){store.set('notify',false)}else{const p=await Notification.requestPermission();store.set('notify',p==='granted')}paintNotify()}; if(store.get('notify',false)&&Notification.permission==='granted'){store.set('notify',false)}else{const p=await Notification.requestPermission();store.set('notify',p==='granted')}paintNotify()};
window.addEventListener('focus',()=>{if(active())markRead()}); window.addEventListener('focus',()=>{if(active())markRead()});
paintNotify();paintSound(); paintNotify();paintSound();
(async()=>{await pollPeople();await pollMessages();if(active())view();setInterval(pollMessages,2000);setInterval(pollPeople,6000);setInterval(refreshMarks,20000)})(); (async()=>{await pollPeople();await pollMessages();if(active())view();
every(pollMessages,2000,10000);every(pollPeople,6000,60000);every(refreshMarks,20000,null)})(); /* hidden: still poll for sound and notifications, slower */
return {view,openThread:setThread}; return {view,openThread:setThread};
})(); })();
el('pulseHours').onchange=renderPulse; el('pulseHours').onchange=renderPulse;
@@ -1421,11 +1470,11 @@ const startQ=new URLSearchParams(location.search).get('q');
const onBoard=/\/board\/?$/.test(location.pathname); const onBoard=/\/board\/?$/.test(location.pathname);
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat'))); show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}}); refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
setInterval(refresh,3000); every(refresh,3000,30000);
setInterval(()=>{if(current==='overview')renderDigest()},30000); every(()=>{if(current==='overview')return renderDigest()},30000,null);
setInterval(()=>{if(current==='activity')renderPulse()},60000); every(()=>{if(current==='activity')return renderPulse()},60000,null);
setInterval(()=>{if(current==='launch')renderLaunch()},5000); every(()=>{if(current==='launch')return renderLaunch()},5000,null);
setInterval(()=>{if(current==='activity')renderFeed()},3000); every(()=>{if(current==='activity')return renderFeed()},3000,null);
</script> </script>
</body> </body>
</html> </html>
+1 -2
View File
@@ -177,9 +177,8 @@ a.tag:hover { text-decoration: none; filter: brightness(.96); }
/* ------------------------------------------------------------------------------------------------ live dot */ /* ------------------------------------------------------------------------------------------------ live dot */
.live { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; } .live { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.live .dot { width: 8px; height: 8px; background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); } .live .dot { width: 8px; height: 8px; background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
.live.ok .dot, .connection.connected .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); animation: breathe 2.4s ease-in-out infinite; } .live.ok .dot, .connection.connected .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); animation: pulse 2.4s ease-in-out infinite; } /* opacity only: an endless box-shadow animation repainted every frame */
.live.bad .dot, .connection.failed .dot { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); } .live.bad .dot, .connection.failed .dot { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
@keyframes breathe { 0%, 100% { box-shadow: 0 0 0 3px var(--green-soft); } 50% { box-shadow: 0 0 0 6px transparent; } }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: .35; } 100% { opacity: 1; } } @keyframes pulse { 0% { opacity: 1; } 50% { opacity: .35; } 100% { opacity: 1; } }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }