← back to Ticket System
board.html
532 lines
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fleet Tickets — Table</title>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<script>try{document.documentElement.dataset.theme=JSON.parse(localStorage.getItem('tkTheme'))||'dark'}catch(e){document.documentElement.dataset.theme='dark'}</script>
<style>
:root{--bg:#0f1115;--card:#171b22;--line:#262a33;--ink:#e6e6e6;--mut:#8a93a5;--faint:#5b6270;--acc:#6db3f2;--gold:#C8A24B;
--open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--stopped:#6b7280;--fs:12.5px;--rowpad:7px;
--thbg:#1b2230;--rowhover:#141821;--selbg:#182233;--selhover:#1c2942;--detbg:#10131a;--grpbg:#12161f;--rowline:#1c2028}
html[data-theme="light"]{
--bg:#f5f6f8;--card:#ffffff;--line:#dfe3e9;--ink:#1b1f27;--mut:#5c6472;--faint:#98a1b1;--acc:#2563c9;--gold:#9a781f;
--open:#2563c9;--doing:#2f7fd6;--blocked:#c0392b;--done:#1a9e6a;--stopped:#8a92a0;
--thbg:#eef1f6;--rowhover:#f0f3f8;--selbg:#e3edfb;--selhover:#d6e6fb;--detbg:#f2f4f8;--grpbg:#eceff4;--rowline:#e7eaf0}
html[data-theme="light"] tr.grp td{color:#7b4fd0} html[data-theme="light"] .who{color:#7b4fd0}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font:14px -apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--ink);display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--bg)}
.stickytop{position:sticky;top:0;z-index:8;background:var(--bg);display:flex;flex-direction:column;flex:0 0 auto}
/* ticket nav bar — full-width strip across the very top; ⤓ Nav toggles it below the toolbar */
.topnav{display:flex;align-items:center;gap:10px;width:100%;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--bg);overflow-x:auto}
.topnav .globalnav{border:0;background:transparent;padding:0;border-radius:0;gap:6px}
.topnav .globalnav button{padding:8px 14px;font-size:11px}
.navpos{margin-left:auto;flex:0 0 auto;border:1px solid var(--line);background:var(--card);color:var(--mut);padding:6px 10px;border-radius:8px;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;cursor:pointer}
.navpos:hover{color:var(--ink);border-color:var(--ink)}
.stickytop.nav-below .topnav{order:2}
h1{font-size:15px;margin:0;letter-spacing:.06em;white-space:nowrap}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 9px;cursor:pointer;user-select:none}
.pill b{color:var(--ink)} .pill.on{outline:1px solid var(--acc)}
.pill .px{margin-left:6px;color:var(--faint);cursor:pointer;font-size:10px;opacity:.55} .pill:hover .px{opacity:1} .pill .px:hover{color:var(--blocked)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 11px;font-size:13px;min-width:200px}
.btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12px;cursor:pointer}
.btn:hover{border-color:var(--acc)} .btn:disabled{opacity:.4;cursor:default}
.globalnav{display:flex;align-items:center;gap:4px;padding:4px;background:var(--card);border:1px solid var(--line);border-radius:10px}
.globalnav button{border:0;background:transparent;color:var(--mut);padding:6px 10px;border-radius:7px;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;cursor:pointer}
.globalnav button:hover,.globalnav button.on{background:var(--ink);color:var(--bg)}
.layoutnav{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.layoutnav button{border:0;border-right:1px solid var(--line);background:var(--card);color:var(--mut);padding:6px 9px;cursor:pointer}.layoutnav button:last-child{border:0}.layoutnav button.on{color:var(--gold);background:var(--thbg)}
select.gsel{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:12px}
.grow{flex:1 1 auto}
#dens{width:96px;accent-color:var(--acc)}
#dtdchip{font-size:11px;color:var(--mut);white-space:nowrap}
#dtdchip b{color:var(--ink)}
.tblwrap{overflow:auto;flex:1 1 auto;min-height:0}
table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:var(--fs);white-space:nowrap;table-layout:fixed}
table.g thead th{position:sticky;top:0;background:var(--thbg);color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;
padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;text-align:left;position:relative;overflow:hidden;text-overflow:ellipsis}
table.g thead th:hover{color:var(--ink)}
table.g thead th.asc::after{content:' ▲';color:var(--gold)} table.g thead th.desc::after{content:' ▼';color:var(--gold)}
th.dragging{opacity:.4} th.dropL{box-shadow:inset 3px 0 0 var(--gold)} th.dropR{box-shadow:inset -3px 0 0 var(--gold)}
th .rsz{position:absolute;right:0;top:0;width:7px;height:100%;cursor:col-resize;z-index:3}
th.cb,td.cb{width:34px;text-align:center;padding-left:6px;padding-right:6px;cursor:default}
th.cb input,td.cb input{cursor:pointer;accent-color:var(--acc)}
table.g td{padding:var(--rowpad) 10px;border-bottom:1px solid var(--rowline);overflow:hidden;text-overflow:ellipsis;vertical-align:top}
tr.row{cursor:pointer} tr.row:hover td{background:var(--rowhover)}
tr.row.sel td{background:var(--selbg)} tr.row.sel:hover td{background:var(--selhover)}
tr.row.stopped td{opacity:.5}
tr.det td{background:var(--detbg);white-space:normal;cursor:default}
tr.grp td{background:var(--grpbg);font-weight:600;color:#c9a4f2;cursor:pointer;letter-spacing:.02em}
tr.grp td .gcount{color:var(--faint);font-weight:400;margin-left:8px} tr.grp td .gcx{color:var(--faint);font-size:10px;margin-right:8px}
tr.grp input{accent-color:var(--acc);margin-right:8px;cursor:pointer;vertical-align:-1px}
.mono{font-family:ui-monospace,Menlo,monospace}
a.cell{color:var(--acc);text-decoration:none} a.cell:hover{text-decoration:underline}
.ticket-top{display:flex;flex-direction:column;gap:2px;line-height:1.15}
.ticket-runtime{font:10px -apple-system,BlinkMacSystemFont,sans-serif;color:var(--mut);white-space:nowrap}
.ticket-runtime.running{color:#8fd49a}
.stpill{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
.s-open{color:var(--open)} .s-doing{color:var(--doing)} .s-blocked{color:var(--blocked)} .s-done{color:var(--done);opacity:.8} .s-stopped{color:var(--stopped);text-decoration:line-through}
/* per-row status reassign dropdown (TK-12076) */
.stsel{font:inherit;font-size:11px;padding:1px 6px;border-radius:20px;border:1px solid var(--line);background:var(--card);cursor:pointer;max-width:118px;text-decoration:none}
.stsel:hover{border-color:var(--acc)} .stsel:disabled{opacity:.5;cursor:progress}
.segsel{font:inherit;background:transparent;border:1px solid var(--line);border-radius:6px;padding:1px 4px;cursor:pointer}.segsel.seg-auto{opacity:.6;font-style:italic}.segsel.seg-set{font-weight:600}.segsel.seg-dw{color:var(--gold)}.segsel.seg-non{color:var(--mut)}
.stsel.s-open{color:var(--open)}.stsel.s-doing{color:var(--doing)}.stsel.s-blocked{color:var(--blocked)}.stsel.s-done{color:var(--done)}.stsel.s-stopped{color:var(--stopped);text-decoration:none}.stsel.s-parked{color:#e879b9}
.stsel option{color:var(--ink);background:var(--card)}
/* blocked-but-working orb: spins when a blocked ticket still has live agent activity (<15m) or a saved schedule that is due */
.stwrap{display:inline-flex;align-items:center;gap:5px}
.orb{width:11px;height:11px;border-radius:50%;flex:none;border:2px solid rgba(143,212,154,.25);border-top-color:#8fd49a;animation:orbspin .9s linear infinite}
.orb.saved{border-color:rgba(127,184,255,.25);border-top-color:#7fb8ff}
@keyframes orbspin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.orb{animation-duration:3s}}
.vb{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid var(--line)}
.vb.yes{color:#34d399;border-color:#2b6f52} .vb.no{color:#e06c75} .vb.unknown{color:var(--faint)} .vb.none{color:var(--faint);opacity:.6}
.vb small{color:var(--faint)}
.who{color:#c9a4f2;cursor:pointer} .proj{color:var(--mut);cursor:pointer}
.num{color:var(--mut);text-align:right}
/* priority ranking + ratings (mirrors :9801 approvals viewer) */
.rank{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:20px;padding:0 5px;border-radius:6px;
font-weight:700;font-size:11px;background:var(--thbg);border:1px solid var(--line);color:var(--ink)}
.rank.r1{background:#3a2f12;border-color:#7a5f1f;color:#f0c65a}
.tier{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
.tier.high{color:#f0857d;border-color:#7a3b40}.tier.med{color:#f0c65a;border-color:#7a5f1f}.tier.low{color:var(--faint)}
.prio{font-weight:700;color:var(--gold);text-align:right}
.bars{display:inline-grid;grid-template-columns:auto 1fr;gap:1px 5px;align-items:center;font-size:9px;min-width:118px}
.bars .bl{color:var(--faint);letter-spacing:.02em;text-transform:uppercase}
.bars .bt{position:relative;height:6px;background:#232936;border-radius:3px;overflow:hidden}
.bars .bf{position:absolute;left:0;top:0;bottom:0;border-radius:3px}
.bf.v{background:#34d399}.bf.u{background:#e06c75}.bf.e{background:#6db3f2}.bf.s{background:#C8A24B}
.thread .c{font-size:12px;margin:4px 0;color:#c6ccd8} .thread .c i{color:var(--mut);font-style:normal;font-size:10px;margin:0 4px}
.c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}.c.none{color:var(--faint)}
.cts{color:var(--faint);font-size:10px;margin-left:6px}
html[data-theme="light"] .thread .c{color:#2b3038} html[data-theme="light"] tr.det td .c.k-action{color:#1a7a4a}
/* selection action bar */
#actionbar{display:none;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--grpbg);position:sticky;top:52px;z-index:6;flex:0 0 auto}
#actionbar.show{display:flex}
#actionbar .n{color:var(--acc);font-weight:600}
.btn.run{border-color:#2b6f52;color:#8fd49a}.btn.run:hover{border-color:#34d399}
.btn.stop{border-color:#7a3b40;color:#e79aa2}.btn.stop:hover{border-color:#e06c75}
.btn.dtd{border-color:#5a4a7a;color:#d8bdf6}.btn.dtd:hover{border-color:#c9a4f2}
.btn.reopen{border-color:#3a5a7a;color:#9ec7f2}
#colmenu,#pillmenu,#detmenu{position:fixed;top:44px;right:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px;display:none;z-index:10;max-height:70vh;overflow:auto;min-width:180px}
/* a11y: visible keyboard focus (was invisible — buttons relied on hover only) */
a:focus-visible,button:focus-visible,select:focus-visible,input:focus-visible,th:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--acc);outline-offset:2px;border-radius:4px}
#colmenu.show,#pillmenu.show,#detmenu.show{display:block} #colmenu label,#pillmenu label,#detmenu label{display:flex;gap:7px;align-items:center;font-size:12px;padding:3px 6px;cursor:pointer;border-radius:6px} #colmenu label:hover,#pillmenu label:hover,#detmenu label:hover{background:#1c2130}
#colmenu .gh,#pillmenu .gh,#detmenu .gh{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:6px 6px 2px}
.btn.off{opacity:.5}
.dms{margin:12px 14px;background:#141821;border:1px solid var(--line);border-radius:8px}
.dms>summary{cursor:pointer;padding:9px 13px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
.convos{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:4px 13px 13px}
.convo{background:var(--card);border:1px solid var(--line);border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px;font-size:12px}
.convo .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:5px}
.dm{margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}.dm .arw{color:var(--mut);margin:0 3px}.dm .dtx{color:#c6ccd8;margin-top:2px}
#catalog{display:none;padding:16px;--catalog-cols:4}
#catalog.show{display:block;flex:1 1 auto;min-height:0;overflow:auto}
#dmwrap{flex:0 0 auto;max-height:50vh;overflow:auto}.catalog-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}.catalog-head h2{margin:0;font:800 20px/1.1 Georgia,serif}.catalog-head small{color:var(--mut)}
.catalog-grid{display:grid;grid-template-columns:repeat(var(--catalog-cols),minmax(220px,1fr));gap:10px}
.catalog-card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;min-width:0}.catalog-card:hover{border-color:var(--acc)}
.catalog-card h3{margin:0 0 7px;font:700 13px/1.25 ui-monospace,Menlo,monospace;overflow-wrap:anywhere}.catalog-card p{color:var(--mut);font-size:12px;line-height:1.45;margin:7px 0}
.kvrow{display:flex;gap:12px;align-items:baseline;border-bottom:1px solid var(--rowline);padding:10px 4px}.kvrow h3{width:230px;flex:0 0 230px;margin:0;font:700 12px ui-monospace,Menlo,monospace}.kvmeta{display:flex;gap:14px;flex-wrap:wrap;min-width:0}.kv{font-size:12px}.kv b{color:var(--faint);font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-right:4px}.stamp{display:block;color:var(--faint);font-size:10px;margin-top:8px}
@media(max-width:900px){.catalog-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}.globalnav{order:10;width:100%}.kvrow{align-items:flex-start;flex-direction:column}.kvrow h3{width:auto;flex:auto}}
</style></head><body>
<div class="stickytop">
<div class="topnav">
<nav class="globalnav" aria-label="Fleet viewer"><button data-section="all">All</button><button data-section="open">Open</button><button data-section="blocked">Blocked</button><button data-section="doing">Doing</button><button data-section="parked" title="Durably parked tickets (parked.mjs registry) — held out of the active queue">🩷 Parked</button><button data-section="dw" title="Tickets related to Designer Wallcoverings">DW</button><button data-section="nondw" title="Tickets NOT related to Designer Wallcoverings">Non-DW</button><button data-section="agents">Agents</button><button data-section="skills">Skills</button></nav>
<button id="navPosBtn" class="navpos" title="Move nav bar below the toolbar" aria-label="Toggle nav bar position">⤓ Nav</button>
</div>
<header>
<h1>FLEET TICKETS</h1>
<select class="gsel" id="view" title="Separate actionable work from recurring registries">
<option value="task">Task Queue</option><option value="continuous_loop">Continuous Loops</option><option value="scheduled_job">Scheduled Jobs</option><option value="all">All</option>
</select>
<span id="pills"></span>
<input class="search" id="q" placeholder="search all fields (space = AND)…">
<label style="font-size:11px;color:var(--mut)">Group
<select class="gsel" id="grp"><option value="none">none</option><option value="status">status</option><option value="blocker">blocker lane</option><option value="project">project</option><option value="assignee">owner</option><option value="segment">DW / Non-DW</option></select>
</label>
<span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
<span class="grow"></span>
<span class="layoutnav" title="Grid or compact KV list"><button data-layout="grid">▦ Grid</button><button data-layout="kv">☷ KV</button></span>
<input type="range" id="dens" min="4" max="14" step="1" title="row density" aria-label="Row density">
<button class="btn" id="colsBtn" aria-label="Toggle column menu">▦ Columns</button>
<button class="btn" id="pillsBtn" title="Toggle stat pills" aria-label="Toggle stat pills">◍ Pills</button>
<button class="btn" id="detBtn" title="Toggle expanded-row detail fields" aria-label="Toggle expanded-row detail fields">⤢ Detail</button>
<button class="btn" id="dmBtn" title="Toggle the DM panel" aria-label="Toggle the DM panel">✉ DMs</button>
<button class="btn" id="resetBtn" title="Reset all view settings" aria-label="Reset all view settings">↺ Reset</button>
<button class="btn" id="themeBtn" title="Toggle day / night theme" aria-label="Toggle day or night theme">🌙 Night</button>
<a class="btn" href="/kanban">Kanban</a><a class="btn" href="/office">Office</a>
</header>
</div><!-- /stickytop -->
<div id="actionbar">
<span><span class="n" id="seln">0</span> selected</span>
<select class="gsel" id="runProfile" title="Model used for Run Now">
<optgroup label="Cloud · Claude"><option value="claude-opus">Claude Opus (4.8)</option><option value="claude-sonnet">Claude Sonnet</option><option value="claude-haiku">Claude Haiku</option><option value="claude-opus-5">Claude Opus 5</option><option value="claude-sonnet-5">Claude Sonnet 5</option><option value="claude-fable">Claude Fable 5.1</option></optgroup>
<optgroup label="Cloud · Codex / OpenAI"><option value="codex">Codex (configured default)</option><option value="codex-gpt6">GPT-6 (Astra)</option><option value="codex-gpt52">Codex gpt-5.2</option></optgroup>
<optgroup label="Local · Ollama"><option value="local-qwen-27b">Local Qwen 27B Heretic</option><option value="local-qwen-14b">Local Qwen 14B</option></optgroup>
</select>
<button class="btn run" id="runBtn" title="Open a new iTerm2 window with the selected model, one per selected ticket">▶ Run Now</button>
<span id="runmsg" style="margin-left:8px;font-size:12px;opacity:.85"></span>
<button class="btn dtd" id="dtdBtn" title="Batched /dtd — clear yes/no 'run now' verdict per recent ticket">🧠 Analyze w/ DTD</button>
<button class="btn" id="yesBtn" title="Add every ticket DTD said YES to the selection">✅ Select all YES</button>
<button class="btn stop" id="stopBtn" title="TicketStopped — stop forever (reversible)">⛔ Stop Forever</button>
<button class="btn reopen" id="reopenBtn" title="Un-stop / reopen selected">↩ Reopen</button>
<span class="grow"></span>
<button class="btn" id="clearBtn">Clear selection</button>
</div>
<div id="colmenu"></div>
<div id="pillmenu"></div>
<div id="detmenu"></div>
<div class="tblwrap"><table class="g"><thead><tr id="thead"></tr></thead><tbody id="tbody"></tbody></table></div>
<section id="catalog"></section>
<div id="dmwrap"></div>
<script>
const $=s=>document.querySelector(s), esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fiveWords=s=>String(s||'').replace(/\bTK-\d+\b/gi,'').replace(/[^\p{L}\p{N}$&+/-]+/gu,' ').trim().split(/\s+/).filter(Boolean).slice(0,5).join(' ')||'No summary available yet';
const dt=s=>{if(!s)return '—';const d=new Date(s);if(isNaN(d))return '—';return '<span title="'+esc(new Date(s).toISOString())+'">'+esc(d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))+'</span>';};
const agoH=s=>s?((Date.now()-new Date(s).getTime())/3600000):null;
const elapsed=s=>{const ms=Math.max(0,Date.now()-new Date(s||Date.now()).getTime()),m=Math.floor(ms/60000),h=Math.floor(m/60),d=Math.floor(h/24);return d?`${d}d ${h%24}h`:h?`${h}h ${m%60}m`:`${m}m`;};
const ticketCell=t=>{const short=esc(t.id.replace(/^(TK-\d+).*/,'$1')),running=t.status==='doing',since=running?(t.status_since||t.created_at):t.created_at,age=elapsed(since);return `<span class="ticket-top"><a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${short}</a><span class="ticket-runtime${running?' running':''}" title="since ${esc(since||'unknown')}">${running?'running':'age'} ${age}</span></span>`;};
const KIND_FACE={task:'Task',continuous_loop:'Continuous Loop',scheduled_job:'Scheduled Job'};
const scheduleCell=t=>{const s=t.schedule||{};if(t.kind!=='scheduled_job')return t.kind==='continuous_loop'?'event-driven':'—';return esc(s.cron||s.cadence||'schedule missing')+(s.scheduler_label?`<br><small>${esc(s.scheduler_label)}</small>`:'');};
const BLOCKER_FACE={steve_action:'👤 Steve action',external_wait:'⏳ External wait',technical_dependency:'🛠 Technical',intentional_guardrail:'🛡 Guardrail',resolved_candidate:'✓ Verify/close'};
const blockerCell=t=>t.blocker?`<span class="stpill" title="${esc(t.blocker.condition)}">${BLOCKER_FACE[t.blocker.type]||esc(t.blocker.type)}</span>`:'—';
// ── DTD run-now verdicts (fetched from /api/dtd): { TK-id: {verdict,yes,no,confidence} } ──
let DTD={running:false,verdicts:null};
const verdictOf=t=>{const v=DTD.verdicts&&DTD.verdicts.tickets&&DTD.verdicts.tickets[t.id];return v||null;};
const verdictRank=t=>{const v=verdictOf(t);if(!v)return -1;return v.verdict==='yes'?2:v.verdict==='no'?0:1;};
function verdictCell(t){const v=verdictOf(t);if(!v)return '<span class="vb none">—</span>';
const cls=v.verdict==='yes'?'yes':v.verdict==='no'?'no':'unknown';
const face=v.verdict==='yes'?'✅ YES':v.verdict==='no'?'⛔ no':'? unk';
return `<span class="vb ${cls}" title="${esc(v.voters?v.voters.join(' '):'')}">${face} <small>${v.yes}✓/${v.no}✗</small></span>`;}
// ── priority ranking + ratings (server-computed on /api/tickets, mirrors :9801) ──
const TIERFACE={high:'🔴 high',med:'🟡 med',low:'⚪ low'};
function rankCell(t){if(t.rank==null)return '<span class="num" style="opacity:.4">—</span>';
return `<span class="rank${t.rank===1?' r1':''}" title="priority rank ${t.rank}">#${t.rank}</span>`;}
function tierCell(t){if(!t.tier)return '<span class="tier low">—</span>';
return `<span class="tier ${t.tier}" title="priority ${t.priority}">${TIERFACE[t.tier]||t.tier}</span>`;}
function barsCell(t){const r=t.ratings||{};if(!t.tier)return '<span style="opacity:.35;font-size:10px">—</span>';
const row=(cls,lab,v)=>`<span class="bl">${lab}</span><span class="bt" title="${lab} ${v||0}/5"><span class="bf ${cls}" style="width:${(v||0)/5*100}%"></span></span>`;
return `<span class="bars">${row('v','Val',r.value)}${row('u','Urg',r.urgency)}${row('e','Ease',r.ease)}${row('s','Safe',r.safety)}</span>`;}
// column defs — order here is the default order; every field sortable; g=group for the toggle menu
const COLS=[
{k:'rank', l:'#', g:'Priority', w:52, def:1, cell:t=>rankCell(t), raw:t=>t.rank==null?9e9:t.rank},
{k:'tier', l:'Tier', g:'Priority', w:86, def:1, cell:t=>tierCell(t), raw:t=>({high:3,med:2,low:1}[t.tier]||0)},
{k:'priority',l:'Prio', g:'Priority', w:64, def:1, cell:t=>`<span class="prio">${t.priority!=null?t.priority.toFixed(1):'—'}</span>`, raw:t=>t.priority||0},
{k:'ratings',l:'Ratings',g:'Priority',w:150, def:1, cell:t=>barsCell(t), raw:t=>t.priority||0},
{k:'id', l:'Ticket', g:'Core', w:150, def:1, cell:t=>ticketCell(t), raw:t=>t.id},
{k:'about', l:'About · 5 words',g:'Core',w:230,def:1,cell:t=>`<span title="${esc(t.title)}">${esc(fiveWords(t.title))}</span>`,raw:t=>fiveWords(t.title)},
{k:'segment',l:'DW?',g:'Core',w:118,def:1,cell:t=>segCell(t),raw:t=>isDW(t)?'DW':'Non-DW'},
{k:'status',l:'Status', g:'Core', w:120, def:1, cell:t=>{const cur=t.parked?'parked':t.status;const o=['open','doing','blocked','done','stopped','parked'].map(s=>`<option value="${s}"${s===cur?' selected':''}>${s==='stopped'?'TicketStopped':s==='parked'?'⏸ parked':s}</option>`).join('');const w=workSignal(t);const orb=w?`<span class="orb${w.k==='saved'?' saved':''}" role="img" aria-label="${esc(w.tip)}" title="${esc(w.tip)}"></span>`:'';return `<span class="stwrap">${orb}<select class="stsel s-${cur}" data-id="${t.id}" data-cur="${cur}" title="Reassign status" onpointerdown="event.stopPropagation()" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onchange="reassignStatus(event)">${o}</select></span>`;}, raw:t=>t.parked?'parked':t.status},
{k:'blocker',l:'Blocker lane',g:'Blocker',w:150,def:1,cell:t=>blockerCell(t),raw:t=>t.blocker?.type||''},
{k:'next_action',l:'Next unblock',g:'Blocker',w:320,def:1,cell:t=>esc(t.blocker?.next_action||'—'),raw:t=>t.blocker?.next_action||''},
{k:'blocker_owner',l:'Blocker owner',g:'Blocker',w:145,def:1,cell:t=>esc(t.blocker?.owner||'—'),raw:t=>t.blocker?.owner||''},
{k:'recheck_at',l:'Recheck',g:'Blocker',w:150,def:1,cell:t=>dt(t.blocker?.recheck_at),raw:t=>t.blocker?.recheck_at||''},
{k:'kind',l:'Designation',g:'Core',w:130,def:1,cell:t=>esc(KIND_FACE[t.kind||'task']||t.kind),raw:t=>t.kind||'task'},
{k:'schedule',l:'Schedule',g:'Core',w:170,def:1,cell:t=>scheduleCell(t),raw:t=>(t.schedule&&((t.schedule.cron||t.schedule.cadence)))||''},
{k:'runnow',l:'Run now?',g:'Core',w:118, def:1, cell:t=>verdictCell(t), raw:t=>verdictRank(t)},
{k:'title', l:'Title', g:'Core', w:400, def:1, cell:t=>esc(t.title), raw:t=>t.title},
{k:'project',l:'Project',g:'Core',w:150, def:1, cell:t=>t.project?`<span class="proj" onclick="setQ('${esc(t.project)}')">${esc(t.project)}</span>`:'—', raw:t=>t.project||''},
{k:'assignee',l:'Owner',g:'Core', w:150, def:1, cell:t=>t.assignee?`<span class="who" onclick="setQ('${esc(t.assignee)}')">${esc(t.assignee)}</span>`:'—', raw:t=>t.assignee||''},
{k:'updated_at',l:'Updated',g:'Time',w:150,def:1,cell:t=>dt(t.updated_at),raw:t=>t.updated_at||''},
{k:'created_at',l:'Created',g:'Time',w:150,def:1,cell:t=>dt(t.created_at),raw:t=>t.created_at||''},
{k:'age', l:'Idle(h)',g:'Time', w:80, def:1, cell:t=>{const h=agoH(lastTs(t));return `<span class="num">${h==null?'—':h.toFixed(0)}</span>`;}, raw:t=>agoH(lastTs(t))||0},
{k:'ncom',l:'#Cmt', g:'Activity',w:64,def:1,cell:t=>`<span class="num">${(t.comments||[]).length}</span>`,raw:t=>(t.comments||[]).length},
{k:'nact',l:'#Act', g:'Activity',w:64,def:1,cell:t=>`<span class="num">${(t.actions||[]).length}</span>`,raw:t=>(t.actions||[]).length},
{k:'last',l:'Last activity',g:'Activity',w:460,def:0,cell:t=>esc((lastText(t)||'').slice(0,160)),raw:t=>lastText(t)||''},
];
// blocked-but-working (Steve 2026-09-26): 'running' = agent action/comment in the last 15m;
// 'saved' = a saved cadence/cron schedule whose next run is still due (or ran in the last hour).
function workSignal(t){if(t.status!=='blocked'||t.parked)return null;let ms=0;for(const a of (t.actions||[]))ms=Math.max(ms,+new Date(a.ts));for(const c of (t.comments||[]))ms=Math.max(ms,+new Date(c.ts));const age=ms?(Date.now()-ms)/60000:Infinity;if(age<15)return {k:'running',tip:'working — last agent activity '+Math.round(age)+'m ago'};const sc=t.schedule||{};if(sc.cadence||sc.cron){const nx=sc.next_run_at?+new Date(sc.next_run_at):0,lr=sc.last_run_at?+new Date(sc.last_run_at):0;if(nx>Date.now()||(lr&&Date.now()-lr<3600000))return {k:'saved',tip:'saved schedule running ('+(sc.cron||sc.cadence)+')'+(nx?' — next '+new Date(nx).toLocaleString():'')};}return null;}
function lastTs(t){let ms=+new Date(t.updated_at||t.created_at||0);for(const a of (t.actions||[]))ms=Math.max(ms,+new Date(a.ts));for(const c of (t.comments||[]))ms=Math.max(ms,+new Date(c.ts));return ms;}
function lastText(t){const acts=(t.actions||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));if(acts[0])return acts[0].text;const cs=(t.comments||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));return cs[0]?cs[0].text:'';}
const CK=COLS.map(c=>c.k);
const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
let DATA=[], AGENTS=[], SKILLS=[], q='';
const URLSTATE=new URLSearchParams(location.search);
let SECTION=URLSTATE.get('section')||LS('tkSection','blocked'), LAYOUT=URLSTATE.get('layout')||LS('tkLayout','grid');
if(!['all','open','blocked','doing','parked','agents','skills','dw','nondw'].includes(SECTION))SECTION='blocked';if(!['grid','kv'].includes(LAYOUT))LAYOUT='grid';
let CATALOG_SORT=LS('tkCatalogSort','name'), CATALOG_DIR=LS('tkCatalogDir',1), CATALOG_COLS=LS('tkCatalogCols',4);
let sortKey=LS('tkSortKey','created_at'), sortDir=LS('tkSortDir',-1); if(!CK.includes(sortKey))sortKey='created_at';
let VIEW=LS('tkView','task');
let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
for(const c of COLS)if(!(c.k in VIS))VIS[c.k]=!!c.def; // new cols (runnow) default ON even with an old stored set
let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k)){const prev=CK[CK.indexOf(k)-1],at=ORDER.indexOf(prev);at<0?ORDER.push(k):ORDER.splice(at+1,0,k);} // new cols land beside their COLS neighbour, not at the far right
let WID=LS('tkWid',{}), DENS=LS('tkDens',7);
let GROUP=LS('tkGroup','none');
let SEL=new Set(); // selected ticket ids — survives re-render/auto-refresh
let COLLAPSED=new Set(); // collapsed group keys
let EXPANDED=new Set(); // expanded detail-row ids — survives the 30s auto-refresh re-render
// toggleable stat pills — mirrors the column-visibility pattern (persists to localStorage)
const PILLS=[{k:'open',l:'open'},{k:'doing',l:'doing'},{k:'blocked',l:'blocked'},{k:'done',l:'done'},{k:'stopped',l:'stopped'},{k:'stale',l:'stale'},{k:'total',l:'total'},{k:'shown',l:'shown'}];
let PILL_VIS=Object.assign(Object.fromEntries(PILLS.map(p=>[p.k,1])),LS('tkPills',{})); // merge → new pills default ON even with an old stored set
// toggleable expanded-row detail fields (by kind) + DM-panel show/hide — both persist
const DET=[{k:'comment',l:'comments'},{k:'note',l:'notes'},{k:'action',l:'actions'},{k:'win',l:'wins'},{k:'challenge',l:'challenges'},{k:'cody',l:'cody dissents'}];
let DET_VIS=LS('tkDet',null)||Object.fromEntries(DET.map(d=>[d.k,1]));
let DM_ON=LS('tkDM',1), LASTMSG=[];
let RUN_PROFILE=LS('tkRunProfile','codex');
if(LS('tkRunProfileDefaultV2','')!=='codex'){RUN_PROFILE='codex';save('tkRunProfile','codex');save('tkRunProfileDefaultV2','codex');}
document.documentElement.style.setProperty('--rowpad',DENS+'px'); $('#dens').value=DENS; $('#grp').value=GROUP; $('#view').value=VIEW;
if(![...$('#runProfile').options].some(o=>o.value===RUN_PROFILE))RUN_PROFILE='claude-sonnet';$('#runProfile').value=RUN_PROFILE;
const ordered=()=>ORDER.map(k=>COLS.find(c=>c.k===k)).filter(c=>c&&VIS[c.k]);
function renderHead(){
const th=$('#thead'); th.innerHTML='';
// fixed leading select-all checkbox (outside the draggable/resizable COLS)
const cbth=document.createElement('th'); cbth.className='cb';
cbth.innerHTML='<input type="checkbox" id="selall" title="select all shown" aria-label="Select all shown tickets">';
th.appendChild(cbth);
for(const c of ordered()){
const el=document.createElement('th'); el.dataset.k=c.k; el.draggable=true;
el.tabIndex=0; el.setAttribute('role','button'); el.setAttribute('aria-label','Sort by '+c.l);
el.innerHTML=esc(c.l)+'<span class="rsz"></span>';
el.style.width=(WID[c.k]||c.w)+'px';
if(c.k===sortKey){el.classList.add(sortDir>0?'asc':'desc');el.setAttribute('aria-sort',sortDir>0?'ascending':'descending');}
const doSort=()=>{if(sortKey===c.k)sortDir*=-1;else{sortKey=c.k;sortDir=1;} save('tkSortKey',sortKey);save('tkSortDir',sortDir); render();};
el.onclick=e=>{if(e.target.classList.contains('rsz'))return; doSort();};
el.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!e.target.classList.contains('rsz')){e.preventDefault();doSort();}};
// resize
el.querySelector('.rsz').addEventListener('mousedown',e=>{e.stopPropagation();e.preventDefault();const sx=e.pageX,sw=el.offsetWidth;
const mv=ev=>{const w=Math.max(48,sw+ev.pageX-sx);el.style.width=w+'px';WID[c.k]=w;};
const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);save('tkWid',WID);};
document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
// drag reorder
el.addEventListener('dragstart',e=>{e.dataTransfer.setData('k',c.k);el.classList.add('dragging');});
el.addEventListener('dragend',()=>{el.classList.remove('dragging');document.querySelectorAll('th').forEach(x=>x.classList.remove('dropL','dropR'));});
el.addEventListener('dragover',e=>{e.preventDefault();const r=el.getBoundingClientRect();const after=e.clientX>r.left+r.width/2;el.classList.toggle('dropR',after);el.classList.toggle('dropL',!after);});
el.addEventListener('drop',e=>{e.preventDefault();const src=e.dataTransfer.getData('k');if(!src||src===c.k)return;const r=el.getBoundingClientRect();const after=e.clientX>r.left+r.width/2;
ORDER=ORDER.filter(k=>k!==src);let i=ORDER.indexOf(c.k);ORDER.splice(after?i+1:i,0,src);save('tkOrder',ORDER);render();});
th.appendChild(el);
}
$('#selall').onclick=e=>{const on=e.target.checked;const vis=DATA.filter(match);if(on)for(const t of vis)SEL.add(t.id);else for(const t of vis)SEL.delete(t.id);render();syncBar();};
}
// ── DW (Designer Wallcoverings) scope detector — EDIT the sets/patterns below to reclassify ──
// DW = every dw-* project, the wallcovering sister-sites, AND all DW vendor lines.
// The "Non-DW" nav tab shows every ticket where isDW(t) is false (any status).
// To move a project: add it to DW_DENY (force NON-DW) or DW_EXTRA (force DW), or add a vendor token.
const DW_DENY=new Set(['wallpapersback','abramsego','pattern-vault']); // look wallpaper-ish but are SEPARATE businesses
const DW_RE=/designerwallcoverings|designer-wallcoverings|dw_unified|(^|[^a-z])dw($|[^a-z])|(^|[^a-z])dw-/i;
const DW_KW=/wallcover|wallpaper/i; // DW wallcovering sister-sites (architectural/contract/hospitality/goldleaf)
const DW_VENDOR=/(^|[^a-z])(sanderson|stroheim|muralsource|kravet|greenland|malibu|gracie|astek|fabricut|groundworks|rebel-walls|thibaut|schumacher|koroseal|scalamandre|maya-?romanoff|winfield|phillip-?jeffries|brewster|wallquest|carnegie|designtex|momentum|versa|elitis|fromental|fentucci|hollywood|novasuede|philipperomano|mdc)($|[^a-z])/i;
const DW_EXTRA=new Set(['norma','greenland-onboard','sample-followup','sample-followup-sweep','interiordesignershowroom','marketing-command-center','all-designerwallcoverings','consulting-designerwallcoverings-com','mfr-review-viewer','mfr-review-viewer-corruption','designersalesreps','vendor-command-center','discontinued-agent']);
function isDW(t){if(t.segment==='dw')return true;if(t.segment==='nondw')return false;return autoDW(t);}
function autoDW(t){const p=String(t.project||'').toLowerCase().trim();if(!p||DW_DENY.has(p))return false;return DW_EXTRA.has(p)||DW_RE.test(p)||DW_VENDOR.test(p)||DW_KW.test(p);}
function match(t){if(VIEW!=='all'&&(t.kind||'task')!==VIEW)return false;if(['open','blocked','doing'].includes(SECTION)&&t.status!==SECTION)return false;if(['open','blocked','doing'].includes(SECTION)&&t.parked)return false;/*TK-11946: parked held out of the active queue (mirrors /kanban parked lane)*/if(SECTION==='parked'&&!(t.parked&&['open','doing','blocked'].includes(t.status)))return false;if(SECTION==='nondw'&&isDW(t))return false;if(SECTION==='dw'&&!isDW(t))return false;if(!q)return true;
// search the FULL thread — every comment (note/win/challenge/cody incl.) + every action — not just lastText
const thread=[...(t.comments||[]).map(c=>c.text),...(t.actions||[]).map(a=>a.text)].join(' ');
const hay=(t.id+' '+t.title+' '+(t.project||'')+' '+(t.assignee||'')+' '+t.status+' '+(t.kind||'task')+' '+JSON.stringify(t.schedule||{})+' '+thread).toLowerCase();
return q.toLowerCase().split(/\s+/).every(w=>hay.includes(w));}
const groupLabel=(k,v)=>k==='status'?(v==='stopped'?'TicketStopped':v):k==='blocker'?(BLOCKER_FACE[v]||v||'Unclassified'):k==='assignee'?(v||'unassigned'):(v||'—');
function render(){
if(SECTION==='agents'||SECTION==='skills'){renderCatalog();return;}
$('#catalog').classList.remove('show');$('.tblwrap').style.display='';$('#actionbar').classList.remove('show');
renderHead();
const cols=ordered(); const span=cols.length+1;
let rows=DATA.filter(match);
const sc=COLS.find(c=>c.k===sortKey);
rows.sort((a,b)=>{let x=sc.raw(a),y=sc.raw(b);if(typeof x==='number'&&typeof y==='number')return (x-y)*sortDir;return String(x).localeCompare(String(y))*sortDir;});
const tb=$('#tbody');tb.innerHTML='';
const buildDet=t=>{
const d=document.createElement('tr');d.className='det';
const cShown=(t.comments||[]).filter(c=>DET_VIS[c.kind]!==0);
const aShown=DET_VIS.action!==0?(t.actions||[]).slice(-6):[];
const thread=cShown.map(c=>`<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${dt(c.ts)}</span></div>`).join('')
+aShown.map(a=>`<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${dt(a.ts)}</span></div>`).join('')
+(!cShown.length&&!aShown.length?'<div class="c none">no visible detail (toggle via ⤢ Detail)</div>':'');
d.innerHTML=`<td colspan="${span}"><div class="thread">${thread}</div></td>`;return d;};
const rowTr=t=>{
const tr=document.createElement('tr');tr.className='row'+(SEL.has(t.id)?' sel':'')+(t.status==='stopped'?' stopped':'');tr.dataset.id=t.id;
tr.innerHTML=`<td class="cb"><input type="checkbox" class="rowcb" aria-label="Select ${esc(t.id)}" ${SEL.has(t.id)?'checked':''}></td>`+cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
tr.querySelector('.rowcb').onclick=e=>{e.stopPropagation();if(e.target.checked)SEL.add(t.id);else SEL.delete(t.id);tr.classList.toggle('sel',e.target.checked);setRunMsg('');syncBar();};
tr.onclick=e=>{if(e.target.closest('a,.who,.proj,input'))return;const nx=tr.nextElementSibling;if(nx&&nx.classList.contains('det')){nx.remove();EXPANDED.delete(t.id);return;}
tr.after(buildDet(t));EXPANDED.add(t.id);};
tb.appendChild(tr);
if(EXPANDED.has(t.id))tr.after(buildDet(t)); // re-open across the 30s auto-refresh
};
if(GROUP==='none'){
for(const t of rows)rowTr(t);
}else{
const groups=new Map();
for(const t of rows){const gv=GROUP==='status'?((t.parked&&['open','doing','blocked'].includes(t.status))?'parked':t.status)/*TK-11946*/:GROUP==='blocker'?(t.blocker?.type||''):GROUP==='project'?(t.project||''):GROUP==='segment'?(isDW(t)?'DW':'Non-DW'):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
// order groups: for status use canonical order, else alpha
const order=GROUP==='status'?['open','doing','blocked','done','stopped','parked']:[...groups.keys()].sort((a,b)=>String(a).localeCompare(String(b)));
for(const gv of order){const list=groups.get(gv);if(!list)continue;
const gkey=GROUP+':'+gv; const collapsed=COLLAPSED.has(gkey);
const allSel=list.every(t=>SEL.has(t.id))&&list.length>0;
const gtr=document.createElement('tr');gtr.className='grp';
gtr.innerHTML=`<td colspan="${span}"><span class="gcx">${collapsed?'▸':'▾'}</span><input type="checkbox" ${allSel?'checked':''} title="select all in group" aria-label="Select all in ${esc(groupLabel(GROUP,gv))}"> ${esc(groupLabel(GROUP,gv))}<span class="gcount">${list.length}</span></td>`;
gtr.querySelector('td').onclick=e=>{if(e.target.tagName==='INPUT')return;if(collapsed)COLLAPSED.delete(gkey);else COLLAPSED.add(gkey);render();};
gtr.querySelector('input').onclick=e=>{e.stopPropagation();if(e.target.checked)for(const t of list)SEL.add(t.id);else for(const t of list)SEL.delete(t.id);render();syncBar();};
tb.appendChild(gtr);
if(!collapsed)for(const t of list)rowTr(t);
}
}
$('#count')&&($('#count').textContent=rows.length);
const vis=DATA.filter(match); const sa=$('#selall'); if(sa)sa.checked=vis.length>0&&vis.every(t=>SEL.has(t.id));
renderPills(); syncBar();
}
function catalogRows(){return SECTION==='agents'?AGENTS:SKILLS;}
function catalogFields(){return SECTION==='agents'?[['name','Agent'],['about','About'],['tickets','Tickets'],['assigned','Assigned'],['actions','Actions'],['comments','Comments'],['updated_at','Updated']]:[['name','Skill'],['about','About'],['root','Root'],['slug','Slug'],['updated_at','Updated']];}
function renderCatalog(){
$('.tblwrap').style.display='none';$('#catalog').classList.add('show');$('#actionbar').classList.remove('show');
let rows=catalogRows().map(r=>({...r,about:SECTION==='agents'?`Works across ${r.tickets} durable tickets`:fiveWords(r.description||r.name)})).filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase()));
rows.sort((a,b)=>{const x=a[CATALOG_SORT]??'',y=b[CATALOG_SORT]??'';return (typeof x==='number'&&typeof y==='number'?(x-y):String(x).localeCompare(String(y),undefined,{numeric:true}))*CATALOG_DIR;});
const fields=catalogFields(), opts=fields.map(([k,l])=>`<option value="${k}" ${CATALOG_SORT===k?'selected':''}>${l}</option>`).join('');
const meta=r=>fields.slice(1).map(([k,l])=>r[k]==null||r[k]===''?'':`<span class="kv"><b>${esc(l)}</b>${k.endsWith('_at')?dt(r[k]):esc(r[k])}</span>`).join('');
const stamp=r=>`<span class="stamp" title="${esc(r.created_at||'')}">🕓 ${r.created_at?new Date(r.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'unknown'}</span>`;
const body=LAYOUT==='kv'?rows.map(r=>`<article class="kvrow" title="${esc(r.description||r.about)}"><h3>${esc(r.name)}</h3><div class="kvmeta">${meta(r)}${stamp(r)}</div></article>`).join(''): `<div class="catalog-grid">${rows.map(r=>`<article class="catalog-card" title="${esc(r.description||r.about)}"><h3>${esc(r.name)}</h3><p>${esc(r.about)}</p><div class="kvmeta">${meta(r)}</div>${stamp(r)}</article>`).join('')}</div>`;
$('#catalog').innerHTML=`<div class="catalog-head"><h2>${SECTION==='agents'?'Agent activity':'Installed skills'}</h2><small>${rows.length} shown</small><span class="grow"></span><input type="range" id="catalogCols" min="2" max="6" step="1" value="${CATALOG_COLS}" title="grid density" aria-label="Catalog grid columns" style="width:96px;accent-color:var(--acc)"><select class="gsel" id="catalogSort" aria-label="Sort catalog by">${opts}</select><button class="btn" id="catalogDir" aria-label="Toggle sort direction">${CATALOG_DIR>0?'▲ Asc':'▼ Desc'}</button></div>${body||'<div class="c none">No matching records.</div>'}`;
$('#catalog').style.setProperty('--catalog-cols',CATALOG_COLS);
$('#catalogSort').onchange=e=>{CATALOG_SORT=e.target.value;save('tkCatalogSort',CATALOG_SORT);renderCatalog();};$('#catalogDir').onclick=()=>{CATALOG_DIR*=-1;save('tkCatalogDir',CATALOG_DIR);renderCatalog();};
$('#catalogCols').oninput=e=>{CATALOG_COLS=+e.target.value;save('tkCatalogCols',CATALOG_COLS);$('#catalog').style.setProperty('--catalog-cols',CATALOG_COLS);};
}
function syncUrl(){const u=new URL(location.href);u.searchParams.set('section',SECTION);u.searchParams.set('layout',LAYOUT);history.replaceState(null,'',u);}
function setSection(section){SECTION=section;save('tkSection',SECTION);syncUrl();q='';$('#q').value='';document.querySelectorAll('[data-section]').forEach(b=>b.classList.toggle('on',b.dataset.section===SECTION));render();}
function initSectionTabs(){
const nav=$('.globalnav'),keys=['all','open','blocked','doing','parked','dw','nondw','agents','skills'];
let order=LS('tkSectionOrder',keys).filter(k=>keys.includes(k));for(const k of keys)if(!order.includes(k))order.push(k);
const apply=()=>{for(const k of order){const b=nav.querySelector(`[data-section="${k}"]`);if(b)nav.appendChild(b);}};apply();
nav.querySelectorAll('[data-section]').forEach(b=>{b.draggable=true;b.title='Drag to reorder tabs';
b.ondragstart=e=>{e.dataTransfer.setData('text/plain',b.dataset.section);e.dataTransfer.effectAllowed='move';};
b.ondragover=e=>{e.preventDefault();e.dataTransfer.dropEffect='move';};
b.ondrop=e=>{e.preventDefault();const from=e.dataTransfer.getData('text/plain'),to=b.dataset.section;if(!from||from===to)return;order=order.filter(k=>k!==from);order.splice(order.indexOf(to),0,from);save('tkSectionOrder',order);apply();};
});
}
initSectionTabs();
function setLayout(layout){LAYOUT=layout;save('tkLayout',LAYOUT);syncUrl();document.querySelectorAll('[data-layout]').forEach(b=>b.classList.toggle('on',b.dataset.layout===LAYOUT));if(SECTION==='agents'||SECTION==='skills')renderCatalog();else{const agent=window.NavAgent&&window.NavAgent.instances&&window.NavAgent.instances[0];if(agent&&agent.setView)agent.setView(LAYOUT==='kv'?'list':'grid');}}
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>setSection(b.dataset.section));document.querySelectorAll('[data-layout]').forEach(b=>b.onclick=()=>setLayout(b.dataset.layout));
// nav-bar position toggle (top ⇄ below the toolbar), persisted like other view settings
let NAVPOS=LS('tkNavPos','top');
function setNavPos(pos){NAVPOS=(pos==='bottom'?'bottom':'top');save('tkNavPos',NAVPOS);
const st=document.querySelector('.stickytop'); if(st)st.classList.toggle('nav-below',NAVPOS==='bottom');
const b=document.getElementById('navPosBtn'); if(b){b.textContent=NAVPOS==='top'?'⤓ Nav':'⤒ Nav';b.title=NAVPOS==='top'?'Move nav bar below the toolbar':'Move nav bar above the toolbar';}}
{const _npb=document.getElementById('navPosBtn'); if(_npb)_npb.onclick=()=>setNavPos(NAVPOS==='top'?'bottom':'top');}
setNavPos(NAVPOS);
document.querySelectorAll('[data-section]').forEach(b=>b.classList.toggle('on',b.dataset.section===SECTION));document.querySelectorAll('[data-layout]').forEach(b=>b.classList.toggle('on',b.dataset.layout===LAYOUT));
// ── selection action bar ──
function syncBar(){const n=SEL.size;$('#seln').textContent=n;$('#actionbar').classList.toggle('show',n>0);}
function selIds(){return [...SEL];}
async function post(url,body){const r=await fetch(location.origin+url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});return r.json();}
$('#clearBtn').onclick=()=>{SEL.clear();render();syncBar();};
$('#runProfile').onchange=e=>{RUN_PROFILE=e.target.value;save('tkRunProfile',RUN_PROFILE);};
const setRunMsg=t=>{const e=$('#runmsg');if(e)e.textContent=t||'';};
$('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length){setRunMsg('select ticket(s) first');return;}
const label=($('#runProfile').selectedOptions[0]&&$('#runProfile').selectedOptions[0].textContent)||RUN_PROFILE;
$('#runBtn').disabled=true;setRunMsg(`launching ${ids.length} ${label} session(s)…`);
try{const r=await post('/api/run',{ids,profile:RUN_PROFILE});if(r.error)throw new Error(r.error);const sk=(r.skipped&&r.skipped.length)?(' · skipped '+r.skipped.length):'';setRunMsg(`launched ${(r.launched||[]).length}${sk} — windows opening (check the feed if none appear)`);}catch(e){setRunMsg('run failed: '+e.message);}finally{$('#runBtn').disabled=false;SEL.clear();load();}};
$('#stopBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Stop ${ids.length} ticket(s) FOREVER (TicketStopped)? Reversible via Reopen.`))return;
try{await post('/api/stop',{ids});}catch(e){setRunMsg('stop failed: '+e.message);}SEL.clear();load();};
$('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){setRunMsg('reopen failed: '+e.message);}SEL.clear();load();};
// Per-row status reassign (TK-12076). 'parked' isn't a real status — it's the durable PARKED
// registry — so route it to /api/park; a real status routes to /api/status (unparking first
// if the ticket was parked). On failure, restore the select so the row never lies.
// DW / Non-DW segment (per-ticket). "auto" = the project-name rule above; picking DW or Non-DW
// writes a 'segment' event that overrides it. Bold = set by hand, faint = auto-guessed.
function segCell(t){const auto=autoDW(t)?'DW':'Non-DW',cur=t.segment||'';const o=[['',`auto · ${auto}`],['dw','DW'],['nondw','Non-DW']].map(([v,l])=>`<option value="${v}"${v===cur?' selected':''}>${l}</option>`).join('');return `<select class="segsel ${cur?'seg-set':'seg-auto'} ${isDW(t)?'seg-dw':'seg-non'}" data-id="${t.id}" data-cur="${cur}" title="${cur?'Set by hand':'Auto-guessed from project — pick to assign'}" onpointerdown="event.stopPropagation()" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onchange="reassignSegment(event)">${o}</select>`;}
async function reassignSegment(e){e.stopPropagation();const sel=e.target,id=sel.dataset.id,to=sel.value,cur=sel.dataset.cur;if(to===cur)return;
const ids=(SEL.has(id)&&SEL.size>1)?[...SEL]:[id];sel.disabled=true;setRunMsg(`${ids.length} ticket(s) → ${to==='dw'?'DW':to==='nondw'?'Non-DW':'auto'}…`);
try{const r=await post('/api/segment',{ids,segment:to});if(r&&r.error)throw new Error(r.error);setRunMsg(`${ids.length} ticket(s) → ${to==='dw'?'DW':to==='nondw'?'Non-DW':'auto'}`);}
catch(err){setRunMsg('segment change failed: '+err.message);sel.value=cur;sel.disabled=false;return;}
load();
}
async function reassignStatus(e){e.stopPropagation();const sel=e.target,id=sel.dataset.id,to=sel.value,cur=sel.dataset.cur;if(to===cur)return;
// 'done' bypasses the tk-CLI evidence guard + clears the blocker — confirm it like Stop Forever (reversible via Reopen).
if(to==='done'&&!confirm(`Mark ${id} DONE from the board? This skips the tk evidence guard. Reversible via Reopen.`)){sel.value=cur;return;}
sel.disabled=true;setRunMsg(`${id} → ${to}…`);
try{
if(to==='parked'){const r=await post('/api/park',{ids:[id],park:true});if(r&&r.failed&&r.failed.length)throw new Error(r.failed[0].error||'park failed');}
else{if(cur==='parked'){await post('/api/park',{ids:[id],park:false});}const r=await post('/api/status',{ids:[id],status:to});if(r&&r.error)throw new Error(r.error);}
setRunMsg(`${id} → ${to==='parked'?'⏸ parked':to}`);
}catch(err){setRunMsg('status change failed: '+err.message);sel.value=cur;sel.disabled=false;return;}
load();
}
$('#dtdBtn').onclick=async()=>{const ids=selIds();$('#dtdBtn').disabled=true;
try{const r=await post('/api/dtd',ids.length?{ids}:{});if(r.already)dtdChip('analyzing…');else dtdChip('analyzing '+(ids.length?ids.length+' selected':'recent')+'…');pollDTD();}catch(e){setRunMsg('DTD failed: '+e.message);}finally{setTimeout(()=>{$('#dtdBtn').disabled=false;},1500);}};
$('#yesBtn').onclick=()=>{if(!DTD.verdicts||!DTD.verdicts.tickets){setRunMsg('Run "Analyze w/ DTD" first.');return;}
let n=0;for(const t of DATA){const v=verdictOf(t);if(v&&v.verdict==='yes'){SEL.add(t.id);n++;}}render();syncBar();if(!n)setRunMsg('No YES verdicts yet.');};
// ── DTD status feed ──
function dtdChip(txt){$('#dtdchip').innerHTML='DTD: '+txt;}
function renderDtdChip(){if(DTD.running){dtdChip('<b>analyzing…</b>');return;}
const v=DTD.verdicts;if(!v||!v.tickets){dtdChip('—');return;}
const arr=Object.values(v.tickets);const yes=arr.filter(x=>x.verdict==='yes').length;
dtdChip(`<b>${yes}</b>✓ / <b>${arr.length-yes}</b>✗ · ${v.ranAt?new Date(v.ranAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):''}`);}
async function loadDTD(){try{const r=await fetch(location.origin+'/api/dtd').then(r=>r.json());DTD.running=!!r.running;DTD.verdicts=r.verdicts||DTD.verdicts;renderDtdChip();render();if(DTD.running&&!dtdPoll)pollDTD();}catch(e){}}
let dtdPoll=null;
function pollDTD(){if(dtdPoll)clearInterval(dtdPoll);let n=0;dtdPoll=setInterval(async()=>{await loadDTD();n++;if(!DTD.running||n>80){clearInterval(dtdPoll);dtdPoll=null;}},5000);}
function renderPills(){
const by={open:0,doing:0,blocked:0,done:0,stopped:0};for(const t of DATA)by[t.status]=(by[t.status]||0)+1;
const stale=DATA.filter(t=>t.status==='doing'&&agoH(lastTs(t))>24).length;
const x=k=>`<span class="px" title="hide this pill" onclick="event.stopPropagation();hidePill('${k}')">✕</span>`;
const statPill=s=>`<span class="pill" onclick="setQ('${s}')"><span class="dot s-${s}" style="background:currentColor"></span>${s==='stopped'?'stopped':s} <b>${by[s]}</b>${x(s)}</span>`;
const html={
open:statPill('open'), doing:statPill('doing'), blocked:statPill('blocked'), done:statPill('done'), stopped:statPill('stopped'),
stale:`<span class="pill" title="doing >24h">⚠ stale <b>${stale}</b>${x('stale')}</span>`,
total:`<span class="pill">total <b>${DATA.length}</b>${x('total')}</span>`,
shown:`<span class="pill">shown <b id="count">${DATA.filter(match).length}</b>${x('shown')}</span>`,
};
$('#pills').innerHTML=PILLS.filter(p=>PILL_VIS[p.k]).map(p=>html[p.k]).join('');
}
window.hidePill=k=>{PILL_VIS[k]=0;save('tkPills',PILL_VIS);renderPills();renderPillMenu();};
function renderColMenu(){
const m=$('#colmenu');const groups=[...new Set(COLS.map(c=>c.g))];
m.innerHTML=groups.map(g=>`<div class="gh">${g}</div>`+COLS.filter(c=>c.g===g).map(c=>`<label><input type="checkbox" data-k="${c.k}" ${VIS[c.k]?'checked':''}>${esc(c.l)}</label>`).join('')).join('');
m.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{VIS[cb.dataset.k]=cb.checked;save('tkCols',VIS);render();});
}
function renderPillMenu(){
const m=$('#pillmenu');
m.innerHTML=`<div class="gh">Stat pills</div>`+PILLS.map(p=>`<label><input type="checkbox" data-k="${p.k}" ${PILL_VIS[p.k]?'checked':''}>${esc(p.l)}</label>`).join('');
m.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{PILL_VIS[cb.dataset.k]=cb.checked?1:0;save('tkPills',PILL_VIS);renderPills();});
}
function renderDetMenu(){
const m=$('#detmenu');
m.innerHTML=`<div class="gh">Expanded-row detail fields</div>`+DET.map(d=>`<label><input type="checkbox" data-k="${d.k}" ${DET_VIS[d.k]?'checked':''}>${esc(d.l)}</label>`).join('');
m.querySelectorAll('input').forEach(cb=>cb.onchange=()=>{DET_VIS[cb.dataset.k]=cb.checked?1:0;save('tkDet',DET_VIS);document.querySelectorAll('tr.det').forEach(r=>r.remove());});
}
window.setQ=v=>{$('#q').value=v;q=v;render();};
$('#q').oninput=e=>{q=e.target.value;render();};
$('#grp').onchange=e=>{GROUP=e.target.value;save('tkGroup',GROUP);render();};
$('#view').onchange=e=>{VIEW=e.target.value;save('tkView',VIEW);SEL.clear();render();syncBar();};
$('#dens').oninput=e=>{DENS=+e.target.value;document.documentElement.style.setProperty('--rowpad',DENS+'px');save('tkDens',DENS);};
const closeMenus=except=>['#colmenu','#pillmenu','#detmenu'].forEach(id=>{if(id!==except)$(id).classList.remove('show');});
// anchor a dropdown under its trigger button (position:fixed → viewport coords) so it
// no longer drifts to the right edge when the header flex-wraps on a narrow viewport
const anchorMenu=(menu,btn)=>{const r=btn.getBoundingClientRect();menu.style.top=(r.bottom+4)+'px';menu.style.right=Math.max(6,window.innerWidth-r.right)+'px';};
const toggleMenu=(menuSel,btn)=>{closeMenus(menuSel);const m=$(menuSel);const show=!m.classList.contains('show');m.classList.toggle('show',show);if(show)anchorMenu(m,btn);};
$('#colsBtn').onclick=e=>toggleMenu('#colmenu',e.currentTarget);
$('#pillsBtn').onclick=e=>toggleMenu('#pillmenu',e.currentTarget);
$('#detBtn').onclick=e=>toggleMenu('#detmenu',e.currentTarget);
$('#dmBtn').onclick=()=>{DM_ON=DM_ON?0:1;save('tkDM',DM_ON);$('#dmBtn').classList.toggle('off',!DM_ON);dmPanel(LASTMSG);};
$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM','tkGroup','tkView','tkSortKey','tkSortDir','tkTheme','tkSection','tkLayout','tkSectionOrder','tkCatalogSort','tkCatalogDir','tkCatalogCols','tkRunProfile','tkRunProfileDefaultV2','tkNavPos'].forEach(k=>localStorage.removeItem(k));location.reload();};
document.addEventListener('click',e=>{if(!e.target.closest('#colmenu,#colsBtn'))$('#colmenu').classList.remove('show');if(!e.target.closest('#pillmenu,#pillsBtn'))$('#pillmenu').classList.remove('show');if(!e.target.closest('#detmenu,#detBtn'))$('#detmenu').classList.remove('show');});
function dmPanel(mm){
if(!DM_ON){$('#dmwrap').innerHTML='';return;}
const map=new Map(mm.map(m=>[m.mid,m]));const rootOf=mid=>{let id=mid,c=map.get(mid),seen=new Set([id]);while(c&&c.re&&map.get(c.re)){if(seen.has(c.re))break;seen.add(c.re);id=c.re;c=map.get(id);}return id;};
const th=new Map();for(const m of mm){const r=rootOf(m.mid);(th.get(r)||th.set(r,[]).get(r)).push(m);}
const convos=[...th.values()].map(a=>a.sort((x,y)=>x.ts<y.ts?-1:1)).sort((a,b)=>a[a.length-1].ts<b[b.length-1].ts?1:-1).slice(0,30);
$('#dmwrap').innerHTML=`<details class="dms"><summary>DIRECT MESSAGES <small>${mm.length} msgs · ${convos.length} threads</small></summary><div class="convos">`
+(convos.map(ms=>`<div class="convo"><div class="chd">${esc([...new Set(ms.flatMap(m=>[m.from,m.to]))].filter(Boolean).join(' ⇄ '))}</div>`
+ms.slice(-6).map(m=>`<div class="dm"><b>${esc(m.from)}</b><span class="arw">→</span><b>${esc(m.to)}</b>${m.ticket?` <span class="mono" style="color:var(--acc);font-size:10px">${esc(m.ticket.replace(/^(TK-\d+).*/,'$1'))}</span>`:''}<div class="dtx">${esc(m.text)}</div></div>`).join('')+`</div>`).join('')||'<div class="c none" style="padding:10px">no DMs</div>')
+`</div></details>`;
}
async function load(){
try{
const base=location.origin; // never carries userinfo — fixes "URL includes credentials" fetch error when opened via admin:pass@host
const [t,m,a,s]=await Promise.all([fetch(base+'/api/tickets').then(r=>r.json()),fetch(base+'/api/messages').then(r=>r.json()),fetch(base+'/api/agents').then(r=>r.json()),fetch(base+'/api/skills').then(r=>r.json())]);
DATA=t; LASTMSG=m; AGENTS=a; SKILLS=s;
// drop any selected ids that no longer exist
const live=new Set(DATA.map(x=>x.id));for(const id of [...SEL])if(!live.has(id))SEL.delete(id);
for(const id of [...EXPANDED])if(!live.has(id))EXPANDED.delete(id);
// Deep-link support: a URL like /#q=TK-10454 (from the morning digest email)
// pre-filters the board to that ticket so you land right on it.
const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');
if(hq && !q){ q=hq; $('#q').value=hq; }
render(); dmPanel(m);
}catch(e){$('#tbody').innerHTML=`<tr><td>load failed: ${esc(e.message)}</td></tr>`;}
}
window.addEventListener('hashchange',()=>{const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');setQ(hq);});
renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); loadDTD().then(()=>{ if(DTD.running) pollDTD(); }); setInterval(load,30000);setTimeout(()=>setLayout(LAYOUT),300);
// ── day / night theme toggle (persisted; head-script already applied it — this wires the button) ──
(function(){const b=$('#themeBtn'); if(!b)return; const paint=()=>{b.textContent=document.documentElement.dataset.theme==='light'?'☀ Day':'🌙 Night';}; paint();
b.onclick=()=>{const light=document.documentElement.dataset.theme==='light'; document.documentElement.dataset.theme=light?'dark':'light'; save('tkTheme',light?'dark':'light'); paint();};})();
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>