diff --git a/AGENTS.md b/AGENTS.md index 0eb488a..4d6201b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates - Persist machine-wide settings through the existing atomic JSON-write path and action-token-protected HTTP endpoints. - New settings require validation, idempotent writes, migration behavior, and tests. - Preserve legacy hash routes and stored preferences when changing navigation or native settings. -- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Work → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes. +- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Work → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, `By branch`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, By branch (`#sessions-branches`) owns spend grouped by trace branch, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes. - Global is not a dashboard surface. Keep cross-session aggregation as shared backend data for Sessions All, Daily, Models, Tools, Efficiency, MCP, and the menu bar. - Keep the complete machine-wide monthly budget status and editor on the Spend page (`#spend-budgets`). The native companion deep-links there; preserve `#settings-budgets` and `#budgets` as compatibility redirects. - Use macOS labels such as `⌥`, never `Alt`, in user-facing copy. diff --git a/README.md b/README.md index b63d389..61cd8cf 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,16 @@ It measures effort, not answer quality. Compare sessions view with three runs of the same prompt and cost insights

+### By branch: what did each branch cost? + +Sessions › By branch groups spend by the Git branch each session ran on, with +its apps, sessions, days active, and push status. Filter by app, project, time, +or status, search by branch name or issue key, and open a branch to see its +sessions. A branch chip on cards, rows, and the session header opens the same +view. The branch comes from each app's own session files (Claude Code and +Codex record it). Apps that do not record a branch show as "No branch +recorded". + ### Spend: am I on track this month? Spend by day, platform, project, and session, with a monthly budget and a @@ -209,6 +219,18 @@ of work (features, fixes, refactoring, docs, and more) from their messages, labeled by the local model. Local `git` only; it never contacts a remote. A mechanical signal, not a productivity score. +Known limit: Token Meter keeps one default branch per repository. With several +remotes whose default branches differ, another remote's default can be listed as +a feature branch. + +Git › Branches scores feature branches: the median cost to ship one, the share +of spend on branches left unpushed for a week, the spend on rework after a +branch's first push (an estimate), and branches that cost three times your +typical spend per commit. Push evidence comes from this Mac's Git reflogs and +is matched by a salted key, so the Git ledger never stores a branch name. Branch +names stay on the local dashboard and are never sent anywhere; Token Meter does +not contact GitHub, GitLab, or an issue tracker. +

Git page with spend per 1K pushed lines and push yield

diff --git a/dashboard/js/all-sessions.js b/dashboard/js/all-sessions.js index 32c4ac5..cd16abe 100644 --- a/dashboard/js/all-sessions.js +++ b/dashboard/js/all-sessions.js @@ -6,7 +6,7 @@ function allSessionsView(all,{showChildren=false,app='',project='',rangeStart=0, if(rangeEnd&&(s.mtime||0)>=rangeEnd)return false; return true; }; - const textMatch=s=>!query||[s.title,s.project,s.provider,s.label,s.child_agent_role,(s.models||[]).join(' ')].join(' ').toLowerCase().includes(query); + const textMatch=s=>!query||[s.title,s.project,s.provider,s.label,s.child_agent_role,s.branch,(s.models||[]).join(' ')].join(' ').toLowerCase().includes(query); const matching=(all||[]).filter(s=>baseMatch(s)&&textMatch(s)); const children=matching.filter(s=>s.is_child_session); let rows=matching; diff --git a/dashboard/js/branches.js b/dashboard/js/branches.js new file mode 100644 index 0000000..bd7ae38 --- /dev/null +++ b/dashboard/js/branches.js @@ -0,0 +1,393 @@ +// Sessions › By branch, the shared branch chip, and the session-to-branch lookup. +// Branch names come only from the /logs inventory; the polled /state payload never carries them. +const BRANCH_TRUNK_NAMES=new Set(['main','master','develop','dev','trunk']); +const BRANCH_PREFIX_PATTERN=/^(?:feat|feature|fix|bugfix|hotfix|codex|claude|cursor|chore|docs|refactor|perf|test|tests|build|ci|style|plan|release)\//i; +const BRANCH_ISSUE_PATTERN=/(?:^|[^A-Za-z0-9])([A-Z][A-Z0-9]{1,9}-\d{2,})(?![0-9])/; +const BRANCH_CHIP_ICON=''; +const BRANCH_STATUS_FILTERS=['all','pushed','not_pushed','in_progress','no_git_evidence']; +const BRANCH_STATUS_LABELS={pushed:'Pushed',not_pushed:'Not pushed',in_progress:'In progress',no_git_evidence:'No Git evidence',trunk:'Main',unknown:'--'}; +const BRANCH_SORTS=['spend','sessions','last','branch']; +// All history asks the Git service for its all-time range, so older branches still get push status. +const BRANCH_GIT_RANGES={today:'today',yesterday:'yesterday','7d':'7','30d':'30','90d':'90',month:'month',last_month:'last_month',all:'all'}; +const BRANCH_SESSION_LIMIT=40; +function isTrunkBranch(name){return BRANCH_TRUNK_NAMES.has(String(name||'').trim());} +function branchIssueKey(name){const match=String(name||'').match(BRANCH_ISSUE_PATTERN);return match?match[1]:'';} +function isAutoWorktreeBranch(name){return /^claude\/[a-z]+-[a-z]+-[0-9a-f]{6}$/.test(String(name||''));} +function branchReadableName(name){ + const raw=String(name||'').trim(); + if(!raw)return 'No branch recorded'; + if(isAutoWorktreeBranch(raw))return 'Unnamed worktree'; + let text=raw; + while(BRANCH_PREFIX_PATTERN.test(text))text=text.replace(BRANCH_PREFIX_PATTERN,''); + const key=branchIssueKey(text); + if(key)text=text.replace(key,' '); + text=text.replace(/-(?=[0-9a-f]*\d)[0-9a-f]{6}$/,'').replace(/[-_/]+/g,' ').replace(/\s+/g,' ').trim(); + if(!text)return key||raw; + return text[0].toUpperCase()+text.slice(1); +} +function branchMatchesQuery(name,query){ + const needle=String(query||'').trim().toLowerCase(); + if(!needle)return true; + const raw=String(name||''); + return [raw,branchReadableName(raw),branchIssueKey(raw)].join(' ').toLowerCase().includes(needle); +} +// One chip for every surface: a branch icon, the raw name in mono, and an issue key when present. +function branchChipHtml(branch,{project='',interactive=true,issue=true}={}){ + const name=String(branch||'').trim(); + if(!name)return ''; + const key=issue?branchIssueKey(name):''; + const body=`${BRANCH_CHIP_ICON}${esc(name)}${key?`${esc(key)}`:''}`; + if(!interactive)return `${body}`; + return ``; +} +function sessionBranchIndex(){ + if(branchIndexSource!==allSessionInventory){ + branchIndexSource=allSessionInventory; + branchIndex=new Map(); + (allSessionInventory||[]).forEach(row=>{ + const name=String(row?.branch||''); + [row?.key,row?.session,row?.id].forEach(key=>{ + key=String(key||''); + if(key&&(!branchIndex.has(key)||(!branchIndex.get(key)&&name)))branchIndex.set(key,name); + }); + }); + } + return branchIndex; +} +function sessionBranchFor(row){ + if(row?.branch)return String(row.branch); + const index=sessionBranchIndex(); + for(const key of [row?.key,row?.source?.key,row?.session,row?.id]){ + const value=String(key||''); + if(value&&index.has(value))return index.get(value); + } + return ''; +} +function sessionInventoryKnows(row){ + const index=sessionBranchIndex(); + return [row?.key,row?.source?.key,row?.session,row?.id].some(key=>key&&index.has(String(key))); +} +// Load the /logs inventory once for the branch on cards and the session header, and again at most +// once a minute when a visible session is not in it yet. +function ensureBranchInventory(rows=[]){ + if(allSessionsRequest)return; + const missing=!allSessionInventory||(rows||[]).some(row=>row&&!sessionInventoryKnows(row)); + if(!missing)return; + const now=Date.now(); + if(now-branchInventoryRequestedAt<(allSessionInventory?60000:15000))return; + branchInventoryRequestedAt=now; + loadAllSessions(); +} +function branchProjectName(value){ + const text=String(value||'').replace(/\\/g,'/').split(' · ')[0].replace(/\/+$/,''); + return (text.split('/').pop()||'').toLowerCase(); +} +// Rows from /logs carry the repository label the Git page uses (`repo`), so the match is exact. +// Without it, fall back to the same project name, else a branch recorded in exactly one project. +function branchGitMatch(gitRows,branch,project,repo=''){ + if(!Array.isArray(gitRows))return null; + const candidates=gitRows.filter(row=>row&&row.branch===branch); + if(repo){const exact=candidates.filter(row=>row.project===repo);if(exact.length===1)return exact[0];} + const name=branchProjectName(repo||project),same=candidates.filter(row=>branchProjectName(row.project)===name); + if(same.length===1)return same[0]; + if(!same.length&&candidates.length===1)return candidates[0]; + return null; +} +// One definition on both pages: the range selects which branches appear (any session active in +// it), and each row then covers the whole branch, so Spend is its total across all its sessions +// and the expanded session list sums to it. Main and No branch recorded count sessions active in +// the range. Share is a row's part of the feature-branch total. +function branchSpendView(rows,{query='',app='',project='',rangeStart=0,rangeEnd=0,status='all',showMain=false,sort='spend',gitRows=null,gitWindowStart=0}={}){ + const all=rows||[],roots=new Map(all.filter(row=>row&&!row.is_child_session).map(row=>[String(row.id||''),row])); + const localDay=stamp=>{const date=new Date(stamp*1000);return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;}; + const span=row=>{const last=Number(row.last_activity_ts||row.mtime)||0;return [Math.max(0,last-(Number(row.wall_duration_s)||0)),last];}; + const active=row=>{const [start,last]=span(row);return (!rangeStart||last>=rangeStart)&&(!rangeEnd||start{ + if(!row)return; + if(app&&appFilterGroup(row)!==app)return; + if(project&&projectFilterValue(row.project)!==project)return; + const child=Boolean(row.is_child_session),costed=row?.availability?.cost!==false,cost=costed?Number(row.cost)||0:0,inRange=active(row); + // A child run without its own branch ran on its parent's branch and repository. + const root=child?roots.get(String(row.root_session_id||'')):null; + const name=String(row.branch||root?.branch||'').trim(),repo=String(row.repo||(row.branch?'':root?.repo)||''); + if(!name){ + if(!inRange||totals.noBranchHidden)return; + totals.noBranch+=cost;if(!child)totals.noBranchSessions++; + return; + } + if(!branchMatchesQuery(name,query))return; + const trunk=isTrunkBranch(name); + if(trunk){ + if(!inRange)return; + totals.trunk+=cost;if(!child)totals.trunkSessions++; + if(!showMain)return; + } + // Worktrees of one repository share its label, so one branch is one row. + const projectKey=repo||projectFilterValue(row.project),key=`${name}:${projectKey}`; + let group=groups.get(key); + if(!group){ + group={key,branch:name,project:String(row.project||''),repo,projectKey,trunk,active:false,spend:0,costCovered:0,sessions:0,apps:new Set(),days:new Set(),last:0,sessionRows:[]}; + groups.set(key,group); + } + group.active=group.active||inRange; + if(costed){group.spend+=cost;group.costCovered++;} + if(!child){group.sessions++;group.sessionRows.push(row);} + group.apps.add(appFilterGroup(row)); + const [start,last]=span(row); + [start,last].forEach(stamp=>{if(stamp>0)group.days.add(localDay(stamp));}); + group.last=Math.max(group.last,last); + }); + const list=[...groups.values()].filter(group=>group.active).map(group=>{ + const git=group.trunk?null:branchGitMatch(gitRows,group.branch,group.project,group.repo); + const state=group.trunk?'trunk':!Array.isArray(gitRows)?'unknown':git?String(git.status||'no_git_evidence'):(gitWindowStart&&group.lastspan(b)[1]-span(a)[1]), + }; + }); + list.filter(row=>!row.trunk).forEach(row=>{totals.feature+=row.spend||0;totals.featureSessions+=row.sessions;}); + list.forEach(row=>{if(!row.trunk&&row.spend!=null&&totals.feature>0)row.share=row.spend/totals.feature;}); + totals.top=list.filter(row=>!row.trunk&&row.spend!=null).reduce((best,row)=>!best||row.spend>best.spend?row:best,null); + const spendOf=row=>row.spend==null?-1:row.spend; + const byName=(a,b)=>a.branch.localeCompare(b.branch,undefined,{sensitivity:'base'})||a.projectKey.localeCompare(b.projectKey); + // The Branch sort follows the name people read; the raw name breaks ties. + const byReadable=(a,b)=>a.readable.localeCompare(b.readable,undefined,{sensitivity:'base'})||byName(a,b); + const sorters={ + spend:(a,b)=>spendOf(b)-spendOf(a)||b.last-a.last||byName(a,b), + sessions:(a,b)=>b.sessions-a.sessions||spendOf(b)-spendOf(a)||byName(a,b), + last:(a,b)=>b.last-a.last||byName(a,b), + branch:byReadable, + }; + const visible=list.filter(row=>status==='all'||row.status===status).sort(sorters[sort]||sorters.spend); + return {rows:visible,all:list,total:list.filter(row=>!row.trunk).length,totals}; +} + +let branchSearch=localStorage.getItem('tm_branch_search')||''; +let branchApp=localStorage.getItem('tm_branch_app')||''; +let branchProject=localStorage.getItem('tm_branch_project')||''; +let branchTime=normalizeTimeFilterWindow(localStorage.getItem('tm_branch_time')||DEFAULT_TIME_FILTER_WINDOW); +let branchStatus=BRANCH_STATUS_FILTERS.includes(localStorage.getItem('tm_branch_status'))?localStorage.getItem('tm_branch_status'):'all'; +let branchShowMain=localStorage.getItem('tm_branch_main')==='show'; +let branchSort=BRANCH_SORTS.includes(localStorage.getItem('tm_branch_sort'))?localStorage.getItem('tm_branch_sort'):'spend'; +let branchExpanded='',branchFocusTarget=null,branchFocusWidened=false; +let branchGit={range:'',at:0,rows:null,start:0,loading:''}; +function saveBranchFilters(){ + const save=(key,value,fallback)=>{if(value===fallback)localStorage.removeItem(key);else localStorage.setItem(key,value);}; + save('tm_branch_search',branchSearch,'');save('tm_branch_app',branchApp,'');save('tm_branch_project',branchProject,''); + save('tm_branch_time',branchTime,DEFAULT_TIME_FILTER_WINDOW);save('tm_branch_status',branchStatus,'all'); + save('tm_branch_main',branchShowMain?'show':'hide','hide');save('tm_branch_sort',branchSort,'spend'); +} +async function loadBranchGitStatus(){ + const range=BRANCH_GIT_RANGES[branchTime]||'30'; + if(branchGit.loading===range||(branchGit.range===range&&Date.now()-branchGit.at<60000))return; + branchGit.loading=range; + let rows=null; + try{ + const response=await fetch(`/git-delivery?range=${encodeURIComponent(range)}`,{cache:'no-store'}); + const payload=response.ok?await response.json():null; + rows=payload?.ok&&Array.isArray(payload?.branches?.rows)?payload.branches.rows:null; + }catch(error){rows=null;} + const days={today:1,yesterday:2,'7':7,'30':30,'90':90}[range]||0; + branchGit={range,at:Date.now(),rows,start:days?Date.now()/1000-days*86400:0,loading:''}; + if(sessionScope==='branches')renderBranchSpend(); +} +function branchWhen(stamp,withTime=false){ + const value=Number(stamp)||0; + return value?new Date(value*1000).toLocaleString([],withTime?{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}:{month:'short',day:'numeric'}):'--'; +} +function branchGitCell(row){ + if(row.status==='pushed'&&row.git&&row.git.availability?.commits===true&&Number(row.git.pushes)>0&&row.git.added!=null)return `+${f(row.git.added)} −${f(row.git.deleted)}`; + if(row.status==='unknown')return '--'; + return `${esc(BRANCH_STATUS_LABELS[row.status]||'--')}`; +} +function branchRowHtml(row,index){ + const open=branchExpanded===row.key,panel=`b-sessions-${index}`; + const apps=row.apps.map(id=>appFilterLabel({provider:id})); + const dots=`${row.apps.map(id=>``).join('')}`; + const projectName=branchProjectName(row.repo||row.project)||'local'; + const main=`${esc(projectName)}${dots}${f(row.sessions)}${f(row.daysActive)}${esc(branchWhen(row.last))}${branchGitCell(row)}${row.spend==null?'--':money(row.spend)}${row.share==null?'--':pct(row.share)}`; + if(!open)return main; + const shown=row.sessionRows.slice(0,BRANCH_SESSION_LIMIT),more=row.sessionRows.length-shown.length; + const sessions=shown.map(session=>``).join(''); + return `${main}
${sessions||'
No sessions in this range.
'}${more>0?`
${f(more)} more ${countWord(more,'session')} not shown
`:''}
`; +} +// Counts what is listed, including main rows when Show main is on. +function branchCountText(view){ + const listed=view.rows.length,total=view.all.length; + return `${f(listed)} of ${f(total)} ${countWord(total,'branch','branches')} · ${money(view.totals.feature)} on feature branches`; +} +function branchActiveFilters(appLabel){ + return [ + branchSearch.trim()&&['search',`Search: “${branchSearch.trim()}”`], + branchApp&&['app',`App: ${appLabel}`], + branchProject&&['project',`Project: ${projectFilterLabel(branchProject)}`], + branchTime!==DEFAULT_TIME_FILTER_WINDOW&&['time',TIME_FILTER_LABELS[branchTime]||branchTime], + branchStatus!=='all'&&['status',`Status: ${BRANCH_STATUS_LABELS[branchStatus]}`], + branchShowMain&&['main','Main shown'], + ].filter(Boolean); +} +function renderBranchTotals(view){ + const {totals}=view; + setLogText($('b-total-feature'),money(totals.feature)); + setLogText($('b-total-feature-note'),`${f(view.total)} ${countWord(view.total,'branch','branches')} · ${f(totals.featureSessions)} ${countWord(totals.featureSessions,'session')}`); + setLogText($('b-total-main'),money(totals.trunk)); + setLogText($('b-total-main-note'),`${f(totals.trunkSessions)} ${countWord(totals.trunkSessions,'session')} active in range`); + setLogText($('b-total-none'),totals.noBranchHidden?'--':money(totals.noBranch)); + setLogText($('b-total-none-note'),totals.noBranchHidden?'Search matches branches only':`${f(totals.noBranchSessions)} ${countWord(totals.noBranchSessions,'session')} active in range`); + setLogText($('b-total-top'),totals.top?totals.top.readable:'--'); + setLogAttribute($('b-total-top'),'title',totals.top?totals.top.branch:''); + setLogText($('b-total-top-note'),totals.top?`${money(totals.top.spend)} · ${pct(totals.top.share)} of feature-branch spend`:'No feature branch in this range'); +} +function renderBranchSpend(){ + if(!branchUiReady)return; + const inventory=allSessionInventory; + if(!inventory){ + setLogHtml($('b-list'),'
Loading session history…
'); + setLogText($('b-count'),''); + return; + } + const apps=[...new Map(inventory.map(row=>[appFilterGroup(row),appFilterLabel(row)])).entries()].map(([id,label])=>({id,label})).sort((a,b)=>a.label.localeCompare(b.label,undefined,{sensitivity:'base'})); + if(branchApp&&!apps.some(app=>app.id===branchApp)){branchApp='';saveBranchFilters();} + setLogSelectOptions($('b-app'),''+apps.map(app=>``).join(''),branchApp); + const projects=[...new Set(inventory.map(row=>projectFilterValue(row.project)))].sort((a,b)=>projectFilterLabel(a).localeCompare(projectFilterLabel(b),undefined,{sensitivity:'base'})); + if(branchProject&&!projects.includes(branchProject)){branchProject='';saveBranchFilters();} + setLogSelectOptions($('b-project'),''+projects.map(project=>``).join(''),branchProject); + if($('b-search').value!==branchSearch)$('b-search').value=branchSearch; + if($('b-time').value!==branchTime)$('b-time').value=branchTime; + if($('b-status').value!==branchStatus)$('b-status').value=branchStatus; + [...$('b-sort').querySelectorAll('[data-bsort]')].forEach(button=>{const on=button.dataset.bsort===branchSort;button.classList.toggle('on',on);button.setAttribute('aria-pressed',String(on));}); + [...$('b-main').querySelectorAll('[data-bmain]')].forEach(button=>{const on=(button.dataset.bmain==='show')===branchShowMain;button.classList.toggle('on',on);button.setAttribute('aria-pressed',String(on));}); + const range=BRANCH_GIT_RANGES[branchTime]||'30',gitReady=branchGit.range===range,{start,end}=timeFilterBounds(branchTime); + const view=branchSpendView(inventory,{query:branchSearch,app:branchApp,project:branchProject,rangeStart:start,rangeEnd:end,status:branchStatus,showMain:branchShowMain,sort:branchSort,gitRows:gitReady?branchGit.rows:null,gitWindowStart:gitReady?branchGit.start:0}); + if(branchFocusTarget&&!branchFocusTarget.trunk&&!branchFocusWidened&&branchTime!=='all'&&!view.rows.some(row=>row.branch===branchFocusTarget.branch)){ + branchFocusWidened=true;branchTime='all';saveBranchFilters();renderBranchSpend();return; + } + const filters=branchActiveFilters(apps.find(app=>app.id===branchApp)?.label||branchApp); + $('b-clear').disabled=!filters.length; + $('b-active-filters').hidden=!filters.length; + setLogHtml($('b-active-filters'),filters.length?`Filtered by${filters.map(([key,label])=>``).join('')}`:''); + const gitNote=!gitReady?' · reading Git…':branchGit.rows?'':' · push status unavailable'; + setLogText($('b-count'),`${branchCountText(view)}${gitNote}`); + renderBranchTotals(view); + const anyBranch=inventory.some(row=>row?.branch); + const head='BranchProjectAppsSessionsDaysLast activityPushed linesSpendShare'; + const body=view.rows.length?view.rows.map(branchRowHtml).join(''):''; + setLogHtml($('b-list'),!anyBranch + ?'
No branches recorded yet

Claude Code and Codex record the Git branch each session ran on. Apps that do not record one count as No branch recorded.

' + :view.rows.length?`${head}${body}
` + :'
No branches match these filters

Clear filters or choose a longer time range.

'); + if(!gitReady)loadBranchGitStatus(); + if(branchFocusTarget)focusBranchTarget(view); +} +function focusBranchTarget(view){ + const target=branchFocusTarget; + branchFocusTarget=null;branchFocusWidened=false; + if(target.trunk){ + requestAnimationFrame(()=>{const tile=$('b-total-main-tile');tile.scrollIntoView({block:'center'});tile.focus({preventScroll:true});}); + return; + } + const name=branchProjectName(target.project),matches=view.rows.filter(row=>row.branch===target.branch); + const row=matches.find(item=>branchProjectName(item.project)===name)||matches[0]; + if(!row)return; + if(branchExpanded!==row.key){branchExpanded=row.key;renderBranchSpend();} + requestAnimationFrame(()=>{ + const button=[...$('b-list').querySelectorAll('[data-branch-toggle]')].find(item=>item.dataset.branchToggle===row.key); + if(!button)return; + button.closest('tr').scrollIntoView({block:'center'}); + button.focus({preventScroll:true}); + }); +} +function openBranchSpend(){ + renderBranchSpend(); + if(!allSessionInventory)loadAllSessions(); + loadBranchGitStatus(); +} +function openBranchChip(name,project=''){ + name=String(name||'').trim(); + if(!name)return; + const trunk=isTrunkBranch(name); + // Clear the filters that could hide the branch; a trunk branch turns on its rows. + branchSearch='';branchApp='';branchProject='';branchStatus='all'; + if(trunk)branchShowMain=true; + saveBranchFilters(); + branchFocusTarget={branch:name,project,trunk};branchFocusWidened=false; + if(currentPanel==='sessions'&&sessionScope==='branches'&&$('view-session').classList.contains('on'))renderBranchSpend(); + else openSessionScope('branches'); +} +function handleBranchChipClick(event){ + const chip=event.target instanceof Element?event.target.closest('[data-branch-chip]'):null; + if(!chip)return; + event.preventDefault(); + event.stopPropagation(); + openBranchChip(chip.dataset.branchChip,chip.dataset.branchProject||''); +} +document.addEventListener('click',handleBranchChipClick,true); +function renderSessionBranch(s=CURRENT){ + const target=$('preview-branch'); + if(!target)return; + const lookup=s?{key:s.source?.key,session:s.session,id:stateSessionId(s)}:null; + const branch=lookup?sessionBranchFor(lookup):''; + setLogHtml(target,branch?branchChipHtml(branch,{project:s.project||s.source?.project||''}):''); + if(target.hidden!==!branch)target.hidden=!branch; + if(lookup&&!branch)ensureBranchInventory([lookup]); +} +// Re-render the surfaces that show a branch once the /logs inventory arrives. +function refreshBranchSurfaces(){ + if(!LATEST||!$('view-session').classList.contains('on'))return; + if(currentPanel==='sessions'&&sessionScope==='current')renderCurrentSessions(LATEST); + else if(currentPanel==='sessions'&&sessionScope==='branches')renderBranchSpend(); + else if(currentPanel!=='sessions'&&CURRENT)renderSessionBranch(CURRENT); +} +function placeCurrentSessionBranchChips(root=$('current-session-grid')){ + root.querySelectorAll('.currentSessionSlot').forEach(slot=>{ + const chip=slot.querySelector(':scope>.branchChip'),anchor=slot.querySelector('.currentSessionBranchAnchor'); + if(!chip||!anchor)return; + const frame=slot.getBoundingClientRect(),box=anchor.getBoundingClientRect(); + chip.style.left=`${Math.round(box.left-frame.left)}px`;chip.style.top=`${Math.round(box.top-frame.top)}px`; + chip.style.width=`${Math.ceil(box.width)}px`;chip.style.height=`${Math.round(box.height)}px`; + chip.dataset.placed=''; + }); +} +if(window.ResizeObserver)new ResizeObserver(()=>placeCurrentSessionBranchChips()).observe($('current-session-grid')); +$('current-session-grid').addEventListener('animationend',()=>placeCurrentSessionBranchChips()); +document.fonts?.ready?.then(()=>placeCurrentSessionBranchChips()); + +$('b-search').addEventListener('input',event=>{branchSearch=event.target.value;saveBranchFilters();renderBranchSpend();}); +$('b-app').addEventListener('change',event=>{branchApp=event.target.value;saveBranchFilters();renderBranchSpend();}); +$('b-project').addEventListener('change',event=>{branchProject=event.target.value;saveBranchFilters();renderBranchSpend();}); +$('b-time').addEventListener('change',event=>{branchTime=normalizeTimeFilterWindow(event.target.value);saveBranchFilters();renderBranchSpend();}); +$('b-status').addEventListener('change',event=>{branchStatus=BRANCH_STATUS_FILTERS.includes(event.target.value)?event.target.value:'all';saveBranchFilters();renderBranchSpend();}); +$('b-main').onclick=event=>{const button=event.target.closest('[data-bmain]');if(!button)return;branchShowMain=button.dataset.bmain==='show';saveBranchFilters();renderBranchSpend();}; +$('b-sort').onclick=event=>{const button=event.target.closest('[data-bsort]');if(!button)return;branchSort=button.dataset.bsort;saveBranchFilters();renderBranchSpend();}; +$('b-clear').onclick=()=>{ + branchSearch='';branchApp='';branchProject='';branchTime=DEFAULT_TIME_FILTER_WINDOW;branchStatus='all';branchShowMain=false; + saveBranchFilters();renderBranchSpend(); +}; +$('b-active-filters').onclick=event=>{ + const key=event.target.closest('[data-clear-branch-filter]')?.dataset.clearBranchFilter; + if(!key)return; + if(key==='search')branchSearch=''; + if(key==='app')branchApp=''; + if(key==='project')branchProject=''; + if(key==='time')branchTime=DEFAULT_TIME_FILTER_WINDOW; + if(key==='status')branchStatus='all'; + if(key==='main')branchShowMain=false; + saveBranchFilters();renderBranchSpend(); +}; +$('b-list').onclick=event=>{ + const session=event.target.closest('[data-branch-session]'); + if(session){selectSession(session.dataset.branchSession,{returnScope:'branches'});return;} + const row=event.target.closest('[data-branch-row]'); + if(!row)return; + const key=row.dataset.branchRow,focused=document.activeElement?.dataset?.branchToggle===key; + branchExpanded=branchExpanded===key?'':key; + renderBranchSpend(); + if(focused)[...$('b-list').querySelectorAll('[data-branch-toggle]')].find(item=>item.dataset.branchToggle===key)?.focus(); +}; +branchUiReady=true; +if(sessionScope==='branches'&¤tPanel==='sessions'&&$('view-session').classList.contains('on'))openBranchSpend(); diff --git a/dashboard/js/core.js b/dashboard/js/core.js index 8e38ac2..3822141 100644 --- a/dashboard/js/core.js +++ b/dashboard/js/core.js @@ -238,6 +238,7 @@ function costTipHtml(s,tok){ let LATEST=null, CURRENT=null, pinned=null, sessionRequest=0, selectedPollId='', RUNTIME_CATALOG={}; // Declared early: the first header render looks up trace titles before the All sessions code runs. let allSessionInventory=null,allSessionInventoryAt=0,allSessionsRequest=null,renderedAllSessions=new Map(),renderedAllSessionActions={},forceAllSessionRowRefresh=false; +let branchIndexSource=null,branchIndex=new Map(),branchInventoryRequestedAt=0; const LIVE_XSESSION={state:{snapshot:null,revision:''},session:{snapshot:null,revision:''}}; let AGENT_ACCESS=null, pendingAgentAccess=null, agentAccessLoading=false, agentAccessRechecking=false; const CURRENT_SESSION_ORDER_KEY='tm_current_session_order_v1'; @@ -309,7 +310,7 @@ function openSelectedSessionRoute(route='summary'){ } setHashRoute(route); } -const SESSION_SCOPES=['current','all','compare','subagents']; +const SESSION_SCOPES=['current','all','compare','branches','subagents']; let sessionScope='current',sessionReturnScope='current'; const COMPARE_MAX=4,COMPARE_LETTERS='ABCD',COMPARE_COLORS=THEME.compare; function loadCompareIds(){ @@ -322,10 +323,10 @@ let compareIds=loadCompareIds(),compareBaseline=localStorage.getItem('tm_compare let compareChartMetric=localStorage.getItem('tm_compare_chart')==='tokens'?'tokens':'cost'; let compareMissing=[],compareData=null,compareError='',compareLoading=false,compareRequestSeq=0,compareLoadedKey='',compareLoadedAt=0,compareNotice=''; const compareTitles=new Map(); -let compareUiReady=false; +let compareUiReady=false,branchUiReady=false; function normalizeSessionScope(scope){return SESSION_SCOPES.includes(scope)?scope:'current';} -function sessionScopeRoute(scope){scope=normalizeSessionScope(scope);return scope==='all'?'sessions-all':scope==='compare'?'sessions-compare':scope==='subagents'?'sessions-subagents':'sessions';} -function sessionScopeLabel(scope){return {all:'All sessions',compare:'Compare',subagents:'Subagents'}[normalizeSessionScope(scope)]||'Current sessions';} +function sessionScopeRoute(scope){scope=normalizeSessionScope(scope);return scope==='all'?'sessions-all':scope==='compare'?'sessions-compare':scope==='branches'?'sessions-branches':scope==='subagents'?'sessions-subagents':'sessions';} +function sessionScopeLabel(scope){return {all:'All sessions',compare:'Compare',branches:'By branch',subagents:'Subagents'}[normalizeSessionScope(scope)]||'Current sessions';} function sessionListBackAvailable(scope){return !routedSessionId()&&(location.hash||'').replace(/^#/,'')===sessionScopeRoute(scope);} function renderSessionBack(){ const label=sessionScopeLabel(sessionReturnScope); @@ -341,17 +342,20 @@ function sessionScopeCopy(scope){ return { all:['Local session history.','Search, filter, and compare local session history.'], compare:['Selected sessions side by side.','Compare up to four selected sessions and see which is cheapest and fastest.'], + branches:['Spend grouped by the Git branch each session ran on.','Branch names come from each app\u2019s session files. Select a branch to see its sessions.'], subagents:['Investigate child-agent spend, completion, and role patterns.','Filter child agents, inspect incomplete work, and open their parent sessions.'], }[scope]||['Last 30 minutes','Select a card to inspect it. Drag a card or press ⌥ and an arrow key to reorder.']; } function renderSessionScope(){ - const all=sessionScope==='all',subagents=sessionScope==='subagents',compare=sessionScope==='compare',overview=currentPanel==='sessions'; + const all=sessionScope==='all',subagents=sessionScope==='subagents',compare=sessionScope==='compare',branches=sessionScope==='branches',overview=currentPanel==='sessions'; $('view-session').classList.toggle('sessionAll',all); $('view-session').classList.toggle('sessionSubagents',subagents); $('view-session').classList.toggle('sessionCompare',compare); - $('current-sessions-overview').hidden=all||subagents||compare; + $('view-session').classList.toggle('sessionBranches',branches); + $('current-sessions-overview').hidden=all||subagents||compare||branches; $('all-session-history').hidden=!all; $('session-compare').hidden=!compare; + $('branch-spend').hidden=!branches; if($('subagent-explorer').parentElement===$('preview-surface-sessions'))$('subagent-explorer').hidden=!subagents; [...$('session-scope-tabs').querySelectorAll('[data-session-scope]')].forEach(button=>{ const selected=button.dataset.sessionScope===sessionScope; @@ -375,6 +379,7 @@ function renderSessionScope(){ loadAllSessions(); } if(subagents&&LATEST){renderSubagentExplorer(LATEST.xsession?.agent_usage);loadAllSessions();} + if(branchUiReady&&branches&&overview)openBranchSpend(); } function setSessionScope(scope){ sessionScope=normalizeSessionScope(scope); @@ -391,5 +396,5 @@ function openSessionScope(scope,{replace=false}={}){ if(history.pushState)history[replace?'replaceState':'pushState'](null,'',`/#${route}`); else location.hash=route; applyHashRoute(); - return sessionScope==='all'?allSessionInventory||[]:sessionScope==='compare'?compareData?.sessions||[]:sessionScope==='subagents'?LATEST?.xsession?.agent_usage?.inventory||[]:LATEST?.xsession?.current_sessions||[]; + return sessionScope==='all'||sessionScope==='branches'?allSessionInventory||[]:sessionScope==='compare'?compareData?.sessions||[]:sessionScope==='subagents'?LATEST?.xsession?.agent_usage?.inventory||[]:LATEST?.xsession?.current_sessions||[]; } diff --git a/dashboard/js/current-sessions.js b/dashboard/js/current-sessions.js index 21a3a25..54fcae9 100644 --- a/dashboard/js/current-sessions.js +++ b/dashboard/js/current-sessions.js @@ -544,11 +544,19 @@ function renderCurrentSessions(state=LATEST){ const hintsHtml=hints.length?`
${hints.map(hint=>`${esc(hint.title)}${esc(hint.detail)}`).join('')}
`:''; const arriving=arrivingIds.has(String(row.id))?' is-arriving':''; const desktop=desktopSessionTarget(row); + const branch=sessionBranchFor(row),capsHtml=`${esc(capabilitySummaryText(row.capabilities))}`; + const capsLine=branch?`
${capsHtml}
`:capsHtml; + const branchSibling=currentSessionBranchSibling(row,branch); const desktopLink=desktop?``:''; - return `
${desktopLink}
`; + return `
${branchSibling}${desktopLink}
`; }).join(''):(filterView.activitySelection==='all'?currentSessionsEmptyHtml(state?.xsession):currentSessionsFilteredEmptyHtml(filterView.activitySelection)); + if(branchUiReady)placeCurrentSessionBranchChips(currentGrid); if(focusedId)requestAnimationFrame(()=>focusCurrentSessionCard(focusedId)); } +// The card is one button, so its branch chip is a sibling control laid over the capability line. +function currentSessionBranchSibling(row,branch){ + return branch?branchChipHtml(branch,{project:row.project||''}):''; +} function keepCurrentSessionFilterFocusVisible(event){ const button=event.target.closest('button'); if(button)button.scrollIntoView({block:'nearest',inline:'nearest'}); diff --git a/dashboard/js/git-branches.js b/dashboard/js/git-branches.js new file mode 100644 index 0000000..15899b0 --- /dev/null +++ b/dashboard/js/git-branches.js @@ -0,0 +1,130 @@ +// Git › Branches: three scored delivery metrics, a hard-to-finish count, and the branch table. +// Cost to ship, unshipped spend, rework, and the hard-to-finish spend share each have one healthy +// direction, so their deltas pass improving = down. Pushed-code volume and ratios elsewhere stay neutral. +const GIT_BRANCH_VISIBLE_ROWS=5; +const GIT_BRANCH_HARD_MIN=5; +let gitBranchSort={key:'spend',direction:'desc'},gitBranchQuery='',gitBranchPayload=null,gitBranchUnavailable=''; +function pointsTrendDelta(points,label){ + const value=Number(points); + if(points===null||points===undefined||points===''||!Number.isFinite(value))return {text:'No comparable prior period',direction:''}; + const direction=Math.abs(value)<.05?'flat':value>0?'up':'down',symbol=direction==='up'?'↑':direction==='down'?'↓':'→'; + return {text:`${symbol} ${Math.abs(value).toFixed(1)} pts vs ${label}`,direction}; +} +function gitBranchMetricCards(metrics,label){ + const none={text:'No comparable prior period',direction:''},m=metrics||{}; + const cost=m.cost_to_ship||{},unshipped=m.unshipped_share||{},rework=m.rework_share||{},hard=m.hard_branches||{}; + const lines=cost.median_pushed_lines,branches=Number(cost.branches)||0; + return [ + {id:'cost',label:'Cost to ship',improving:'down', + value:cost.available?money(cost.value):'--', + meta:cost.available?(lines==null?`${f(branches)} ${countWord(branches,'branch','branches')} · pushed lines unavailable`:`median ${f(lines)} pushed lines · ${f(branches)} ${countWord(branches,'branch','branches')}`):'', + reason:cost.available?'':'No branch had its first push in this period.', + delta:cost.available?percentTrendDelta(cost.comparison?.change_pct,label):none}, + {id:'unshipped',label:'Unshipped spend',improving:'down', + value:unshipped.available?pct(unshipped.value):'--', + meta:unshipped.available?`in progress ${pct(unshipped.in_progress_share)} · ${money(unshipped.in_progress_spend)}`:'', + reason:unshipped.available?'':'No Git evidence for these projects, or no branch spend in this period.', + delta:unshipped.available?pointsTrendDelta(unshipped.comparison?.change_pp,label):none}, + {id:'rework',label:'Rework after push',improving:'down', + value:rework.available?pct(rework.value):'--', + meta:rework.available?`Estimate${rework.spend!=null?` · ${money(rework.spend)}`:''}`:'', + reason:rework.available?'':'No spend on pushed branches in this period.', + delta:rework.available?pointsTrendDelta(rework.comparison?.change_pp,label):none}, + // Scored on its spend share, the figure its delta compares. + {id:'hard',label:'Hard to finish',improving:'down', + value:hard.available&&hard.count!=null?f(hard.count):'--', + meta:hard.available?`${hard.spend_share==null?'--':pct(hard.spend_share)} of spend · ${f(hard.eligible)} ${countWord(hard.eligible,'branch','branches')}`:'', + reason:hard.available?'':`Needs ${GIT_BRANCH_HARD_MIN} pushed branches with $1 or more spend; ${f(hard.eligible)} so far.`, + delta:hard.available?pointsTrendDelta(hard.comparison?.change_pp,label):none}, + ]; +} +function gitBranchSortValue(row,key){ + if(key==='branch')return String(row?.branch||''); + if(key==='status')return BRANCH_STATUS_LABELS[row?.status]||''; + if(key==='lines'){ + if(row?.added==null&&row?.deleted==null)return null; + return (Number(row.added)||0)+(Number(row.deleted)||0); + } + if(key==='spend'&&row?.availability?.cost!==true)return null; + if(key==='spend_per_commit'&&row?.availability?.spend_per_commit!==true)return null; + const value=row?.[key]; + if(value===null||value===undefined)return null; + const number=Number(value); + return Number.isFinite(number)?number:null; +} +function gitBranchRowsView(rows,{query='',sort={key:'spend',direction:'desc'}}={}){ + const direction=sort?.direction==='asc'?1:-1,key=sort?.key||'spend'; + const byName=(a,b)=>String(a?.branch||'').localeCompare(String(b?.branch||''),undefined,{sensitivity:'base'})||String(a?.project||'').localeCompare(String(b?.project||'')); + return (rows||[]).filter(row=>row&&branchMatchesQuery(row.branch,query)).sort((left,right)=>{ + const a=gitBranchSortValue(left,key),b=gitBranchSortValue(right,key); + if(a===null&&b===null)return byName(left,right); + if(a===null)return 1; + if(b===null)return -1; + const compared=typeof a==='string'?a.localeCompare(b,undefined,{sensitivity:'base'}):a-b; + return compared?compared*direction:byName(left,right); + }); +} +function gitBranchDate(stamp){ + const value=Number(stamp)||0; + return value?new Date(value*1000).toLocaleDateString([],{month:'short',day:'numeric'}):'--'; +} +function gitBranchRowHtml(row,typical){ + const available=row?.availability||{},git=available.git===true,perCommit=available.spend_per_commit===true; + const commitsShown=available.commits===true||(git&&row.status!=='pushed'&&row.commits!=null); + const hard=row.hard&&perCommit&&typical>0?`${(Number(row.spend_per_commit)/typical).toFixed(1)}× your typical per commit`:''; + const lines=git&&available.commits===true&&Number(row.pushes)>0&&row.added!=null?`+${f(row.added)} −${f(row.deleted)}`:'--'; + return `
${esc(branchReadableName(row.branch))}${branchChipHtml(row.branch,{project:row.project})}${hard}
${esc(BRANCH_STATUS_LABELS[row.status]||'--')}${available.cost===true?money(row.spend):'--'}${f(row.sessions)}${git&&row.pushes!=null?f(row.pushes):'--'}${commitsShown?f(row.commits):'--'}${lines}${perCommit?money(row.spend_per_commit):'--'}${esc(gitBranchDate(row.last_push))}`; +} +// Five rows show, then the table scrolls inside its own frame under a sticky header. +function fitGitBranchRows(){ + const wrap=$('d-branch-wrap'),head=wrap?.querySelector('thead'); + if(!wrap||!head)return; + const rows=[...wrap.querySelectorAll('tbody tr')]; + if(rows.length<=GIT_BRANCH_VISIBLE_ROWS){wrap.style.removeProperty('max-height');return;} + const height=head.getBoundingClientRect().height+rows.slice(0,GIT_BRANCH_VISIBLE_ROWS).reduce((sum,row)=>sum+row.getBoundingClientRect().height,0); + if(height>0)wrap.style.maxHeight=`${Math.ceil(height)}px`; +} +function renderGitBranchTable(){ + const payload=gitBranchPayload,rows=Array.isArray(payload?.rows)?payload.rows:[],body=$('d-branch-table').querySelector('tbody'); + const visible=gitBranchRowsView(rows,{query:gitBranchQuery,sort:gitBranchSort}); + const typical=Number(payload?.metrics?.hard_branches?.median_spend_per_commit)||0; + const empty=gitBranchUnavailable||(!rows.length?'No branches recorded in this period. Claude Code and Codex record the branch each session ran on.':'No branches match this search.'); + body.innerHTML=visible.length?visible.map(row=>gitBranchRowHtml(row,typical)).join(''):`
${esc(empty)}
`; + const total=Number(payload?.total_rows)||rows.length; + $('d-branch-count').textContent=payload?`${f(visible.length)} of ${f(total)} ${countWord(total,'branch','branches')}${payload.truncated?` · first ${f(rows.length)} shown`:''}`:''; + $('d-branch-table').querySelectorAll('th').forEach(header=>{ + const button=header.querySelector('[data-delivery-branch-sort]'),active=button?.dataset.deliveryBranchSort===gitBranchSort.key; + header.setAttribute('aria-sort',active?(gitBranchSort.direction==='asc'?'ascending':'descending'):'none'); + const mark=button?.querySelector('.deliverySortMark');if(mark)mark.textContent=active?(gitBranchSort.direction==='asc'?'↑':'↓'):''; + }); + fitGitBranchRows(); +} +function renderGitBranches(branches,unavailable=''){ + gitBranchPayload=branches&&typeof branches==='object'?branches:null; + gitBranchUnavailable=unavailable||(gitBranchPayload?'':'Branch evidence unavailable.'); + const label=deliveryRange==='month'?'same days last month':efficiencyComparisonWindows(deliveryRange)?.label||'prior period'; + gitBranchMetricCards(gitBranchPayload?.metrics,label).forEach(card=>{ + const value=$(`d-branch-${card.id}`); + value.textContent=card.value; + value.classList.toggle('fieldtip',Boolean(card.reason)); + if(card.reason){value.dataset.tip=card.reason;value.setAttribute('aria-description',card.reason);value.tabIndex=0;} + else{delete value.dataset.tip;value.removeAttribute('aria-description');value.tabIndex=-1;} + $(`d-branch-${card.id}-note`).textContent=card.reason||card.meta; + renderTrendDelta(`d-branch-${card.id}-change`,card.delta,card.improving,card.value==='--'); + }); + const trunk=gitBranchPayload?.trunk?.share,none=gitBranchPayload?.no_branch?.share; + $('d-branch-caption').textContent=trunk==null&&none==null + ?'Not counted: spend on main and sessions with no branch recorded.' + :`Not counted: ${trunk==null?'--':pct(trunk)} of this period\u2019s spend on main and ${none==null?'--':pct(none)} with no branch recorded.`; + renderGitBranchTable(); +} +$('d-branch-search').addEventListener('input',event=>{gitBranchQuery=event.target.value;$('d-branch-wrap').scrollTop=0;renderGitBranchTable();}); +$('d-branch-table').querySelector('thead').addEventListener('click',event=>{ + const button=event.target.closest('[data-delivery-branch-sort]'); + if(!button)return; + const key=button.dataset.deliveryBranchSort; + gitBranchSort=gitBranchSort.key===key?{key,direction:gitBranchSort.direction==='asc'?'desc':'asc'}:{key,direction:key==='branch'||key==='status'?'asc':'desc'}; + $('d-branch-wrap').scrollTop=0; + renderGitBranchTable(); +}); +window.addEventListener('resize',()=>{if($('view-git').classList.contains('on'))fitGitBranchRows();}); diff --git a/dashboard/js/git.js b/dashboard/js/git.js index 0087de3..f8598c6 100644 --- a/dashboard/js/git.js +++ b/dashboard/js/git.js @@ -500,6 +500,6 @@ function renderDelivery(payload){ coverageNode.classList.toggle('partial',evidencePartial||!checked); coverageNode.textContent=!checked?'Scanning local Git':selectedCount?(evidencePartial?'Partial evidence':'Evidence ready'):'No Git projects found'; drawDeliveryChart(days);drawDeliveryTypeChart(payload?.work_types);renderDeliveryModelTable(payload?.model_rows||[],payload?.model_coverage);renderDeliveryTable(payload?.project_rows||[]); - renderDeliveryEvidence(payload);renderDeliverySignals(payload);renderDeliveryShape(payload); + renderDeliveryEvidence(payload);renderDeliverySignals(payload);renderDeliveryShape(payload);renderGitBranches(payload?.branches); } let deliveryRetryTimer=0,deliveryLoadRetries=0; diff --git a/dashboard/js/live.js b/dashboard/js/live.js index 6b603ed..749e352 100644 --- a/dashboard/js/live.js +++ b/dashboard/js/live.js @@ -127,6 +127,11 @@ function applyLiveState(state){ if(inventoryStale)loadAllSessions(); else renderAllSessions(LATEST.xsession); } + if(sessionSurfaceActive&¤tPanel==='sessions'&&sessionScope==='branches'){ + const inventoryStale=!allSessionInventory||allSessionInventoryAt!==Number(LATEST.xsession?.generated_at||0); + if(inventoryStale)loadAllSessions(); + } + if(sessionSurfaceActive&¤tPanel==='sessions'&&sessionScope==='current'&&branchUiReady)ensureBranchInventory(LATEST.xsession?.current_sessions); if(sessionSurfaceActive&¤tPanel==='sessions'&&sessionScope==='compare'&&compareIds.length&&!compareLoading&&Date.now()-compareLoadedAt>15000)loadComparison({force:true}); if(sessionSurfaceActive&¤tPanel==='sessions'&&sessionScope==='subagents'){ const inventoryStale=!allSessionInventory||allSessionInventoryAt!==Number(LATEST.xsession?.generated_at||0); diff --git a/dashboard/js/router.js b/dashboard/js/router.js index fad76c6..1a0f9c0 100644 --- a/dashboard/js/router.js +++ b/dashboard/js/router.js @@ -15,8 +15,8 @@ function applyHashRoute(){ if(LATEST)renderSubagentExplorer(LATEST.xsession?.agent_usage); return; } - if(h==='sessions'||h==='sessions-all'||h==='sessions-compare'||h==='current-sessions'){ - const scope=h==='sessions-all'?'all':h==='sessions-compare'?'compare':'current',route=sessionScopeRoute(scope); + if(h==='sessions'||h==='sessions-all'||h==='sessions-compare'||h==='sessions-branches'||h==='current-sessions'){ + const scope=h==='sessions-all'?'all':h==='sessions-compare'?'compare':h==='sessions-branches'?'branches':'current',route=sessionScopeRoute(scope); if((routedSessionId()||h==='current-sessions')&&history.replaceState)history.replaceState(null,'',`/#${route}`); if(pinned){sessionRequest++;pinned=null;CURRENT=LATEST;} setSessionScope(scope); diff --git a/dashboard/js/session-budgets-filters.js b/dashboard/js/session-budgets-filters.js index 05c970c..1320430 100644 --- a/dashboard/js/session-budgets-filters.js +++ b/dashboard/js/session-budgets-filters.js @@ -220,6 +220,7 @@ async function loadAllSessions(){ allSessionInventoryAt=Number(payload.generated_at||0); if(LATEST&&$('view-session').classList.contains('on')&&sessionScope==='all')renderAllSessions(LATEST.xsession); if(LATEST&&($('view-subagents').classList.contains('on')||($('view-session').classList.contains('on')&&sessionScope==='subagents')))renderSubagentExplorer(LATEST.xsession?.agent_usage); + if(branchUiReady)refreshBranchSurfaces(); return allSessionInventory; }).catch(()=>allSessionInventory||[]).finally(()=>{allSessionsRequest=null;}); return allSessionsRequest; diff --git a/dashboard/js/session-detail.js b/dashboard/js/session-detail.js index 0e33273..39ff365 100644 --- a/dashboard/js/session-detail.js +++ b/dashboard/js/session-detail.js @@ -192,6 +192,7 @@ function renderCurrentRun(s){ $('preview-provider').innerHTML=runtimeBadgeContent(s,source.label||s.provider||'source'); $('preview-provider').className='badge app '+appBadgeClass(s); $('preview-project').textContent=s.project||source.project||''; + if(branchUiReady)renderSessionBranch(s); $('preview-model').textContent=s.primary_model||'model unavailable'; renderWorkTags(s.work_tags); renderDesktopSessionLink(s); diff --git a/dashboard/js/session-logs-compare.js b/dashboard/js/session-logs-compare.js index 2053b11..2f7729d 100644 --- a/dashboard/js/session-logs-compare.js +++ b/dashboard/js/session-logs-compare.js @@ -13,7 +13,7 @@ const CAPABILITY_SUMMARY_TIP='Used / loaded skills and MCP servers. * means conf function logRowRenderKey(s,active){ // Child subagent rows are part of this row's content, so a change in the // children must invalidate the cached innerHTML. - return JSON.stringify([active,compareIds.indexOf(compareKeyFor(s)),compareIds.length>=COMPARE_MAX,sessionDeleteAvailable(s,renderedAllSessionActions),s.subagent,s.client,s.provider,s.label,s.title,s.project,s.last,s.start,s.wait_time,s.turns,s.cost,s.availability,s.models,s.model_identity,s.tokens,s.provenance,s.usage_basis,s.token_estimate,s.throughput,s.cost_approx,s.capabilities,childAgentsFor(s).map(c=>[c.id,c.cost,c.tokens,c.title,c.last,c.child_agent_role,c.child_depth,c.availability,c.models])]); + return JSON.stringify([active,compareIds.indexOf(compareKeyFor(s)),compareIds.length>=COMPARE_MAX,sessionDeleteAvailable(s,renderedAllSessionActions),s.subagent,s.client,s.provider,s.label,s.title,s.project,s.branch,s.last,s.start,s.wait_time,s.turns,s.cost,s.availability,s.models,s.model_identity,s.tokens,s.provenance,s.usage_basis,s.token_estimate,s.throughput,s.cost_approx,s.capabilities,childAgentsFor(s).map(c=>[c.id,c.cost,c.tokens,c.title,c.last,c.child_agent_role,c.child_depth,c.availability,c.models])]); } function logRowContent(s){ const rowCost=metricAvailable(s,'cost'),localEstimate=hasLocalEstimate(s),avg=rowCost&&s.turns?money((s.cost||0)/s.turns)+estimateSuffix(s):'--'; @@ -24,7 +24,7 @@ function logRowContent(s){ const modelText=unresolvedIdentity?unresolvedModelText:`${(s.models||[]).join(', ')||'--'}${identityLabel}`,throughput=s.throughput||{},speed=throughput.available?` · ${speedFmt(throughput.output_tps)} ${localEstimate?'visible out tok/s est':'out tok/s'}`:''; const identityAction=identity.assignable&&identity.key?``:''; return `${compareToggleHtml(s)}
${runtimeMarkSvg(s)}${esc(s.label||s.provider)}
-
${s.subagent?'Subagent ':''}${esc(s.title)}
${esc(s.project||'local')} · ${esc(s.last||s.start||'')} · ${esc(capabilitySummaryText(s.capabilities))}
${childAgentSubsection(s)}
+
${s.subagent?'Subagent ':''}${esc(s.title)}
${esc(s.project||'local')} · ${esc(s.last||s.start||'')} · ${esc(capabilitySummaryText(s.capabilities))}${s.branch?` ${branchChipHtml(s.branch,{project:s.project})}`:''}
${childAgentSubsection(s)}
${esc(modelText)}
${costValueHtml(money(s.cost)+(s.cost_approx?' est':''),rowCost)}
${identityAction}
${sessionDeleteAvailable(s,renderedAllSessionActions)?``:''}
`; diff --git a/dashboard/js/subagents.js b/dashboard/js/subagents.js index e4a0187..06f0958 100644 --- a/dashboard/js/subagents.js +++ b/dashboard/js/subagents.js @@ -2,7 +2,8 @@ function mergeAllSessionInventory(inventory,liveSessions){ // One row per trace file: the server's opaque trace key when present, else the session id. const key=row=>row?.key?compareKeyFor(row):sessionRowKey(row); const rows=new Map((inventory||[]).map(row=>[key(row),row])); - (liveSessions||[]).forEach(row=>rows.set(key(row),row)); + // Live rows win, but only the /logs inventory carries the trace branch, so keep it. + (liveSessions||[]).forEach(row=>{const prior=rows.get(key(row));rows.set(key(row),prior?.branch&&!row?.branch?{...row,branch:prior.branch}:row);}); return [...rows.values()]; } function subagentUsageIdentity(row,dimension){ diff --git a/dashboard/js/work-insights.js b/dashboard/js/work-insights.js index 932a50a..50a7197 100644 --- a/dashboard/js/work-insights.js +++ b/dashboard/js/work-insights.js @@ -370,6 +370,7 @@ async function loadDelivery(){ $('d-push-yield').textContent='--';$('d-push-yield-note').textContent='Waiting for output tokens.';$('d-rework').textContent='--'; $('d-signals').innerHTML='
Git evidence unavailable.
'; $('d-shape').innerHTML='
Git evidence unavailable.
'; + renderGitBranches(null,'Git evidence unavailable.'); $('d-evidence-spend').textContent='--';setDeliveryEvidenceRatio('Git evidence unavailable.','Git evidence unavailable. Ratios require overlapping spend and Git evidence.');$('d-evidence-freshness').textContent='Git scan unavailable.'; ['comparable','spend','git','unavailable'].forEach(key=>{$(`d-evidence-count-${key}`).textContent='--';}); }finally{if(request===deliveryRequest)deliveryLoading=false;} diff --git a/dashboard/markup/git-learn-tools.html b/dashboard/markup/git-learn-tools.html index 76c7967..de21ffb 100644 --- a/dashboard/markup/git-learn-tools.html +++ b/dashboard/markup/git-learn-tools.html @@ -61,6 +61,12 @@

Code by model

AppEffort
+
+

Branches

+
Cost to ship
--
Waiting for branch evidence.
Unshipped spend
--
Waiting for branch evidence.
Rework after push
--
Waiting for branch evidence.
Hard to finish
--
Waiting for branch evidence.
+

+
+

Projects

diff --git a/dashboard/markup/shell.html b/dashboard/markup/shell.html index c993d22..4c9834a 100644 --- a/dashboard/markup/shell.html +++ b/dashboard/markup/shell.html @@ -195,6 +195,7 @@

Sessions

+ @@ -267,6 +268,42 @@

All sessions

+