← back to Answer Cockpit

public/index.html

476 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Answer Cockpit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='52' font-size='52'%3E%F0%9F%8E%AF%3C/text%3E%3C/svg%3E">
<style>
:root{
  --bg:#f6f7f9;--fg:#111;--muted:#5b6470;--line:#d9dee5;--card:#fff;--code:#f0f2f5;
  --ok:#0a8f3c;--warn:#b7791f;--bad:#c62828;--accent:#1f6feb;--btn:#111;--btnfg:#fff;
  --tint:rgba(0,0,0,0);--bar:#888;
}
@media (prefers-color-scheme:dark){
  :root{--bg:#0f1115;--fg:#e8eaf0;--muted:#9aa4b2;--line:#2a3140;--card:#171a21;--code:#0b0d12;
    --ok:#3ddc84;--warn:#f2b84b;--bad:#ff6b6b;--accent:#6ea8ff;--btn:#e8eaf0;--btnfg:#111}
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--fg);
  background:var(--bg);min-height:100vh;overflow-x:hidden}
body::before{content:"";position:fixed;inset:0;background:var(--tint);pointer-events:none;z-index:0;transition:background .35s}
#topbar{position:fixed;top:0;left:0;right:0;height:6px;background:var(--bar);z-index:5;transition:background .35s}
#banner{display:none;position:sticky;top:6px;z-index:4;background:#c62828;color:#fff;padding:10px 16px;font-weight:600;text-align:center}
#banner.on{display:block}
.wrap{position:relative;z-index:1;max-width:820px;margin:0 auto;padding:22px 16px 96px}
.hdr{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.hdr h1{font-size:17px;margin:0;font-weight:700;letter-spacing:.2px}
.hdr .sub{color:var(--muted);font-size:13px}
.ctrls{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0 14px}
.ctrls .sp{flex:1}
.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:12px;
  background:var(--card);color:var(--fg);white-space:nowrap}
.chip.ok{border-color:var(--ok);color:var(--ok)}
.chip.warn{border-color:var(--warn);color:var(--warn)}
.chip.bad{border-color:var(--bad);color:var(--bad)}
.chip.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.meter{height:9px;background:var(--line);border-radius:9px;overflow:hidden;position:relative;margin:6px 0 2px}
.meter>i{display:block;height:100%;width:0;border-radius:9px;transition:width .5s,background .5s}
button,.btn{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--fg);
  border-radius:10px;padding:9px 14px;min-height:40px;line-height:1.2;text-decoration:none;display:inline-flex;align-items:center;gap:8px}
button:focus-visible,.btn:focus-visible,textarea:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
button[disabled]{opacity:.45;cursor:not-allowed}
button.primary{background:var(--btn);color:var(--btnfg);border-color:var(--btn);font-weight:700;font-size:17px;padding:14px 22px;min-height:54px}
button.opt{width:100%;justify-content:flex-start;text-align:left;padding:14px 16px;min-height:56px;font-size:16px;background:var(--card)}
button.opt .k{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;
  background:var(--line);font-weight:700;font-size:13px;flex:none}
button.opt .d{display:block;color:var(--muted);font-size:13px;font-weight:400;margin-top:3px}
button.opt.sel{border-color:var(--accent);box-shadow:inset 0 0 0 2px var(--accent)}
button.tg{padding:6px 10px;min-height:32px;font-size:13px}
button.tg.on{border-color:var(--accent);color:var(--accent)}
button.danger{border-color:var(--bad);color:var(--bad)}
button.warn{border-color:var(--warn);color:var(--warn)}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px 18px 16px;box-shadow:0 6px 24px rgba(0,0,0,.06)}
.card .top{display:flex;align-items:flex-start;gap:14px}
.card .emo{font-size:44px;line-height:1;flex:none}
.card .ttl{font-size:20px;font-weight:700;margin:2px 0 6px;word-break:break-word}
.card .chips{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.card .kind{font-size:12px;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:4px}
.sec{margin-top:14px}
.sec h3{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:0 0 6px}
pre{background:var(--code);border:1px solid var(--line);border-radius:10px;padding:12px;margin:0;overflow-x:auto;
  white-space:pre-wrap;word-break:break-word;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;max-height:340px;overflow-y:auto}
pre.cmd{white-space:pre;word-break:normal;max-height:none}
.codewrap{position:relative}
.codewrap .copy{position:absolute;top:8px;right:8px;padding:4px 9px;min-height:28px;font-size:12px}
.q{font-size:18px;font-weight:600;margin:0 0 10px}
.q .h{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-weight:600;margin-bottom:3px}
.opts{display:grid;gap:8px}
.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:16px}
.actions .sp{flex:1}
textarea{width:100%;min-height:78px;font:inherit;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--fg);resize:vertical}
.note{margin-top:10px;padding:12px;border:1px dashed var(--line);border-radius:12px}
.pill{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700;letter-spacing:.5px}
.pill.APPROVE{background:rgba(10,143,60,.15);color:var(--ok)}
.pill.BLOCK{background:rgba(198,40,40,.15);color:var(--bad)}
.pill.REVISE{background:rgba(183,121,31,.15);color:var(--warn)}
.pill.none{background:var(--line);color:var(--muted)}
.empty{text-align:center;padding:60px 16px;color:var(--muted)}
.empty .big{font-size:56px}
.orph{margin-top:22px}
.orph .item{border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:10px;background:var(--card)}
.orph .item b{display:block;margin-bottom:4px}
.orph .item .m{color:var(--muted);font-size:12px}
.foot{position:fixed;left:0;right:0;bottom:0;z-index:3;background:var(--card);border-top:1px solid var(--line);
  padding:8px 16px;font-size:12px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.foot .sp{flex:1}
.foot kbd{border:1px solid var(--line);border-radius:5px;padding:0 5px;font:11px ui-monospace,Menlo,monospace}
#toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);background:var(--btn);color:var(--btnfg);
  padding:10px 16px;border-radius:12px;font-weight:600;z-index:9;opacity:0;pointer-events:none;transition:opacity .2s;max-width:92vw;text-align:center}
#toast.on{opacity:1}
.sr{position:absolute;left:-9999px}
.link{color:var(--accent)}
@media (max-width:560px){.card .emo{font-size:34px}.card .ttl{font-size:17px}button.primary{width:100%;justify-content:center}}
</style>
</head>
<body>
<div id="topbar"></div>
<div id="banner" role="alert"></div>
<div class="wrap">
  <div class="hdr">
    <h1>Answer Cockpit</h1>
    <span class="sub">one needs-Steve card at a time · one button to continue</span>
    <span class="sp" style="flex:1"></span>
    <span class="chip" id="remaining">— remaining</span>
  </div>
  <div class="meter" title="done / total this session"><i id="meter"></i></div>
  <div class="ctrls">
    <button class="tg" id="prev" title="previous (←)">◀ prev</button>
    <button class="tg" id="next" title="next / skip (→)">skip ▶</button>
    <button class="tg" id="focus" title="focus this tab in iTerm (f)">⌖ Focus tab</button>
    <span class="sp"></span>
    <button class="tg" id="tgOrph" title="show unlinked pending-approval memos (decide-only)">orphan memos</button>
    <button class="tg" id="tgRepaint" title="after a successful answer, repaint the dot green">repaint green</button>
    <button class="tg" id="tgForce" title="allow pushing into green/pink tabs (confirm each time)">force</button>
  </div>
  <div id="card"></div>
  <div id="orph" class="orph" hidden></div>
</div>
<div class="foot">
  <span id="cost">$0 (local)</span>
  <span id="scan"></span>
  <span class="sp"></span>
  <span><kbd>1</kbd>–<kbd>4</kbd> option · <kbd>Enter</kbd> primary · <kbd>⌘Enter</kbd> in text · <kbd>→</kbd> skip · <kbd>f</kbd> focus · <kbd>Esc</kbd> cancel</span>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<script>
// creds-in-URL fetch guard: relative fetch() breaks when the page is opened with user:pass@host
// (poisoned baseURI). Resolve relative URLs against a credential-free base instead.
(function(){var _f=window.fetch,BASE=location.origin+location.pathname;
 window.fetch=function(u,o){try{if(typeof u==='string'&&!/^[a-z]+:\/\//i.test(u))u=new URL(u,BASE).href;}catch(e){}return _f.call(this,u,o);};})();
const KEY='ac_'+location.port;
const $=id=>document.getElementById(id);
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function get(k,d){try{const v=localStorage.getItem(KEY+'_'+k);return v==null?d:v}catch(e){return d}}
function set(k,v){try{localStorage.setItem(KEY+'_'+k,v)}catch(e){}}
function fmtWhen(iso){if(!iso)return '';try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return iso}}
function fmtTime(iso){if(!iso)return '';try{return new Date(iso).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit',second:'2-digit'})}catch(e){return iso}}

const MOCK=/[?&]mock=1/.test(location.search);
const POLL_MS=8000, GUARD_MS=1500, CONFIRM_MS=3000;
const PRIORITY={lightblue:0,orange:1,purple:2,yellow:3,green:4,pink:5,none:6};
const COLORS={green:['🟢',[0,200,83]],yellow:['🟡',[255,204,0]],purple:['🟣',[148,0,211]],orange:['🟠',[255,140,0]],
  pink:['🩷',[255,105,180]],lightblue:['🔵',[0,176,240]],none:['',[120,120,120]]};

const state={items:[],idx:0,orphans:[],remaining:0,done:0,total:0,stale:false,down:false,scannedAt:null,
  busyUntil:0,note:null,sel:{},loaded:false,pollTimer:null};

// ---------- helpers ----------
function toast(msg,ms){const t=$('toast');t.textContent=msg;t.classList.add('on');clearTimeout(toast._t);toast._t=setTimeout(()=>t.classList.remove('on'),ms||2000);}
function stripEmoji(s){return String(s||'').replace(/^[\s\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{200D}\u{1F1E6}-\u{1F1FF}]+/u,'').trim();}
function rgbOf(it){const r=it&&Array.isArray(it.rgb)&&it.rgb.length===3?it.rgb:(COLORS[it&&it.color]||COLORS.none)[1];return r;}
function emojiOf(it){return (it&&it.emoji)||(COLORS[it&&it.color]||COLORS.none)[0];}
function sortItems(a){return a.slice().sort((x,y)=>{
  const px=PRIORITY[x.color]??9,py=PRIORITY[y.color]??9;if(px!==py)return px-py;
  const sx=x.variant==='stopped'?0:1,sy=y.variant==='stopped'?0:1;if(sx!==sy)return sx-sy;
  return String(x.createdAt||'').localeCompare(String(y.createdAt||''));});}
function current(){return state.items[state.idx]||null;}
function busy(){return Date.now()<state.busyUntil||state.down||state.stale;}
function guard(){state.busyUntil=Date.now()+GUARD_MS;render();setTimeout(render,GUARD_MS+20);}
async function copyText(s){try{await navigator.clipboard.writeText(s);toast('copied ✓',1200);return;}catch(e){}
  try{const ta=document.createElement('textarea');ta.value=s;ta.className='sr';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();toast('copied ✓',1200);}catch(e){toast('copy failed — select manually');}}
function setTint(it){const [r,g,b]=rgbOf(it);document.documentElement.style.setProperty('--tint',it?`rgba(${r},${g},${b},.18)`:'rgba(0,0,0,0)');
  document.documentElement.style.setProperty('--bar',it?`rgb(${r},${g},${b})`:'#888');}
function banner(msg){const b=$('banner');if(msg){b.textContent=msg;b.classList.add('on');}else{b.textContent='';b.classList.remove('on');}}

// ---------- API (mock-aware) ----------
async function api(path,opts){
  if(MOCK)return mockApi(path,opts);
  const r=await fetch(path,Object.assign({headers:{'Content-Type':'application/json'}},opts||{}));
  let body=null;try{body=await r.json();}catch(e){}
  if(r.status===401){banner('401 — reload with credentials (admin / DW2024!)');if(!api._401){api._401=1;if(confirm('Session not authenticated (401). Reload to enter credentials?'))location.reload();}throw {status:401,body};}
  if(!r.ok)throw {status:r.status,body};
  return body;
}

// ---------- mock backend ----------
function mockItems(){const now=Date.now(),iso=ms=>new Date(now-ms).toISOString();
  return [
   {key:'ttys004|TK-11783',tty:'ttys004',pid:4001,color:'lightblue',rgb:[0,176,240],emoji:'🔵',ticket:'TK-11783',label:'🔵 TK-11783 · credential desk · needs Steve',variant:'stopped',kind:'needs-steve',
    createdAt:iso(3.2e6),updatedAt:iso(6e4),session:{sessionId:'aaa',transcriptPath:'/x/aaa.jsonl',cwd:'/Users/macstudio3/Projects/credential-desk',confidence:'exact',candidates:1},
    lastText:'I need the Stripe restricted key for the professional-directory project before I can wire the webhook.\n\nPaste it via the secrets skill when ready, then tell me "go".',question:null,pasteCmd:null,memo:null,memos:[],warnings:[]},
   {key:'ttys007|TK-11790',tty:'ttys007',pid:4007,color:'orange',rgb:[255,140,0],emoji:'🟠',ticket:'TK-11790',label:'🟠 TK-11790 · 1 paste',variant:'stopped',kind:'paste',
    createdAt:iso(1.9e6),updatedAt:iso(1.2e5),session:{sessionId:'bbb',transcriptPath:'/x/bbb.jsonl',cwd:'/Users/macstudio3/Projects/dw-domain-fleet',confidence:'likely',candidates:1},
    lastText:'Paste this in your own session, then tell me continue.',question:null,
    pasteCmd:'! ssh root@45.61.58.125 "nginx -t && systemctl reload nginx && pm2 restart dw-fleet-monetize"',
    memo:{file:'TK-11790-nginx-reload.md',path:'/x/TK-11790-nginx-reload.md',title:'Reload nginx after vhost fix',recommendation:'APPROVE',hasUngateRun:false,pasteLines:[],createdAt:iso(2e6),excerpt:'One bad vhost is freezing box-wide reloads. This paste re-tests and reloads.'},memos:[],warnings:[]},
   {key:'ttys012|TK-11758',tty:'ttys012',pid:4012,color:'purple',rgb:[148,0,211],emoji:'🟣',ticket:'TK-11758',label:'🟣 TK-11758 · 1 gated',variant:'stopped',kind:'gated',
    createdAt:iso(9e5),updatedAt:iso(3e5),session:{sessionId:'ccc',transcriptPath:'/x/ccc.jsonl',cwd:'/Users/macstudio3/Projects/dw-kravet-hires',confidence:'ambiguous',candidates:3},
    lastText:null,question:null,pasteCmd:null,
    memo:{file:'TK-11758-kravet-hires-phase3.md',path:'/x/TK-11758-kravet-hires-phase3.md',title:'Kravet hi-res phase 3 — 412 featured-image swaps (reversible, ledgered)',recommendation:'APPROVE',hasUngateRun:true,
      pasteLines:['node scripts/apply-hires.mjs --phase 3 --apply','node scripts/apply-hires.mjs --rollback --ledger data/apply-ledger.jsonl --scope phase3'],
      createdAt:iso(9.5e5),excerpt:'Scope: 412 ACTIVE Kravet products with a verified hi-res asset.\nUndo: scoped rollback via ledger (phase3 ids only).\nExternality: none — internal Shopify media re-point, no price/status change.\n\n**APPROVE** — reversible, bounded, ledgered.'},
    memos:[],warnings:['transcript ambiguous — showing memo only']},
   {key:'ttys015|TK-11787',tty:'ttys015',pid:4015,color:'yellow',rgb:[255,204,0],emoji:'🟡',ticket:'TK-11787',label:'🟡 TK-11787 · 1 question',variant:'stopped',kind:'question',
    createdAt:iso(4e5),updatedAt:iso(2e4),session:{sessionId:'ddd',transcriptPath:'/x/ddd.jsonl',cwd:'/Users/macstudio3/Projects/retrowalls',confidence:'exact',candidates:1},
    lastText:'Before I fix the 7 flattened sites, which canonical strategy?',
    question:{toolUseId:'toolu_01',questions:[{question:'Which canonical strategy for the 7 retrowalls-flattened sites?',header:'Canonical',multiSelect:false,
      options:[{label:'Self-canonical per site',description:'Each site canonicals to itself; og:url matches. Safest, reversible.'},
               {label:'Canonical to retrowalls.com',description:'Consolidate SEO on one domain; other 6 become satellites.'},
               {label:'Noindex the 6 satellites',description:'Keep retrowalls only in Google; fastest dedupe.'},
               {label:'Leave as-is, just fix og:',description:'Minimal change; revisit later.'}]}]},
    pasteCmd:null,memo:null,memos:[],warnings:[]},
  ];}
let MOCKDB=null;
async function mockApi(path,opts){await new Promise(r=>setTimeout(r,120));
  if(!MOCKDB)MOCKDB={items:mockItems(),orphans:[{file:'TK-11701-adsense-unit.md',title:'Create AdSense unit for allnewsdaily',recommendation:'REVISE',createdAt:new Date(Date.now()-8e6).toISOString(),excerpt:'Portal-driver step; needs a one-time Google login first.'}]};
  const m=(opts&&opts.method)||'GET';const body=opts&&opts.body?JSON.parse(opts.body):{};
  if(path.startsWith('/api/queue'))return {items:MOCKDB.items,remaining:MOCKDB.items.length,orphanMemos:MOCKDB.orphans,scannedAt:new Date().toISOString(),cost:'$0 (local)',stale:false};
  if(path.startsWith('/api/item/')){const tty=path.split('/').pop();const it=MOCKDB.items.find(i=>i.tty===tty);if(!it)throw {status:404,body:{gone:true}};return it;}
  if(path==='/api/focus')return {ok:true};
  if(path==='/api/answer'||path==='/api/continue'){const it=MOCKDB.items.find(i=>i.tty===body.tty);if(!it)throw {status:409,body:{gone:true}};
    if(body.expectKey&&body.expectKey!==it.key)throw {status:409,body:{stale:true}};
    if((it.color==='green'||it.color==='pink')&&!body.force)throw {status:403,body:{error:'not waiting on you'}};
    MOCKDB.items=MOCKDB.items.filter(i=>i.tty!==body.tty);return {ok:true,typed:true,key:it.key};}
  if(path==='/api/memo/decide'){MOCKDB.orphans=MOCKDB.orphans.filter(o=>o.file!==body.file);if(body.tty)MOCKDB.items=MOCKDB.items.filter(i=>i.tty!==body.tty);return {ok:true,moved:body.decision!=='revise',typed:!!body.tty};}
  if(path==='/api/memo/undo')return {ok:true};
  if(path==='/api/health')return {verdict:'PASS',status:'PASS'};
  throw {status:404,body:{}};
}

// ---------- polling / merge ----------
async function poll(){
  let q;
  try{q=await api('/api/queue?orphans=1');}
  catch(e){
    if(e.status===401)return;
    state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':(e.status?('backend error '+e.status+' — actions disabled'):'backend unreachable — actions disabled (retrying every 8s)'));render();return;}
  state.down=false;state.stale=!!q.stale;state.scannedAt=q.scannedAt||new Date().toISOString();
  // stale-client guard: a new server bootId means new code — reload rather than keep running old JS
  if(q.bootId){if(state.bootId&&q.bootId!==state.bootId){toast('cockpit updated — reloading');setTimeout(()=>location.reload(),600);return;}state.bootId=q.bootId;}
  state.orphans=q.orphanMemos||[];state.backlog=q.ticketBacklog||[];state.backlogCount=q.ticketBacklogCount||0;state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
  $('cost').textContent=q.cost||'$0 (local)';
  banner(state.stale?'iTerm unreachable / scan stale — actions disabled':'');
  const fresh=q.items||[];const byKey=new Map(fresh.map(i=>[i.key,i]));
  if(!state.loaded){state.items=sortItems(fresh);state.idx=0;state.loaded=true;}
  else{
    const cur=current();const curKey=cur&&cur.key;
    // update in place by key, drop vanished, append new at the end — never re-sort under the cursor
    const kept=[];for(const it of state.items){const n=byKey.get(it.key);if(n){kept.push(n);byKey.delete(it.key);}}
    for(const n of fresh){if(byKey.has(n.key)){kept.push(n);byKey.delete(n.key);}}
    if(curKey&&!kept.some(i=>i.key===curKey)){
      toast('resolved ✓ / vanished',2000);
      state.items=sortItems(kept);state.idx=Math.min(state.idx,Math.max(0,state.items.length-1));state.sel={};state.note=null;
    }else{state.items=kept;if(curKey)state.idx=state.items.findIndex(i=>i.key===curKey);if(state.idx<0)state.idx=0;}
  }
  state.total=Math.max(state.total,state.done+state.items.length);
  render();
}
function schedule(){clearTimeout(state.pollTimer);state.pollTimer=setTimeout(async()=>{await poll();schedule();},POLL_MS);}

// ---------- push (answer / continue / decide) ----------
function removeCurrent(){const cur=current();if(!cur)return;state.items=state.items.filter(i=>i.key!==cur.key);state.done++;
  state.total=Math.max(state.total,state.done+state.items.length);state.remaining=Math.max(0,state.remaining-1);
  state.idx=Math.min(state.idx,Math.max(0,state.items.length-1));state.sel={};state.note=null;}
function confirmLater(it,kindHint){setTimeout(async()=>{
  try{const n=await api('/api/item/'+encodeURIComponent(it.tty));
    const bits=[];
    if(kindHint==='question'&&n.question==null)bits.push('answer landed ✓');
    if(n.color&&n.color!==it.color)bits.push('dot → '+n.color);
    if(!bits.length&&n.key===it.key&&kindHint==='question'&&n.question)bits.push('⚠ question still pending — check the pane');
    if(bits.length)toast(esc(it.ticket||it.tty)+': '+bits.join(' · '),2600);
  }catch(e){/* gone (404/409) = resolved; stay silent */}},CONFIRM_MS);}
async function push(path,payload,it,kindHint){
  if(busy())return;
  payload=Object.assign({tty:it.tty,expectKey:it.key,repaint:$('tgRepaint').classList.contains('on')},payload||{});
  if($('tgForce').classList.contains('on')||it.color==='green'||it.color==='pink'){
    if(!confirm('FORCE push into '+it.tty+' ('+it.color+' — not waiting on you)?'))return;payload.force=true;}
  guard();
  try{await api(path,{method:'POST',body:JSON.stringify(payload)});
    toast('typed ✓ → '+(it.ticket||it.tty),1800);removeCurrent();render();confirmLater(it,kindHint);}
  catch(e){await onErr(e,it,()=>push(path,Object.assign({},payload,{force:undefined}),it,kindHint));}
}
async function onErr(e,it,retryForce){
  const s=e.status,b=e.body||{};
  if(s===409){toast('card changed under you — refreshing',2200);
    try{const n=await api('/api/item/'+encodeURIComponent(it.tty));const i=state.items.findIndex(x=>x.key===it.key||x.tty===it.tty);
      if(i>=0){state.items[i]=n;state.idx=i;}render();}
    catch(e2){toast('tab gone — advancing',1800);removeCurrent();state.done--;render();}
    return;}
  if(s===403){if(confirm((b.error||'refused: tab not waiting on you')+'\n\nFORCE anyway?')){$('tgForce').classList.add('on');state.busyUntil=0;return retryForce();}return;}
  if(s===503||s===502){state.down=true;banner('iTerm unreachable / scan stale — actions disabled');render();return;}
  if(s===401)return;
  toast('error '+(s||'')+': '+(b.error||b.message||'request failed'),3000);
}
async function decide(file,decision,note,it){
  if(busy())return;guard();
  try{const r=await api('/api/memo/decide',{method:'POST',body:JSON.stringify({file,decision,note:note||'',tty:it?it.tty:undefined,expectKey:it?it.key:undefined})});
    toast(decision.toUpperCase()+' ✓ '+file+(r&&r.typed?' → typed':''),2000);
    if(it){removeCurrent();render();confirmLater(it,'gated');}
    else{state.orphans=state.orphans.filter(o=>o.file!==file);render();}}
  catch(e){await onErr(e,it||{tty:'',key:''},()=>decide(file,decision,note,it));}
}
async function focusTab(it){if(!it||state.down)return;try{await api('/api/focus',{method:'POST',body:JSON.stringify({tty:it.tty})});toast('focused '+it.tty,1200);}catch(e){toast('focus failed',1500);}}

// ---------- render ----------
function chips(it){
  const conf=(it.session&&it.session.confidence)||'none';
  const cc=conf==='exact'?'ok':(conf==='likely'||conf==='pane')?'':conf==='ambiguous'?'warn':'bad';
  const h=[];
  h.push('<span class="chip" title="'+esc(it.createdAt||'')+'">🕓 '+esc(fmtWhen(it.createdAt)||'unknown')+'</span>');
  h.push('<span class="chip mono">'+esc(it.tty)+(it.pid?' · pid '+esc(it.pid):'')+'</span>');
  h.push('<span class="chip '+cc+'" title="tty→transcript resolution">'+esc(conf)+(conf==='ambiguous'&&it.session&&it.session.candidates?' ('+it.session.candidates+')':'')+'</span>');
  if(it.variant)h.push('<span class="chip">'+esc(it.variant)+'</span>');
  if(it.session&&it.session.cwd)h.push('<span class="chip mono" title="'+esc(it.session.cwd)+'">'+esc(String(it.session.cwd).split('/').pop())+'</span>');
  (it.warnings||[]).forEach(w=>h.push('<span class="chip warn">⚠ '+esc(w)+'</span>'));
  return h.join('');
}
function codeBlock(text,cls){const id='cb'+(codeBlock._n=(codeBlock._n||0)+1);
  return '<div class="codewrap"><pre class="'+(cls||'')+'" id="'+id+'">'+esc(text)+'</pre><button class="copy" data-copy="'+id+'" title="copy to clipboard">⧉ Copy</button></div>';}
function textarea(ph){return '<textarea id="free" placeholder="'+esc(ph||'free-text reply — ⌘/Ctrl+Enter to send')+'" aria-label="free-text reply"></textarea>';}
function dis(){return busy()?' disabled':'';}

function bodyQuestion(it){
  const qs=(it.question&&it.question.questions)||[];const multiQ=qs.length>1;let h='';
  qs.forEach((q,qi)=>{
    h+='<div class="sec"><p class="q">'+(q.header?'<span class="h">'+esc(q.header)+'</span>':'')+esc(q.question)+'</p><div class="opts" data-q="'+qi+'">';
    (q.options||[]).forEach((o,oi)=>{const sel=(state.sel[qi]||[]).includes(oi);
      h+='<button class="opt'+(sel?' sel':'')+'" data-q="'+qi+'" data-o="'+oi+'"'+dis()+' aria-keyshortcuts="'+(qi===0?String(oi+1):'')+'"><span class="k">'+(qi===0&&oi<9?(oi+1):(q.multiSelect?'☐':'·'))+'</span><span><span>'+esc(o.label)+'</span>'+(o.description?'<span class="d">'+esc(o.description)+'</span>':'')+'</span></button>';});
    h+='</div>'+((q.multiSelect||multiQ)?'':'')+'</div>';});
  const needsSend=qs.some(q=>q.multiSelect)||multiQ;
  h+='<div class="sec">'+(needsSend?'<div class="actions"><button class="primary" id="primary"'+dis()+'>Send selected</button><span class="sp"></span></div>':'')+
     '<h3 style="margin-top:12px">or free text</h3>'+textarea()+'<div class="actions"><button id="send"'+dis()+'>Send text</button><span class="sp"></span><button id="focusBtn">⌖ Focus</button></div></div>';
  if(!needsSend)h=h.replace('<button id="send"','<button id="primary"');
  return h;
}
function bodyGated(it){const m=it.memo;let h='';
  if(m){h+='<div class="sec"><div class="q">'+esc(m.title||m.file)+' <span class="pill '+esc(m.recommendation||'none')+'">'+esc(m.recommendation||'no rec')+'</span></div>';
    h+='<div class="chips"><span class="chip mono">'+esc(m.file)+'</span><span class="chip" title="'+esc(m.createdAt||'')+'">memo 🕓 '+esc(fmtWhen(m.createdAt))+'</span>'+
       (m.hasUngateRun?'<span class="chip warn">has ungate-run block (never executed here)</span>':'')+
       '<a class="chip link" href="http://127.0.0.1:9795" target="_blank" rel="noopener noreferrer">Open in :9795 ↗</a></div>';
    if(m.excerpt)h+='<pre>'+esc(m.excerpt)+'</pre>';
    if(m.pasteLines&&m.pasteLines.length){h+='<h3 style="margin-top:12px">paste lines (copy-only — never executed from here)</h3>';m.pasteLines.forEach(l=>h+=codeBlock(l,'cmd'));}
    h+='</div>';}
  else h+='<div class="sec"><pre>'+esc(it.lastText||'no memo linked to '+(it.ticket||it.tty)+' — decide in :9795 or send free text')+'</pre></div>';
  (it.memos||[]).filter(x=>!m||x.file!==m.file).forEach(x=>h+='<div class="sec"><span class="chip mono">also: '+esc(x.file)+'</span></div>');
  if(state.note){h+='<div class="note"><b>'+esc(state.note.toUpperCase())+' note</b>'+textarea('why? (typed into the pane with the decision) — ⌘/Ctrl+Enter to send, Esc to cancel')+
    '<div class="actions"><button class="primary" id="noteSend">Send '+esc(state.note.toUpperCase())+'</button><button id="noteCancel">Cancel (Esc)</button></div></div>';}
  h+='<div class="actions">'+(m?'<button class="primary" id="primary"'+dis()+'>✅ APPROVE</button><button class="danger" id="block"'+dis()+'>BLOCK</button><button class="warn" id="revise"'+dis()+'>REVISE</button>':'')+
     '<span class="sp"></span><button id="focusBtn">⌖ Focus</button></div>';
  if(!m)h+='<div class="sec">'+textarea()+'<div class="actions"><button class="primary" id="primary"'+dis()+'>Send text</button></div></div>';
  return h;
}
function bodyPaste(it){let h='<div class="sec"><h3>run this yourself (copy-only — the cockpit never executes it)</h3>';
  h+=it.pasteCmd?codeBlock(it.pasteCmd,'cmd'):'<pre>no paste line found in the transcript — check the pane</pre>';
  if(it.memo&&it.memo.excerpt)h+='<h3 style="margin-top:12px">memo · '+esc(it.memo.title||it.memo.file)+'</h3><pre>'+esc(it.memo.excerpt)+'</pre>';
  if(it.lastText&&!it.memo)h+='<h3 style="margin-top:12px">last message</h3><pre>'+esc(it.lastText)+'</pre>';
  h+='</div><div class="actions"><button class="primary" id="primary"'+dis()+'>I ran it → Continue</button><span class="sp"></span><button id="focusBtn">⌖ Focus</button></div>';
  return h;}
function bodyText(it){const conf=(it.session&&it.session.confidence)||'none';let h='<div class="sec">';
  if(conf==='ambiguous')h+='<pre>transcript ambiguous ('+esc((it.session&&it.session.candidates)||'?')+' candidates) — showing label only\n\n'+esc(it.label||'')+'</pre>';
  else if(it.lastText)h+='<pre>'+esc(it.lastText)+'</pre>';
  else h+='<pre>'+esc(it.label||'(no text)')+'</pre>';
  h+='</div><div class="sec">'+textarea()+'<div class="actions"><button class="primary" id="primary"'+dis()+'>Send</button><span class="sp"></span><button id="focusBtn">⌖ Focus</button></div></div>';
  return h;}

function render(){
  const it=current();setTint(it);
  $('remaining').textContent=(state.items.length)+' remaining'+(state.remaining!==state.items.length?' ('+state.remaining+' server)':'');
  const total=Math.max(state.total,1),frac=Math.min(1,state.done/total);
  $('meter').style.width=(frac*100)+'%';$('meter').style.background='hsl('+Math.round(120*frac)+',70%,45%)';
  $('scan').textContent=(MOCK?'MOCK MODE · ':'')+(state.scannedAt?'scanned '+fmtTime(state.scannedAt)+' · poll 8s':'');
  $('prev').disabled=state.idx<=0;$('next').disabled=state.idx>=state.items.length-1;$('focus').disabled=!it||state.down;
  $('tgOrph').classList.toggle('on',get('orph','0')==='1');$('tgRepaint').classList.toggle('on',get('repaint','1')==='1');
  const c=$('card');
  if(!it){c.innerHTML='<div class="empty"><div class="big">'+(state.loaded?'🎉':'⏳')+'</div><div>'+(state.loaded?'Nothing is waiting on you.':'loading…')+'</div>'+(state.done?'<div class="sub" style="margin-top:6px">'+state.done+' cleared this session</div>':'')+'</div>';}
  else{
    const ttl=(it.ticket?it.ticket+' · ':'')+stripEmoji(it.label).replace(new RegExp('^'+(it.ticket||'').replace(/[-]/g,'\\-')+'\\s*·?\\s*'),'');
    let body='';
    if(it.kind==='question'&&it.question)body=bodyQuestion(it);
    else if(it.kind==='gated')body=bodyGated(it);
    else if(it.kind==='paste')body=bodyPaste(it);
    else body=bodyText(it);
    c.innerHTML='<div class="card" data-key="'+esc(it.key)+'"><div class="top"><div class="emo" aria-hidden="true">'+emojiOf(it)+'</div><div style="min-width:0;flex:1">'+
      '<div class="kind">'+esc(it.color)+' · '+esc(it.kind)+' · '+(state.idx+1)+' / '+state.items.length+'</div><div class="ttl">'+esc(ttl)+'</div><div class="chips">'+chips(it)+'</div></div></div>'+body+'</div>';
    bind(it);
  }
  renderOrphans();
}
// ?panel=1 forces the orphan/backlog panel open (bookmarkable; also lets a fresh headless browser see it)
function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1'||new URLSearchParams(location.search).get('panel')==='1';o.hidden=!on;if(!on)return;
  // ticket backlog renders FIRST (it was buried under 100+ orphan memos); orphans are capped at 25.
  const bl0=state.backlog||[];const kp0=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
  const laneHtml='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl0.length))+' waiting on you — route only</h3>'+
    (bl0.length?bl0.slice(0,25).map(t=>'<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kp0(t.kind)+'">'+esc(t.kind)+'</span></b>'+
      '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
      '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
      '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')+(bl0.length>25?'<div class="m">…and '+(bl0.length-25)+' more on the ticket board</div>':'')
    :'<div class="m">none</div>');
  if(!state.orphans.length){o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;margin-top:18px">orphan memos — none</h3>';return;}
  const orphShown=state.orphans.slice(0,25);
  o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">orphan memos (no live tab) — '+esc(String(state.orphans.length))+' — decide only'+(state.orphans.length>25?' (showing 25)':'')+'</h3>'+orphShown.map(m=>
    '<div class="item" data-file="'+esc(m.file)+'"><b>'+esc(m.title||m.file)+' <span class="pill '+esc(m.recommendation||'none')+'">'+esc(m.recommendation||'no rec')+'</span></b>'+
    '<div class="m mono" title="'+esc(m.createdAt||'')+'">'+esc(m.file)+' · 🕓 '+esc(fmtWhen(m.createdAt))+'</div>'+(m.excerpt?'<pre style="margin-top:8px;max-height:140px">'+esc(m.excerpt)+'</pre>':'')+
    '<div class="actions"><button class="oa" data-d="approve"'+dis()+'>APPROVE</button><button class="oa danger" data-d="block"'+dis()+'>BLOCK</button><button class="oa warn" data-d="revise"'+dis()+'>REVISE</button>'+
    '<a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9795" target="_blank" rel="noopener noreferrer">:9795 ↗</a></div></div>').join('');
  o.querySelectorAll('.oa').forEach(b=>b.addEventListener('click',()=>{const file=b.closest('.item').dataset.file,d=b.dataset.d;
    let note='';if(d!=='approve'){note=prompt(d.toUpperCase()+' note for '+file+':');if(note==null)return;}
    else if(!confirm('APPROVE '+file+' → moves to _approved/ (undo available)?'))return;
    decide(file,d,note,null);}));
}

function bind(it){
  const c=$('card');
  c.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>copyText($(b.dataset.copy).textContent)));
  const fb=$('focusBtn');if(fb)fb.addEventListener('click',()=>focusTab(it));
  if(it.kind==='question'&&it.question){
    const qs=it.question.questions;const needsSend=qs.some(q=>q.multiSelect)||qs.length>1;
    c.querySelectorAll('button.opt').forEach(b=>b.addEventListener('click',()=>pickOpt(it,+b.dataset.q,+b.dataset.o)));
    if(needsSend){const p=$('primary');if(p)p.addEventListener('click',()=>sendSelected(it));
      const s=$('send');if(s)s.addEventListener('click',()=>sendFree(it));}
    else{const p=$('primary');if(p)p.addEventListener('click',()=>sendFree(it));}
  }else if(it.kind==='gated'){
    const m=it.memo;const p=$('primary');
    if(m){if(p)p.addEventListener('click',()=>{if(confirm('APPROVE '+m.file+' → moves to _approved/ and types APPROVED into '+it.tty+'?'))decide(m.file,'approve','',it);});
      $('block').addEventListener('click',()=>{state.note='block';render();setTimeout(()=>{const f=$('free');if(f)f.focus();},30);});
      $('revise').addEventListener('click',()=>{state.note='revise';render();setTimeout(()=>{const f=$('free');if(f)f.focus();},30);});
      const ns=$('noteSend');if(ns)ns.addEventListener('click',()=>{const n=($('free')||{}).value||'';if(!n.trim()){toast('note required');return;}decide(m.file,state.note,n.trim(),it);});
      const nc=$('noteCancel');if(nc)nc.addEventListener('click',()=>{state.note=null;render();});}
    else if(p)p.addEventListener('click',()=>sendFree(it));
  }else if(it.kind==='paste'){
    $('primary').addEventListener('click',()=>push('/api/continue',{},it,'paste'));
  }else{
    $('primary').addEventListener('click',()=>sendFree(it));
  }
}
// The TUI shows ONE question at a time, so a click answers the displayed question by NUMBER
// immediately; the next question (if any) shows up as a fresh card on the next poll.
// Only a multi-select menu needs staging — and it cannot be typed at all (see sendSelected).
function pickOpt(it,qi,oi){const q=it.question.questions[qi];const needsSend=!!q.multiSelect;
  // A real AskUserQuestion menu selects by its DISPLAYED NUMBER, not by typed label (typing the
  // label + Enter fires the cursor default — proven live 2026-09-15). Type the number; label for audit.
  if(!needsSend){const o=q.options[oi];return push('/api/answer',{text:String(o.n||(oi+1)),optionLabel:o.label},it,'question');}
  const cur=state.sel[qi]||[];
  if(q.multiSelect)state.sel[qi]=cur.includes(oi)?cur.filter(x=>x!==oi):cur.concat(oi);else state.sel[qi]=[oi];
  render();}
function sendSelected(it){
  // A multi-select menu is toggled with Space/Enter INSIDE the TUI; there is no typed form that
  // selects several options — typing labels fires the cursor default (proven live 2026-09-15).
  // Refuse and hand the user to the terminal instead of mis-answering (Cody FIX-FIRST #1).
  toast('multi-select menus must be picked in the terminal — focusing the tab');
  push('/api/focus',{},it,'focus');}
function sendFree(it){const f=$('free');const t=f?f.value.replace(/\r/g,'').replace(/\n+/g,' ').trim():'';if(!t){toast('type something first');if(f)f.focus();return;}
  if(t.length>2000){toast('too long (max 2000 chars)');return;}push('/api/answer',{text:t},it,it.kind==='question'?'question':'text');}

// ---------- controls ----------
$('prev').addEventListener('click',()=>{if(state.idx>0){state.idx--;state.sel={};state.note=null;render();}});
$('next').addEventListener('click',()=>{if(state.idx<state.items.length-1){state.idx++;state.sel={};state.note=null;render();}});
$('focus').addEventListener('click',()=>focusTab(current()));
$('tgOrph').addEventListener('click',()=>{set('orph',get('orph','0')==='1'?'0':'1');render();});
$('tgRepaint').addEventListener('click',()=>{set('repaint',get('repaint','1')==='1'?'0':'1');render();});
$('tgForce').addEventListener('click',()=>{const b=$('tgForce');if(b.classList.contains('on')){b.classList.remove('on');return;}
  if(confirm('FORCE mode: every push will type into the tab even if it is green/pink (not waiting on you). Each push still asks. Enable?'))b.classList.add('on');});

document.addEventListener('keydown',e=>{
  const it=current();const inText=/^(TEXTAREA|INPUT)$/.test((e.target&&e.target.tagName)||'');
  if(e.key==='Escape'){if(state.note){state.note=null;render();e.preventDefault();}else if(inText){e.target.blur();}return;}
  if(inText){if(e.key==='Enter'&&(e.metaKey||e.ctrlKey)){e.preventDefault();const ns=$('noteSend');if(ns){ns.click();return;}
      if(it&&it.kind==='question'&&it.question){const s=$('send');if(s){s.click();return;}}const p=$('primary');if(p&&it&&it.kind!=='gated'&&it.kind!=='paste')p.click();else if(p)p.click();}
    return;}
  if(e.metaKey||e.ctrlKey||e.altKey)return;
  if(e.key==='ArrowRight'){$('next').click();e.preventDefault();return;}
  if(e.key==='ArrowLeft'){$('prev').click();e.preventDefault();return;}
  if(e.key==='f'||e.key==='F'){focusTab(it);e.preventDefault();return;}
  if(e.key==='o'||e.key==='O'){$('tgOrph').click();return;}
  if(/^[1-9]$/.test(e.key)&&it&&it.kind==='question'&&it.question){const b=document.querySelector('button.opt[data-q="0"][data-o="'+(+e.key-1)+'"]');if(b&&!b.disabled){b.click();e.preventDefault();}return;}
  if(e.key==='Enter'){const p=$('primary');if(p&&!p.disabled){p.click();e.preventDefault();}return;}
});

// ---------- boot ----------
(async function(){render();await poll();schedule();
  document.addEventListener('visibilitychange',()=>{if(!document.hidden){poll();}});})();
</script>
</body>
</html>