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)=>''; 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?'
'+m.attachments.map(f=>''+ic('file','sm')+esc(f.slice(13))+'').join('')+'
':''; - const pinBtn=''; - const foot=last||m.ack||m.pinned?'
'+esc(d?centralTime(d):m.time)+''+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'
':''; - 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?'
'+m.attachments.map(f=>''+ic('file','sm')+esc(f.slice(13))+'').join('')+'
':''; + const pinBtn=''; + const foot=last||m.ack||m.pinned?'
'+esc(d?centralTime(d):m.time)+''+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'
':''; + 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); diff --git a/harbor/web/harbor.css b/harbor/web/harbor.css index 958e0d6..c16e8d0 100644 --- a/harbor/web/harbor.css +++ b/harbor/web/harbor.css @@ -177,9 +177,8 @@ a.tag:hover { text-decoration: none; filter: brightness(.96); } /* ------------------------------------------------------------------------------------------------ live dot */ .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.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); } -@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 rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }