From e8e72529a1f98d4fba1f2ac783747b3026b09af0 Mon Sep 17 00:00:00 2001
From: "autohub-codex[bot]"
<334634853+autohub-codex[bot]@users.noreply.github.com>
Date: Sun, 8 Nov 2026 08:25:36 +0100
Subject: [PATCH] feat(command): add lenses dispatch threads and workbench
---
src/command-center.mjs | 247 ++++++++++++++++++++++++++++++++
src/command-styles.mjs | 156 ++++++++++++++++++++
src/command-workbench.mjs | 59 ++++++++
test/command-center-ui.test.mjs | 118 +++++++++++++++
test/command-workbench.test.mjs | 90 ++++++++++++
5 files changed, 670 insertions(+)
create mode 100644 src/command-center.mjs
create mode 100644 src/command-styles.mjs
create mode 100644 src/command-workbench.mjs
create mode 100644 test/command-center-ui.test.mjs
create mode 100644 test/command-workbench.test.mjs
diff --git a/src/command-center.mjs b/src/command-center.mjs
new file mode 100644
index 0000000..8eeb5ca
--- /dev/null
+++ b/src/command-center.mjs
@@ -0,0 +1,247 @@
+/** A presentation coordinator. Polling, walking and the single inspector stay with town.mjs. */
+import {fleetView, durationLabel} from './command-model.mjs';
+import {tradePanelHTML,tradeStoryForSite,scopedTrades} from './trade-panel.mjs';
+import {commandStyles} from './command-styles.mjs';
+import {townPulses,townPulseLabel,activityVisit,bindSceneBalance} from './command-workbench.mjs';
+import {escapeHTML as e,townOf,ownerOf,stageLabel,attentionQueue,filteredTasks,searchTasks,dispatchHTML,threadsHTML,previewHTML} from './command-render.mjs';
+const VIEWS=['town','dispatch','threads'],LENSES=['village','attention','time','delivery','owner'];
+const label=value=>value[0].toUpperCase()+value.slice(1);
+const editing=target=>Boolean(target?.closest?.('input,textarea,select,[contenteditable=true],[role=textbox]'));
+
+export function lensLabel(task,lens){
+ if(lens==='attention')return task.input?'INPUT':task.attention?'! CHECK':'';
+ if(lens==='time')return task.quiet?`Q ${durationLabel(task.progressAgeMs)}`:durationLabel(task.taskAgeMs);
+ if(lens==='delivery')return task.pr.number?`#${task.pr.number} ${stageLabel(task.prStage)}`:stageLabel(task.prStage);
+ if(lens==='owner')return ownerOf(task);
+ return '';
+}
+export function lensContext(task,lens){
+ if(!task)return 'Select a cottage for its recorded details.';
+ if(lens==='attention')return `${task.title} · ${task.input?.prompt||task.pr.reason||task.raw.attention||'No recorded attention request.'}`;
+ if(lens==='time')return `${task.title} · Task ${durationLabel(task.taskAgeMs)} · Execution ${durationLabel(task.recordedExecutionMs)} · Session ${durationLabel(task.sessionAgeMs)} · Progress ${durationLabel(task.progressAgeMs)}${task.progressAgeMs===null?'':' ago'}`;
+ if(lens==='delivery')return `${task.title} · ${stageLabel(task.prStage)} · ${task.pr.reason||'Further delivery evidence unavailable.'}`;
+ return `${task.title} · Owner ${ownerOf(task)} · Harness ${task.harness} · Model ${task.model}`;
+}
+/** Labels only: world coordinates, plot geometry and household membership are never changed. */
+export function drawPlotLens(ctx,plots,tasks,lens,{selectedId=null,hoverId=null,townFilter=null,visible=()=>true}={}){
+ if(lens==='village'||!LENSES.includes(lens))return;
+ const byId=new Map(tasks.map(task=>[task.id,task]));
+ const draw=(agent,x,y,w=54,roommates=[])=>{
+ const members=[agent,...roommates].map(member=>byId.get(String(member.id))).filter(Boolean);
+ const allowed=members.filter(task=>(!townFilter||townOf(task)===townFilter)&&visible(task.raw)!==false);
+ if(!allowed.length)return;
+ const focus=allowed.find(task=>task.id===selectedId||task.id===hoverId),task=focus||allowed.find(task=>task.attention)||allowed[0];
+ let text=lensLabel(task,lens);if(!text)return;
+ if(text==='Unavailable')text='Time ?';
+ text=text.slice(0,16)+(members.length>1?` +${members.length-1}`:'');
+ ctx.save();
+ try{
+ ctx.globalAlpha=focus?1:.9;ctx.font='6px monospace';ctx.textBaseline='middle';ctx.textAlign='center';
+ const width=Math.min(125,Math.ceil(ctx.measureText(text).width)+8),cx=Math.round(x+w/2),cy=Math.round(y-10);
+ ctx.fillStyle='#23332f';ctx.fillRect(cx-width/2,cy-5,width,10);
+ ctx.fillStyle=lens==='attention'?'#efd397':lens==='delivery'&&task.prStage==='ready'?'#b9d9a4':'#e2dfbd';
+ ctx.fillRect(cx-width/2,cy-5,1,10);ctx.fillText(text,cx,cy);
+ }finally{ctx.restore();}
+ };
+ for(const plot of plots||[]){
+ if(!plot.agent)continue;
+ draw(plot.agent,plot.x,plot.y,plot.w||54,plot.agent.roommates||[]);
+ for(const child of plot.kids||[])if(child.agent)draw(child.agent,child.x,child.y,16);
+ }
+}
+
+export function createCommandCenter({root,onInspect=()=>{},onLocate=()=>{},onViewChange=()=>{},onTownFilter=()=>{},onBalanceChange=()=>{},onRehearseGrowth=()=>{}}){
+ if(!root)throw new TypeError('Command center requires the existing map shell.');
+ const doc=root.ownerDocument,win=doc.defaultView;
+ const bar=root.querySelector('#command-bar'),attention=root.querySelector('#command-attention'),surface=root.querySelector('#command-view');
+ if(!bar||!attention||!surface)throw new TypeError('Command center mounts are missing.');
+ const style=doc.createElement('style');style.dataset.commandStyles='';style.textContent=commandStyles;root.append(style);
+ bar.classList.add('cc-bar');attention.classList.add('cc-attention');surface.classList.add('cc-surface');surface.tabIndex=-1;
+ surface.setAttribute('aria-label','Command view');surface.hidden=true;
+ const pulse=doc.createElement('div');pulse.className='cc-pulse-rail';pulse.setAttribute('aria-label','Project heartbeat');
+ attention.before(pulse);
+ const balance=doc.createElement('div');balance.className='cc-balance';balance.hidden=true;
+ balance.innerHTML='Dispatch workbench';
+ surface.before(balance);
+ const disposeBalance=bindSceneBalance({input:balance.querySelector('input'),output:balance.querySelector('output'),root,onResize:onBalanceChange});
+ const guide=doc.createElement('details');guide.className='cc-field-guide';
+ guide.innerHTML='
Find your neighbourhood. Choose a project above to frame its cottages. All towns brings the wider village back. Click a cottage for evidence; walk into its doorway to visit.
Watch the ground. Moving lights mark working cottages. Amber lanterns mark attention. Miners mark recent recorded read/search tools at a working cottage. Workshops mark other recent tool use, such as Bash or shell, without guessing what the command did. Missing tool evidence is unknown. Roadmenders mark newly recorded explicit links, never guessed connections.
A growing garden. Seedlings count residents first observed after the initial town inventory, within ten minutes. Three or more arrivals bring a gardener’s pause. These are arrival observations, never inferred stress or productivity.
One PR, one parcel. A newly observed merge sends a labeled cart down its cottage lane. The parcel is that PR’s changes, not a measure of size or proof of deployment. Shared PRs travel once.
Closing is a return. A PR closed without merging sends an empty cart home. Villagers keep their place. Stale feeds pause the new activity cues; no fresh news is invented.
WASD / arrows · walk E · talk 2 / 3 · workbenches Esc · close ⌘ K · find a cottage
'; + pulse.after(guide); + const tradesPanel=doc.createElement('details');tradesPanel.className='cc-trades';tradesPanel.setAttribute('aria-label','Village activity');guide.before(tradesPanel); + let selectedTradeId=null; + const state={view:'town',lens:'village',selectedId:null,filter:'all',sourceKey:null,counts:{},townFilter:null}; + let options={},fleet=fleetView([]),scoped=fleetView([]),queue=[],attentionIndex=0,searchIndex=0,searchResults=[],searchReturn=null,previewTarget=null,previewDismissedKey=null,lastWidth=0; + const dialog=doc.createElement('dialog');dialog.className='cc-search';dialog.setAttribute('aria-label','Find a cottage'); + dialog.innerHTML='↑ ↓ choose · Enter inspect · Escape return
'; + root.append(dialog); + const searchInput=dialog.querySelector('input'),resultsNode=dialog.querySelector('#cc-search-results'); + const preview=doc.createElement('div');preview.id='cc-pr-preview';preview.className='cc-preview';preview.setAttribute('role','tooltip');preview.hidden=true;root.append(preview); + const taskById=id=>scoped.tasks.find(task=>task.id===id)||fleet.tasks.find(task=>task.id===id); + const sourceNote=()=>options.builtInDemo||options.publicDemo?'Illustrative town · local demo':scoped.stale?'Source stale · last recorded evidence': + scoped.checkedAt!==null&&scoped.checkedAt<=Date.now()?`Live feed · checked ${durationLabel(Date.now()-scoped.checkedAt)} ago`:'Recorded feed'; + const generatedMarkup=new WeakMap(); + function replace(node,html){ + // Runtime text/ARIA updates must not invalidate the stable generated structure. + if(generatedMarkup.get(node)===html)return; + const active=doc.activeElement,key=node.contains(active)?active?.getAttribute('data-cc-key'):null,scroll=node.scrollTop; + const openTrace=node.querySelector?.('.cc-trace-details')?.open; + node.innerHTML=html;generatedMarkup.set(node,html); + if(openTrace&&node.querySelector('.cc-trace-details'))node.querySelector('.cc-trace-details').open=true; + if(key){const target=[...node.querySelectorAll('[data-cc-key]')].find(item=>item.getAttribute('data-cc-key')===key);target?.focus({preventScroll:true});} + node.scrollTop=scroll; + } + function renderBar(){ + const towns=[...new Set(fleet.tasks.map(townOf))].sort(),visit=activityVisit(scoped.tasks,state.selectedId); + replace(bar,`${e(state.townFilter||'All towns')} · ${scoped.counts.tasks} cottages · ${e(sourceNote())}
No matching cottages in the current town.
'; + if(searchResults.length)searchInput.setAttribute('aria-activedescendant',`cc-result-${searchIndex}`);else searchInput.removeAttribute('aria-activedescendant'); + } + function openSearch(){ + hidePreview();searchReturn=doc.activeElement;searchIndex=0;renderSearch(); + if(!dialog.open)dialog.showModal();searchInput.focus();searchInput.select(); + } + function closeSearch({restore=true}={}){ + if(dialog.open)dialog.close();if(restore&&searchReturn?.isConnected)searchReturn.focus({preventScroll:true});searchReturn=null; + } + function chooseSearch(id){closeSearch();inspect(id,taskById(id)?.input?'talk':'overview');} + function click(event){ + const target=event.target.closest?.('button');if(!target||!root.contains(target))return; + if(target.hasAttribute('data-cc-trade')){selectedTradeId=target.dataset.ccTrade;renderTrades();} + else if(target.hasAttribute('data-cc-trade-close')){selectedTradeId=null;renderTrades();} + else if(target.hasAttribute('data-cc-rehearse-growth')&&(options.builtInDemo||options.publicDemo))onRehearseGrowth(); + else if(target.hasAttribute('data-cc-view'))setView(target.dataset.ccView); + else if(target.hasAttribute('data-cc-focus-town'))focusTown(target.dataset.ccFocusTown); + else if(target.hasAttribute('data-cc-visit')){const visit=activityVisit(scoped.tasks,state.selectedId);if(visit){state.selectedId=visit.task.id;setView('town');onLocate(visit.task.id);}} + else if(target.hasAttribute('data-cc-search'))openSearch(); + else if(target.hasAttribute('data-cc-close-search'))closeSearch(); + else if(target.hasAttribute('data-cc-search-result'))chooseSearch(target.dataset.ccSearchResult); + else if(target.hasAttribute('data-cc-pr'))inspect(target.dataset.ccPr,'review'); + else if(target.hasAttribute('data-cc-inspect'))inspect(target.dataset.ccInspect,target.dataset.ccTab||'overview'); + else if(target.hasAttribute('data-cc-filter')){state.filter=target.dataset.ccFilter;renderSurface();} + else if(target.hasAttribute('data-cc-next')){attentionIndex++;renderAttention();} + else if(target.hasAttribute('data-cc-locate')&&state.selectedId){setView('town');onLocate(state.selectedId);} + } + function change(event){ + if(event.target.hasAttribute('data-cc-lens')){state.lens=event.target.value;renderBar();} + else if(event.target.hasAttribute('data-cc-town'))focusTown(event.target.value); + else if(event.target.hasAttribute('data-cc-trace'))inspect(event.target.value); + } + function refreshScope(){ + const envelope=Array.isArray(options.snapshot)?{agents:options.snapshot}:options.snapshot||{agents:[]}; + const modelOptions={endpoint:options.endpoint,stale:options.stale,includeSettled:Boolean(options.showSettled),builtInDemo:Boolean(options.builtInDemo||options.publicDemo)}; + fleet=fleetView(envelope,modelOptions); + // Scope after global PR reconciliation: another town may hold the Hub authority. + const agents=fleet.tasks.filter(task=>!state.townFilter||townOf(task)===state.townFilter).map(task=>({...task.raw,pr:task.pr})); + scoped=fleetView({...envelope,agents},modelOptions); + state.counts={...scoped.counts};const prior=queue[attentionIndex]?.id;queue=attentionQueue(scoped.tasks);attentionIndex=Math.max(0,queue.findIndex(task=>task.id===prior)); + } + function keydown(event){ + const otherDialog=[...doc.querySelectorAll('dialog[open],[role=dialog][aria-modal=true]')].some(node=>node!==dialog&&!node.hidden); + if(otherDialog)return; + const consume=()=>{event.preventDefault();event.stopImmediatePropagation();}; + if(dialog.open){ + if(event.key==='Escape'){consume();closeSearch();return;} + if(event.key==='ArrowDown'||event.key==='ArrowUp'){ + consume();if(searchResults.length)searchIndex=(searchIndex+(event.key==='ArrowDown'?1:-1)+searchResults.length)%searchResults.length; + // Preserve input focus while exposing the active option to assistive technology. + for(const [index,node] of [...resultsNode.querySelectorAll('[role=option]')].entries())node.setAttribute('aria-selected',String(index===searchIndex)); + const active=resultsNode.querySelector(`#cc-result-${searchIndex}`);if(active){searchInput.setAttribute('aria-activedescendant',active.id);active.scrollIntoView({block:'nearest'});}return; + } + if(event.key==='Enter'&&event.target===searchInput){consume();const task=searchResults[searchIndex];if(task)chooseSearch(task.id);}return; + } + if(editing(event.target))return; + if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='k'){consume();openSearch();return;} + if(event.metaKey||event.ctrlKey||event.altKey)return; + if(event.key==='Escape'){ + if(!preview.hidden){consume();previewDismissedKey=previewTarget?.getAttribute('data-cc-key');hidePreview();return;} + if(state.view!=='town'){consume();setView('town');}return; + } + if(['1','2','3'].includes(event.key)){consume();setView(VIEWS[Number(event.key)-1]);return;} + if(state.view!=='town'&&['j','k'].includes(event.key.toLowerCase())){ + consume();const tasks=state.view==='dispatch'?filteredTasks(scoped.tasks,state.filter):scoped.tasks;if(!tasks.length)return; + const current=tasks.findIndex(task=>task.id===state.selectedId),forward=event.key.toLowerCase()==='j',next=current<0?(forward?0:tasks.length-1):(current+(forward?1:-1)+tasks.length)%tasks.length; + inspect(tasks[next].id);const row=[...surface.querySelectorAll('[data-cc-row]')].find(node=>node.dataset.ccRow===tasks[next].id);row?.scrollIntoView({block:'nearest'}); + } + } + const over=event=>{const target=event.target.closest?.('[data-cc-pr]');if(target&&target!==previewTarget)showPreview(target);}; + const out=event=>{const target=event.target.closest?.('[data-cc-pr]');if(target&&!target.contains(event.relatedTarget)){previewDismissedKey=null;hidePreview();}}; + root.addEventListener('click',click);root.addEventListener('change',change);root.addEventListener('pointerover',over);root.addEventListener('pointerout',out);root.addEventListener('focusin',over);root.addEventListener('focusout',out); + surface.addEventListener('scroll',hidePreview,{passive:true});doc.addEventListener('keydown',keydown,true); + searchInput.addEventListener('input',()=>{searchIndex=0;searchResults=[];renderSearch();}); + dialog.addEventListener('cancel',event=>{event.preventDefault();closeSearch();}); + const observer=win?.ResizeObserver?new win.ResizeObserver(()=>{const width=surface.clientWidth;if(width!==lastWidth){lastWidth=width;if(state.view==='threads')renderSurface();}}):null;observer?.observe(surface); + root.dataset.commandView='town';render(); + return { + update(next={}){ + const changed=state.sourceKey!==null&&next.sourceKey!==undefined&&next.sourceKey!==state.sourceKey; + options={...options,...next}; + if(changed&&!Object.hasOwn(next,'trades'))options.trades={}; + if(changed){selectedTradeId=null;tradesPanel.open=false;state.selectedId=null;state.lens='village';state.filter='all';queue=[];attentionIndex=0;searchInput.value='';searchResults=[];closeSearch({restore:false});hidePreview();surface.scrollTop=0;} + state.sourceKey=options.sourceKey??null;state.townFilter=options.townFilter||null; + if(!changed&&Object.hasOwn(next,'selectedId'))state.selectedId=next.selectedId==null?null:String(next.selectedId); + refreshScope();if(state.selectedId&&!fleet.tasks.some(task=>task.id===state.selectedId))state.selectedId=null;render(); + }, + select(id){const next=id==null?null:String(id);if(next===state.selectedId)return;state.selectedId=next;render();}, + setView, + inspectTrade, + drawLens(ctx,plots,settings={}){ + drawPlotLens(ctx,plots,fleet.tasks,state.lens,{selectedId:state.selectedId,townFilter:state.townFilter,...settings}); + const context=bar.querySelector('[data-cc-lens-context]'),task=taskById(settings.hoverId||settings.selectedId||state.selectedId); + if(context){const text=lensContext(task,state.lens);if(context.textContent!==text)context.textContent=text;} + }, + get tasks(){return fleet.tasks;}, + get state(){return {...state,counts:{...state.counts}};}, + destroy(){disposeBalance();balance.remove();pulse.remove();guide.remove();tradesPanel.remove();observer?.disconnect();doc.removeEventListener('keydown',keydown,true);root.removeEventListener('click',click);root.removeEventListener('change',change);root.removeEventListener('pointerover',over);root.removeEventListener('pointerout',out);root.removeEventListener('focusin',over);root.removeEventListener('focusout',out);surface.removeEventListener('scroll',hidePreview);dialog.remove();preview.remove();style.remove();} + }; +} diff --git a/src/command-styles.mjs b/src/command-styles.mjs new file mode 100644 index 0000000..ea4dab8 --- /dev/null +++ b/src/command-styles.mjs @@ -0,0 +1,156 @@ +/** Installed inside the existing town shell; no global typography or theme changes. */ +export const commandStyles = ` +#map-shell{--cc-paper:#e9e0c9;--cc-brass:#cfad70;--cc-muted:#b3b5ad;--cc-line:#465048;--cc-ground:#25312f} +#map-shell .cc-bar,#map-shell .cc-attention,#map-shell .cc-surface,#map-shell .cc-search,#map-shell .cc-preview{font-family:ui-sans-serif,-apple-system,"Segoe UI",sans-serif;font-size:13px;line-height:1.5;color:var(--cc-paper)} +#map-shell .cc-bar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin:0 0 8px;padding-bottom:8px;border-bottom:1px solid var(--cc-line)} +#map-shell .cc-views{display:flex;gap:3px;padding:3px;background:#192321;border:1px solid var(--cc-line);border-radius:5px} +#map-shell .cc-bar button,#map-shell .cc-attention button,#map-shell .cc-surface button,#map-shell .cc-search button{font:inherit;color:inherit;cursor:pointer} +#map-shell .cc-bar button,#map-shell .cc-filter-row button,#map-shell .cc-locate{background:transparent;border:1px solid transparent;border-radius:3px;padding:6px 10px} +#map-shell .cc-bar button[aria-pressed=true],#map-shell .cc-filter-row button[aria-pressed=true]{background:#425047;border-color:#7e8060;color:#fff1cb} +#map-shell .cc-bar button:hover,#map-shell .cc-filter-row button:hover{background:#39433b} +#map-shell .cc-key{font:10px ui-monospace,monospace;color:#a5ac9d;margin-right:5px} +#map-shell .cc-tools{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap} +#map-shell .cc-label{display:flex;gap:5px;align-items:center;color:var(--cc-muted);font-size:12px} +#map-shell .cc-bar select,#map-shell .cc-trace select{max-width:180px;background:#25312c;color:var(--cc-paper);border:1px solid #627064;border-radius:3px;font:inherit;padding:5px} +#map-shell .cc-bar .cc-search-open{border-color:#627064;background:#2d3731} +#map-shell .cc-attention{display:flex;align-items:center;gap:10px;min-width:0;padding:8px 10px;background:#303831;border-left:3px solid var(--cc-brass);margin-bottom:8px;border-radius:0 4px 4px 0} +#map-shell .cc-attention.cc-calm{border-left-color:#7a9d86} +#map-shell .cc-attention-label{color:var(--cc-brass);font-size:11px;white-space:nowrap;font-weight:650;letter-spacing:.03em} +#map-shell .cc-attention-main{border:0;background:none;text-align:left;min-width:0;flex:1;padding:0;display:block} +#map-shell .cc-attention-main strong{display:block;font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#map-shell .cc-attention-main span{display:block;color:#efdfb9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#map-shell .cc-attention-meta{font-size:11px;color:var(--cc-muted);max-width:260px} +#map-shell .cc-attention-next{background:#414a3d;border:1px solid #6b775b;border-radius:3px;padding:5px 9px;white-space:nowrap} +#map-shell .cc-surface{min-width:0;overflow:auto;overscroll-behavior:contain;max-height:70vh;min-height:380px;background:linear-gradient(120deg,#26332e,#242e2b);border:1px solid var(--cc-line);border-radius:4px;padding:16px;scrollbar-color:#697865 #25312c} +#map-shell .cc-surface-head{display:flex;align-items:center;gap:12px;margin-bottom:13px;flex-wrap:wrap} +#map-shell .cc-surface-head h2{font:17px "Silkscreen",monospace;color:var(--cc-paper);margin:0} +#map-shell .cc-surface-head p{margin:2px 0 0;color:var(--cc-muted);font-size:12px} +#map-shell .cc-locate{margin-left:auto!important;border:1px solid #7e8060!important;color:#efdbaf!important;max-width:100%} +#map-shell .cc-locate:disabled{opacity:.5;cursor:default} +#map-shell .cc-filter-row{display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--cc-line);padding-bottom:10px} +#map-shell .cc-filter-row button{padding:5px 9px} +#map-shell .cc-filter-row button span{margin-left:5px;font-size:11px;color:var(--cc-brass);font-variant-numeric:tabular-nums} +#map-shell .cc-note{font-size:12px;color:var(--cc-muted);margin:10px 0 15px;overflow-wrap:anywhere} +#map-shell .cc-ledger-head,#map-shell .cc-row{display:grid;grid-template-columns:minmax(0,2.4fr) minmax(0,1fr) minmax(0,1.15fr) minmax(0,1fr);gap:16px} +#map-shell .cc-ledger-head{padding:8px 10px;color:var(--cc-brass);font-size:10px;text-transform:uppercase;letter-spacing:.07em;border-bottom:1px solid #68705a} +#map-shell .cc-row{padding:14px 10px;border-bottom:1px solid #3c4940;border-left:2px solid transparent;align-items:start} +#map-shell .cc-row:hover{background:#2e3a31} +#map-shell .cc-row.cc-selected{border-left-color:var(--cc-brass);background:#364034} +#map-shell .cc-work{display:flex;gap:9px;min-width:0} +#map-shell .cc-work>div{min-width:0} +#map-shell .cc-status{color:#9db39a;font-size:12px;padding-top:3px} +#map-shell .cc-status-blocked{color:#e5b77a} +#map-shell .cc-title{display:block;text-align:left;background:none;border:0;padding:0;color:#f5ebd3!important;font-weight:600!important;line-height:1.45!important;overflow-wrap:anywhere} +#map-shell .cc-title:hover{text-decoration:underline;text-underline-offset:3px} +#map-shell .cc-work p{margin:5px 0 0;color:var(--cc-muted);font-size:12px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +#map-shell .cc-work .cc-input-excerpt{color:#ecd09d} +#map-shell .cc-owner,#map-shell .cc-clocks,#map-shell .cc-delivery{display:flex;flex-direction:column;gap:3px;min-width:0;overflow-wrap:anywhere;font-size:12px} +#map-shell .cc-owner strong{font-weight:500} +#map-shell .cc-owner span,#map-shell .cc-owner small,#map-shell .cc-delivery small{color:var(--cc-muted);font-size:11px} +#map-shell .cc-clocks{font-variant-numeric:tabular-nums} +#map-shell .cc-clocks small{display:inline-block;min-width:58px;color:var(--cc-muted);font-size:10px} +#map-shell .cc-pr{align-self:flex-start;background:#3d4637;border:1px solid #6b7457;color:#e6d5ab!important;border-radius:3px;padding:4px 6px;font-size:11px!important;text-align:left;max-width:100%} +#map-shell .cc-tone-ready,#map-shell .cc-tone-merged{color:#bce2b5!important;border-color:#698961} +#map-shell .cc-tone-blocked{color:#f0c2ab!important;border-color:#926f56} +#map-shell .cc-thread-intro{display:flex;justify-content:space-between;gap:12px;color:var(--cc-brass);font-size:12px;flex-wrap:wrap} +#map-shell .cc-graph-head{position:relative;min-height:15px;font-size:10px;letter-spacing:.09em;text-transform:uppercase;color:var(--cc-brass);margin:15px 0 0} +#map-shell .cc-graph-head span{position:absolute;top:0} +#map-shell .cc-graph{position:relative;width:100%;overflow:hidden} +#map-shell .cc-graph svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none} +#map-shell .cc-graph svg path{stroke:#647968;fill:none;stroke-width:1.5} +#map-shell .cc-graph svg path.cc-connected{stroke:#d0b273;stroke-width:2} +#map-shell .cc-graph svg marker path{fill:#cfad70;stroke:none} +#map-shell .cc-node{display:flex;flex-direction:column;align-items:flex-start;text-align:left;border:1px solid #586b5a;background:#2b382e;border-radius:4px;padding:9px 11px;color:var(--cc-paper);text-decoration:none;min-width:0;overflow:hidden;font:inherit;line-height:1.35} +#map-shell .cc-graph .cc-node{position:absolute;height:90px} +#map-shell .cc-node strong{font-size:12px;line-height:1.4;font-weight:550;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere} +#map-shell .cc-node small{font-size:10px;color:var(--cc-brass);margin-bottom:3px} +#map-shell .cc-node span{font-size:10px;color:var(--cc-muted);margin-top:4px;overflow-wrap:anywhere} +#map-shell .cc-node.cc-selected{border-color:#c0a56b;background:#3a4433} +#map-shell .cc-node-pr{border-style:double;border-width:3px} +#map-shell .cc-node:hover{background:#3b4936} +#map-shell .cc-trace{display:flex;flex-direction:column;gap:10px;max-width:620px;margin:14px auto 0} +#map-shell .cc-trace>label{font-size:12px;color:var(--cc-brass)} +#map-shell .cc-trace select{max-width:100%;width:100%} +#map-shell .cc-trace-edge{padding:12px;border:1px solid #435343;border-radius:4px;display:flex;flex-direction:column;gap:8px} +#map-shell .cc-relation{font-size:11px;color:var(--cc-brass)} +#map-shell .cc-arrow{align-self:center;color:var(--cc-brass)} +#map-shell .cc-trace-details{border-top:1px solid var(--cc-line);margin-top:10px;padding-top:12px} +#map-shell .cc-trace-details summary{cursor:pointer;color:var(--cc-brass);font-size:12px} +#map-shell .cc-empty{padding:40px 16px;text-align:center;color:var(--cc-muted)} +#map-shell .cc-search{background:#27342d;border:1px solid #9b966b;border-radius:7px;box-shadow:0 20px 90px #0009;width:min(620px,calc(100vw - 24px));max-height:80vh;padding:16px;overflow:auto} +#map-shell .cc-search::backdrop{background:#111a18bf;backdrop-filter:blur(3px)} +#map-shell .cc-search-head{display:flex;justify-content:space-between;align-items:center;gap:10px} +#map-shell .cc-search-head label{color:var(--cc-brass);font-size:12px} +#map-shell .cc-search-close{border:1px solid #69775d;border-radius:3px;background:none;padding:3px 8px} +#map-shell .cc-search input{width:100%;padding:12px;margin:10px 0;background:#1d2823;border:1px solid #718466;border-radius:4px;color:var(--cc-paper);font:inherit;box-sizing:border-box} +#map-shell .cc-search-results{display:flex;flex-direction:column;gap:3px} +#map-shell .cc-search-result{padding:10px;border:1px solid transparent;border-radius:3px;background:none;text-align:left;display:flex;flex-direction:column;gap:3px;overflow-wrap:anywhere} +#map-shell .cc-search-result[aria-selected=true]{background:#42513a;border-color:#bba16b} +#map-shell .cc-search-result small{color:var(--cc-muted)} +#map-shell .cc-preview{position:fixed;z-index:500;width:min(330px,calc(100vw - 24px));max-height:50vh;overflow:auto;border:1px solid #a29267;border-radius:5px;padding:12px;background:#253329;box-shadow:0 10px 35px #0007;pointer-events:none} +#map-shell .cc-preview strong,#map-shell .cc-preview span,#map-shell .cc-preview small{display:block} +#map-shell .cc-preview strong{color:#f3dfb4;font-size:12px;margin-bottom:4px} +#map-shell .cc-preview p{font-size:12px;margin:7px 0} +#map-shell .cc-preview small{color:var(--cc-muted);font-size:11px;margin-top:3px} +#map-shell .cc-bar :focus-visible,#map-shell .cc-attention :focus-visible,#map-shell .cc-surface :focus-visible,#map-shell .cc-search :focus-visible{outline:2px solid #efcb7b;outline-offset:3px} +#map-shell[data-command-view=dispatch] .cc-surface,#map-shell[data-command-view=threads] .cc-surface{animation:cc-appear .16s ease-out} +@keyframes cc-appear{from{opacity:.5;transform:translateY(3px)}to{opacity:1;transform:none}} +@media(max-width:1050px){#map-shell .cc-ledger-head,#map-shell .cc-row{grid-template-columns:minmax(0,1.8fr) minmax(0,1fr) minmax(0,1fr)}#map-shell .cc-ledger-head span:last-child{display:none}#map-shell .cc-delivery{grid-column:1/-1;flex-direction:row;align-items:center;gap:12px}#map-shell .cc-delivery small{flex:1}#map-shell .cc-attention-meta{max-width:150px}} +@media(max-width:700px){#map-shell .cc-tools{margin-left:0;width:100%}#map-shell .cc-tools .cc-search-open{margin-left:auto}#map-shell .cc-attention{flex-wrap:wrap;gap:6px 9px}#map-shell .cc-attention-label{flex-basis:100%}#map-shell .cc-attention-meta{max-width:none;flex-basis:100%}#map-shell .cc-surface{padding:12px;max-height:72vh}#map-shell .cc-ledger-head{display:none}#map-shell .cc-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px;padding:14px 6px}#map-shell .cc-work{grid-column:1/-1}#map-shell .cc-clocks{font-size:11px}#map-shell .cc-bar .cc-views button{padding:5px 8px}#map-shell .cc-label select{max-width:140px}#map-shell .cc-key{display:none}} +@media(prefers-reduced-motion:reduce){#map-shell .cc-surface{animation:none!important}} + +/* Workbenches inhabit the same place. The scene never disappears. */ +#tally [data-town-scope]{display:none} +#map-shell .cc-pulse-rail{display:flex;gap:6px;overflow:auto;padding:2px 0 8px;scrollbar-width:thin;scrollbar-color:#65745d transparent;font-family:ui-sans-serif,system-ui} +#map-shell .cc-pulse-rail button{position:relative;flex:0 0 auto;min-width:110px;display:grid;grid-template-columns:22px 1fr;gap:2px 7px;text-align:left;padding:7px 11px;border:1px solid #4c5b4a;border-radius:4px;background:#26352f;color:var(--cc-paper);cursor:pointer} +#map-shell .cc-pulse-rail button[aria-pressed=true]{border-color:#c7ad75;background:#3a4537;box-shadow:inset 0 -2px #c7ad75} +#map-shell .cc-pulse-rail strong{font-size:11px;font-weight:550} +#map-shell .cc-pulse-rail small{grid-column:2;font-size:10px;color:#b2bca9} +#map-shell .cc-heart,#map-shell .cc-pulse-lamps{grid-row:1/3;align-self:center;color:#c4c59a} +#map-shell .cc-pulse-lamps{display:flex;gap:2px;align-items:end;height:18px} +#map-shell .cc-pulse-lamps i{width:3px;height:5px;background:#617263} +#map-shell .cc-pulse-lamps i:nth-child(2),#map-shell .cc-pulse-lamps i:nth-child(4){height:10px} +#map-shell .cc-pulse-lamps i:nth-child(3){height:15px} +#map-shell .cc-pulse-lamps .lit{background:#bfce92;box-shadow:0 0 5px #d7d89133} +#map-shell .cc-alive .cc-pulse-lamps{animation:cc-breathe 3.6s ease-in-out infinite} +#map-shell .cc-faded .cc-pulse-lamps{opacity:.45} +@keyframes cc-breathe{0%,100%{opacity:.55}50%{opacity:1}} +#map-shell .cc-field-guide{font:11px/1.6 ui-sans-serif,system-ui;color:#b4bcac;padding:0 0 7px} +#map-shell .cc-field-guide summary{cursor:pointer;width:fit-content;color:#d0bf92} +#map-shell .cc-field-guide summary span{color:#a3b0a2} +#map-shell .cc-guide-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 20px;background:#27342e;padding:12px 16px;border:1px solid #4e5e4b;margin-top:6px;border-radius:4px} +#map-shell .cc-guide-grid p{margin:0} +#map-shell .cc-guide-grid b{display:block;color:#e7d9b6;font-weight:550} +#map-shell .cc-guide-foot{margin:7px 0 2px;color:#c2c6af} +#map-shell .cc-balance[hidden]{display:none} +#map-shell .cc-balance{display:flex;align-items:center;gap:12px;padding:6px 10px;background:#303b32;border-block:1px solid #677052;color:#dcd3b6;font:11px/1.4 ui-sans-serif,system-ui;min-width:0} +#map-shell .cc-workbench-name{font-weight:650;margin-right:auto} +#map-shell .cc-balance label{display:flex;align-items:center;gap:8px;white-space:nowrap} +#map-shell .cc-balance input{width:110px;accent-color:#d5bd7d;cursor:ew-resize;margin:0} +#map-shell .cc-balance output{font-size:10px;width:58px;font-variant-numeric:tabular-nums} +#map-shell .cc-balance button{background:transparent;border:0;color:#dcd3b6;cursor:pointer;padding:3px 5px} +#map-shell .cc-balance :focus-visible,#map-shell .cc-pulse-rail :focus-visible{outline:2px solid #efcb7b;outline-offset:2px} +#map-shell:not([data-command-view=town]) #perspective-stage{display:grid;height:clamp(400px,calc(100dvh - 360px),880px);grid-template-rows:minmax(0,var(--cc-scene-share,55fr)) auto minmax(0,var(--cc-desk-share,45fr));overflow:hidden;border:1px solid #4d5f50;border-radius:4px} +#map-shell:not([data-command-view=town]) .map-viewport{height:100%;min-height:0;border-radius:0} +#map-shell:not([data-command-view=town]) #command-view{position:relative;inset:auto;min-height:0;max-height:none;border:0;border-radius:0;padding:12px;background:linear-gradient(115deg,#2a382f,#26322d)} +#map-shell.is-fullscreen:not([data-command-view=town]) #perspective-stage{height:auto;min-height:0} +#map-shell.is-fullscreen:not(.details-closed):not([data-command-view=town]) #perspective-stage{margin-right:364px} +#map-shell:not([data-command-view=town]) .cc-surface-head{margin-bottom:6px} +#map-shell:not([data-command-view=town]) .cc-surface-head h2{display:none} +#map-shell:not([data-command-view=town]) .cc-note{margin:6px 0;font-size:11px} +#map-shell:not([data-command-view=town]) .cc-row{padding:10px 8px} +#map-shell:not([data-command-view=town]) .cc-work p{-webkit-line-clamp:2} +@media(max-width:700px){#map-shell .cc-workbench-name{font-size:10px}#map-shell .cc-balance{gap:5px;padding:6px}#map-shell .cc-balance label{font-size:9px;gap:3px}#map-shell .cc-balance input{width:75px}#map-shell .cc-balance output{width:49px}#map-shell .cc-balance button span{display:none}#map-shell .cc-guide-grid{grid-template-columns:1fr}#map-shell:not([data-command-view=town]) #perspective-stage{height:660px}} +@media(max-width:600px){#map-shell.is-fullscreen:not(.details-closed):not([data-command-view=town]) #perspective-stage{margin-right:0}#map-shell.is-fullscreen:not(.details-closed):not([data-command-view=town]) .details-shell{top:auto;bottom:12px;max-height:45dvh}} +@media(prefers-reduced-motion:reduce){#map-shell .cc-alive .cc-pulse-lamps{animation:none}} +#map-shell .cc-trades{margin:4px 0;color:#e4dfc2;border:1px solid #657762;border-radius:8px;background:#283b33;font-size:11px} +#map-shell .cc-trades summary{cursor:pointer;padding:7px 10px;min-height:28px;line-height:1.5} +#map-shell .cc-trades summary span{margin-left:8px;color:#bcc9a8;font-size:10px} +#map-shell .cc-trade-body{padding:0 10px 9px;max-height:260px;overflow:auto} +#map-shell .cc-trade-stories{display:flex;flex-wrap:wrap;gap:5px;margin:7px 0} +#map-shell .cc-trades button{border:1px solid #718369;border-radius:5px;background:#354b3c;color:#efead1;padding:6px 8px;font:inherit;cursor:pointer} +#map-shell .cc-trades button[aria-pressed=true]{background:#5b714f;border-color:#c4c798} +#map-shell .cc-trades button:focus-visible,#map-shell .cc-trades summary:focus-visible{outline:2px solid #efd397;outline-offset:2px} +#map-shell .cc-trade-story{border-left:2px solid #b5bb83;padding:5px 9px;margin:7px 0;line-height:1.5} +#map-shell .cc-trade-story p{margin:4px 0;overflow-wrap:anywhere} +`; diff --git a/src/command-workbench.mjs b/src/command-workbench.mjs new file mode 100644 index 0000000..0bddf2d --- /dev/null +++ b/src/command-workbench.mjs @@ -0,0 +1,59 @@ +/** Small, truthful summaries shared by the living town's controls. */ +import {validTime} from './feed-client.mjs'; +export function townPulses(tasks) { + const towns=new Map(); + for(const task of tasks){ + const name=task.raw.town||task.raw.role||'WildTown'; + if(!towns.has(name))towns.set(name,{name,total:0,working:0,attention:0,quiet:0,stale:false,ready:new Set()}); + const town=towns.get(name);town.total++;town.working+=Number(task.status==='working'); + town.attention+=Number(task.attention);town.quiet+=Number(task.quiet);town.stale||=task.sourceStale; + if(task.prStage==='ready'&&task.prKey)town.ready.add(task.prKey); + } + return [...towns.values()].map(town=>({...town,ready:town.ready.size})); +} +export function townPulseLabel(town) { + return [town.stale?'Last seen':town.working?`${town.working} working`:'Resting', + town.attention?`${town.attention} check`:'',town.ready?`${town.ready} ready`:''].filter(Boolean).join(' · '); +} +/** Pick within the already scoped fleet; locating never changes task state. */ +export function activityVisit(tasks,selectedId=null,now=Date.now()) { + const working=tasks.filter(task=>task.status==='working'&&!task.sourceStale&&!task.quiet); + let recent=null,latestAt=-Infinity; + for(const task of working){ + const tool=task.raw?.toolActivity,at=validTime(tool?.at); + if(!tool||tool.stale||typeof tool.name!=='string'||!tool.name.trim()||!['started','succeeded'].includes(tool.state)|| + at===null||at>now||now-at>=120000)continue; + if(at>latestAt){recent=task;latestAt=at;} + } + if(recent)return {task:recent,reason:'working cottage with recent tool activity'}; + if(working.length)return {task:working[0],reason:'working cottage'}; + const input=tasks.find(task=>task.input); + if(input)return {task:input,reason:'cottage awaiting input'}; + const ready=tasks.find(task=>task.prStage==='ready'); + if(ready)return {task:ready,reason:'cottage with a ready PR'}; + const selected=tasks.find(task=>task.id===selectedId); + return selected?{task:selected,reason:'selected cottage'}:null; +} +export function sceneShare(value) { + const number=Number(value); + return Number.isFinite(number)?Math.max(30,Math.min(75,Math.round(number))):55; +} +/** Keep the control outside generated/streaming markup so dragging never loses its node. */ +export function bindSceneBalance({input,output,root,onResize=()=>{}}) { + const apply=()=>{ + const value=sceneShare(input.value);input.value=String(value); + root.style.setProperty('--cc-scene-share',`${value}fr`); + root.style.setProperty('--cc-desk-share',`${100-value}fr`); + output.textContent=`${value}% town`; + input.setAttribute('aria-valuetext',`${value}% town, ${100-value}% workbench`); + onResize(value); + }; + input.addEventListener('input',apply);input.addEventListener('change',apply);apply(); + return ()=>{input.removeEventListener('input',apply);input.removeEventListener('change',apply);}; +} +export function projectBounds(plots,town) { + const matching=plots.filter(plot=>!town||(plot.agent.town||plot.agent.role||'WildTown')===town); + if(!matching.length)return null; + const x=Math.min(...matching.map(p=>p.x))-24,y=Math.min(...matching.map(p=>p.y))-36; + return {x,y,w:Math.max(...matching.map(p=>p.x+78))-x,h:Math.max(...matching.map(p=>p.y+146))-y}; +} diff --git a/test/command-center-ui.test.mjs b/test/command-center-ui.test.mjs new file mode 100644 index 0000000..0034137 --- /dev/null +++ b/test/command-center-ui.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fleetView,taskView} from '../src/command-model.mjs'; +import {attentionQueue,dispatchHTML,threadsHTML,graphData,searchTasks,previewHTML} from '../src/command-render.mjs'; +import {drawPlotLens,lensContext,lensLabel} from '../src/command-center.mjs'; + +const now=Date.parse('2026-11-04T06:00:00Z'); +const agent=(id,extra={})=>({id,taskId:id,task:`Work ${id}`,town:'HubTown',status:'working',inventoryScope:'dashboard',harness:'Codex',model:'GPT-6.1',updatedAt:now,taskStartedAt:now-3600000,...extra}); +const view=raw=>taskView(raw,{now}); +const pr={url:'https://github.com/example/project/pull/42',number:42,state:'open',authority:'hub',reviewState:'ready',stage:'ready',checkedAt:now,headSha:'1234567',reason:'Recorded checks passed'}; + +test('dispatch escapes all source strings and keeps clocks independent',()=>{ + const task=view(agent('x" onclick="bad',{task:'