Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/dashboard-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const DASHBOARD_HEAD = `<!DOCTYPE html>
details summary::-webkit-details-marker{display:none}details summary{list-style:none}
:focus-visible{outline:2px solid rgba(96,165,250,.85);outline-offset:2px}
select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235e6a82' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:22px}
.project-link[aria-current="true"]{color:#e2e8f0}
.team-link[aria-current="true"]{color:#e2e8f0;border-left-color:#22c55e;background:rgba(34,197,94,.06)}
#content.nav-collapsed{grid-template-columns:2rem minmax(0,1fr)}
#projects[hidden]{display:none!important}
#project-rail[hidden]{display:none!important}
.xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
.card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
.md pre{background:#1a1f2e;border:1px solid #1e2433;border-radius:6px;padding:8px 12px;overflow-x:auto;margin:6px 0;font-size:12px;line-height:1.5}
Expand All @@ -47,8 +52,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
<header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-3 sm:px-4 z-50">
<div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<span class="font-mono font-semibold text-[13px] tracking-[.08em] text-txt-200">ensemble</span>
<span class="text-base-700">|</span>
<select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors max-w-[180px] sm:max-w-[320px] min-w-0"></select>
<span id="crumb" class="text-[11px] text-txt-500 font-mono truncate"></span>
</div>
<div class="flex items-center gap-2 sm:gap-4 shrink-0">
<div id="hring" class="w-6 h-6 rounded-full" title="Team health"></div>
Expand All @@ -67,7 +71,10 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
<div class="text-txt-400 text-sm">Waiting for a team</div>
<div class="text-txt-500 text-[11px]">Run <code class="px-1.5 py-0.5 bg-base-900 rounded text-txt-300 font-mono text-[11px]">team_create</code> in OpenCode to get started</div>
</div>
<div id="content" class="hidden max-w-[1600px] mx-auto">
<div id="content" class="hidden max-w-[1720px] mx-auto grid grid-cols-1 lg:grid-cols-[260px_minmax(0,1fr)] gap-4 items-start">
<div id="project-rail" hidden class="lg:sticky lg:top-[88px]"><button id="nav-expand" type="button" aria-label="Show project navigation" aria-controls="projects" aria-expanded="false" class="h-8 w-8 rounded border border-base-800 text-txt-500 hover:text-txt-200 hover:border-base-700 transition-colors">&gt;</button></div>
<aside id="projects" aria-label="Project navigation" class="bg-base-950/60 border-r border-base-800/70 pr-3 lg:sticky lg:top-[88px]"></aside>
<div class="min-w-0">
<section id="attention" aria-label="Team attention" class="mb-3"></section>
<div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
<section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
Expand All @@ -77,6 +84,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
</div>
</div>
</div>
</div>
</main>
<div id="tl" aria-label="Event timeline" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
<div id="sco" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick="closeShortcuts()">
Expand Down
14 changes: 11 additions & 3 deletions src/dashboard-js-part1.ts → src/dashboard-js-core.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Dashboard JS — utilities, data helpers, and state management. */
export const DASHBOARD_JS_PART1 = `
let S=null,selId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1;
export const DASHBOARD_JS_CORE = `
let S=null,selId=null,selProjectId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1,navCollapsed=false;
const expCards=new Set(),expMsgs=new Set();
const E=s=>s?String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';
const D=ms=>{const s=Math.floor(Math.abs(ms)/1000);return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
Expand Down Expand Up @@ -63,13 +63,21 @@ function allTeams(){
const archived=[...S.teams.filter(t=>t.status!=='active')].sort((a,b)=>b.timeUpdated-a.timeUpdated);
return{active,archived};
}
function cur(){const{active,archived}=allTeams(),all=[...active,...archived];if(!all.length)return null;if(selId){const t=all.find(t=>t.id===selId);if(t)return t}return active[0]||all[0]}
function allProjects(){return S?.projects?[...S.projects].sort((a,b)=>b.timeUpdated-a.timeUpdated):[]}
function projectLabel(p){return p?(p.name||p.path||p.id):''}
function curProject(){const ps=allProjects();if(!ps.length)return null;if(selProjectId){const p=ps.find(p=>p.id===selProjectId);if(p)return p}var t=cur();return t?ps.find(p=>p.id===t.projectId)||ps[0]:ps[0]}
function cur(){const{active,archived}=allTeams(),all=[...active,...archived];if(!all.length)return null;if(selId){const t=all.find(t=>t.id===selId);if(t){selProjectId=t.projectId;return t}}const p=selProjectId&&S?.projects?.find(p=>p.id===selProjectId);const pt=p?[...(p.teams||[])].filter(t=>t.status==='active'):[ ];const t=pt.sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||active[0]||all[0];if(t){selId=t.id;selProjectId=t.projectId}return t}

function deriveHealth(t){
const mm=t.members||[];if(!mm.length)return{w:0,i:0,e:0,d:0,total:0};
return{w:mm.filter(m=>m.status==='busy').length,i:mm.filter(m=>m.status==='ready').length,e:mm.filter(m=>m.status==='error').length,d:mm.filter(m=>m.status==='shutdown'||m.status==='shutdown_requested').length,total:mm.length};
}

function coarseTeamStatus(t){const h=deriveHealth(t),blocked=(t.tasks||[]).filter(x=>x.status==='blocked').length;if(h.e)return{label:'error',color:'red',dot:'bg-red-500'};if(blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500'};if(h.w)return{label:'working',color:'blue',dot:'bg-blue-500'};if(h.i)return{label:'idle',color:'muted',dot:'bg-txt-500'};return{label:t.status==='active'?'empty':t.status,color:'muted',dot:'bg-base-600'}}
function projectStatus(p){const teams=p.teams||[],counts={working:0,blocked:0,error:0,idle:0,done:0};teams.forEach(t=>{const s=coarseTeamStatus(t).label;if(s==='working')counts.working++;else if(s==='blocked')counts.blocked++;else if(s==='error')counts.error++;else if(s==='idle'||s==='empty')counts.idle++;else counts.done++});if(counts.error)return{label:'error',color:'red',dot:'bg-red-500',counts};if(counts.blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500',counts};if(counts.working)return{label:'working',color:'blue',dot:'bg-blue-500',counts};return{label:'idle',color:'muted',dot:'bg-txt-500',counts}}
function statusTitleProject(p){const s=projectStatus(p),teams=p.teams||[];return projectLabel(p)+'\\nStatus: '+s.label+'\\nTeams: '+teams.length+'\\nWorking: '+s.counts.working+' · Blocked: '+s.counts.blocked+' · Error: '+s.counts.error+' · Idle: '+s.counts.idle}
function statusTitleTeam(t){const h=deriveHealth(t),tasks=t.tasks||[],blocked=tasks.filter(x=>x.status==='blocked').length,active=tasks.filter(x=>x.status==='in_progress').length,pending=tasks.filter(x=>x.status==='pending').length,done=tasks.filter(x=>x.status==='completed').length;return t.name+'\\nStatus: '+coarseTeamStatus(t).label+'\\nAgents: '+h.total+' total, '+h.w+' working, '+h.i+' idle, '+h.e+' error\\nTasks: '+active+' active, '+blocked+' blocked, '+pending+' pending, '+done+' done'}

function lastMessageFor(name,msgs){return msgs.filter(m=>m.fromName===name||m.toName===name).sort((a,b)=>b.timeCreated-a.timeCreated)[0]||null}
function activeTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='in_progress')||tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
function blockedTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
Expand Down
29 changes: 25 additions & 4 deletions src/dashboard-js-part3.ts → src/dashboard-js-events.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
/** Dashboard JS — interaction handlers, keyboard, polling. */
export const DASHBOARD_JS_PART3 = `
export const DASHBOARD_JS_EVENTS = `
function toggleMsg(id){if(expMsgs.has(id))expMsgs.delete(id);else expMsgs.add(id);render()}

function applyNavCollapse(){
const content=document.getElementById('content'),projects=document.getElementById('projects'),rail=document.getElementById('project-rail'),toggle=document.getElementById('nav-toggle'),expand=document.getElementById('nav-expand');
content.classList.toggle('nav-collapsed',navCollapsed);
projects.hidden=navCollapsed;
projects.setAttribute('aria-hidden',String(navCollapsed));
rail.hidden=!navCollapsed;
if(toggle)toggle.setAttribute('aria-expanded',String(!navCollapsed));
expand.setAttribute('aria-expanded',String(!navCollapsed));
if(document.activeElement===toggle&&navCollapsed)expand.focus();
if(document.activeElement===expand&&!navCollapsed&&toggle)toggle.focus();
}

function render(){
rSel();const t=cur();
const empty=document.getElementById('empty'),content=document.getElementById('content');
if(!t){empty.classList.remove('hidden');empty.classList.add('flex');content.classList.add('hidden');document.getElementById('tl').classList.add('hidden');return}
empty.classList.add('hidden');empty.classList.remove('flex');content.classList.remove('hidden');
applyNavCollapse();
const p=curProject();document.getElementById('crumb').textContent=p?' / '+projectLabel(p)+' / '+t.name:'';
rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
}

function selectProject(id){selProjectId=id;const p=S?.projects?.find(p=>p.id===id);const t=(p?.teams||[]).filter(t=>t.status==='active').sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||(p?.teams||[])[0];if(t)selId=t.id;selCard=-1;render()}
function selectTeam(id){selId=id;selCard=-1;render()}

function conn(ok){
document.getElementById('cd').className='w-[7px] h-[7px] rounded-full '+(ok?'bg-emerald-500 pulse':'bg-red-500');
document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
Expand Down Expand Up @@ -56,6 +73,13 @@ setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
// Poll every 2.5s
setInterval(poll,2500);

document.addEventListener('click',function(e){
const id=e.target&&e.target.id;
if(id!=='nav-toggle'&&id!=='nav-expand')return;
navCollapsed=id==='nav-toggle';
applyNavCollapse();
});

// Keyboard shortcuts
document.addEventListener('keydown',function(e){
const shortcutsOpen=document.getElementById('sco').classList.contains('show');
Expand All @@ -80,9 +104,6 @@ document.addEventListener('keydown',function(e){
}
});

// Select handler
document.getElementById('sel').addEventListener('change',function(){selId=this.value;render()});

// Initial poll
poll();

Expand Down
21 changes: 13 additions & 8 deletions src/dashboard-js-part2.ts → src/dashboard-js-render.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/** Dashboard JS — render functions. */
export const DASHBOARD_JS_PART2 = `
export const DASHBOARD_JS_RENDER = `
function rSel(){
const el=document.getElementById('sel'),{active,archived}=allTeams(),c=cur();
if(!active.length&&!archived.length){el.innerHTML='<option>No teams</option>';return}
let h='';
if(active.length){h+='<optgroup label="Active">';h+=active.map(t=>'<option value="'+t.id+'">'+E(t.name)+' ('+relT(t.timeUpdated)+')</option>').join('');h+='</optgroup>'}
if(archived.length){h+='<optgroup label="Archived">';h+=archived.slice(0,5).map(t=>'<option value="'+t.id+'">'+E(t.name)+'</option>').join('');if(archived.length>5)h+='<option disabled>+ '+(archived.length-5)+' more</option>';h+='</optgroup>'}
if(el._lh!==h){el.innerHTML=h;el._lh=h}
if(c)el.value=c.id;
const el=document.getElementById('projects'),ps=allProjects(),c=cur(),cp=curProject();
const head=renderProjectNavHeader();
if(!ps.length){patch(el,head+'<div class="text-center text-txt-500 text-[12px] py-6">No projects yet</div>');return}
let h='<nav class="text-[12px]" aria-label="Projects">'+head+'<div class="space-y-4">';
ps.forEach(function(p){h+=renderProjectSection(p,cp,c)});
h+='</div></nav>';
patch(el,h);
}

function renderProjectNavHeader(){return '<div class="flex items-center justify-between gap-2 mb-3"><div class="text-[10px] uppercase tracking-[.18em] text-txt-500">Projects</div><button id="nav-toggle" type="button" aria-label="Hide project navigation" aria-controls="projects" aria-expanded="true" class="text-[10px] text-txt-500 border border-base-800 rounded px-1.5 py-[2px] hover:text-txt-200 hover:border-base-700 transition-colors">hide</button></div>'}
function renderProjectSection(p,cp,c){const teams=p.teams||[];return '<section>'+renderProjectButton(p,cp)+'<div class="mt-2 ml-[7px] border-l border-base-800/80">'+[...teams].sort((a,b)=>b.timeUpdated-a.timeUpdated).map(t=>renderTeamLink(t,c)).join('')+'</div></section>'}
function renderProjectButton(p,cp){const teams=p.teams||[],active=teams.filter(t=>t.status==='active').length,sel=cp&&cp.id===p.id,pss=projectStatus(p);return '<button type="button" aria-current="'+(sel?'true':'false')+'" title="'+E(statusTitleProject(p))+'" class="project-link group w-full text-left text-txt-300 hover:text-txt-100 transition-colors" data-project="'+E(p.id)+'" onclick="selectProject(this.dataset.project)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+pss.dot+(pss.label==='working'?' pulse':'')+' shrink-0"></span><span class="font-mono font-semibold truncate">'+E(projectLabel(p))+'</span>'+chip(pss.label,pss.color)+'</div><div class="mt-1 ml-3 text-[10px] text-txt-500">'+active+' team'+(active!==1?'s':'')+'</div></button>'}
function renderTeamLink(t,c){const ss=coarseTeamStatus(t),tsel=c&&c.id===t.id;return '<button type="button" title="'+E(statusTitleTeam(t))+'" class="team-link block w-full text-left border-l-2 border-transparent -ml-px py-1.5 pl-3 pr-2 text-[11px] text-txt-500 hover:text-txt-200 hover:bg-base-900/70 transition-colors" aria-current="'+(tsel?'true':'false')+'" data-team="'+E(t.id)+'" onclick="selectTeam(this.dataset.team)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+ss.dot+(ss.label==='working'?' pulse':'')+' shrink-0"></span><span class="truncate font-mono">'+E(t.name)+'</span><span class="ml-auto text-[9px] uppercase tracking-wide text-txt-500">'+E(ss.label)+'</span></div></button>'}

function rHealth(t){
const el=document.getElementById('hring'),h=deriveHealth(t);
if(!h.total){el.style.background='conic-gradient(#2a3144 0deg,#2a3144 360deg)';return}
Expand Down
83 changes: 62 additions & 21 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import type { Database } from "bun:sqlite"
import { DASHBOARD_HEAD } from "./dashboard-html"
import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
import { DASHBOARD_JS_PART3 } from "./dashboard-js-part3"
import { DASHBOARD_JS_CORE } from "./dashboard-js-core"
import { DASHBOARD_JS_EVENTS } from "./dashboard-js-events"
import { DASHBOARD_JS_RENDER } from "./dashboard-js-render"
import { log } from "./log"

/** Assemble the full dashboard HTML from parts. */
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_PART1 + DASHBOARD_JS_PART2 + DASHBOARD_JS_PART3 + "<\/script>\n</body></html>"
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_RENDER + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"

interface TeamRow {
id: string
name: string
project_id: string
status: string
lead_agent: string | null
time_created: number
time_updated: number
}

interface ProjectRow {
id: string
name: string
path: string
status: string
time_created: number
time_updated: number
}

interface MemberRow {
name: string
agent: string
Expand Down Expand Up @@ -73,32 +83,35 @@ function parseDependsOn(value: string | null): string[] {
return []
}

function buildState(db: Database): { teams: unknown[] } {
const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")

return {
teams: teams.map((t) => ({
const mappedTeams = teams.map((t) => {
const members = (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
name: m.name,
agent: m.agent,
status: m.status,
executionStatus: m.execution_status,
worktreeBranch: m.worktree_branch,
prompt: m.prompt,
model: m.model,
planApproval: m.plan_approval,
timeCreated: m.time_created,
timeUpdated: m.time_updated,
}))
return {
id: t.id,
name: t.name,
projectId: t.project_id,
status: t.status,
leadAgent: t.lead_agent,
timeCreated: t.time_created,
timeUpdated: t.time_updated,
members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
name: m.name,
agent: m.agent,
status: m.status,
executionStatus: m.execution_status,
worktreeBranch: m.worktree_branch,
prompt: m.prompt,
model: m.model,
planApproval: m.plan_approval,
timeCreated: m.time_created,
timeUpdated: m.time_updated,
})),
members,
tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
id: tk.id,
content: tk.content,
Expand All @@ -118,7 +131,35 @@ function buildState(db: Database): { teams: unknown[] } {
read: msg.read === 1,
timeCreated: msg.time_created,
})),
})),
}
})

const teamsByProject = new Map<string, unknown[]>()
mappedTeams.forEach(team => {
const projectId = (team as { projectId: string }).projectId
teamsByProject.set(projectId, [...(teamsByProject.get(projectId) ?? []), team])
})

return {
projects: projects.flatMap(project => {
const projectTeams = teamsByProject.get(project.id) ?? []
if (projectTeams.length === 0) return []
return {
id: project.id,
name: project.name,
path: project.path,
status: project.status,
timeCreated: project.time_created,
timeUpdated: project.time_updated,
activeTeams: projectTeams.filter(team => (team as { status: string }).status === "active").length,
workingAgents: projectTeams.reduce<number>((count, team) => {
const members = (team as { members: Array<{ status: string }> }).members
return count + members.filter(member => member.status === "busy").length
}, 0),
teams: projectTeams,
}
}),
teams: mappedTeams,
}
}

Expand Down
Loading