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 workbench55% town'; + 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='Read the village · a field guide to its heartbeat

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,`
${VIEWS.map((view,index)=>``).join('')}
${state.lens!=='village'?``:''}`); + const context=bar.querySelector('[data-cc-lens-context]'); + if(context){const text=lensContext(taskById(state.selectedId),state.lens);if(context.textContent!==text)context.textContent=text;} + } + function renderAttention(){ + attentionIndex=queue.length?attentionIndex%queue.length:0; + const task=queue[attentionIndex];attention.classList.toggle('cc-calm',!task); + const tab=task?.input?'talk':task?.prStage==='blocked'?'review':'overview'; + replace(attention,`${queue.length?`${queue.length} need attention`:'Village in view'}${task?``:`${scoped.counts.working} working · ${scoped.counts.ready} ready PRs`}${e(sourceNote())}${queue.length?` · ${scoped.counts.answerable} answerable here`:''}${queue.length>1?``:''}`); + } + function renderSurface(){ + if(state.view==='town')return; + const previewKey=previewTarget?.getAttribute('data-cc-key'); + const title=state.view==='dispatch'?'Dispatch':'Threads'; + const selected=taskById(state.selectedId); + replace(surface,`

${title}

${e(state.townFilter||'All towns')} · ${scoped.counts.tasks} cottages · ${e(sourceNote())}

${state.view==='dispatch'?dispatchHTML(scoped.tasks,{...state,counts:scoped.counts}):threadsHTML(scoped.tasks,scoped,{selectedId:state.selectedId,width:surface.clientWidth-32})}`); + if(previewKey){const target=[...surface.querySelectorAll('[data-cc-pr]')].find(node=>node.getAttribute('data-cc-key')===previewKey);if(target)showPreview(target);else hidePreview();} + } + function renderPulse(){ + const towns=townPulses(fleet.tasks); + replace(pulse,`${towns.map(town=>``).join('')}`); + } + function focusTown(town){state.townFilter=town||null;onTownFilter(state.townFilter||'');refreshScope();render();} + function renderTrades(){ + if(selectedTradeId&&!scopedTrades(options.trades,state.townFilter).stories.some(s=>s.id===selectedTradeId))selectedTradeId=null; + replace(tradesPanel,tradePanelHTML({trades:options.trades,tasks:fleet.tasks,townFilter:state.townFilter,stale:options.stale||scoped.stale,illustrative:options.builtInDemo||options.publicDemo,selectedId:selectedTradeId,canRehearseGrowth:options.canRehearseGrowth})); + } + function inspectTrade(site){ + const story=tradeStoryForSite(site,options.trades,state.townFilter); + selectedTradeId=story?.id||null;renderTrades();tradesPanel.open=true; + tradesPanel.querySelector('.cc-trade-story')?.scrollIntoView({block:'nearest'}); + } + function render(){renderTrades();renderBar();renderPulse();renderAttention();renderSurface();if(dialog.open)renderSearch();} + function inspect(id,tab='overview'){ + if(!taskById(id))return; + state.selectedId=id;hidePreview();render();onInspect(id,tab); + } + function setView(view){ + if(!VIEWS.includes(view)||view===state.view)return; + state.view=view;hidePreview();surface.hidden=view==='town';balance.hidden=view==='town';balance.querySelector('.cc-workbench-name').textContent=`${label(view)} workbench`;root.dataset.commandView=view;render();onViewChange(view); + } + function hidePreview(){preview.hidden=true;previewTarget?.removeAttribute('aria-describedby');previewTarget=null;} + function showPreview(target){ + const task=taskById(target?.dataset.ccPr);if(!task||target?.getAttribute('data-cc-key')===previewDismissedKey)return; + hidePreview();previewTarget=target;preview.innerHTML=previewHTML(task);preview.hidden=false;target.setAttribute('aria-describedby',preview.id); + const rect=target.getBoundingClientRect(),box=preview.getBoundingClientRect(); + preview.style.left=`${Math.max(12,Math.min(rect.left,(win?.innerWidth||900)-box.width-12))}px`; + preview.style.top=`${Math.max(12,Math.min(rect.bottom+8,(win?.innerHeight||700)-box.height-12))}px`; + } + function renderSearch(){ + const previous=searchResults[searchIndex]?.id;searchResults=searchTasks(scoped.tasks,searchInput.value); + const same=searchResults.findIndex(task=>task.id===previous);searchIndex=Math.max(0,Math.min(same>=0?same:searchIndex,searchResults.length-1)); + resultsNode.innerHTML=searchResults.map((task,index)=>``).join('')||'

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:'',activity:'',harness:'',model:'" autofocus',taskStartedAt:null,sessionStartedAt:now-7200000,durationMs:60000})); + const html=dispatchHTML([task]); + assert.ok(!html.includes(''));assert.ok(!html.includes('Task<\/small> Unavailable/);assert.match(html,/Execution<\/small> 1m 0s/);assert.match(html,/Session<\/small> 2h 0m/); +}); + +test('attention puts answerable recorded input before source-only input and blocked delivery',()=>{ + const blocked=view(agent('blocked',{status:'blocked',pr:{...pr,stage:'blocked',reviewState:'blocked'}})); + const source=view(agent('source',{inputRequest:{prompt:'Open the original source?'}})); + const answer={...view(agent('answer',{inputRequest:{prompt:'Keep seven days?'}})),capability:{available:true,mode:'respond'}}; + const working=view(agent('working')); + assert.deepEqual(attentionQueue([blocked,source,working,answer]).map(task=>task.id),['answer','source','blocked']); +}); + +test('search matches multiple words across title, town, explicit owner and PR identity',()=>{ + const tasks=[view(agent('a',{task:'Repair read receipts',owner:'Mira',pr})),view(agent('b',{task:'Repair width',town:'MemTown'}))]; + assert.deepEqual(searchTasks(tasks,'MIRA 42').map(task=>task.id),['a']); + assert.deepEqual(searchTasks(tasks,'repair mem').map(task=>task.id),['b']); + assert.equal(searchTasks(tasks,'absent').length,0); +}); + +test('thread graph deduplicates shared delivery and keeps prerequisite direction',()=>{ + const fleet=fleetView({agents:[agent('prerequisite',{pr}),agent('dependent',{pr,parent:'prerequisite'})],relationships:[{from:'task:prerequisite',to:'task:dependent',kind:'depends-on'},{from:'task:missing',to:'task:dependent',kind:'depends-on'}]},{now}); + const {nodes,edges}=graphData(fleet.tasks,fleet); + assert.equal([...nodes.values()].filter(node=>node.kind==='pr').length,1); + assert.deepEqual(edges.filter(edge=>edge.kind==='depends-on'),[{from:'task:prerequisite',to:'task:dependent',kind:'depends-on'}]); + const html=threadsHTML(fleet.tasks,fleet,{selectedId:'dependent',width:400}); + assert.match(html,/prerequisite → dependent/);assert.match(html,/Issue links are not recorded/);assert.match(html,/1 distinct PRs/); + assert.ok(!html.includes('{ + const raw=agent('a',{issues:[{title:'',url:'javascript:alert(1)'},{title:'',url:'https://example.org/issue/1'}]}); + const fleet=fleetView([raw],{now}); + const html=threadsHTML(fleet.tasks,fleet,{width:900}); + assert.ok(!html.includes('javascript:'));assert.match(html,/href="https:\/\/example.org\/issue\/1"/);assert.match(html,/<safe>/); + const illustrative=fleetView([{...raw,illustrative:true}],{now}); + assert.ok(!threadsHTML(illustrative.tasks,illustrative,{width:900}).includes('href=')); +}); + +test('PR preview shows available evidence without inventing a check result',()=>{ + const html=previewHTML(view(agent('a',{pr:{...pr,reason:'',checks:[]}}))); + assert.match(html,/CI unavailable/);assert.match(html,/Hub authority/);assert.match(html,/<review pending>/); +}); + +function canvas(){ + const calls=[],stack=[]; + const ctx={globalAlpha:.4,font:'original',fillStyle:'pink',textBaseline:'alphabetic',textAlign:'left',measureText:text=>({width:text.length*4}), + save(){stack.push({globalAlpha:this.globalAlpha,font:this.font,fillStyle:this.fillStyle,textBaseline:this.textBaseline,textAlign:this.textAlign});}, + restore(){Object.assign(this,stack.pop());},fillRect(...args){calls.push(['rect',...args]);},fillText(...args){calls.push(['text',...args]);}}; + return {ctx,calls,stack}; +} + +test('lens annotates existing household and child positions, restores canvas state, and obeys dimming',()=>{ + const a=agent('a'),roommate=agent('b',{harness:'Claude Code'}),kid=agent('kid',{harness:'Cursor'}),other=agent('other',{town:'MemTown'}); + const plots=[{agent:{...a,roommates:[roommate]},x:20,y:60,w:54,kids:[{agent:kid,x:30,y:150}]},{agent:other,x:400,y:90}]; + const before=JSON.stringify(plots),{ctx,calls,stack}=canvas(); + drawPlotLens(ctx,plots,[a,roommate,kid,other].map(view),'owner',{selectedId:'b',townFilter:'HubTown',visible:raw=>raw.id!=='kid'}); + assert.equal(JSON.stringify(plots),before);assert.equal(stack.length,0); + assert.equal(ctx.globalAlpha,.4);assert.equal(ctx.font,'original');assert.equal(ctx.fillStyle,'pink'); + const labels=calls.filter(call=>call[0]==='text');assert.deepEqual(labels,[['text','Claude Code +1',47,50]]); +}); + +test('village lens performs no drawing and missing clocks stay unavailable',()=>{ + const {ctx,calls}=canvas(),task=view(agent('a',{taskStartedAt:null,sessionStartedAt:now-60000})); + drawPlotLens(ctx,[{agent:task.raw,x:0,y:0}],[task],'village');assert.equal(calls.length,0); + assert.equal(lensLabel(task,'time'),'Unavailable');assert.match(lensContext(task,'time'),/Task Unavailable · Execution Unavailable · Session 1m 0s/); +}); + +test('Threads fills two actual lanes when issue evidence is absent and aligns delivery arrows',()=>{ + const fleet=fleetView([agent('a',{pr})],{now}); + const html=threadsHTML(fleet.tasks,fleet,{width:900}); + const header=html.match(/
]*>(.*?)<\/div>/s)?.[1]; + assert.equal(header,'CottagesPull requests'); + assert.match(html,/left:1%;top:20px;width:45%/);assert.match(html,/left:52%;top:20px;width:45%/); + assert.match(html,/d="M414,62 C441,62 441,62 468,62"/); +}); + +test('Threads retains three evidence lanes and removes absent PR lanes',()=>{ + const issue={title:'Recorded issue',url:'https://example.org/issue/1'}; + const full=fleetView([agent('a',{issues:[issue],pr})],{now}); + const fullHTML=threadsHTML(full.tasks,full,{width:900}); + assert.match(fullHTML,/Issues<\/span>Cottages<\/span>Pull requests<\/span>/); + const noPr=fleetView([agent('a',{issues:[issue]})],{now}); + const noPrHTML=threadsHTML(noPr.tasks,noPr,{width:900}); + assert.match(noPrHTML,/Issues<\/span>Cottages<\/span>/); + assert.ok(!noPrHTML.includes('>Pull requests')); +}); + +test('parent-only Threads uses one lane with same-lane arrows preserving direction',()=>{ + const fleet=fleetView([agent('parent'),agent('child',{parent:'parent'})],{now}); + const html=threadsHTML(fleet.tasks,fleet,{width:900,selectedId:'child'}); + assert.match(html,/Cottages<\/span>/); + assert.match(html,/d="M873,62 C897,62 897,190 873,190"/); + assert.match(html,/Work parent — parent → child — Work child/); + assert.match(html,/value="child" selected/); +}); + +test('search finds recorded cottage names and linked issue titles, numbers and URLs',()=>{ + const tasks=[view(agent('a',{name:'Bolt',issues:[{title:'Retain the receipt cursor',number:701,url:'https://example.org/issues/701'}]})),view(agent('b',{name:'Fern'}))]; + for(const query of ['bolt','receipt cursor','701','example.org/issues/701'])assert.deepEqual(searchTasks(tasks,query).map(task=>task.id),['a']); + assert.deepEqual(searchTasks(tasks,'fern').map(task=>task.id),['b']); + assert.equal(searchTasks(tasks,'702').length,0); +}); diff --git a/test/command-workbench.test.mjs b/test/command-workbench.test.mjs new file mode 100644 index 0000000..98d4080 --- /dev/null +++ b/test/command-workbench.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {townPulses,townPulseLabel,activityVisit,bindSceneBalance,projectBounds} from '../src/command-workbench.mjs'; +import {fleetView} from '../src/command-model.mjs'; + +test('project pulse deduplicates shared PRs and preserves stale and quiet evidence',()=>{ + const now=Date.parse('2026-11-05T00:00:00Z'); + const pr={url:'https://github.com/example/repo/pull/2',number:2,authority:'hub',state:'open',reviewState:'ready',checkedAt:now}; + const agents=[{id:'a',town:'MemTown',status:'working',lastProgressAt:now-20*60000,pr},{id:'b',town:'MemTown',status:'blocked',pr},{id:'c',town:'HubTown',status:'idle',isStale:true}]; + const pulses=townPulses(fleetView(agents,{now,includeSettled:true}).tasks); + assert.deepEqual(pulses,[{name:'MemTown',total:2,working:1,attention:1,quiet:1,stale:false,ready:1},{name:'HubTown',total:1,working:0,attention:0,quiet:0,stale:true,ready:0}]); +}); + +test('balance drag and keyboard change update both layout shares and accessible output immediately',()=>{ + const listeners=new Map(),styles=new Map(),attrs=new Map(),changes=[]; + const input={value:'55',addEventListener:(kind,fn)=>listeners.set(kind,fn),removeEventListener:kind=>listeners.delete(kind),setAttribute:(key,value)=>attrs.set(key,value)}; + const output={textContent:''}; + const dispose=bindSceneBalance({input,output,root:{style:{setProperty:(key,value)=>styles.set(key,value)}},onResize:value=>changes.push(value)}); + for(const [value,expected] of [['30',30],['72',72],['200',75],['invalid',55]]){ + input.value=value;listeners.get('input')(); + assert.equal(styles.get('--cc-scene-share'),`${expected}fr`); + assert.equal(styles.get('--cc-desk-share'),`${100-expected}fr`); + assert.equal(output.textContent,`${expected}% town`); + assert.equal(attrs.get('aria-valuetext'),`${expected}% town, ${100-expected}% workbench`); + } + input.value='63';listeners.get('change')();assert.equal(changes.at(-1),63); + dispose();assert.equal(listeners.size,0); +}); + +test('project focus includes resting cottages and never mutates plot geometry',()=>{ + const plots=[{x:100,y:100,agent:{town:'MemTown',status:'working'}},{x:300,y:400,agent:{town:'MemTown',status:'idle'}},{x:900,y:100,agent:{town:'HubTown'}}]; + const before=JSON.stringify(plots); + assert.deepEqual(projectBounds(plots,'MemTown'),{x:76,y:64,w:302,h:482}); + assert.equal(projectBounds(plots,'MissingTown'),null); + assert.equal(JSON.stringify(plots),before); +}); + +test('project pulse retains ready PRs alongside attention counts',()=>{ + assert.equal(townPulseLabel({working:1,attention:25,ready:6}),'1 working · 25 check · 6 ready'); + assert.equal(townPulseLabel({stale:true,working:1,attention:25,ready:6}),'Last seen · 25 check · 6 ready'); + assert.equal(townPulseLabel({working:0,attention:0,ready:0}),'Resting'); +}); + +test('Visit activity prefers fresh working then input then ready then exact selected cottage',()=>{ + const selected={id:'selected'},ready={id:'ready',prStage:'ready'},input={id:'input',input:{prompt:'Help?'}}, + stale={id:'stale',status:'working',sourceStale:true},quiet={id:'quiet',status:'working',quiet:true},working={id:'working',status:'working'}; + const tasks=[selected,ready,input,stale,quiet,working],before=structuredClone(tasks); + assert.equal(activityVisit(tasks,'selected').task,working); + assert.equal(activityVisit(tasks.slice(0,-1),'selected').task,input); + assert.equal(activityVisit([selected,ready,stale,quiet],'selected').task,ready); + assert.equal(activityVisit([selected,stale,quiet],'selected').task,selected); + assert.equal(activityVisit([stale,quiet],'not-in-this-town'),null); + assert.equal(activityVisit([]),null);assert.deepEqual(tasks,before); +}); + +test('Visit activity prefers the latest recent tool evidence and preserves input order on ties',()=>{ + const now=Date.parse('2026-11-06T12:00:00Z'); + const working=(id,toolActivity)=>({id,status:'working',raw:{toolActivity}}); + const unreported=working('unreported'),older=working('grep',{name:'Grep',state:'succeeded',at:now-30000}), + latest=working('bash',{name:'Bash',state:'started',at:new Date(now-5000).toISOString()}), + tied=working('shell',{name:'shell',state:'succeeded',at:now-5000}); + const tasks=[unreported,older,latest,tied],before=structuredClone(tasks); + assert.equal(activityVisit(tasks,null,now).task,latest); + assert.equal(activityVisit([unreported,tied,latest],null,now).task,tied); + assert.deepEqual(tasks,before); +}); + +test('Visit activity never prefers untrusted tool evidence over the existing working fallback',()=>{ + const now=Date.parse('2026-11-06T12:00:00Z'); + const fallback={id:'first-working',status:'working'}; + const valid={name:'Bash',state:'succeeded',at:now-1000}; + const invalidTools=[ + {...valid,state:'failed'},{...valid,state:'cancelled'},{...valid,state:'unknown'}, + {...valid,at:now+1},{...valid,at:'invalid'},{...valid,at:null},{...valid,at:0}, + {...valid,at:now-120000},{...valid,at:now-120001},{...valid,stale:true}, + {...valid,name:''},{...valid,name:' '},{...valid,name:42}, + ]; + for(const toolActivity of invalidTools){ + const candidate={id:'candidate',status:'working',raw:{toolActivity}}; + assert.equal(activityVisit([fallback,candidate],null,now).task,fallback,JSON.stringify(toolActivity)); + } + for(const extra of [{sourceStale:true},{quiet:true},{status:'idle'},{status:'blocked'}]){ + const candidate={id:'candidate',status:'working',raw:{toolActivity:valid},...extra}; + assert.equal(activityVisit([fallback,candidate],null,now).task,fallback,JSON.stringify(extra)); + } + for(const at of [now,now-119999]){ + const candidate={id:'candidate',status:'working',raw:{toolActivity:{...valid,at}}}; + assert.equal(activityVisit([fallback,candidate],null,now).task,candidate); + } +});