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.
+### 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.
+
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=`
`;
+}
+// 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)}
`;
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='
source--
+
--
diff --git a/dashboard/page.manifest b/dashboard/page.manifest
index 1c93a97..a472663 100644
--- a/dashboard/page.manifest
+++ b/dashboard/page.manifest
@@ -32,6 +32,7 @@ js/capabilities.js
js/spend.js
js/motion-readouts.js
js/git.js
+js/git-branches.js
js/work-status.js
js/work-insights.js
js/efficiency.js
@@ -40,6 +41,7 @@ js/session-logs-compare.js
js/subagents.js
js/subagents-explorer.js
js/all-sessions.js
+js/branches.js
js/current-sessions.js
js/live.js
tail.html
diff --git a/dashboard/styles/components.css b/dashboard/styles/components.css
index 92fd0dc..5424cd8 100644
--- a/dashboard/styles/components.css
+++ b/dashboard/styles/components.css
@@ -51,6 +51,14 @@
:where(.levelBar[data-tone=warn]){--level-tone:var(--warn)}
:where(.levelBar>i){display:block;width:var(--fill,0%);min-width:2px;height:100%;border-radius:inherit;background:var(--level-tone);transition:width var(--dur-data) var(--ease-out)}
:where(.levelBar>b){position:absolute;top:-4px;bottom:-4px;left:var(--mark,0%);width:2px;border-radius:var(--radius-2xs);background:var(--fg);transform:translateX(-50%)}
+:where(.branchChip){display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:100%;padding:2px 8px;border:1px solid var(--line2);border-radius:var(--radius-pill);background:rgb(var(--tint-rgb)/.045);color:var(--dim);font-family:var(--font-sans);font-size:var(--fs-11);font-weight:var(--fw-medium);line-height:1.4;letter-spacing:normal;text-transform:none;vertical-align:middle;white-space:nowrap}
+:where(.branchChip)[hidden]{display:none}
+:where(button.branchChip){cursor:pointer}
+:where(button.branchChip:hover,button.branchChip:focus-visible){border-color:var(--line3);color:var(--fg)}
+:where(button.branchChip:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
+:where(.branchChip .branchChipIcon){flex:0 0 auto;width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
+:where(.branchChip .branchChipName){min-width:0;overflow:hidden;font-family:var(--font-mono);text-overflow:ellipsis}
+:where(.branchChip .branchChipKey){flex:0 0 auto;color:var(--accent);font-family:var(--font-mono);font-weight:var(--fw-bold)}
@media(prefers-reduced-motion:reduce){:where(.readoutScale>i){transition:none}}
.badge.codex{color:var(--provider-codex);border-color:rgb(var(--provider-codex-rgb)/.42);background:rgb(var(--provider-codex-rgb)/.08)}
.badge.claude{color:var(--provider-claude);border-color:rgb(var(--provider-claude-rgb)/.42);background:rgb(var(--provider-claude-rgb)/.08)}
@@ -233,6 +241,11 @@
.currentSessionMetric b.mono{font-size:var(--fs-18);letter-spacing:-.015em}
.currentSessionMove{display:inline-flex;align-items:center;gap:4px}
.currentSessionCaps{display:block;margin-top:6px;overflow:hidden;color:var(--faint);font-size:var(--fs-11);text-overflow:ellipsis;white-space:nowrap}
+.currentSessionCapsRow{display:flex;align-items:center;gap:8px;min-width:0;margin-top:6px}
+.currentSessionCapsRow .currentSessionCaps{flex:0 1 auto;min-width:0;margin-top:0}
+.currentSessionBranchAnchor{display:flex;flex:0 1 auto;min-width:0;max-width:60%;visibility:hidden}
+.currentSessionSlot>.branchChip{position:absolute;top:0;left:0;z-index:2}
+.currentSessionSlot>.branchChip:not([data-placed]){visibility:hidden}
.currentSessionProject{flex:0 1 auto;color:var(--faint);font-family:var(--font-mono);font-size:var(--fs-11);font-weight:var(--fw-medium);letter-spacing:0}
.currentSessionProject:before{content:"·";margin-right:8px}
.currentSessionTopMeta{display:inline-flex;flex:0 0 auto;align-items:center;gap:12px;color:var(--faint);font-size:var(--fs-11)}
diff --git a/dashboard/styles/git.css b/dashboard/styles/git.css
index 0acc20d..aa08bbe 100644
--- a/dashboard/styles/git.css
+++ b/dashboard/styles/git.css
@@ -99,3 +99,18 @@
.deliveryTypeActions{display:flex;align-items:center;gap:12px;flex-shrink:0}
.deliveryTypeActions .workStatusPill{font-weight:var(--fw-semibold);letter-spacing:normal;text-transform:none}
+.deliveryBranchMetrics{padding:0 18px}
+.deliveryBranchMetrics .deliveryMetricMeta{overflow:hidden;text-overflow:ellipsis}
+.deliveryBranchCaption{margin:0;padding:8px 18px 12px;color:var(--faint);font-size:var(--fs-11)}
+.deliveryBranches .deliveryEvidenceFilter .searchbox{height:32px;min-width:200px}
+.deliveryBranchTableWrap{max-height:320px;overflow:auto;overscroll-behavior:contain;border-top:1px solid var(--line)}
+.deliveryBranchTableWrap .tbl{min-width:860px}
+.deliveryBranchTableWrap .tbl th{position:sticky;top:0;z-index:2;background:var(--panel)}
+.deliveryBranchTableWrap .tbl th,.deliveryBranchTableWrap .tbl td{padding:10px 8px}
+.deliveryBranchTableWrap .tbl th:first-child,.deliveryBranchTableWrap .tbl td:first-child{padding-left:16px}
+.deliveryBranchTableWrap .tbl td:first-child{min-width:220px}
+.deliveryBranchTableWrap .tbl td.num{white-space:nowrap}
+.deliveryBranchTableWrap:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.deliveryBranchName{display:grid;gap:4px;justify-items:start;min-width:0;max-width:300px}
+.deliveryBranchName>b{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.deliveryBranchTableWrap .chip{white-space:nowrap}
diff --git a/dashboard/styles/motion-sessions.css b/dashboard/styles/motion-sessions.css
index 21b1837..0f517f7 100644
--- a/dashboard/styles/motion-sessions.css
+++ b/dashboard/styles/motion-sessions.css
@@ -26,7 +26,7 @@ body.sessionRoute #view-session .sessionFlowHead[data-session-flow=idle] .pageSi
body.sessionRoute #view-session .sessionFlowHead[data-session-flow=unavailable] .pageSignalField{opacity:.62}
body.sessionRoute #view-session.sessionDetail .sessionFlowHead :is(.pageSignalField,.pageHeroBorder),body.sessionRoute #view-session.sessionDetail .spectrumPageActions{display:none}
@keyframes sessionFlowStatusPulse{0%,70%{box-shadow:0 0 0 0 rgb(var(--green-rgb)/.4)}100%{box-shadow:0 0 0 9px transparent}}
-body.sessionRoute #view-session .sessionScopeTabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;width:100%;margin:-4px 0 12px;padding:4px;border-color:rgb(var(--signal-rgb)/.24);background:rgb(var(--bg-rgb)/.72)}
+body.sessionRoute #view-session .sessionScopeTabs{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;width:100%;margin:-4px 0 12px;padding:4px;border-color:rgb(var(--signal-rgb)/.24);background:rgb(var(--bg-rgb)/.72)}
body.sessionRoute #view-session .sessionScopeTabs .tab{display:flex;min-height:44px;align-items:center;justify-content:center;gap:8px;padding:10px 14px;text-align:center}
body.sessionRoute #view-session .sessionScopeTabs .tab .tabIcon{width:17px;height:17px;flex:0 0 auto}
body.sessionRoute #view-session .sessionScopeTabs .tab[aria-selected=true]{background:var(--spectrum-active);color:var(--on-accent);box-shadow:0 7px 20px rgb(var(--signal-rgb)/.16)}
diff --git a/dashboard/styles/sessions-subagents.css b/dashboard/styles/sessions-subagents.css
index db31c13..ae20ef4 100644
--- a/dashboard/styles/sessions-subagents.css
+++ b/dashboard/styles/sessions-subagents.css
@@ -340,3 +340,34 @@ body.sessionRoute #view-session.sessionSubagents .currentDaySummary{display:none
#view-subagents .subagentCommand .subagentFilterPrimary{padding-bottom:12px}
#view-subagents .subagentCommand .subagentFilterFooter{margin-top:0}
@media(max-width:1100px){#view-subagents .subagentRoleKpi{padding:12px}#view-subagents .subagentRoleKpi strong{font-size:var(--fs-20)}}
+body.sessionRoute #view-session .logsSummary.branchSummary .logKpis{grid-template-columns:repeat(4,minmax(0,1fr))}
+#branch-spend .branchKpis .logStat:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+#branch-spend .branchStatusFilter .filterSelect{min-width:160px;height:32px}
+.srow .meta .branchChip{max-width:280px;margin-left:4px}
+#preview-branch .branchChip{max-width:320px}
+.branchTableWrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius-md);background:linear-gradient(180deg,rgb(var(--panel-rgb)/.94),rgb(var(--bg2-rgb)/.94))}
+.branchTable{min-width:820px}
+.branchTable th,.branchTable td{padding:10px 8px;vertical-align:middle}
+.branchTable th:first-child,.branchTable td:first-child{min-width:200px;padding-left:16px}
+.branchTable th:last-child,.branchTable td:last-child{padding-right:16px}
+.branchTable td.num,.branchTable th.num{text-align:right;white-space:nowrap}
+.branchRow{cursor:pointer}
+.branchRow:hover{background:rgb(var(--tint-rgb)/.03)}
+.branchRow.open{background:rgb(var(--signal-rgb)/.06)}
+.branchToggle{display:grid;gap:4px;justify-items:start;min-width:0;max-width:360px;padding:0;border:0;background:none;color:var(--fg);font:inherit;text-align:left;cursor:pointer}
+.branchToggle:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:var(--radius-xs)}
+.branchIdentity{display:flex;align-items:center;gap:6px;min-width:0;max-width:100%}
+.branchName{overflow:hidden;font-weight:var(--fw-bold);text-overflow:ellipsis;white-space:nowrap}
+.branchProject{max-width:140px;overflow:hidden;color:var(--dim);text-overflow:ellipsis;white-space:nowrap}
+.branchApps{display:inline-flex;gap:4px;align-items:center}
+.branchAppDot{display:block;width:8px;height:8px;border-radius:var(--radius-round);background:var(--branch-app)}
+.branchMuted{color:var(--faint);font-size:var(--fs-12)}
+.branchSessionsRow>td{padding-top:0}
+.branchSessions{display:grid;gap:4px;padding:0 0 8px 16px}
+.branchSession{display:grid;grid-template-columns:minmax(0,1fr) 150px 140px 90px;gap:12px;align-items:center;padding:8px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:rgb(var(--bg-rgb)/.4);color:var(--fg);font:inherit;font-size:var(--fs-12);text-align:left;cursor:pointer}
+.branchSession:hover{border-color:var(--line3)}
+.branchSession:focus-visible{border-color:var(--line3);outline:2px solid var(--accent);outline-offset:2px}
+.branchSession>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.branchSession>.num{text-align:right}
+.branchSessionTitle{font-weight:var(--fw-semibold)}
+.branchEmpty{min-height:160px}
diff --git a/specs/AGENTS.md b/specs/AGENTS.md
index e2c3adc..115780c 100644
--- a/specs/AGENTS.md
+++ b/specs/AGENTS.md
@@ -87,7 +87,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/specs/ARCHITECTURE.md b/specs/ARCHITECTURE.md
index 6f56a27..644bbd1 100644
--- a/specs/ARCHITECTURE.md
+++ b/specs/ARCHITECTURE.md
@@ -211,6 +211,8 @@ effective-dated prices, estimates, availability, and provenance. Timing and
throughput live in `domain/timing.py`; tool/capability evidence in
`domain/tools.py`; derived guidance in `domain/insights.py`; and cross-session,
daily, model, and tool aggregation in `domain/aggregates.py`.
+`domain/branches.py` normalizes trace branch names and builds the per-branch
+spend and delivery rollup served only to the dashboard's Git page.
`domain/agents.py` resolves only adapter-supplied opaque relationships, rejects
ambiguous or cyclic graphs, preserves exact full-group totals behind a
100-agent display bound, and aggregates child-only cohorts. Runtime adapters
@@ -551,6 +553,19 @@ of that project's covered spend in the period. Public project discriminators use
salt. Clearing the ledger establishes a timestamped baseline so older reflogs
do not repopulate it. The service does not contact remotes.
+Ledger schema 2 adds per-branch push evidence (`services/git_branches.py`)
+beside, never inside, the per-repository totals. A push to a remote-tracking
+ref other than a trunk (`main`, `master`, `develop`, `dev`, `trunk`, or the
+remote's default from its local `HEAD` symref) records a salted per-repository
+branch key, the push time, and that branch's own commits: non-merge, authored by
+the repository identity, and not reachable from the remote's trunk refs as their
+reflogs stood at push time. A push older than every trunk reflog entry is stored
+incomplete (unknown, never zero), and a push whose remote has a trunk ref that
+was not read this pass waits for a later pass. A commit counts once per branch key. Claude and
+Codex adapters record each session's trace branch privately as `_branch`;
+`GET /git-delivery` joins it to the ledger by computing the same salted key and
+returns a bounded `branches` rollup. MCP `git_stats` never requests it.
+
The Git page's Delivery economics surfaces are derived in the browser from that
same projection; they add no field, endpoint, or stored state. Ranked signals,
and daily distributions are pure functions over the returned
@@ -745,7 +760,7 @@ size limits, sanitized errors, and in-memory caching. No credential is copied to
Token Meter storage or another provider.
Git performs no network operation. Its SQLite ledger contains only salted
-repository/object keys, local observation timestamps and days, numeric text-line
+repository/object/branch keys, local observation timestamps and days, numeric text-line
totals, coverage state, and, when Work insights are on, one commit work-type label
per recorded commit (type, confidence, question version, model digest). The scan
reads a commit subject only to hand it to the Work insights in-memory queue
@@ -754,6 +769,13 @@ messages, filenames, diffs, raw object IDs, command output, and exceptions are
never persisted or projected. Missing Git, identity, repository access, or
reflog history remains partial or unavailable.
+Branch names come only from agent traces. They may appear on the local
+dashboard, like session titles, and never in MCP, the menu bar, notifications,
+or logs. The Git ledger stores a salted branch key per push, never the ref name.
+That key is pseudonymous, not secret: the salt lives in the same local ledger, so
+anyone who can read the ledger can test guessable names such as `main` or
+`fix/login`. This is the same exposure class as the project suffix in Git labels.
+
`token_meter/telemetry/` is mapping-only. It contains no OpenTelemetry SDK,
exporter, collector address, socket, file sink, subprocess, or background
setting. Adding export is a separate privacy and dependency decision.
diff --git a/specs/DESIGN.md b/specs/DESIGN.md
index 5dd7abe..947f58a 100644
--- a/specs/DESIGN.md
+++ b/specs/DESIGN.md
@@ -349,6 +349,10 @@ negative status score.
The daily chart follows the overview and pairs pushed text lines with trailing
seven-day cost intensity. It stands on its own without a secondary metric rail.
+Branches sits before the project table: a four-tile strip (cost to ship, unshipped
+spend, rework after first push, hard to finish), a caption for the main and
+no-branch spend it does not count, and a searchable, sortable branch table that
+shows five rows and then scrolls inside its frame under a sticky header.
The sortable project table is the final drill-down. Visible helper copy is
limited to the product boundary; detailed metric definitions live in accessible
field tooltips. Git does not attribute pushed work to a session or model and
@@ -498,6 +502,7 @@ Components own identity (color, type, tracking, surface), not layout.
| Frame callout | `.frameCallout`, rendered by `calloutHtml()` | A 24px numbered marker on the active gradient with a dark ring, so it reads on any component. Inline by default, beside the value it explains; `data-at=tr`, `tl`, `t`, `r`, or `l` pins it to an edge of the nearest `[data-callout]`. It is a `` (no product rule styles that tag) and is `aria-hidden`; the legend carries the words | `1` |
| Frame legend | `.frameLegend` | The numbered explanation under a frame: an ordered list whose items repeat the marker, then one line of at most 12 words | `
1…
` |
| Level bar | `.levelBar`, rendered by `levelBarHtml()` | A pill track filled to `--fill` with the active gradient; `data-tone=warn` switches to `--warn`; an optional `` tick at `--mark` (for example, a projection). Fills only from real values, never decoratively | `` |
+| Branch chip | `.branchChip`, rendered by `branchChipHtml()` | The Git branch a session ran on: a 12px branch icon, the raw branch name in `--font-mono`, and an issue key such as `ABC-12` in `--accent` when the name has one. A pill that hugs its content and truncates the name with an ellipsis. As a `