From 580fcfacb46b1e6d6d41ac041131ea9d2c0f28d1 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Mon, 5 Oct 2026 18:35:39 -0500
Subject: [PATCH] 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.
---
harbor/web/app.html | 143 ++++++++++++++++++++++++++++--------------
harbor/web/harbor.css | 3 +-
2 files changed, 97 insertions(+), 49 deletions(-)
diff --git a/harbor/web/app.html b/harbor/web/app.html
index c4400ee..477dcd5 100644
--- a/harbor/web/app.html
+++ b/harbor/web/app.html
@@ -16,7 +16,7 @@ body { overflow: hidden; }
/* sidebar */
.side {
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; }
.logo {
@@ -47,7 +47,7 @@ body { overflow: hidden; }
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.topbar {
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 .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 */
.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; }
+/* 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::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); }
@@ -414,7 +416,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
}
/* 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: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); }
@@ -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}
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}
+/* 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'],
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 log=el('log');
const fmt=s=>esc(s).replace(/`([^`]+)`/g,'$1');
-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)]));
-const centralDay=d=>{const p=centralParts(d);return [p.year,p.month,p.day].join('-')};
-const centralTime=d=>d.toLocaleTimeString('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
+/* one formatter each, made once: building an Intl formatter per message cost milliseconds per render */
+const DAY_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'});
+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'};
-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}
const face=(p,state,size)=>'
'+esc(p.initials||String(p.name).slice(0,2))+'
';
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=>{
if(thread)return m.topic===thread;
return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})}
-function renderLog(force=false){
- const visible=visibleMessages();
- 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])]);
- if(!force&&signature===shownSignature)return;
- const atBottom=nearBottom()||shownSignature==='';const oldTop=log.scrollTop;const frag=document.createDocumentFragment();let prev=null,day='';
- if(!visible.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'Nothing here yet '+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+' ';frag.appendChild(e)}
- visible.forEach((m,i)=>{
- const d=parseDate(m.at||m.time),next=visible[i+1],cday=d?centralDay(d):String(m.time);
- if(cday!==day){const dv=document.createElement('div');dv.className='date';dv.innerHTML=''+esc(dateLabel(d)||m.time)+' ';frag.appendChild(dv);day=cday;prev=null}
- const pd=prev?parseDate(prev.at||prev.time):null;
- const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
- const nd=next?parseDate(next.at||next.time):null;
- const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
- const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
- const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');
- const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
- if(!Array.isArray(m.attachments))m.attachments=[];
- const files=m.attachments.length?'':'';
- const pinBtn=''+(m.pinned?'Unpin':'Pin')+' ';
- const foot=last||m.ack||m.pinned?'':'';
- row.innerHTML=face(p)+''+esc(display(m.from))+' → '+esc(to)+chips(m)+'
'+fmt(m.text)+files+questionBox(m)+'
'+foot+'
';
- frag.appendChild(row);prev=m});
- 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)}
- log.replaceChildren(frag);shownSignature=signature;
- if(atBottom||force)scrollBottom();else{log.scrollTop=oldTop;el('newMessages').hidden=false}
+/* The log keeps a bounded window of the newest messages in the DOM (WINDOW rows; "Load earlier" adds WINDOW more).
+ Rendering all of them (800+ long messages, 10,000 nodes, a 440,000 px column) and rebuilding the whole log on every
+ new message or receipt froze the page for about two seconds each time. Now new messages are appended, a changed
+ receipt or mark repaints only its row, and the whole window is rebuilt only when the view changes. */
+const WINDOW=150;
+let windowSize=WINDOW,shown={key:null,ids:[],sigs:new Map()};
+const dayKey=m=>{const d=parseDate(m.at||m.time);return 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 */
+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])}
+function dateLine(m){const d=parseDate(m.at||m.time);const dv=document.createElement('div');dv.className='date';dv.innerHTML=''+esc(dateLabel(d)||m.time)+' ';return dv}
+function buildRow(m,prev,next){
+ const d=parseDate(m.at||m.time);
+ const pd=prev?parseDate(prev.at||prev.time):null;
+ const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
+ const nd=next?parseDate(next.at||next.time):null;
+ const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
+ const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
+ const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');row.dataset.id=m.id;
+ const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
+ if(!Array.isArray(m.attachments))m.attachments=[];
+ const files=m.attachments.length?'':'';
+ const pinBtn=''+(m.pinned?'Unpin':'Pin')+' ';
+ const foot=last||m.ack||m.pinned?'':'';
+ row.innerHTML=face(p)+''+esc(display(m.from))+' → '+esc(to)+chips(m)+'
'+fmt(m.text)+files+questionBox(m)+'
'+foot+'
';
+ 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')+'Nothing here yet '+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+' ';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;ia.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 setFilter(v){filter=v;thread=null;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 setFilter(v){filter=v;thread=null;windowSize=WINDOW;el('shell').classList.remove('open');if(active())view();else show('chat')}
+function setThread(t){thread=t;windowSize=WINDOW;if(active())view();else show('chat');el('to').value='@thread'}
function updateHeader(){
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:'')}
@@ -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}
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)}}});
-async function loadEarlier(){const first=messages[0];if(!first)return;const before=log.scrollHeight;
- try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
- messages=older.concat(messages);shownSignature='x';renderLog();log.scrollTop=log.scrollHeight-before}catch(e){}}
-async function refreshMarks(){if(!messages.length)return;try{const ids=new Set(messages.slice(-400).map(m=>m.id));
- const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=Object.fromEntries(tail.map(m=>[m.id,m]));
- 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}}
+/* show WINDOW more rows: from memory first, from the server once everything loaded is on screen */
+async function loadEarlier(){const first=messages[0];if(!first)return;
+ if(visibleMessages().length<=shown.ids.length){
+ try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
+ messages=older.concat(messages)}catch(e){return}}
+ 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){}}
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}
@@ -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()};
window.addEventListener('focus',()=>{if(active())markRead()});
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};
})();
el('pulseHours').onchange=renderPulse;
@@ -1421,11 +1470,11 @@ const startQ=new URLSearchParams(location.search).get('q');
const onBoard=/\/board\/?$/.test(location.pathname);
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()}});
-setInterval(refresh,3000);
-setInterval(()=>{if(current==='overview')renderDigest()},30000);
-setInterval(()=>{if(current==='activity')renderPulse()},60000);
-setInterval(()=>{if(current==='launch')renderLaunch()},5000);
-setInterval(()=>{if(current==='activity')renderFeed()},3000);
+every(refresh,3000,30000);
+every(()=>{if(current==='overview')return renderDigest()},30000,null);
+every(()=>{if(current==='activity')return renderPulse()},60000,null);
+every(()=>{if(current==='launch')return renderLaunch()},5000,null);
+every(()=>{if(current==='activity')return renderFeed()},3000,null);