← back to Desktop Dotbar
.claude/worktrees/dotbar-dw-folders
307 lines
commit 9053da32c8413061c8bcc0160adaf799e9d0907f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 09:52:06 2026 -0700
dotbar: DW / Non-DW folder chips scope the bar to one folder (TK-12234)
Server tags each live + parked session with realm (dw|other) from the ticket's
project/title, then the doing label, then the claude cwd (realm.js, incremental
index of the tk event log). UI adds 📁 DW / 📁 Non-DW chips; picking one filters
every colour chip, dropdown, parked item and the live total. Persisted in
localStorage. Tests incl. a negative case (never a false DW).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhKndRAeekFseXqd9WFzYA
diff --git a/public/index.html b/public/index.html
index 54c69d1..4edbf31 100644
--- a/public/index.html
+++ b/public/index.html
@@ -111,6 +111,17 @@
.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); }
+ /* 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; }
</style>
</head>
<body class="orient-top">
@@ -124,6 +135,10 @@ const BAR_H = 48, PANEL_H = 360;
// (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 };
@@ -151,9 +166,42 @@ function resize(open){
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 === 'green');
+ 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 } });
+}
+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':'');
@@ -235,6 +283,7 @@ function renderBar(){
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'){
@@ -263,6 +312,7 @@ function renderPanel(){
+ `<span class="rdot" style="background:#ff8fc8"></span>`
+ `<span class="tk">${s.ticket || s.id || '—'}</span>`
+ `<span class="doing" title="${(s.doing||'').replace(/"/g,'"')}">${s.doing || ('('+s.kind+')')}</span>`
+ + (folder === 'all' ? rtag(s) : '')
+ `<span class="tty">${s.tty || s.kind}</span>`
+ (s.tty
? (s.attached === false
@@ -275,13 +325,14 @@ function renderPanel(){
}
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.</div>`; return; }
+ 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="Live process, but its iTerm2 tab was closed — nothing to focus.">orphaned · no tab</span>`
@@ -371,11 +422,12 @@ async function tick(){
// Fetch ticket-board summary in parallel (Steve 2026-09-23).
const t = await fetchTickets();
if (t && t.updated) tickets = t;
+ const v = view();
const openStillHasItems = openColor==='ram'
? true // RAM panel is always valid; never auto-close it
: openColor==='parked'
- ? !!(data.parked && data.parked.count)
- : data.groups.some(g=>g.color===openColor && g.count>0);
+ ? !!(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; }
diff --git a/realm.js b/realm.js
new file mode 100644
index 0000000..0bde619
--- /dev/null
+++ b/realm.js
@@ -0,0 +1,76 @@
+'use strict';
+// realm.js — sort each live session into a FOLDER: 'dw' (Designer Wallcoverings work) or
+// 'other' (everything else), so the bar can be scoped to one folder at a time (TK-12234).
+// Signals, strongest first: the ticket's project + title (tk event log), the session's
+// `doing` label, then the claude process cwd. Zero deps, $0 local.
+
+const fs = require('fs');
+const { execFile } = require('child_process');
+
+const EVENTS = `${process.env.HOME}/.claude/tickets/events.jsonl`;
+
+// DW vocabulary. Word-ish boundaries so "dw" doesn't match inside other words.
+const DW_RE = new RegExp([
+ '(^|[^a-z])dw([^a-z]|$)', // dw, dw-commerce, dw_unified, DWPP-, "dw ops"
+ 'designer[\\s_-]?wall', // designerwallcoverings / Designer-Wallcoverings
+ 'shopify', 'gmc', 'merchant center', 'filemaker',
+ '_catalog', 'vendor_registry', 'sample[\\s_-]?follow', 'samplesshipped',
+ 'kravet', 'phillipe romano', 'malibu wall', 'hollywood wall', 'fentucci',
+ 'microsite', 'mfr[\\s_-]?sku', 'memo sample',
+].join('|'), 'i');
+
+function isDwText(s) { return !!s && DW_RE.test(String(s)); }
+
+// ---- ticket index: TK-N -> "project title", built incrementally from the append-only log ----
+const idx = new Map();
+let offset = 0, carry = '';
+function indexTickets(file = EVENTS) {
+ let st;
+ try { st = fs.statSync(file); } catch { return idx; }
+ if (st.size < offset) { idx.clear(); offset = 0; carry = ''; } // log rotated/truncated -> rebuild
+ if (st.size === offset) return idx;
+ const fd = fs.openSync(file, 'r');
+ try {
+ const buf = Buffer.alloc(st.size - offset);
+ fs.readSync(fd, buf, 0, buf.length, offset);
+ offset = st.size;
+ const lines = (carry + buf.toString('utf8')).split('\n');
+ carry = lines.pop(); // partial last line waits for next pass
+ for (const l of lines) {
+ if (!l.includes('"create"')) continue; // cheap pre-filter before JSON.parse
+ let e; try { e = JSON.parse(l); } catch { continue; }
+ if (e.type !== 'create' || !e.id) continue;
+ const m = /^(TK-\d+)/i.exec(e.id);
+ if (m) idx.set(m[1].toUpperCase(), `${e.project || ''} ${e.title || ''}`);
+ }
+ } finally { fs.closeSync(fd); }
+ return idx;
+}
+function _resetIndex() { idx.clear(); offset = 0; carry = ''; }
+
+// ---- cwd of a pid (lsof), cached — a session's cwd rarely changes ----
+const cwdCache = new Map();
+function cwdOf(pid) {
+ return new Promise((resolve) => {
+ if (!pid) return resolve('');
+ if (cwdCache.has(pid)) return resolve(cwdCache.get(pid));
+ execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 4000 }, (err, out) => {
+ const line = String(out || '').split('\n').find(x => x.startsWith('n')) || '';
+ const cwd = line.slice(1);
+ if (cwd) cwdCache.set(pid, cwd);
+ resolve(cwd);
+ });
+ });
+}
+
+// Classify one session {ticket, doing, pid}. Returns 'dw' | 'other'.
+async function realmOf(s, opts = {}) {
+ const tix = opts.index || indexTickets();
+ if (s.ticket && isDwText(tix.get(s.ticket))) return 'dw';
+ if (isDwText(s.doing)) return 'dw';
+ const cwd = opts.cwd !== undefined ? opts.cwd : await cwdOf(s.pid);
+ if (isDwText(cwd.replace(/^.*\/Projects\//, ''))) return 'dw'; // only the project part of the path
+ return 'other';
+}
+
+module.exports = { realmOf, isDwText, indexTickets, _resetIndex };
diff --git a/server.js b/server.js
index 57ce537..7cb39a9 100755
--- a/server.js
+++ b/server.js
@@ -8,7 +8,8 @@ const http = require('http');
const { execFile, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
-const jevDots = require('./jev-dots'); // re-classifies each dot COLOR via Jev ($0 builtin; paid flip gated)
+const jevDots = require('./jev-dots');
+const realm = require('./realm'); // DW / Non-DW folder per session (TK-12234) // re-classifies each dot COLOR via Jev ($0 builtin; paid flip gated)
const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
const ROUTER = `${process.env.HOME}/.claude/skills/dot-screen-router/router.sh`;
@@ -117,6 +118,10 @@ async function getDots() {
doing: cleanLabel(r.label),
});
}
+ // Folder tag per session (DW vs Non-DW) so the bar can be scoped to one folder. Fail-open to 'other'.
+ try { realm.indexTickets(); } catch { /* keep going; realmOf falls back to label + cwd */ }
+ const tagAll = (list) => Promise.all(list.map(async s => { try { s.realm = await realm.realmOf(s); } catch { s.realm = 'other'; } }));
+ await Promise.all(ORDER.map(c => tagAll(groups[c])));
const out = ORDER
.map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
.filter(g => g.count > 0 || g.color === 'green'); // always show green; hide empty others
@@ -127,6 +132,7 @@ async function getDots() {
tty, attached: tty ? isAttached(tty) : true, doing: cleanLabel(e.label), parked_at: e.parked_at,
};
});
+ await tagAll(parkedItems);
return { updated: Date.now(), total: rows.length, groups: out,
parked: { count: parkedItems.length, items: parkedItems } };
}
diff --git a/test/realm.test.js b/test/realm.test.js
new file mode 100644
index 0000000..ba32cd8
--- /dev/null
+++ b/test/realm.test.js
@@ -0,0 +1,47 @@
+'use strict';
+// realm.js — DW / Non-DW folder classification (TK-12234). Zero deps.
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const realm = require('../realm');
+
+const idx = new Map([
+ ['TK-1', 'dw-commerce Fix Kravet MAP rows'],
+ ['TK-2', 'kamatera certbot fleet-wide ACME'],
+ ['TK-3', 'Designer-Wallcoverings theme fix'],
+]);
+
+test('ticket project/title decides first', async () => {
+ assert.equal(await realm.realmOf({ ticket: 'TK-1', doing: '', pid: 0 }, { index: idx, cwd: '' }), 'dw');
+ assert.equal(await realm.realmOf({ ticket: 'TK-3', doing: '', pid: 0 }, { index: idx, cwd: '' }), 'dw');
+ assert.equal(await realm.realmOf({ ticket: 'TK-2', doing: 'renewing certs', pid: 0 }, { index: idx, cwd: '/Users/x' }), 'other');
+});
+
+test('falls back to label, then cwd project', async () => {
+ assert.equal(await realm.realmOf({ ticket: '', doing: 'shopify metafield backfill', pid: 0 }, { index: idx, cwd: '' }), 'dw');
+ assert.equal(await realm.realmOf({ ticket: '', doing: 'working', pid: 0 }, { index: idx, cwd: '/Users/x/Projects/dw-kravet-hires' }), 'dw');
+ assert.equal(await realm.realmOf({ ticket: '', doing: 'working', pid: 0 }, { index: idx, cwd: '/Users/x/Projects/homesonspec' }), 'other');
+});
+
+test('negative: "dw" inside other words and the home path never read as DW', async () => {
+ for (const t of ['crowdwork', 'bandwidth test', 'shadow dom', 'windows'])
+ assert.equal(realm.isDwText(t), false, t);
+ // a home dir containing letters like "dw" must not flip the session to DW
+ assert.equal(await realm.realmOf({ ticket: '', doing: '', pid: 0 }, { index: idx, cwd: '/Users/dwayne' }), 'other');
+ // unknown ticket, no signals -> other (fail to Non-DW, never a false DW)
+ assert.equal(await realm.realmOf({ ticket: 'TK-999', doing: '', pid: 0 }, { index: idx, cwd: '' }), 'other');
+});
+
+test('indexTickets reads the log incrementally and keeps partial lines', () => {
+ const f = path.join(os.tmpdir(), `realm-ev-${process.pid}.jsonl`);
+ realm._resetIndex();
+ fs.writeFileSync(f, JSON.stringify({ type: 'create', id: 'TK-10-a', project: 'dw', title: 'x' }) + '\n{"type":"cre');
+ let m = realm.indexTickets(f);
+ assert.equal(m.get('TK-10'), 'dw x');
+ fs.appendFileSync(f, 'ate","id":"TK-11-b","project":"ops","title":"y"}\n');
+ m = realm.indexTickets(f);
+ assert.equal(m.get('TK-11'), 'ops y');
+ fs.unlinkSync(f); realm._resetIndex();
+});