← back to Desktop Dotbar
public/index.html
550 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dot Bar</title>
<style>
:root { --bar-h: 48px; --bar-w: 210px; --bg:#14161a; --bg2:#1c1f26; --line:#2a2e37; --fg:#e8eaed; --dim:#9aa0aa; }
* { box-sizing: border-box; }
html, body { margin:0; height:100%; background:transparent; overflow:hidden;
font: 13px -apple-system, "SF Pro Text", system-ui, sans-serif; color: var(--fg);
-webkit-user-select:none; user-select:none; }
/* Body is a flex container; orientation decides the axis. TOP = bar over panel (column);
LEFT = bar left of panel (row); RIGHT = bar right of panel (row-reverse). TK-11885. */
body { display:flex; }
body.orient-top { flex-direction:column; }
body.orient-left { flex-direction:row; }
body.orient-right { flex-direction:row-reverse; }
/* the always-visible strip */
#bar { display:flex; align-items:center; gap:4px; padding:0 10px;
background:linear-gradient(180deg,#191c22,#14161a); border-bottom:1px solid var(--line);
-webkit-app-region: drag; }
body.orient-top #bar { height: var(--bar-h); width:100%; flex-direction:row; }
/* SIDE dock: the strip is a vertical column, chips stacked, controls at the bottom. */
body.orient-left #bar, body.orient-right #bar {
width: var(--bar-w); height:100%; flex-direction:column; align-items:stretch;
padding:8px 6px; gap:4px; overflow-y:auto; border-bottom:none; }
body.orient-left #bar { border-right:1px solid var(--line); }
body.orient-right #bar { border-left:1px solid var(--line); }
/* Drag-to-resize grip on the bar's inner edge. TOP: bottom edge (ns-resize, changes height).
SIDE: inner vertical edge (ew-resize, changes width). Kept OUT of #bar (renderBar() wipes
#bar every tick), so it lives as a fixed sibling that tracks the bar thickness. */
#grip { position:fixed; z-index:50; -webkit-app-region:no-drag; }
body.orient-top #grip { left:0; right:0; top: calc(var(--bar-h) - 8px); height:8px; cursor:ns-resize; }
body.orient-left #grip { top:0; bottom:0; left: calc(var(--bar-w) - 8px); width:8px; cursor:ew-resize; }
body.orient-right #grip { top:0; bottom:0; right: calc(var(--bar-w) - 8px); width:8px; cursor:ew-resize; }
/* Visible grab-handle (Steve 2026-09-23): a persistent DOTTED handle so the resize grip is
discoverable, not an invisible hover strip. Baseline .75 opacity, brightens to --fg on hover. */
#grip::after { content:""; position:absolute; border-radius:3px; opacity:.75; background:var(--dim); }
body.orient-top #grip::after { left:50%; bottom:1px; width:58px; height:4px; transform:translateX(-50%);
background-image: repeating-linear-gradient(90deg, var(--bg) 0 2px, transparent 2px 6px); }
body.orient-left #grip::after, body.orient-right #grip::after { top:50%; left:2px; width:4px; height:58px; transform:translateY(-50%);
background-image: repeating-linear-gradient(0deg, var(--bg) 0 2px, transparent 2px 6px); }
#grip:hover::after { opacity:1; background:var(--fg); }
/* Right-end LENGTH grip — TOP dock only: drag the bar's right edge to contract/expand its horizontal
span (ew-resize), left-anchored. Hidden on side docks (there the main #grip changes width). Stops
8px short of the bottom so it never fights the height grip in the corner. Steve 2026-09-17. */
#gripx { position:fixed; z-index:50; -webkit-app-region:no-drag; display:none; }
body.orient-top #gripx { display:block; top:0; right:0; width:8px; height: calc(var(--bar-h) - 8px); cursor:ew-resize; }
#gripx::after { content:""; position:absolute; right:2px; top:20%; height:60%; width:4px; border-radius:3px;
opacity:.75; background:var(--dim); background-image: repeating-linear-gradient(0deg, var(--bg) 0 2px, transparent 2px 6px); }
#gripx:hover::after { opacity:1; background:var(--fg); }
.chip { -webkit-app-region:no-drag; cursor:pointer; display:flex; align-items:center; gap:6px;
padding:6px 10px; border-radius:9px; border:1px solid transparent; line-height:1; white-space:nowrap; }
.chip:hover { background:var(--bg2); border-color:var(--line); }
.chip.active { background:var(--bg2); border-color:var(--line); }
body.orient-left .chip, body.orient-right .chip { justify-content:flex-start; }
.dot { width:12px; height:12px; border-radius:50%; box-shadow:0 0 0 1px rgba(0,0,0,.35) inset; flex:0 0 auto; }
/* Steve 2026-09-15 dot-flash directive: the four needs-Steve states PULSE; green/pink stay solid. */
@keyframes dotpulse { 0%,100%{ opacity:1; transform:scale(1); } 50%{ opacity:.35; transform:scale(1.4); } }
.dot.pulse, .rdot.pulse { animation: dotpulse 1.1s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce){ .dot.pulse, .rdot.pulse { animation:none; opacity:1; } }
.cnt { font-variant-numeric:tabular-nums; font-weight:700; font-size:14px; }
.nm { color:var(--dim); font-size:11px; }
/* Ticket-state chips (Steve 2026-09-23): colour dot + count stay left, the state WORD
(blocked/open/idle/…) is pushed to the chip's right edge. margin-left:auto eats the free
space between count and label on the full-width side-dock chip. */
.tkchip .nm { margin-left:auto; padding-left:8px; }
/* Hairline before the dw chip: the five chips above it are a cut by STATE, the two below are the
same tickets cut by BUSINESS, so the counts read down the column but must not be summed across
the line. The rule follows the bar's axis — a left edge when the bar is a row, a top edge when
it is a column (Steve 2026-09-25 "add dw and non dw here"). */
body.orient-top .tkchip.seg-start { border-left:1px solid var(--line); margin-left:6px; padding-left:10px; }
body.orient-left .tkchip.seg-start, body.orient-right .tkchip.seg-start { border-top:1px solid var(--line); margin-top:6px; padding-top:8px; }
/* the flexible gap that pushes #meta to the far end (right on top; bottom on side) */
#spacer, #spacer2 { flex:1; -webkit-app-region:drag; }
/* Individually-clickable ticket-state segments (Steve 2026-09-23): hover highlights the one under the cursor. */
.tkseg { -webkit-app-region:no-drag; transition:background .1s; }
.tkseg:hover { background:var(--bg2); box-shadow:inset 0 0 0 1px var(--line); }
#meta { -webkit-app-region:no-drag; color:var(--dim); font-size:11px; display:flex; gap:10px; align-items:center; }
body.orient-left #meta, body.orient-right #meta { flex-direction:column; align-items:stretch; gap:6px; }
#meta b { color:var(--fg); font-variant-numeric:tabular-nums; }
.icobtn { -webkit-app-region:no-drag; cursor:pointer; color:var(--dim); padding:4px 6px; border-radius:6px; }
.icobtn:hover { background:var(--bg2); color:var(--fg); }
/* orientation / auto-hide toggle buttons (TK-11885) */
.cfgbtn { -webkit-app-region:no-drag; cursor:pointer; font-size:11px; padding:5px 9px; border-radius:7px;
border:1px solid var(--line); background:var(--bg2); color:var(--fg); white-space:nowrap; }
.cfgbtn:hover { background:#2a2e37; }
.cfgbtn.on { border-color:#35507a; background:#1d2836; color:#cfe2ff; }
/* One-click re-tile of every screen. */
.arrbtn { -webkit-app-region:no-drag; cursor:pointer; font-weight:700; font-size:12px;
padding:5px 11px; border-radius:8px; border:1px solid #35507a; background:#1d2836; color:#cfe2ff;
white-space:nowrap; }
.arrbtn:hover { background:#25344a; border-color:#4a6ea8; }
.arrbtn.busy { opacity:.6; cursor:progress; }
/* the dropdown region (only visible when a chip is open; window grows to fit) */
#panel { display:none; background:var(--bg); overflow:auto; }
#panel.open { display:block; }
body.orient-top #panel { border-bottom:1px solid var(--line); max-height: calc(100vh - var(--bar-h)); }
body.orient-left #panel, body.orient-right #panel { flex:1; height:100%; max-height:100%; }
.phead { padding:8px 14px; color:var(--dim); font-size:11px; letter-spacing:.04em; text-transform:uppercase;
position:sticky; top:0; background:var(--bg); border-bottom:1px solid var(--line); }
.row { display:flex; align-items:center; gap:10px; padding:9px 14px; border-bottom:1px solid #20232b; }
.row:hover { background:#191c22; }
.rdot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; }
.tk { font-weight:700; min-width:82px; font-variant-numeric:tabular-nums; }
.doing { color:var(--fg); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tty { color:var(--dim); font-size:11px; font-variant-numeric:tabular-nums; }
.open { -webkit-app-region:no-drag; cursor:pointer; font-size:11px; padding:5px 9px; border-radius:7px;
border:1px solid var(--line); background:var(--bg2); color:var(--fg); white-space:nowrap; }
.open:hover { background:#2a2e37; }
/* Orphaned row: live claude process whose iTerm2 tab was closed — nothing to focus, so no open button. */
.orphan { -webkit-app-region:no-drag; font-size:11px; padding:5px 9px; border-radius:7px;
border:1px dashed var(--line); background:transparent; color:var(--dim); white-space:nowrap; opacity:.7; }
.empty { padding:14px; color:var(--dim); }
/* TK-12236: liveness NOT-MEASURED (ps/board probe failed) — shown, but never as live-green. */
.unk { font-size:11px; padding:5px 9px; border-radius:7px; border:1px dashed #d4a017;
color:#d4a017; white-space:nowrap; }
.chip.zero { opacity:.42; }
/* DW / Non-DW folder chips (TK-12234): pick one to scope every colour chip + dropdown to it. */
.fchip { -webkit-app-region:no-drag; cursor:pointer; display:flex; align-items:center; gap:5px;
padding:5px 9px; border-radius:8px; border:1px solid var(--line); background:var(--bg2);
color:var(--dim); font-size:11px; white-space:nowrap; }
.fchip:hover { color:var(--fg); }
.fchip.on { color:#ffe7b3; border-color:#8a6a2a; background:#2a2316; }
.fchip b { color:var(--fg); font-variant-numeric:tabular-nums; }
.fsep { width:1px; align-self:stretch; margin:8px 4px; background:var(--line); }
body.orient-left .fsep, body.orient-right .fsep { width:auto; height:1px; margin:4px 0; }
.rtag { font-size:10px; padding:2px 6px; border-radius:5px; border:1px solid var(--line); color:var(--dim); }
.rtag.dw { color:#ffe7b3; border-color:#8a6a2a; }
.chip.offline { opacity:.6; border-style:dashed; }
</style>
</head>
<body class="orient-top">
<div id="bar"></div>
<div id="grip" title="drag to resize the bar's thickness"></div>
<div id="gripx" title="drag to contract/expand the bar's width"></div>
<div id="panel"></div>
<script>
const BAR_H = 48, PANEL_H = 360;
// One-click compact/full toggle presets (Steve 2026-09-23). COMPACT_W = the side-dock min
// (icon-only strip); COMPACT_LEN = a short top-dock length. "Full" restores the user's last
// dragged size (persisted in localStorage) so the toggle returns exactly where they had it.
const COMPACT_W = 120, FULL_W_DEFAULT = 210, COMPACT_LEN = 320;
// Folder scope (TK-12234): 'all' | 'dw' | 'other'. Persisted per viewer.
const FOLDERS = [ { key:'dw', label:'DW' }, { key:'other', label:'Non-DW' } ];
let folder = 'all';
try { const f = localStorage.getItem('dotbar.folder'); if (f === 'dw' || f === 'other') folder = f; } catch(_){}
let openColor = null, data = null, _miss = 0, curBarH = BAR_H, curBarW = 210, curBarLen = 0, arranging = false, arrangeMsg = '';
// Live config from the main process (orientation + auto-hide + top-bar length). Defaults match a fresh install.
let cfg = { orientation: 'top', autohide: false, barW: 210, barLen: 0 };
// RAM Sniper readout (RAM%/CPU% + daemon on/off + top hogs), polled from /api/ram.
let ram = { ram_pct: '–', cpu_pct: '–', on: false, hogs: [] };
// RAM pressure bands (Steve 2026-09-23): green < 50, amber < 75, red >= 75. Input is the
// memory-pressure-based used% from the server, so these thresholds mean real strain, not cache.
function ramColor(p){ p = parseFloat(p); if (isNaN(p)) return 'var(--fg)'; return p < 50 ? '#4ade80' : (p < 75 ? '#fbbf24' : '#f87171'); }
// Ticket-board summary (BLOCKED / DOING / OPEN counts + latest event), polled from /api/tickets (Steve 2026-09-23).
let tickets = { updated: 0, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
// The four needs-Steve states pulse; green/pink stay solid (Steve 2026-09-15 dot-flash directive).
const WAIT = new Set(['lightblue','orange','purple','yellow']);
const ORIENT_LABEL = { top: '▲ Top', left: '◀ Left', right: '▶ Right' };
async function fetchDots(){ try { const r = await fetch('/api/dots'); return await r.json(); } catch { return null; } }
async function fetchTickets(){ try { const r = await fetch('/api/tickets'); return await r.json(); } catch { return null; } }
// Open the Fleet board filtered to one section (blocked/open/stopped/doing/parked/all).
// Electron renderers can't window.open() an external URL, so POST to the server, which shells
// out to macOS `open` (same pattern as reveal() for terminals).
async function openBoard(section){ try { await fetch('/api/open-board', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({section}) }); } catch(e){} }
function resize(open){
// Electron shell owns the window bounds via IPC; fall back to resizeTo in a plain browser.
if (window.dotbar && window.dotbar.setOpen){ window.dotbar.setOpen(open); return; }
try { window.resizeTo(screen.availWidth, open ? BAR_H + PANEL_H : BAR_H); } catch(e){}
}
// The dot data scoped to the chosen folder: sessions filtered by realm, counts recomputed.
function inFolder(s){ return folder === 'all' || (s.realm || 'other') === folder; }
function view(){
if (!data) return data;
const groups = data.groups.map(g => { const ss = g.sessions.filter(inFolder); return Object.assign({}, g, { sessions: ss, count: ss.length }); })
.filter(g => g.count > 0 || g.color !== 'none'); // always show every real colour (dimmed at 0)
const pitems = ((data.parked && data.parked.items) || []).filter(inFolder);
return Object.assign({}, data, { groups, total: groups.reduce((n, g) => n + g.count, 0),
parked: { count: pitems.length, items: pitems, hidden: (data.parked && data.parked.hidden) || [] } });
}
function folderCount(key){
if (!data) return 0;
let n = 0;
for (const g of data.groups) for (const s of g.sessions) if ((s.realm || 'other') === key) n++;
return n;
}
function setFolder(key){
folder = (folder === key) ? 'all' : key;
try { localStorage.setItem('dotbar.folder', folder); } catch(_){}
renderBar(); renderPanel();
}
function rtag(s){ return (s.realm === 'dw') ? '<span class="rtag dw">DW</span>' : '<span class="rtag">Non-DW</span>'; }
function renderBar(){
const bar = document.getElementById('bar');
bar.innerHTML = '';
for (const f of FOLDERS){
const fc = document.createElement('div');
fc.className = 'fchip' + (folder === f.key ? ' on' : '');
fc.title = folder === f.key ? `showing ${f.label} only — click to show all` : `show only ${f.label} sessions`;
fc.innerHTML = `${folder === f.key ? '📂' : '📁'} ${f.label} <b>${folderCount(f.key)}</b>`;
fc.onclick = () => setFolder(f.key);
bar.appendChild(fc);
}
const fsep = document.createElement('div'); fsep.className = 'fsep'; bar.appendChild(fsep);
const data = view();
for (const g of data.groups){
const chip = document.createElement('div');
chip.className = 'chip' + (openColor===g.color ? ' active':'') + (g.count ? '' : ' zero');
// On a side dock the chip always shows its name (there's vertical room); on top only when open.
const showName = (openColor===g.color) || cfg.orientation !== 'top';
chip.innerHTML = `<span class="dot${WAIT.has(g.color)&&g.count?' pulse':''}" style="background:${g.css}"></span>`
+ `<span class="cnt">${g.count}</span>`
+ (showName ? `<span class="nm">${g.name}</span>` : '');
chip.onclick = () => toggle(g.color);
bar.appendChild(chip);
}
// Durable PARKED chip (TK-11946) — collapsed badge, distinct from live pink; opens a
// panel listing the registry (parked tabs + parked tickets). TK-12236: the count is the
// LIVE-filtered row list the panel renders (same array => chip == panel rows), and the chip
// stays visible (dimmed) at 0 per the always-show-every-state rule.
{
const pItems = (data.parked && data.parked.items) || [];
const pchip = document.createElement('div');
pchip.className = 'chip' + (openColor==='parked' ? ' active':'') + (pItems.length ? '' : ' zero');
const showName = (openColor==='parked') || cfg.orientation !== 'top';
pchip.innerHTML = `<span class="dot" style="background:#ff8fc8"></span>`
+ `<span class="cnt">${pItems.length}</span>`
+ (showName ? `<span class="nm">parked</span>` : '');
pchip.onclick = () => toggle('parked');
bar.appendChild(pchip);
}
// RAM Sniper lives in the LEFT chip cluster (always visible on the compact bar; the
// right-side #meta gets clipped off the pill). RAM+CPU are ONE atomic .cnt span so
// they can never render/clip asymmetrically (the earlier split hid RAM, showed CPU).
const rchip = document.createElement('div');
rchip.className = 'chip ramchip' + (openColor==='ram' ? ' active':'');
rchip.title = 'RAM / CPU — click for top memory hogs';
rchip.innerHTML = `<span class="dot" style="background:${ramColor(ram.ram_pct)}"></span>`
+ `<span class="cnt">🧠 <span style="color:${ramColor(ram.ram_pct)};font-weight:700">${ram.ram_pct}%</span> <span style="color:var(--dim);font-weight:600">⚙ ${ram.cpu_pct}%</span></span>`;
rchip.onclick = () => toggle('ram');
bar.appendChild(rchip);
const snbtn = document.createElement('button');
snbtn.className = 'cfgbtn' + (ram.on ? ' on':'');
snbtn.title = `RAM Sniper daemon — throttles background CPU/RAM hogs (never kills). Click to turn ${ram.on?'off':'on'}.`;
snbtn.textContent = ram.on ? '🔫 ON' : '🔫 OFF';
snbtn.onclick = toggleSniper;
bar.appendChild(snbtn);
// Ticket-board states as INDIVIDUAL vertical chips — ALWAYS all of them (Steve 2026-09-25: "nav bar
// should be including all from orig nav bar... blocked, parked, doing, idle" + "add dw and non dw
// here"), the five states then the dw / non-dw cut of the same population. Zero = dimmed;
// board unreachable / never fetched = "–" + dashed OFFLINE ring (never a fake 0, never hidden).
// The server computes `states` (ticket-states.js); fall back to the same list client-side.
{
const showName = cfg.orientation !== 'top'; // a side dock has vertical room for the label
const measured = !!tickets.updated && tickets.ok !== false;
const TSTATES = (tickets.states && tickets.states.length) ? tickets.states : [
{ key:'blocked', section:'blocked', label:'blocked', css:'#f87171' },
{ key:'open', section:'open', label:'open', css:'#e5e7eb' },
{ key:'idle', section:'stopped', label:'idle', css:'#9ca3af' },
{ key:'doing', section:'doing', label:'doing', css:'#60a5fa' },
{ key:'parked', section:'parked', label:'parked', css:'#f9a8d4' },
{ key:'dw', section:'dw', label:'dw', css:'#34d399', divider:true },
{ key:'nondw', section:'nondw', label:'non-dw', css:'#c084fc' },
].map(t => ({ ...t, n: measured ? tickets[t.key] : null }));
for (const t of TSTATES) {
const off = t.n === null || t.n === undefined;
const tc = document.createElement('div');
tc.className = 'chip tkchip' + (t.divider ? ' seg-start' : '') + (off ? ' offline' : (t.n ? '' : ' zero'));
const what = (t.key === 'dw' || t.key === 'nondw')
? `active tickets, ${t.key === 'dw' ? 'Designer Wallcoverings' : 'NOT Designer Wallcoverings'}`
: t.label;
tc.title = off ? `${what} — ticket board unreachable (not measured). Click to open the Fleet board`
: `${t.n} ${what} — open the Fleet board`;
tc.innerHTML = `<span class="dot" style="background:${t.css}"></span>`
+ `<span class="cnt">${off ? '–' : t.n}</span>`
+ (showName ? `<span class="nm">${t.label}${off ? ' · offline' : ''}</span>` : '');
tc.onclick = () => openBoard(t.section);
bar.appendChild(tc);
}
}
const sp1 = document.createElement('div'); sp1.id='spacer'; bar.appendChild(sp1);
const sp2 = document.createElement('div'); sp2.id='spacer2'; sp2.className='spacer-grow'; bar.appendChild(sp2);
const meta = document.createElement('div'); meta.id='meta';
const nextO = cfg.orientation==='top'?'left':cfg.orientation==='left'?'right':'top';
meta.innerHTML = `<button class="arrbtn${arranging?' busy':''}" ${arranging?'disabled':''} title="run the master dot-screen arrangement now" onclick="arrange()">${arranging?'⧉ Arranging…':(arrangeMsg?('⧉ '+arrangeMsg):'⧉ Arrange master')}</button>`
+ `<button class="cfgbtn" title="dock the bar on another edge (revert to Top from a side)" onclick="cycleOrient()">${ORIENT_LABEL[cfg.orientation]||'▲ Top'} ▸ ${ORIENT_LABEL[nextO].replace(/^[^ ]+ /,'')}</button>`
+ `<button class="cfgbtn${cfg.autohide?' on':''}" title="edge auto-hide: slide the bar off its edge; hover the edge to reveal" onclick="toggleAutohide()">${cfg.autohide?'👁 Auto-hide':'📌 Pinned'}</button>`
+ `<button class="cfgbtn" title="one-click: snap between a compact (icon-only) bar and full width" onclick="toggleCompact()">${isCompact()?'⤢ Expand':'⤡ Compact'}</button>`
+ `<span><b>${data.total}</b> live</span>`
+ `<span class="icobtn" title="refresh" onclick="tick()">⟳</span>`
+ `<span class="icobtn" title="quit bar" onclick="(window.dotbar&&window.dotbar.quit)&&window.dotbar.quit()">✕</span>`;
bar.appendChild(meta);
}
function renderPanel(){
const panel = document.getElementById('panel');
const data = view();
if (!openColor){ panel.className=''; panel.innerHTML=''; return; }
// RAM Sniper panel: top memory hogs (not a live colour group).
if (openColor==='ram'){
panel.className = 'open';
const hogs = (ram && ram.hogs) || [];
let html = `<div class="phead">🧠 RAM ${ram.ram_pct}% · ⚙ CPU ${ram.cpu_pct}% · sniper ${ram.on?'🔫 ON (throttling)':'OFF'} — top memory</div>`;
if (!hogs.length){ panel.innerHTML = html + `<div class="empty">No data yet.</div>`; return; }
for (const h of hogs){
html += `<div class="row">`
+ `<span class="rdot" style="background:#59d0ff"></span>`
+ `<span class="tk">${h.pid}</span>`
+ `<span class="doing" title="${(h.cmd||'').replace(/"/g,'"')}">${h.name}</span>`
+ `<span class="tty">${h.gb} GB · ${h.pct}%</span>`
+ `</div>`;
}
panel.innerHTML = html; return;
}
// Durable PARKED panel (TK-11946): registry entries, not a live colour group.
if (openColor==='parked'){
panel.className = 'open';
const items = (data.parked && data.parked.items) || [];
const nHidden = (data.parked && data.parked.hidden || []).length;
const hiddenNote = nHidden ? ` · ${nHidden} dead hidden` : '';
if (!items.length){ panel.innerHTML = `<div class="empty">No live parked items${hiddenNote}.</div>`; return; }
let html = `<div class="phead">🩷 parked (durably held) — ${items.length}${hiddenNote}</div>`;
const esc = (v) => String(v||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
for (const s of items){
const unk = s.liveness === 'unknown';
let tail;
if (s.kind === 'ticket') {
// Ticket park: tty is provenance only — no tty, no orphan badge, no open button.
tail = unk ? `<span class="unk" title="${esc(s.liveness_reason)} — ticket status not measured, shown anyway">status unknown</span>` : '';
} else if (unk) {
tail = `<span class="unk" title="${esc(s.liveness_reason)} — liveness not measured, shown anyway">liveness unknown</span>`
+ (s.tty && s.attached !== false ? `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>` : '');
} else if (!s.tty) {
tail = '';
} else if (s.attached === false) {
tail = `<span class="orphan" title="Claude is running on ${esc(s.tty)} but has no iTerm2 tab">running · no tab</span>`;
} else {
tail = `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`;
}
html += `<div class="row">`
+ `<span class="rdot" style="background:#ff8fc8"></span>`
+ `<span class="tk">${s.ticket || s.id || '—'}</span>`
+ `<span class="doing" title="${esc(s.doing)}">${s.doing || ('('+s.kind+')')}</span>`
+ (folder === 'all' ? rtag(s) : '')
+ `<span class="tty" title="${esc(s.liveness_reason)}">${s.kind === 'ticket' ? (s.status || 'ticket') : (s.tty || s.kind)}</span>`
+ tail
+ `</div>`;
}
panel.innerHTML = html; return;
}
const g = data.groups.find(x=>x.color===openColor);
panel.className = 'open';
if (!g || !g.sessions.length){ panel.innerHTML = `<div class="empty">No ${openColor} sessions${folder==='all'?'':' in '+(folder==='dw'?'DW':'Non-DW')}.</div>`; return; }
let html = `<div class="phead">${g.emoji} ${g.name} — ${g.count}</div>`;
for (const s of g.sessions){
html += `<div class="row">`
+ `<span class="rdot${WAIT.has(g.color)?' pulse':''}" style="background:${g.css}"></span>`
+ `<span class="tk">${s.ticket || '—'}</span>`
+ `<span class="doing" title="${(s.doing||'').replace(/"/g,'"')}">${s.doing || '(no label)'}</span>`
+ (folder === 'all' ? rtag(s) : '')
+ `<span class="tty">${s.tty}</span>`
+ (s.attached === false
? `<span class="orphan" title="Claude is running on ${s.tty} but has no iTerm2 tab">running · no tab</span>`
: `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`)
+ `</div>`;
}
panel.innerHTML = html;
}
function toggle(color){
openColor = (openColor===color) ? null : color;
resize(!!openColor);
renderBar(); renderPanel();
}
async function reveal(tty){
try { await fetch('/api/reveal', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({tty}) }); } catch(e){}
}
// Dock the bar on the next edge (top -> left -> right -> top). This is how a SIDE bar reverts to a
// TOP bar (Steve TK-11885). The main process owns bounds + persistence; here we just ask it.
function cycleOrient(){ if (window.dotbar && window.dotbar.cycleOrientation) window.dotbar.cycleOrientation(); }
function toggleAutohide(){ if (window.dotbar && window.dotbar.setAutohide) window.dotbar.setAutohide(!cfg.autohide); }
// One-click compact/full toggle (Steve 2026-09-23). Targets the resizable axis for the current
// dock — barLen for a TOP strip, barW for a SIDE strip — reusing the existing setBarLen/setBarW IPC.
function isCompact(){
if (cfg.orientation === 'top') { const l = cfg.barLen; return !!(l && l > 0 && l <= COMPACT_LEN + 20); }
return curBarW <= COMPACT_W + 20;
}
function toggleCompact(){
if (!window.dotbar) return;
if (cfg.orientation === 'top') {
const cur = (cfg.barLen && cfg.barLen > 0) ? cfg.barLen : (window.innerWidth || 0);
if (!isCompact() && window.dotbar.setBarLen) { // full -> compact (remember where we were)
if (cur > COMPACT_LEN) { try { localStorage.setItem('dotbar.fullLen', String(cur)); } catch(_){} }
window.dotbar.setBarLen(COMPACT_LEN);
} else if (window.dotbar.setBarLen) { // compact -> full (0 = span the screen)
const full = parseInt(localStorage.getItem('dotbar.fullLen') || '0', 10);
window.dotbar.setBarLen(full > COMPACT_LEN ? full : 0);
}
} else {
if (!isCompact() && window.dotbar.setBarW) { // full -> compact
if (curBarW > COMPACT_W) { try { localStorage.setItem('dotbar.fullW', String(curBarW)); } catch(_){} }
window.dotbar.setBarW(COMPACT_W);
} else if (window.dotbar.setBarW) { // compact -> full (restore last, else default)
const full = parseInt(localStorage.getItem('dotbar.fullW') || String(FULL_W_DEFAULT), 10);
window.dotbar.setBarW(full > COMPACT_W ? full : FULL_W_DEFAULT);
}
}
}
async function arrange(){
if (arranging) return;
arrangeMsg = ''; arranging = true; renderBar();
let msg;
try {
const r = await fetch('/api/arrange', { method:'POST' });
const j = await r.json().catch(() => null);
msg = (j && j.busy) ? 'master running' : ((j && j.ok) ? `moved ${j.moved || 0}` : ('✗ ' + ((j && j.err) || ('HTTP ' + r.status))));
} catch (e) { msg = '✗ ' + ((e && e.message) || 'no server'); }
arranging = false; arrangeMsg = msg; renderBar();
clearTimeout(window._arrTimer); window._arrTimer = setTimeout(() => { arrangeMsg = ''; renderBar(); }, 2200);
tick();
}
// Turn the ram-sniper.sh daemon on/off from the bar (reversible — it only reniced).
async function toggleSniper(){
try {
const r = await fetch('/api/ram/toggle', { method:'POST' });
const j = await r.json().catch(()=>null);
if (j) { ram = j; renderBar(); if (openColor==='ram') renderPanel(); }
} catch(e){}
}
// Poll live RAM%/CPU% + hogs on its own faster cadence than the (slow) dot scan.
async function ramTick(){
try {
const r = await fetch('/api/ram');
const j = await r.json();
if (j && j.updated){ ram = j; renderBar(); if (openColor==='ram') renderPanel(); }
} catch(e){}
}
async function tick(){
const d = await fetchDots();
if (!d) return;
data = d;
// Fetch ticket-board summary in parallel (Steve 2026-09-23).
const t = await fetchTickets();
if (t && (t.updated || t.states)) tickets = t;
else if (!t) tickets = { ...tickets, ok: false, states: null }; // dotbar API unreachable -> show OFFLINE, not stale counts
const v = view();
const openStillHasItems = openColor==='ram'
? true // RAM panel is always valid; never auto-close it
: openColor==='parked'
? !!(v.parked && v.parked.count)
: v.groups.some(g=>g.color===openColor && g.count>0);
if (openColor && !openStillHasItems) {
if (++_miss >= 2) { openColor=null; resize(false); _miss=0; }
} else { _miss = 0; }
renderBar(); renderPanel();
}
// ---- Config from main (orientation + auto-hide + side width) ----
function applyCfg(c){
cfg = Object.assign(cfg, c || {});
document.body.className = 'orient-' + (cfg.orientation || 'top');
if (cfg.barW) { curBarW = cfg.barW; document.documentElement.style.setProperty('--bar-w', cfg.barW + 'px'); }
curBarLen = cfg.barLen || 0; // 0 = full-screen span; main owns the actual top-bar window width
if (data) renderBar();
}
if (window.dotbar && window.dotbar.onConfig) window.dotbar.onConfig(applyCfg);
if (window.dotbar && window.dotbar.onBarH) {
window.dotbar.onBarH(h => { curBarH = h; document.documentElement.style.setProperty('--bar-h', h + 'px'); });
}
// ---- Resize the bar: drag the grip. TOP grip changes height (setBarH, screen-Y); SIDE grip
// changes width (setBarW, screen-X, inverted for a right dock so dragging inward shrinks it). ----
(function wireGrip(){
const grip = document.getElementById('grip');
if (!grip) return;
let dragging = false, startX = 0, startY = 0, startH = 0, startW = 0;
grip.addEventListener('pointerdown', e => {
dragging = true; startX = e.screenX; startY = e.screenY; startH = curBarH; startW = curBarW;
try { grip.setPointerCapture(e.pointerId); } catch(_) {}
if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(true);
e.preventDefault();
});
grip.addEventListener('pointermove', e => {
if (!dragging) return;
if (cfg.orientation === 'top') {
const h = startH + (e.screenY - startY);
if (window.dotbar && window.dotbar.setBarH) window.dotbar.setBarH(h);
} else {
const dx = e.screenX - startX;
const w = startW + (cfg.orientation === 'right' ? -dx : dx); // right dock grows leftward
if (window.dotbar && window.dotbar.setBarW) window.dotbar.setBarW(w);
}
});
const end = e => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch(_) {}
if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(false); };
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
// ---- Contract/expand the TOP bar horizontally: drag the right-end grip. Left-anchored, so dragging
// right widens and left narrows; screen-X deltas + pointer capture keep it correct as the right edge
// (where the grip lives) moves during the drag. Full span when barLen is 0 (== the window width). ----
(function wireGripX(){
const grip = document.getElementById('gripx');
if (!grip) return;
let dragging = false, startX = 0, startLen = 0;
grip.addEventListener('pointerdown', e => {
dragging = true; startX = e.screenX;
startLen = (cfg.barLen && cfg.barLen > 0) ? cfg.barLen : window.innerWidth; // full -> current window width
try { grip.setPointerCapture(e.pointerId); } catch(_) {}
if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(true);
e.preventDefault();
});
grip.addEventListener('pointermove', e => {
if (!dragging) return;
const len = startLen + (e.screenX - startX);
if (window.dotbar && window.dotbar.setBarLen) window.dotbar.setBarLen(len);
});
const end = e => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch(_) {}
if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(false); };
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
// optional deep-link: ?open=orange pre-opens that dropdown
const _pre = new URLSearchParams(location.search).get('open');
resize(false);
tick().then(() => { if (_pre) toggle(_pre); });
setInterval(tick, 3000);
ramTick();
setInterval(ramTick, 2000);
</script>
</body>
</html>