kaizen-agent 1.31.3 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/cli/board.test.ts +15 -21
- package/cli/browser.test.ts +3 -0
- package/cli/browser.ts +4 -2
- package/cli/build-web.ts +19 -9
- package/cli/notes.test.ts +4 -2
- package/cli/sources.test.ts +2 -2
- package/cli/state.ts +1 -1
- package/cli/web.ts +16 -7
- package/package.json +5 -2
- package/skills/kaizen/spec.md +5 -0
- package/web/board/agent-term.ts +51 -0
- package/web/board/api.ts +102 -0
- package/web/board/approvals.ts +100 -0
- package/web/board/cards.ts +600 -0
- package/web/board/chat.ts +108 -0
- package/web/board/dictation.ts +300 -0
- package/web/board/dom.ts +5 -0
- package/web/board/drafts.ts +21 -0
- package/web/board/fixes.ts +90 -0
- package/web/board/git.ts +76 -0
- package/web/board/icebox.ts +869 -0
- package/web/board/keys.ts +126 -0
- package/web/board/labels.ts +40 -0
- package/web/board/live.ts +69 -0
- package/web/board/main.ts +19 -0
- package/web/board/markdown.ts +197 -0
- package/web/board/panel.ts +331 -0
- package/web/board/projects.ts +220 -0
- package/web/board/settings.ts +191 -0
- package/web/board/store.ts +33 -0
- package/web/board/toast.ts +32 -0
- package/web/board/update.ts +75 -0
- package/web/board/views.ts +65 -0
- package/web/board.html +6 -978
- package/web/board.js +30 -0
- package/web/notebook.js +1 -1
- package/web/notebook.ts +726 -180
- package/web/notes-lib.ts +51 -15
- package/web/notes.html +1 -1
- package/web/term.js +1 -1
- package/web/term.ts +17 -5
package/web/board.html
CHANGED
|
@@ -209,8 +209,8 @@ aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:520px;max-width:100
|
|
|
209
209
|
aside.open{transform:none;visibility:visible}
|
|
210
210
|
/* Settings and the Icebox are pages: the same panel, docked into the app column, full width like the board */
|
|
211
211
|
body.paged #board,body.paged .ftr,body.paged .scrim{display:none}
|
|
212
|
-
/* The Icebox keeps the project bar
|
|
213
|
-
body.paged:not(.ice) .hdr,body.ice .
|
|
212
|
+
/* The Icebox keeps the project bar with its own title, without New idea or the panel's header; Settings has none */
|
|
213
|
+
body.paged:not(.ice) .hdr,body.ice aside.docked>header{display:none}body.ice #newtop{visibility:hidden}
|
|
214
214
|
aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
|
|
215
215
|
aside.docked>header,aside.docked>.body{padding-inline:28px}
|
|
216
216
|
aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}
|
|
@@ -288,6 +288,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
|
|
|
288
288
|
.toast.on{opacity:1}
|
|
289
289
|
.rail a.upd svg{overflow:visible}.rail a.upd{position:relative}.rail a.upd::after{content:"";position:absolute;left:28px;top:8px;width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--bg)}
|
|
290
290
|
.updbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between;margin:0 0 36px;padding:10px 14px;border:1px solid var(--accent);border-radius:var(--r);background:color-mix(in srgb,var(--accent) 10%,var(--bg-2));font-size:13px}
|
|
291
|
+
.updbar.quiet{border-color:var(--line);background:var(--bg-2);color:var(--dim)}
|
|
291
292
|
.toast.on.rep{pointer-events:auto}.toast a{color:inherit;margin-left:8px;text-decoration:underline;text-underline-offset:2px}
|
|
292
293
|
.help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px)}
|
|
293
294
|
.help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:24px 28px;box-shadow:var(--shadow-2)}
|
|
@@ -460,9 +461,9 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
|
|
|
460
461
|
<div class="top"><a class="home" href="/" title="Board" aria-label="kaizen board"><pre class="mark"><span>█ █ ▄▀█ █ ▀▀█ █▀▀ █▄ █</span>
|
|
461
462
|
<span>█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█</span>
|
|
462
463
|
<span>█ █ █ █ █ █▄▄ █▄▄ █ █</span></pre><span class="mascot"></span></a><button class="ib" id="fold" aria-label="Collapse menu" title="Collapse menu"><svg><use href="#i-side"/></svg></button></div>
|
|
464
|
+
<a class="it" href="/?view=icebox" data-view="sources" title="Icebox: requests gathered from your sources"><svg><use href="#i-inbox"/></svg><span class="lbl">Icebox</span></a>
|
|
463
465
|
<a class="it" href="/" data-view="board" title="Board"><svg><use href="#i-board"/></svg><span class="lbl">Board</span></a>
|
|
464
466
|
<a class="it" href="/notebook" data-view="notes" title="Notes"><svg><use href="#i-note"/></svg><span class="lbl">Notes</span></a>
|
|
465
|
-
<a class="it" href="/?view=icebox" data-view="sources" title="Icebox: requests gathered from your sources"><svg><use href="#i-inbox"/></svg><span class="lbl">Icebox</span></a>
|
|
466
467
|
<a class="it" href="/?view=settings" data-view="settings" title="Settings"><svg><use href="#i-gear"/></svg><span class="lbl">Settings</span></a>
|
|
467
468
|
<span class="end"></span>
|
|
468
469
|
<div class="theme" role="group" aria-label="Theme"><button data-t="light" title="Light theme"><svg><use href="#i-sun"/></svg><span class="lbl">Light</span></button><button data-t="dark" title="Dark theme"><svg><use href="#i-moon"/></svg><span class="lbl">Dark</span></button></div>
|
|
@@ -509,983 +510,10 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
|
|
|
509
510
|
<dl><dt><kbd>←↓↑→</kbd><kbd>hjkl</kbd></dt><dd>move between cards</dd><dt><kbd>Enter</kbd></dt><dd>open</dd><dt><kbd>←→</kbd><kbd>hl</kbd></dt><dd>tabs, in an open run</dd><dt><kbd>↑↓</kbd><kbd>jk</kbd></dt><dd>scroll, in an open run</dd><dt><kbd>n</kbd></dt><dd>new idea</dd><dt><kbd>e</kbd></dt><dd>edit</dd><dt><kbd>x</kbd></dt><dd>reject / abandon</dd><dt><kbd>r</kbd></dt><dd>run</dd><dt><kbd>a</kbd></dt><dd>all projects</dd><dt><kbd>Esc</kbd></dt><dd>close</dd><dt><kbd>?</kbd></dt><dd>this list</dd></dl></div></div>
|
|
510
511
|
|
|
511
512
|
<script>
|
|
512
|
-
// The page never touches the filesystem. Everything comes from /state and the run
|
|
513
|
-
// files behind /run/:id; every change goes through a POST and comes back as an SSE
|
|
514
|
-
// tick that reloads the board. See design.md for the visual rules.
|
|
515
|
-
const $=id=>document.getElementById(id);
|
|
516
|
-
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""})[c]);
|
|
517
|
-
const ico=n=>`<svg><use href="#i-${n}"/></svg>`;
|
|
518
|
-
|
|
519
|
-
// ---- dictation: Whisper in the page. Audio never leaves the browser; the model is
|
|
520
|
-
// fetched once from huggingface.co and cached. Library and model are pinned here.
|
|
521
|
-
const TFJS="https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",ASR_MODELS={tiny:"onnx-community/whisper-tiny",base:"onnx-community/whisper-base",small:"onnx-community/whisper-small","large-v3-turbo":"onnx-community/whisper-large-v3-turbo"};
|
|
522
|
-
// The model is a setting (dictation.model); the worker is rebuilt when it changes.
|
|
523
|
-
let asrModel=null;
|
|
524
|
-
// Inference lives in a module Worker so a slow chunk never freezes the board.
|
|
525
|
-
const WORKER_SRC=`import{pipeline}from"${TFJS}";let asr=null;
|
|
526
|
-
onmessage=async e=>{const m=e.data;try{
|
|
527
|
-
if(m.type==="load"){const prog=p=>{if(p.status==="progress")postMessage({type:"progress",file:p.file,pct:p.progress||0})};
|
|
528
|
-
// WebGPU first, with the encoder in fp16 only where the adapter has shader-f16; any GPU failure falls back to the CPU.
|
|
529
|
-
const gpu=m.webgpu?{device:"webgpu",dtype:{encoder_model:m.f16?"fp16":"fp32",decoder_model_merged:"q4"}}:null,cpu={device:"wasm",dtype:"q8"};
|
|
530
|
-
try{if(!gpu)throw 0;asr=await pipeline("automatic-speech-recognition",m.model,{...gpu,progress_callback:prog})}
|
|
531
|
-
catch(e){if(gpu)postMessage({type:"fallback",why:String(e&&e.message||e)});asr=await pipeline("automatic-speech-recognition",m.model,{...cpu,progress_callback:prog})}
|
|
532
|
-
postMessage({type:"ready"})}
|
|
533
|
-
if(m.type==="run"){const r=await asr(m.audio,{chunk_length_s:30,return_timestamps:false});postMessage({type:"text",sid:m.sid,seq:m.seq,text:(r.text||"").trim()})}
|
|
534
|
-
}catch(err){postMessage({type:"error",sid:m.sid,seq:m.seq,error:String(err&&err.message||err)})}}`;
|
|
535
|
-
// The worklet only forwards 128-frame blocks; chunking and silence detection stay on the main thread.
|
|
536
|
-
const WORKLET_SRC=`class T extends AudioWorkletProcessor{process(i){const c=i[0]&&i[0][0];if(c)this.port.postMessage(c.slice(0));return true}}registerProcessor("kz-tap",T)`;
|
|
537
|
-
const HAS_MIC=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.AudioWorkletNode&&window.WebAssembly&&window.Worker);
|
|
538
|
-
// `active` is the one session listening; `sessions` also holds stopped ones still waiting on a chunk.
|
|
539
|
-
let asrWorker=null,asrReady=null,asrProgress=()=>{},active=null,starting=false;const sessions=new Set();
|
|
540
|
-
// One worker for the page; loading is awaited by whoever pressed first, later presses join.
|
|
541
|
-
// Loads the model named in settings; a model changed since the last load replaces the worker.
|
|
542
|
-
async function loadAsr(){
|
|
543
|
-
let want="small";try{const cfg=await fetch("/settings").then(r=>r.json());want=(cfg.rows.find(r=>r.key==="dictation.model")||{}).value||want}catch(e){}
|
|
544
|
-
if(!ASR_MODELS[want])want="small";
|
|
545
|
-
if(asrReady&&asrModel!==want){try{asrWorker.terminate()}catch(e){}asrWorker=null;asrReady=null;sessions.clear();active=null}
|
|
546
|
-
if(asrReady)return asrReady;asrModel=want;asrReady=new Promise(async(res,rej)=>{
|
|
547
|
-
asrWorker=new Worker(URL.createObjectURL(new Blob([WORKER_SRC],{type:"text/javascript"})),{type:"module"});
|
|
548
|
-
const files={};asrWorker.onmessage=e=>{const m=e.data;if(m.type==="progress"){files[m.file]=m.pct;const v=Object.values(files);asrProgress(Math.round(v.reduce((a,b)=>a+b,0)/v.length))}else if(m.type==="ready")res();else if(m.type==="fallback")toast("GPU could not run the model, using the CPU: "+m.why);else if(m.type==="error"&&m.seq==null)rej(new Error(m.error));for(const s of sessions)s.onmsg(m)};
|
|
549
|
-
asrWorker.onerror=e=>rej(new Error(e.message||"worker failed"));
|
|
550
|
-
// Ask the adapter what it can do: no adapter means the CPU path; no shader-f16 means an fp32 encoder.
|
|
551
|
-
let adapter=null;try{adapter=navigator.gpu&&await navigator.gpu.requestAdapter()}catch(e){}
|
|
552
|
-
if(!adapter)toast("No WebGPU: dictation will run on the CPU and be slower");asrWorker.postMessage({type:"load",model:ASR_MODELS[asrModel],webgpu:!!adapter,f16:!!(adapter&&adapter.features.has("shader-f16"))})});
|
|
553
|
-
asrReady.catch(()=>{asrReady=null;try{asrWorker.terminate()}catch(e){}asrWorker=null});return asrReady}
|
|
554
|
-
// After a re-render only a listener whose field is gone is stopped; closing the panel stops any.
|
|
555
|
-
function stopDictation(all){if(active&&(all||!active.field.isConnected))active.stop()}
|
|
556
|
-
// mic(fields): a button that dictates into the last-focused of `fields` at its caret.
|
|
557
|
-
function mic(fields){
|
|
558
|
-
if(!HAS_MIC)return null;
|
|
559
|
-
const b=document.createElement("button");b.className="ib sm mic";b.title="Dictate";b.setAttribute("aria-label","Dictate");b.type="button";
|
|
560
|
-
const idle=()=>{b.innerHTML=ico("mic");b.classList.remove("on","busy")};idle();
|
|
561
|
-
let target=fields[0];fields.forEach(f=>f.addEventListener("focus",()=>target=f));
|
|
562
|
-
b.onclick=async e=>{e.preventDefault();e.stopPropagation();
|
|
563
|
-
if(active){const mine=active.btn===b;active.stop();if(mine)return}
|
|
564
|
-
// One press at a time: the model load and the permission prompt are both awaits a second press could slip through.
|
|
565
|
-
if(starting)return;starting=true;
|
|
566
|
-
b.classList.add("busy");asrProgress=pct=>{b.innerHTML=`<i>${pct}%</i>`};
|
|
567
|
-
try{await loadAsr()}catch(err){idle();asrProgress=()=>{};starting=false;toast("Dictation model could not load: "+err.message);return}
|
|
568
|
-
asrProgress=()=>{};b.innerHTML=ico("mic");if(!b.isConnected){idle();starting=false;return}
|
|
569
|
-
let stream;try{stream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,noiseSuppression:true,echoCancellation:true}})}catch(err){idle();starting=false;toast("Microphone not available: "+(err.name==="NotAllowedError"?"permission denied":err.message));return}
|
|
570
|
-
if(active)active.stop();
|
|
571
|
-
const ctx=new AudioContext({sampleRate:16000});await ctx.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET_SRC],{type:"text/javascript"})));
|
|
572
|
-
const src=ctx.createMediaStreamSource(stream),tap=new AudioWorkletNode(ctx,"kz-tap");src.connect(tap);
|
|
573
|
-
// Text lands where the caret is when each chunk arrives (or after the last insertion when the
|
|
574
|
-
// field is not focused), so typing while the mic is on is kept.
|
|
575
|
-
const f=target;let at=f.selectionStart??f.value.length;
|
|
576
|
-
const vu=document.createElement("span");vu.className="vu";vu.setAttribute("aria-hidden","true");vu.innerHTML=[.5,.9,1.3,.8,.45].map(w=>`<i style="--w:${w}"></i>`).join("");
|
|
577
|
-
let buf=[],len=0,voiced=0,quiet=0,seq=0,done=0,pending=new Map(),stopping=false;
|
|
578
|
-
const SR=16000,MAX=6*SR,SIL=0.8*SR,MIN=1*SR;
|
|
579
|
-
const insert=t=>{if(!t||!f.isConnected)return;const v=f.value;if(document.activeElement===f)at=f.selectionStart;at=Math.min(at,v.length);const head=v.slice(0,at),sep=head&&!/\s$/.test(head)?" ":"";f.value=head+sep+t+v.slice(at);at=head.length+sep.length+t.length;f.selectionStart=f.selectionEnd=at;f.dispatchEvent(new Event("input",{bubbles:true}))};
|
|
580
|
-
const flush=()=>{if(!len||voiced<MIN){buf=[];len=0;voiced=0;quiet=0;return}const a=new Float32Array(len);let o=0;for(const c of buf){a.set(c,o);o+=c.length}buf=[];len=0;voiced=0;quiet=0;pending.set(seq,null);asrWorker.postMessage({type:"run",sid,seq,audio:a},[a.buffer]);seq++;b.classList.add("busy")};
|
|
581
|
-
const drain=()=>{while(pending.has(done)&&pending.get(done)!==null){insert(pending.get(done));pending.delete(done);done++}if(!pending.size){b.classList.remove("busy");if(stopping){idle();sessions.delete(sess)}}};
|
|
582
|
-
tap.port.onmessage=e=>{const c=e.data;buf.push(c);len+=c.length;let s=0;for(let i=0;i<c.length;i++)s+=c[i]*c[i];const rms=Math.sqrt(s/c.length);
|
|
583
|
-
if(rms>0.01){voiced+=c.length;quiet=0}else quiet+=c.length;{const l=Math.min(1,rms*8).toFixed(2);f.style.setProperty("--lvl",l);vu.style.setProperty("--lvl",l)}
|
|
584
|
-
if(len>=MAX||(voiced>=MIN&&quiet>=SIL))flush()};
|
|
585
|
-
// Chunk seqs are per session, so a result is matched by session id too.
|
|
586
|
-
const sid=Math.random().toString(36).slice(2);
|
|
587
|
-
const sess={btn:b,field:f,onmsg:m=>{if(m.sid!==sid)return;if(m.type==="text"){pending.set(m.seq,m.text);drain()}else if(m.type==="error"&&m.seq!=null){pending.set(m.seq,"");toast("Dictation failed: "+m.error);drain()}},
|
|
588
|
-
stop:()=>{if(stopping)return;stopping=true;b.classList.remove("on");f.classList.remove("dictating");f.style.removeProperty("--lvl");b.innerHTML=ico("mic");try{tap.port.onmessage=null;src.disconnect();tap.disconnect();stream.getTracks().forEach(t=>t.stop());ctx.close()}catch(e){}active=null;flush();drain()}};
|
|
589
|
-
sessions.add(sess);active=sess;starting=false;
|
|
590
|
-
b.classList.remove("busy");b.classList.add("on");f.classList.add("dictating");b.replaceChildren(vu);f.focus()};
|
|
591
|
-
return b}
|
|
592
|
-
const m=60e3,h=3600e3,d=86400e3;
|
|
593
|
-
const age=(t,now)=>{const x=now-t;return x<h?Math.round(x/m)+"m":x<d?Math.round(x/h)+"h":Math.round(x/d)+"d"};
|
|
594
|
-
const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",yolo:"yolo",unrechecked:"fix not rechecked"};
|
|
595
|
-
const MARKS={claude:1,codex:1,agy:1,opencode:1,gemini:1,cursor:1,full:1,lite:1};
|
|
596
|
-
const COLS=["Ideas","Planning","Building","Review","Done"];
|
|
597
|
-
// state.ts statuses onto the icon set in the <defs> above.
|
|
598
|
-
const ICON={idea:"idea",starting:"start",running:"run",stalled:"stall",waiting:"wait",done:"done",abandoned:"aband"};
|
|
599
|
-
const NEED={"approvals.plan":"plan needs your approval","approvals.review":"review needs your approval"},need=a=>NEED[a]||a;
|
|
600
|
-
const WORD={idea:"idea",starting:"starting",running:"running",stalled:"stalled",waiting:"waiting on you",done:"done",abandoned:"abandoned"};
|
|
601
|
-
|
|
602
|
-
let S={project:null,projects:[],cards:[],now:Date.now(),all:false};
|
|
603
|
-
let all=new URLSearchParams(location.search).get("all")!=="0",openKey=null,mode=null,modeKey=null,filter=null,menuKey=null,tab="request",lastTab="request",runDoc=null;
|
|
604
|
-
// A column shows this many cards, then a "more" line; Done is where it matters.
|
|
605
|
-
const FOLD=6;const unfolded=new Set();
|
|
606
|
-
// Per column: "old" puts the oldest first; anything else is newest first.
|
|
607
|
-
let order=(()=>{try{return JSON.parse(localStorage.getItem("kz-order"))||{}}catch(e){return{}}})();
|
|
608
|
-
let archive=false;
|
|
609
|
-
let clearMenu=false,picking=false;const picked=new Set(); // Clear on Done: menu, then cherry-pick mode // showing the archive instead of the board
|
|
610
513
|
// The server writes notifier() from state.ts over the marker: one rule for both UIs.
|
|
611
514
|
const due=(/*notifier*/)();
|
|
612
|
-
|
|
613
|
-
// Two ideas may carry the same text; load() numbers the repeats so a key names one card.
|
|
614
|
-
const key=c=>c._k||(c.kind==="idea"?"idea:"+c.state+":"+c.text:"run:"+c.state+":"+c.id);
|
|
615
|
-
|
|
616
|
-
// The picked project outlives a reload. Saved only where the user changes it, so a
|
|
617
|
-
// second tab refreshing cannot undo it; a view the URL asked for is a one-off.
|
|
618
|
-
const urlAll=new URLSearchParams(location.search).has("all");
|
|
619
|
-
function keep(d){if(urlAll)return;try{d?localStorage.setItem("kz-proj",d):localStorage.removeItem("kz-proj")}catch(e){}}
|
|
620
|
-
// Half-typed text outlives a redraw and a page reload. Per tab, so two tabs cannot
|
|
621
|
-
// overwrite each other; form drafts are under "f:", notes under "n:".
|
|
622
|
-
const DK="kz-d:";
|
|
623
|
-
function draft(el,k){try{const v=sessionStorage.getItem(DK+k);if(v!=null)el.value=v}catch(e){}el.addEventListener("input",()=>{try{sessionStorage.setItem(DK+k,el.value)}catch(e){}})}
|
|
624
|
-
function undraft(p){try{Object.keys(sessionStorage).forEach(k=>{if(k.startsWith(DK+p))sessionStorage.removeItem(k)})}catch(e){}}
|
|
625
|
-
// quiet: the server said something changed, nobody asked for a redraw.
|
|
626
|
-
async function load(quiet){
|
|
627
|
-
let r;try{r=await fetch("/state"+(all?"?all=1":""))}catch{toast(GONE);return}const was=openKey&&S.cards.find(x=>key(x)===openKey);S=await r.json();
|
|
628
|
-
{const seen=new Map();for(const c of S.cards){const b=key(c),n=seen.get(b)||0;seen.set(b,n+1);c._k=n?b+"#"+n:b}}
|
|
629
|
-
for(const c of S.cards){if(c.kind!=="run"||c.archived)continue;if(due(key(c),c.notice,c.status==="done",S.now))notifyUser(c.notice)}
|
|
630
|
-
// A redraw under a form being typed in would reset its fields and its focus.
|
|
631
|
-
if(!(quiet&&mode&&$("board").querySelector(".inl input")))render();
|
|
632
|
-
// Run on an idea closed the panel: open the card on its agent once the board knows the session.
|
|
633
|
-
if(wantTerm&&!openKey&&!mode){const n=S.cards.find(x=>key(x)===wantTerm&&x.pty);if(n){wantTerm=null;open(n);return}}
|
|
634
|
-
if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(ta&&document.activeElement===ta)return;
|
|
635
|
-
if(!c){if(was&&was.kind==="idea")follow(was);return}if(runDoc)openRun(c,true,true);else if(c.kind==="idea")ideaBody(c)}}
|
|
636
|
-
// An idea's card goes when its run starts. Follow it to that run rather than keep
|
|
637
|
-
// offering "Run again": the newest run in its project whose request holds the idea's
|
|
638
|
-
// text, the rule boardCards() retires it by. No such run: close the panel.
|
|
639
|
-
// ponytail: checks the five newest runs, one /run fetch each; a run that took the idea
|
|
640
|
-
// further back than that closes the panel instead.
|
|
641
|
-
async function follow(idea){
|
|
642
|
-
const k=key(idea),norm=s=>s.toLowerCase().replace(/\s+/g," ").trim(),t=norm(idea.text);
|
|
643
|
-
for(const r of S.cards.filter(x=>x.kind==="run"&&x.state===idea.state).sort((a,b)=>(b.moved??0)-(a.moved??0)).slice(0,5)){
|
|
644
|
-
const d=await fetch(`/run/${encodeURIComponent(r.id)}?dir=${encodeURIComponent(r.state)}`).then(x=>x.json()).catch(()=>null);
|
|
645
|
-
if(openKey!==k)return;if(norm(d?.request||"").includes(t)){open(r);return}}
|
|
646
|
-
if(openKey===k)close()}
|
|
647
|
-
// A tab outliving its server would otherwise swallow every click: say so.
|
|
648
|
-
const GONE="The board server is not running — start it with kaizen web (or the shortcut), then reload.";
|
|
649
|
-
async function post(path,body){let r;try{r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})}catch{toast(GONE);return{ok:false}}const j=await r.json().catch(()=>({}));if(!r.ok){const m=j.error||j.why||"That did not work";toast(m,{title:m.slice(0,80),what:`POST ${path} failed (HTTP ${r.status}): ${m}`});if(j.manual)console.log("run manually:",j.manual)}return j}
|
|
650
|
-
|
|
651
|
-
function card(c){
|
|
652
|
-
const k=key(c),el=document.createElement("div");el.className="card"+(k===openKey?" open":"")+(k===menuKey?" menu":"")+(c.archived?" archived":"");el.tabIndex=0;el.dataset.k=k;el.setAttribute("role","button");
|
|
653
|
-
const st=ICON[c.status]||"idea",idt=c.kind==="idea"?"idea":c.text;
|
|
654
|
-
// The status icon carries the state; what a waiting run needs is its tooltip.
|
|
655
|
-
const sttl=c.status==="waiting"?need(c.awaiting)||"needs you":c.status||"idea";
|
|
656
|
-
// Footer: words on the left (project, what it needs), icon-only marks on the right.
|
|
657
|
-
const chips=[],marks=[],mk=(id,t,cls="")=>`<span class="mk${cls}" role="img" title="${esc(t)}" aria-label="${esc(t)}"><svg><use href="#${id}"/></svg></span>`;
|
|
658
|
-
if(all){const n=c.where.split("/").filter(Boolean).pop()||c.where;chips.push(`<span class="chip proj" title="${esc(c.where)}">${esc(n)}</span>`)}
|
|
659
|
-
for(const t of c.tags||[]){if(MARKS[t])marks.push(mk("c-"+t,t==="full"?"Full run: separate reviewer":t==="lite"?"Lite run: one session":NAMES[t]||t,t==="full"||t==="lite"?" accent":""));else if(t==="yolo")marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);else chips.push(`<span class="chip">${esc(NAMES[t]||t)}</span>`)}
|
|
660
|
-
if(c.pty&&c.pty.alive)marks.push(mk("i-term","The agent is running in this card: open it, terminal button at the top"," run"));
|
|
661
|
-
// A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
|
|
662
|
-
// the icebox); its chip names the source.
|
|
663
|
-
const g=c.notes&&/^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);if(g)chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);else if(c.notes)chips.push(`<span class="chip">notes</span>`);
|
|
664
|
-
// Information only: the panel's button is where it is committed and pushed.
|
|
665
|
-
if(c.git)marks.push(mk("c-git",c.git==="pushed"?"Committed & pushed":"Not committed & pushed yet",c.git==="pushed"?" ok":" warn"));
|
|
666
|
-
const pick=picking&&!archive&&c.column===4;
|
|
667
|
-
el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k===menuKey}">${ico("more")}</button></div>
|
|
668
|
-
<div class="c-title"${c.kind==="idea"&&brief(c.text).parts.length?` title="${esc(c.text)}"`:""}>${esc(c.kind==="idea"?brief(c.text).head:(c.title||c.id))}</div>
|
|
669
|
-
${chips.length||marks.length?`<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>`:""}`;
|
|
670
|
-
if(k===menuKey){
|
|
671
|
-
const items=[];
|
|
672
|
-
if(archive){
|
|
673
|
-
items.push(["restore","Restore",ico("restore")]);
|
|
674
|
-
items.push(["open","Open",ico("open")]);
|
|
675
|
-
if(c.kind==="idea")items.push(null,["delete","Delete",ico("trash"),"danger"]);
|
|
676
|
-
}else if(c.kind==="idea"&&c.status==="idea"){
|
|
677
|
-
items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],...(S.projects.some(p=>p.dir!==c.state)?[["move","Move to project",ico("ext")]]:[]),["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
|
|
678
|
-
}else if(c.kind==="idea"){
|
|
679
|
-
items.push(["open","Open",ico("open")],["run","Run again",ico("play")],["archive","Archive",ico("archive")],null,["delete","Remove",ico("trash"),"danger"]);
|
|
680
|
-
}else{
|
|
681
|
-
const openLabel=c.status==="waiting"?(c.awaiting==="approvals.plan"?"Review plan":c.awaiting==="findings"?"See findings":c.awaiting==="approvals.review"?"Review work":"Open"):"Open";
|
|
682
|
-
items.push(["open",openLabel,ico("open")]);
|
|
683
|
-
items.push(["archive","Archive",ico("archive")]);
|
|
684
|
-
if(c.status==="waiting"||c.status==="running"||c.status==="stalled"){
|
|
685
|
-
items.push(null,["reject","Abandon",ico("x"),"danger"]);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
el.innerHTML+=`<div class="menu-pop" role="menu">${items.map(i=>i?`<button role="menuitem" data-a="${i[0]}" class="${i[3]||""}">${i[2]}${i[1]}</button>`:"<hr>").join("")}</div>`}
|
|
689
|
-
el.querySelectorAll("[data-a]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();const a=bt.dataset.a;const was=menuKey;menuKey=null;if(a==="menu"){menuKey=was===k?null:k;render();return}if(a==="open"){open(c);return}
|
|
690
|
-
if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}
|
|
691
|
-
mode=a;modeKey=k;render()});
|
|
692
|
-
if(pick){el.classList.toggle("picked",picked.has(k));el.onclick=()=>{picked.has(k)?picked.delete(k):picked.add(k);render()};return el}
|
|
693
|
-
el.onclick=()=>{if(menuKey){menuKey=null;render();return}open(c)};el.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();open(c)}};
|
|
694
|
-
return el;}
|
|
695
|
-
|
|
696
|
-
function inlineForm(c){
|
|
697
|
-
const li=document.createElement("li");li.className="inl";const dir=c.state;
|
|
698
|
-
if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
|
|
699
|
-
if(mode==="reject"){const idea=c.kind==="idea";li.innerHTML=`<div class="q">${idea?"Reject this idea?":"Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea?"Reject":"Abandon"}</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
|
|
700
|
-
if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
|
|
701
|
-
if(mode==="move"){const ps=S.projects.filter(p=>p.dir!==dir);li.innerHTML=`<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map(p=>`<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const sel=li.querySelector("select"),to=sel.value,name=sel.selectedOptions[0]?.textContent;mode=null;const r=await post("/inbox",{op:"move",dir,to,text:c.text});if(r.ok)toast(`Moved to ${name}`);load()}}
|
|
702
|
-
if(mode==="run"){li.innerHTML=`<div class="q">Run "${esc(c.text)}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite");runForm(li,c)}
|
|
703
|
-
li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
|
|
704
|
-
// Edit and reject take dictation; the run prompt has no field.
|
|
705
|
-
li.querySelectorAll("input,textarea").forEach((f,i)=>draft(f,"f:"+mode+":"+modeKey+":"+i));
|
|
706
|
-
if(mode!=="run"){const m=mic([...li.querySelectorAll("input,textarea")]);if(m)li.querySelector(".row").prepend(m)}
|
|
707
|
-
const inp=li.querySelector("input");if(inp){inp.onkeydown=e=>{if(e.key==="Enter")li.querySelector('[data-a=s]').click();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
|
|
708
|
-
return li;}
|
|
709
|
-
|
|
710
|
-
// The agent and Basic/Yolo picked in the open Run form. Kept here, not in the form:
|
|
711
|
-
// the board is rebuilt on every server change, and the choice must outlive that.
|
|
712
|
-
// Yolo never outlives the form: render() drops it once the form is closed.
|
|
713
|
-
let runPick=null;
|
|
714
|
-
// The tool a launch opened, and why when it is not the one asked for.
|
|
715
|
-
const via=a=>a.name+(a.note?". "+a.note:"");
|
|
716
|
-
async function runForm(li,c){
|
|
717
|
-
const k=key(c),box=li.querySelector("#run-agent");
|
|
718
|
-
const d=await fetch("/agents?dir="+encodeURIComponent(c.state)).then(r=>r.json()).catch(()=>null);
|
|
719
|
-
if(!d||!d.agents||!d.agents.length){li.querySelector("#run-pick").hidden=true;return}
|
|
720
|
-
// Antigravity and Gemini CLI are both Google's agent on Gemini; offer one of them.
|
|
721
|
-
if(d.agents.some(a=>a.cmd==="agy"))d.agents=d.agents.filter(a=>a.cmd!=="gemini");
|
|
722
|
-
if(!runPick||runPick.k!==k)runPick={k,agent:d.agents.some(a=>a.cmd===d.default)?d.default:d.agents[0].cmd};
|
|
723
|
-
box.innerHTML=d.agents.map(a=>`<button type="button" class="agent" role="radio" aria-checked="${a.cmd===runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`).join("");
|
|
724
|
-
const yb=li.querySelector("#run-yolo");
|
|
725
|
-
// Yolo only where the picked tool has a skip-permissions mode; Basic otherwise.
|
|
726
|
-
const sw=yb.querySelector(".chk");
|
|
727
|
-
const showYolo=()=>{const can=!!d.agents.find(a=>a.cmd===runPick.agent)?.yolo,on=!!runPick.yolo;yb.hidden=!can;sw.classList.toggle("on",on);sw.setAttribute("aria-checked",on)};
|
|
728
|
-
box.querySelectorAll(".agent").forEach(b=>b.onclick=()=>{runPick.agent=b.dataset.cmd;box.querySelectorAll(".agent").forEach(x=>x.setAttribute("aria-checked",x===b));showYolo()});
|
|
729
|
-
sw.onclick=()=>{runPick.yolo=!runPick.yolo;showYolo()};
|
|
730
|
-
showYolo();
|
|
731
|
-
box.onkeydown=sw.onkeydown=e=>{if(e.key==="Escape"){mode=null;render()}}}
|
|
732
|
-
|
|
733
|
-
async function startRun(c,kind){
|
|
734
|
-
mode=null;askNotify();
|
|
735
|
-
const p=runPick&&runPick.k===key(c)?runPick:null;runPick=null;
|
|
736
|
-
const yolo=!!(p&&p.yolo&&!$("run-yolo")?.hidden);
|
|
737
|
-
const r=await post("/run",{dir:c.state,text:c.text,kind,agent:p?p.agent:undefined,yolo});
|
|
738
|
-
if(r.ok){toast((kind==="full"?"Full":"Lite")+(yolo?" yolo":"")+" run "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c)}
|
|
739
|
-
load()}
|
|
740
|
-
|
|
741
|
-
function render(){
|
|
742
|
-
// A closed form leaves no draft behind; an open one is reopened after a reload.
|
|
743
|
-
// A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
|
|
744
|
-
if(mode&&mode!=="new"&&!S.cards.some(c=>key(c)===modeKey))mode=null;
|
|
745
|
-
if(mode!=="run"&&runPick)runPick.yolo=false;
|
|
746
|
-
if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
|
|
747
|
-
// The board is rebuilt below; the card that had focus gets it back.
|
|
748
|
-
const fk=focusedKey();
|
|
749
|
-
// All projects: only the ones ticked in the filter, or every one when none is.
|
|
750
|
-
const only=all&&shown.size&&showable().some(p=>shown.has(p.dir));
|
|
751
|
-
const cs=S.cards.filter(c=>c.archived===archive&&(!only||shown.has(c.state))),b=$("board");b.innerHTML="";
|
|
752
|
-
const nArch=S.cards.filter(c=>c.archived).length;
|
|
753
|
-
const n=s=>cs.filter(c=>c.status===s).length;
|
|
754
|
-
const strip=[];
|
|
755
|
-
// One chip per status on the board: its mark, its count, and a click filters to it. Running always shows.
|
|
756
|
-
for(const st of ["waiting","running","stalled","starting","idea","done","abandoned"]){const k=n(st);if(!k&&st!=="running")continue;
|
|
757
|
-
const mark=st==="running"?`<span class="d-run"><span class="pulse"></span></span>`:`<span class="st d-${ICON[st]}"><svg><use href="#s-${ICON[st]}"/></svg></span>`;
|
|
758
|
-
strip.push(`<button class="${st==="waiting"?"attn ":""}${filter===st?"on":""}" data-f="${st}" title="Show only ${WORD[st]}">${mark}${k} ${WORD[st]}${st==="idea"&&k!==1?"s":""}</button>`)}
|
|
759
|
-
strip.push(`<button class="${archive?"on":""}" data-f="__archive" title="${cs.filter(c=>c.kind==="idea").length} ideas · ${cs.filter(c=>c.kind==="run").length} runs on the board">${archive?"Back to board":"Archive "+nArch}</button>`);
|
|
760
|
-
$("strip").innerHTML=strip.join("");
|
|
761
|
-
$("strip").querySelectorAll("button").forEach(c=>c.onclick=()=>{if(c.dataset.f==="__archive"){archive=!archive;filter=null;render();return}filter=filter===c.dataset.f?null:c.dataset.f;render()});
|
|
762
|
-
// [label, folder, from the projects folder]: the button names the current one, the open list is redrawn.
|
|
763
|
-
PL=[["All projects","all"],...S.projects.map(p=>[p.label,p.dir]),...S.fresh.map(p=>[p.label,p.dir,1]),["Find projects on this PC…","__find"]];
|
|
764
|
-
$("proj").textContent=all||!S.dir?"All projects":(PL.find(p=>p[1]===S.dir)||["All projects"])[0];
|
|
765
|
-
if(!$("projpop").hidden)list();
|
|
766
|
-
$("showc").hidden=!all;const sp=showable(),ns=sp.filter(p=>shown.has(p.dir)).length;$("show").textContent=ns?`Showing ${ns} of ${sp.length}`:"Showing all";
|
|
767
|
-
if(!$("showpop").hidden)showList();
|
|
768
|
-
if(!cs.length&&mode!=="new"){b.innerHTML=archive?'<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>':'<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';if(!archive)$("newempty").onclick=()=>{mode="new";render()};}
|
|
769
|
-
COLS.forEach((name,ci)=>{
|
|
770
|
-
const s=document.createElement("section");const list=cs.filter(c=>c.column===ci&&(!filter||c.status===filter));
|
|
771
|
-
// Runs age by their last move, ideas by inbox position (new ones are appended).
|
|
772
|
-
if(ci<4){const at=c=>c.kind==="run"?c.moved||0:S.cards.indexOf(c),old=order[ci]==="old";list.sort((a,b)=>old?at(a)-at(b):at(b)-at(a))}
|
|
773
|
-
const clearable=ci===4&&!archive&&list.length;
|
|
774
|
-
const olderThan=days=>list.filter(c=>S.now-(c.moved||S.now)>days*d);
|
|
775
|
-
let head=`<h2>${name}<i>${list.length||""}</i>${ci<4&&list.length>1?`<button class="btn sm ghost ord" title="Sort order">${order[ci]==="old"?"Oldest":"Newest"}</button>`:""}${ci===0&&!archive?`<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>`:""}`;
|
|
776
|
-
if(clearable&&picking)head+=`<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size?"":"disabled"}>Archive ${picked.size||""}</button></span>`;
|
|
777
|
-
else if(clearable)head+=`<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
|
|
778
|
-
if(clearable&&clearMenu)head+=`<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
|
|
779
|
-
s.innerHTML=head+`</h2>`;
|
|
780
|
-
{const o=s.querySelector(".ord");if(o)o.onclick=e=>{e.stopPropagation();order[ci]=order[ci]==="old"?"new":"old";try{localStorage.setItem("kz-order",JSON.stringify(order))}catch(e){}render()}}
|
|
781
|
-
if(ci===0&&!archive)s.querySelector(".ib").onclick=()=>{mode="new";render()};
|
|
782
|
-
if(clearable){
|
|
783
|
-
const cl=s.querySelector(".clear");if(cl)cl.onclick=e=>{e.stopPropagation();clearMenu=!clearMenu;render()};
|
|
784
|
-
s.querySelectorAll("[data-c]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();clearMenu=false;const v=bt.dataset.c;
|
|
785
|
-
if(v==="pick"){picking=true;picked.clear();render();return}
|
|
786
|
-
const set=olderThan(+v);if(!set.length){toast("Nothing that old");render();return}
|
|
787
|
-
for(const c of set)await post("/archive",{dir:c.state,id:c.id});toast("Archived "+set.length);load()});
|
|
788
|
-
s.querySelectorAll("[data-p]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();if(bt.dataset.p==="cancel"){picking=false;picked.clear();render();return}
|
|
789
|
-
const set=list.filter(c=>picked.has(key(c)));picking=false;picked.clear();for(const c of set)await post("/archive",{dir:c.state,id:c.id});toast("Archived "+set.length);load()});}
|
|
790
|
-
const ul=document.createElement("ul");
|
|
791
|
-
if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`${all?`<div class="combo" id="ndc"><button class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>`:""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add idea</button></div>`;ul.appendChild(f);
|
|
792
|
-
const inp=f.querySelector("#newidea");draft(inp,"f:new:0");draft(f.querySelector("textarea"),"f:new:1");const save=async()=>{const text=inp.value.trim();if(!text)return;const notes=f.querySelector("textarea").value.trim();mode=null;const dir=all?newDir:null;const r=await post("/inbox",dir?{op:"add",dir,text,notes}:{op:"add",text,notes});if(r.ok)toast("Idea added");load()};
|
|
793
|
-
if(all)ideaCombo(f);f.querySelector("#ns").onclick=save;f.querySelector("#nc").onclick=()=>{mode=null;render()};{const m=mic([inp,f.querySelector("textarea")]);if(m)f.querySelector(".row").prepend(m)}inp.onkeydown=e=>{if(e.key==="Enter")save();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
|
|
794
|
-
const shown=unfolded.has(ci)||list.length<=FOLD+1?list:list.slice(0,FOLD);
|
|
795
|
-
shown.forEach(c=>{if(modeKey===key(c)&&mode&&mode!=="new"){ul.appendChild(inlineForm(c));return}const li=document.createElement("li");li.appendChild(card(c));ul.appendChild(li)});
|
|
796
|
-
if(shown.length<list.length){const li=document.createElement("li");li.innerHTML=`<button class="more-btn">Show ${list.length-shown.length} more</button>`;li.firstChild.onclick=()=>{unfolded.add(ci);render()};ul.appendChild(li)}
|
|
797
|
-
else if(unfolded.has(ci)&&list.length>FOLD+1){const li=document.createElement("li");li.innerHTML=`<button class="more-btn">Show less</button>`;li.firstChild.onclick=()=>{unfolded.delete(ci);render()};ul.appendChild(li)}
|
|
798
|
-
s.appendChild(ul);b.appendChild(s)});
|
|
799
|
-
stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
|
|
800
|
-
if(fk&&document.activeElement===document.body)cardEl(fk)?.focus({preventScroll:true});
|
|
801
|
-
}
|
|
802
|
-
const focusedKey=()=>document.activeElement?.closest?.("#board .card")?.dataset.k;
|
|
803
|
-
const cardEl=k=>k&&$("board").querySelector('.card[data-k="'+CSS.escape(k)+'"]');
|
|
804
|
-
// Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
|
|
805
|
-
// nearest column with cards, at the same row or its last card.
|
|
806
|
-
function nav(dx,dy){
|
|
807
|
-
const cols=[...$("board").querySelectorAll("section")].map(s=>[...s.querySelectorAll(".card")]).filter(l=>l.length);if(!cols.length)return;
|
|
808
|
-
const cur=document.activeElement.closest(".card");let ci=cols.findIndex(l=>l.includes(cur)),ri=0;
|
|
809
|
-
if(ci<0)ci=0;else{ri=cols[ci].indexOf(cur);if(dy)ri=Math.max(0,Math.min(cols[ci].length-1,ri+dy));else{ci=Math.max(0,Math.min(cols.length-1,ci+dx));ri=Math.min(ri,cols[ci].length-1)}}
|
|
810
|
-
const t=cols[ci][ri];t.focus({preventScroll:true});t.scrollIntoView({block:"nearest",inline:"nearest"})}
|
|
811
|
-
|
|
812
|
-
// ---- detail panel
|
|
813
|
-
// A run's tabs, its phases in board order. A card opens on its column's tab.
|
|
814
|
-
const TABS=[["Request","request"],["Notes","notes"],["Plan","plan"],["Backlog","backlog"],["Work","impl"],["Review","review"],["Preview","preview"]];
|
|
815
|
-
// keepTab: a link already chose the tab.
|
|
816
|
-
function open(c,keepTab){
|
|
817
|
-
if(view!=="board")go("board",true);
|
|
818
|
-
stopDictation(true); // the panel body is about to be replaced
|
|
819
|
-
fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.kind==="idea"?brief(c.text).head:c.text;render();
|
|
820
|
-
$("pterm").hidden=true;
|
|
821
|
-
if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";ideaBody(c);
|
|
822
|
-
const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn ghost danger" data-a="delete">Delete</button><button class="btn ghost" data-a="archive">Archive</button><span class="dec"><button class="btn" data-a="reject">Reject</button><button class="btn" data-a="edit">Edit</button><button class="btn pri" data-a="run">Run</button></span>`:`<span class="note">${c.pty?"Starting: the agent runs below.":"Starting: a run was launched for this idea. No terminal with your agent? Run it again."}</span><span class="dec"><button class="btn" data-a="run">Run again</button></span>`;
|
|
823
|
-
foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}const k=key(c);close();mode=a;modeKey=k;render()});return}
|
|
824
|
-
if(!keepTab)lastTab=tab=c.status==="abandoned"?"plan":({1:"plan",2:"backlog",3:"review",4:"preview"})[c.column]||"request";
|
|
825
|
-
// A run its agent is working on in this board, while planning, building or reviewing, opens on that agent.
|
|
826
|
-
if(!keepTab&&c.pty&&c.pty.alive&&c.column>=1&&c.column<=3)tab="term";
|
|
827
|
-
openRun(c,false)}
|
|
828
|
-
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
829
|
-
function parseNotes(src){const out=[];for(const raw of (src||"").split("\n")){const m=/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);if(m){out.push({at:m[1],text:m[2]});continue}const last=out[out.length-1];if(last)last.text+="\n"+raw.replace(/^\s{2}/,"");else if(raw.trim())out.push({at:"",text:raw})}return out.map(n=>({...n,text:n.text.trim()})).filter(n=>n.text)}
|
|
830
|
-
let nfocus=null;
|
|
831
|
-
function chat(c,src,ro){
|
|
832
|
-
const fix=t=>attach(t,c);
|
|
833
|
-
const entries=parseNotes(src);
|
|
834
|
-
const b=c.kind==="idea"?brief(c.text):{parts:[]};
|
|
835
|
-
let h=(b.parts.length?`<section class="desc">${md(b.parts.map(p=>`### ${p.label}\n${p.body}`).join("\n\n"))}</section>`:"")+`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
|
|
836
|
-
if(!ro)h+=`<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
837
|
-
$("pbody").innerHTML=h;
|
|
838
|
-
if(ro)return;
|
|
839
|
-
const ta=$("pbody").querySelector("textarea"),files=[];
|
|
840
|
-
draft(ta,"n:"+key(c));
|
|
841
|
-
// Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
|
|
842
|
-
// so the note shows, and the new box takes focus back for the next note.
|
|
843
|
-
if(nfocus===key(c)){nfocus=null;ta.focus()}
|
|
844
|
-
{const m=mic([ta]);if(m)$("npick").before(m)}
|
|
845
|
-
const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
|
|
846
|
-
const add=f=>{files.push(f);show()};
|
|
847
|
-
$("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
|
|
848
|
-
ta.onpaste=e=>{for(const it of e.clipboardData.items)if(it.kind==="file"){const f=it.getAsFile();add(new File([f],f.name==="image.png"?`paste-${Date.now()}.png`:f.name,{type:f.type}));e.preventDefault()}};
|
|
849
|
-
const send=async()=>{stopDictation(true);const text=ta.value.trim();if(!text&&!files.length)return;const enc=await Promise.all(files.map(f=>new Promise(r=>{const rd=new FileReader();rd.onload=()=>r({name:f.name,data:rd.result});rd.readAsDataURL(f)})));
|
|
850
|
-
const body=c.kind==="run"?{dir:c.state,id:c.id,text,files:enc}:{dir:c.state,for:c.text,text,files:enc};const r=await post("/note",body);if(r.ok){files.length=0;ta.value="";try{sessionStorage.removeItem(DK+"n:"+key(c))}catch(e){}toast("Noted");nfocus=key(c);ta.blur();load()}};
|
|
851
|
-
$("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
|
|
852
|
-
$("pbody").scrollTop=0}
|
|
853
|
-
// The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself,
|
|
854
|
-
// and its "### How to test" steps.
|
|
855
|
-
// "## Commit" is the message the builder wrote for the work, offered at commit and push.
|
|
856
|
-
function section(impl,name){const m=impl&&impl.match(new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$(?![\\s\\S]))`,"m"));return m?m[1].trim():null}
|
|
857
|
-
const preview=impl=>section(impl,"Preview");
|
|
858
|
-
// Links to a card's attachments/ (notes, or screenshots the builder put in Preview) via /file.
|
|
859
|
-
const attach=(t,c)=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent((c.kind==="run"?`runs/${c.id}/`:"")+p)})`);
|
|
860
|
-
const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
|
|
861
|
-
// A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
|
|
862
|
-
// each, "(recommended)" marking the default and "multi-select" in the question allowing
|
|
863
|
-
// several. A blank or other line ends a question's options. The format is pinned in
|
|
864
|
-
// spec-plan.md; a section with content in any other shape is null, none at all is [].
|
|
865
|
-
function planQuestions(src){const lines=(src||"").split("\n"),at=lines.findIndex(l=>/^#+\s*(\d+\.\s*)?open questions/i.test(l.trim()));if(at<0)return[];
|
|
866
|
-
const lvl=/^#+/.exec(lines[at].trim())[0].length,out=[];let cur=null,said=false;
|
|
867
|
-
for(const raw of lines.slice(at+1)){const l=raw.trim(),h=/^#+(?=\s)/.exec(l);if(h&&h[0].length<=lvl)break;
|
|
868
|
-
if(!h&&l&&!/^```/.test(l)&&!/^none\.?$/i.test(l))said=true;
|
|
869
|
-
const q=/^\**Q\d*\s*[:.)]\**\s*(.+)$/.exec(l);if(q){out.push(cur={q:q[1].trim(),multi:/multi-?select/i.test(q[1]),options:[]});continue}
|
|
870
|
-
const o=/^[-*]\s+(.+)$/.exec(l);
|
|
871
|
-
if(o&&cur){const t=o[1].trim();cur.options.push({text:t,rec:/\(recommended\)/i.test(t),label:t.replace(/\s*\(recommended\)/i,"").split(/\s+[—–-]\s+|:\s/)[0].trim()})}
|
|
872
|
-
else if(cur&&cur.options.length)cur=null}
|
|
873
|
-
const qs=out.filter(x=>x.options.length);return qs.length||!said?qs:null}
|
|
874
|
-
// A building run's Backlog tab opens with its progress, above the deferred items. The
|
|
875
|
-
// first draw of a run animates nothing; later ones animate what moved, the bar included.
|
|
876
|
-
let lastProg=null;
|
|
877
|
-
function showProgress(c){
|
|
878
|
-
const src=c.column===2&&runDoc.progress,prev=lastProg&&lastProg.id===c.id?lastProg:null,h=src?progress(src,prev&&prev.st):"";
|
|
879
|
-
if(!h)return;lastProg={id:c.id,st:[...src.matchAll(/^\s*-\s*(todo|doing|done):/gm)].map(m=>m[1]),w:+(/width:(\d+)%/.exec(h)[1])};
|
|
880
|
-
$("pbody").innerHTML=h+(runDoc.backlog?"<h3>Backlog</h3>"+md(runDoc.backlog,true):"");
|
|
881
|
-
const bar=$("pbody").querySelector(".pbar i");if(prev&&prev.w!==lastProg.w){bar.style.width=prev.w+"%";bar.offsetWidth;bar.style.width=lastProg.w+"%"}}
|
|
882
|
-
// again: something changed on the server, so the doc is read afresh: a note sent, a plan
|
|
883
|
-
// written or a commit made elsewhere shows in nothing the page already holds. One /run
|
|
884
|
-
// fetch per change while a run panel is open; a tab switch reuses the doc.
|
|
885
|
-
// ---- the in-card agent (agent.launch: card). xterm.js is loaded the first time a
|
|
886
|
-
// terminal is shown. One terminal is mounted at a time, in #pbody; a refresh that
|
|
887
|
-
// finds it already showing this session leaves it alone, so it never reconnects.
|
|
888
|
-
let wantTerm=null,termKey=null,termOff=null,termLib=null;
|
|
889
|
-
const where=r=>r.pty?"running in its card":"opened in your terminal";
|
|
890
|
-
const termLoad=()=>termLib||=new Promise((ok,no)=>{const l=document.createElement("link");l.rel="stylesheet";l.href="/term.css";document.head.append(l);const sc=document.createElement("script");sc.src="/term.js";sc.onload=()=>ok(window.kzTerm);sc.onerror=()=>{termLib=null;no()};document.head.append(sc)});
|
|
891
|
-
function termStop(){if(termOff)termOff();termOff=null;termKey=null;$("panel").classList.remove("wide")}
|
|
892
|
-
async function showTerm(c){const k=c.pty.key;if(termKey===k&&$("pbody").querySelector(".term"))return;
|
|
893
|
-
termStop();termKey=k;$("panel").classList.add("wide");$("pbody").innerHTML=`<div class="term" aria-label="Agent terminal"></div>`;
|
|
894
|
-
let mount;try{mount=await termLoad()}catch{$("pbody").innerHTML=`<p style="color:var(--dim)">The terminal could not load.</p>`;return}
|
|
895
|
-
if(termKey!==k)return;termOff=mount($("pbody").querySelector(".term"),k,()=>load(true))}
|
|
896
|
-
// An idea's panel: its terminal while the run it started is being born there, else its notes.
|
|
897
|
-
function ideaBody(c){if(c.pty)showTerm(c);else{termStop();chat(c,c.notes,c.archived||c.status!=="idea")}}
|
|
898
|
-
async function openRun(c,refresh,again){
|
|
899
|
-
if(!refresh||!runDoc||runDoc.id!==c.id||again){const d=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json());
|
|
900
|
-
// A file list being confirmed, or a push under way, keeps the doc it was drawn from.
|
|
901
|
-
if(refresh&&$("pfoot").querySelector(".inl,[data-busy],.more[open]"))return;runDoc=d}
|
|
902
|
-
if(openKey!==key(c))return;
|
|
903
|
-
// The fetch above may have finished after a newer /state: draw the card as it is now
|
|
904
|
-
// (a resumed agent's terminal, not the one that ended).
|
|
905
|
-
c=S.cards.find(x=>key(x)===openKey)||c;
|
|
906
|
-
// A refresh while notes are being typed would wipe the textarea.
|
|
907
|
-
const ta=$("pbody").querySelector(".notes textarea");if(refresh&&ta&&document.activeElement===ta)return;
|
|
908
|
-
$("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+need(c.awaiting):"");
|
|
909
|
-
// Only tabs with something in them show. A card whose agent runs in the board gets its
|
|
910
|
-
// terminal behind the header's terminal button, chosen once right after a launch from here.
|
|
911
|
-
const tabs=TABS.filter(t=>t[1]==="request"||t[1]==="notes"||(t[1]==="preview"?preview(runDoc.impl):runDoc[t[1]]||t[1]==="backlog"&&c.column===2&&runDoc.progress));
|
|
912
|
-
if(c.pty&&wantTerm===key(c)){wantTerm=null;tab="term"}
|
|
913
|
-
if(tab==="term"&&!c.pty)tab=lastTab;
|
|
914
|
-
// A tab not written yet falls back to the latest one that is.
|
|
915
|
-
if(tab!=="term"&&!tabs.some(t=>t[1]===tab))tab=[...tabs].reverse().find(t=>t[1]!=="notes")[1];
|
|
916
|
-
if(tab!=="term")lastTab=tab;
|
|
917
|
-
$("panel").classList.toggle("wide",tab==="term");
|
|
918
|
-
const pt=$("pterm");pt.hidden=!c.pty;pt.classList.toggle("on",tab==="term");pt.setAttribute("aria-pressed",tab==="term");pt.onclick=()=>{tab=tab==="term"?lastTab:"term";openRun(c,true)};
|
|
919
|
-
$("ptabs").innerHTML=tabs.map(t=>`<button class="${t[1]===tab?"on":""}" data-k="${t[1]}">${t[0]}</button>`).join("");
|
|
920
|
-
[...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=b.dataset.k;openRun(c,true)});
|
|
921
|
-
const k=tab,doc=k==="preview"?(preview(runDoc.impl)&&attach(preview(runDoc.impl),c)):runDoc[k];
|
|
922
|
-
if(k!=="term")termStop();
|
|
923
|
-
if(k==="term")showTerm(c);
|
|
924
|
-
else if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
|
|
925
|
-
chat(c,doc,ro);}
|
|
926
|
-
else $("pbody").innerHTML=doc?md(doc,k==="backlog"):`<p style="color:var(--dim)">No ${({request:"request",plan:"plan",impl:"work report",preview:"preview",review:"review",backlog:"backlog"})[k]} yet.</p>`;
|
|
927
|
-
if(k==="backlog")showProgress(c);
|
|
928
|
-
if(k==="review")fixForm(c);
|
|
929
|
-
// The notebook's notes that link here, each opening that note: above the run's own notes.
|
|
930
|
-
if(k==="notes"&&runDoc.linkedFrom&&runDoc.linkedFrom.length)$("pbody").insertAdjacentHTML("afterbegin",`<h3>Linked from notes</h3><ul class="lnk">${runDoc.linkedFrom.map(n=>`<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&note=${encodeURIComponent(n)}">${esc(n)}</a></li>`).join("")}</ul>`);
|
|
931
|
-
// Only the panel the link asked for: another card opened meanwhile drops the mark.
|
|
932
|
-
if(hit&&!refresh){if(hit.k===key(c))mark(hit);else hit=null}
|
|
933
|
-
const foot=$("pfoot");
|
|
934
|
-
// A refresh while the commit is being confirmed would wipe the message.
|
|
935
|
-
if(refresh&&foot.querySelector(".inl,[data-busy],.more[open]"))return;
|
|
936
|
-
const arch=gitBtn(c)+(c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`);
|
|
937
|
-
// A plan that asks questions takes their answers with the approval.
|
|
938
|
-
// One the board cannot read is said, so it is answered in the terminal instead of skipped.
|
|
939
|
-
const pq=c.status==="waiting"&&c.awaiting==="approvals.plan"?planQuestions(runDoc.plan):[],qs=pq||[];
|
|
940
|
-
// Its in-card agent ended on a waiting or done run: reopen that conversation, nothing sent.
|
|
941
|
-
const rs=c.pty&&!c.pty.alive?`<button class="btn" data-a="resume">Resume session</button>`:"";
|
|
942
|
-
if(c.archived)foot.innerHTML=arch;
|
|
943
|
-
else if((c.status==="waiting"||c.awaiting==="findings")&&APPROVAL[c.awaiting]){const sent=decided.has(answer(c)),asks=!pq?" It has open questions the board cannot show: answer them in the terminal running this run, or approve and your agent asks them.":qs.length?` It asks ${qs.length===1?"one question":qs.length+" questions"}: Approve opens ${qs.length===1?"it":"them"}.`:"";
|
|
944
|
-
// gitBtn's buttons restyled as menu rows.
|
|
945
|
-
const more=gitBtn(c).replace(/class="btn( pri)?"/g,"")+`<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
|
|
946
|
-
foot.innerHTML=`<span class="note">${sent?"Sent to your agent.":APPROVAL[c.awaiting]+asks+" Answering here opens a new agent session."}</span><details class="more"><summary class="btn ghost">More</summary><div class="menu-pop">${more}</div></details>${rs}<span class="dec"><button class="btn" data-a="revise"${sent?" disabled":""}>Revise</button><button class="btn pri" data-a="approve"${sent?" disabled":""}>${c.awaiting==="findings"?"Approve as-is":"Approve"}</button></span>`}
|
|
947
|
-
else if(c.status==="waiting")foot.innerHTML=`<span class="note">Waiting on you: ${esc(need(c.awaiting))}. Answer in the ${c.pty&&c.pty.alive?"terminal (button at the top)":"terminal running this run"}.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>${rs}`;
|
|
948
|
-
else if(c.status==="done")foot.innerHTML=arch+rs;
|
|
949
|
-
else if(c.status==="abandoned")foot.innerHTML=arch;
|
|
950
|
-
else foot.innerHTML=`<span class="note">${c.pty&&!c.pty.alive?"Its agent ended before the stage finished. Resume continues it here.":c.status==="stalled"?"No progress for a while. Resume it, or abandon it.":c.pty?"":"Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c)?`<button class="btn pri" data-a="resume">Resume</button>`:""}`;
|
|
951
|
-
foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}if(a==="push"){commitPush(c,"",b);return}if(a==="commit"){confirmCommit(c);return}if(a==="approve"){if(qs.length)confirmAnswers(c,qs);else decide(c,b);return}if(a==="revise"){confirmRevise(c);return}if(a==="resume"){resume(c,b);return}close();mode="reject";modeKey=key(c);render()})}
|
|
952
|
-
// Answering an approval from the panel opens your agent on the run, told the decision and,
|
|
953
|
-
// for a revise, the comment. Answered approvals are remembered outside the footer, which
|
|
954
|
-
// every refresh rebuilds, so a second press cannot open a second agent. Keyed by when the
|
|
955
|
-
// run last moved: the same approval reached again after a revise is a new question.
|
|
956
|
-
// The footer's More menu is a <details>: close it on a click anywhere else.
|
|
957
|
-
document.addEventListener("click",e=>{for(const d of document.querySelectorAll(".more[open]"))if(!d.contains(e.target))d.open=false});
|
|
958
|
-
// Resume: a run whose in-card agent ended, or one stalled with no progress, gets its agent
|
|
959
|
-
// back on the run (its conversation, where the tool can resume one), in its card.
|
|
960
|
-
const resumable=c=>c.status==="stalled"||!!(c.pty&&!c.pty.alive);
|
|
961
|
-
async function resume(c,b){b.disabled=true;askNotify();const r=await post("/resume",{dir:c.state,id:c.id});
|
|
962
|
-
if(!r.ok){b.disabled=false;return}
|
|
963
|
-
toast("Resumed, "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c);
|
|
964
|
-
if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
|
|
965
|
-
const APPROVAL={"approvals.plan":"The plan is ready. Read it in the Plan tab, then approve or send it back.","approvals.review":"The work is ready to check. Approve to finish it, or revise with what to change.","findings":"The review has open findings. Approve as-is to accept them and finish, or fix selected findings first."},decided=new Set(),answer=c=>key(c)+":"+c.awaiting+":"+c.moved;
|
|
966
|
-
async function decide(c,b,why,answers){b.disabled=true;askNotify();const r=await post("/decide",{dir:c.state,id:c.id,awaiting:c.awaiting,...(why===undefined?{}:{why}),...(answers?{answers}:{})});
|
|
967
|
-
// A failed send keeps the footer, and with it a typed comment.
|
|
968
|
-
if(!r.ok){b.disabled=false;return}
|
|
969
|
-
decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+", "+where(r)+": "+via(r.agent));
|
|
970
|
-
if(r.pty)wantTerm=key(c);
|
|
971
|
-
if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
|
|
972
|
-
function confirmRevise(c){const foot=$("pfoot"),plan=c.awaiting==="approvals.plan";
|
|
973
|
-
foot.innerHTML=`<div class="inl" style="flex:1"><div class="q">${plan?"What should the plan do differently?":"What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan?" It revises the plan and comes back here for approval.":" It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
|
|
974
|
-
const inp=foot.querySelector("textarea");inp.focus();
|
|
975
|
-
foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
|
|
976
|
-
foot.querySelector("[data-a=s]").onclick=e=>{const why=inp.value.trim();if(!why){toast("Say what should change");inp.focus();return}decide(c,e.target,why)}}
|
|
977
|
-
// The plan's questions, answered with the approval: the recommended option preselected,
|
|
978
|
-
// a note for what the options miss. Each answer reaches the agent as one line.
|
|
979
|
-
function confirmAnswers(c,qs){const foot=$("pfoot");
|
|
980
|
-
foot.innerHTML=`<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length===1?"one question":qs.length+" questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x,i)=>`<fieldset><legend>${esc(x.q)}</legend>${x.options.map((o,j)=>`<label><input type="${x.multi?"checkbox":"radio"}" name="q${i}" value="${j}"${o.rec||!x.multi&&!j&&!x.options.some(p=>p.rec)?" checked":""}> ${esc(o.text)}</label>`).join("")}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${esc(x.q)}"></fieldset>`).join("")}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
|
|
981
|
-
foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
|
|
982
|
-
foot.querySelector("[data-a=s]").onclick=e=>decide(c,e.target,undefined,qs.map((x,i)=>{
|
|
983
|
-
const picked=[...foot.querySelectorAll(`[name=q${i}]:checked`)].map(n=>`"${x.options[+n.value].label}"`).join(", ")||"none of the options",note=foot.querySelector(`[data-n="${i}"]`).value.trim();
|
|
984
|
-
return `${x.q} — ${picked}${note?"; "+note:""}`}))}
|
|
985
|
-
// A link from a note (?run= or ?idea=, with dir, tab, item) opens that card's panel;
|
|
986
|
-
// a finding or backlog item named is scrolled to and marked. Found by its text, since
|
|
987
|
-
// the rendered list does not carry the numbers.
|
|
988
|
-
let hit=null;
|
|
989
|
-
function deep(){const p=new URLSearchParams(location.search),d=p.get("dir"),id=p.get("run"),idea=p.get("idea");if(!id&&idea===null)return;
|
|
990
|
-
history.replaceState(null,"",location.pathname+"?all=1");
|
|
991
|
-
const c=S.cards.find(x=>x.state===d&&(id?x.kind==="run"&&x.id===id:x.kind==="idea"&&x.text===idea));
|
|
992
|
-
if(!c){toast(id?"That run is not on this board":"That idea is not on this board");return}
|
|
993
|
-
const m=/^(finding|backlog)-(\d+)$/.exec(p.get("item")||"");
|
|
994
|
-
// An item's tab is its own when the link does not name one.
|
|
995
|
-
const n=p.get("tab")||(m?(m[1]==="finding"?"review":"backlog"):""),t=TABS.find(x=>x[1]===(n==="work"?"impl":n));if(t)tab=t[1];hit=m?{type:m[1],n:+m[2],k:key(c)}:null;open(c,!!t)}
|
|
996
|
-
// What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
|
|
997
|
-
function mark(h){hit=null;const flat=t=>String(t).replace(/!?\[([^\]]*)\]\([^)\s]+\)/g,"$1").replace(/[`*]/g,"").replace(/\s+/g," ").trim();
|
|
998
|
-
const b=[...(runDoc.backlog||"").matchAll(/^\s*-\s*(?:open|done|rejected):\s*(.*)$/gm)];
|
|
999
|
-
const text=h.type==="finding"?(runDoc.findings||[]).find(f=>f.n===h.n)?.text:b[h.n-1]?.[1];
|
|
1000
|
-
if(!text){toast("That item is not in this run");return}
|
|
1001
|
-
const want=flat(text).slice(0,60),el=[...$("pbody").querySelectorAll("li,p")].find(e=>!e.closest(".prog")&&flat(e.textContent).includes(want));
|
|
1002
|
-
if(!el)return;el.scrollIntoView({block:"center"});el.classList.add("hit");setTimeout(()=>el.classList.remove("hit"),2400)}
|
|
1003
|
-
// Fix picked findings, under the review: open ones ticked, closed and sent ones ticked but
|
|
1004
|
-
// locked. Unticked and sent findings live outside the form, which every refresh rebuilds,
|
|
1005
|
-
// keyed "run:number"; sent ones also survive a reload of this tab. A finding is one line,
|
|
1006
|
-
// so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
|
|
1007
|
-
const SK="kz-sent",fixOff=new Set(),fixSent=new Set((()=>{try{return JSON.parse(sessionStorage.getItem(SK))||[]}catch(e){return[]}})());
|
|
1008
|
-
function fixForm(c){const k=n=>c.id+":"+n,sent=f=>fixSent.has(k(f.n)),fs=runDoc.findings||[],open=fs.filter(f=>!f.done&&!sent(f));
|
|
1009
|
-
if(c.archived||!fs.some(f=>!f.done)||!(c.status==="done"||c.awaiting==="findings"||c.awaiting==="approvals.review"))return;
|
|
1010
|
-
const el=document.createElement("div");el.className="inl fix";
|
|
1011
|
-
el.innerHTML=`<div class="q">Fix findings</div><div class="q dim">Opens your agent in this project, told to fix the ticked findings and recheck them.</div>${c.status==="waiting"?`<div class="q dim">A session is already waiting on this run in a terminal; this opens another one.</div>`:""}`+fs.map(f=>{const x=f.done?" (closed)":sent(f)?" (sent)":"";return`<label class="${x?"off":""}"><input type="checkbox" data-n="${f.n}" ${x?"checked disabled":fixOff.has(k(f.n))?"":"checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g,"")}${x}</span></label>`}).join("")+(open.length?`<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`:"");
|
|
1012
|
-
if(!open.length)return $("pbody").appendChild(el);
|
|
1013
|
-
const go=el.querySelector("[data-a=s]"),allb=el.querySelector("[data-a=all]"),picks=()=>open.filter(f=>!fixOff.has(k(f.n))).map(f=>f.n);
|
|
1014
|
-
const sync=()=>{const n=picks().length;go.textContent=n===open.length?"Fix all "+n:"Fix "+n+" selected";go.disabled=!n;allb.textContent=n===open.length?"Select none":"Select all";el.querySelectorAll("input:not(:disabled)").forEach(i=>i.checked=!fixOff.has(k(i.dataset.n)))};
|
|
1015
|
-
el.onchange=e=>{const n=k(e.target.dataset.n);e.target.checked?fixOff.delete(n):fixOff.add(n);sync()};
|
|
1016
|
-
allb.onclick=()=>{const none=picks().length===open.length;open.forEach(f=>none?fixOff.add(k(f.n)):fixOff.delete(k(f.n)));sync()};
|
|
1017
|
-
go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
|
|
1018
|
-
// Sent findings lock as sent, so a second press cannot open a second agent on them.
|
|
1019
|
-
if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+", "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c);nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
|
|
1020
|
-
sync();$("pbody").appendChild(el)}
|
|
1021
|
-
// Commit and push, for a finished run in a project with a git remote. What the board
|
|
1022
|
-
// did is recorded per run, so the settled label outlives later changes to the tree.
|
|
1023
|
-
function gitBtn(c){const g=runDoc.git,k=runDoc.commit;
|
|
1024
|
-
if(c.archived||!g||!g.repo||!(c.status==="done"||c.awaiting==="approvals.review"))return"";
|
|
1025
|
-
// A run already pushed keeps saying so; work changed since is offered beside it.
|
|
1026
|
-
if(k&&k.pushed)return `<button class="btn" disabled title="${esc(k.sha)}">Committed & pushed</button>`+(g.files.length?`<button class="btn" data-a="commit">Commit new changes</button>`:g.ahead>0?`<button class="btn" data-a="push">Push</button>`:"");
|
|
1027
|
-
if(g.files.length)return `<button class="btn pri" data-a="commit">Commit & push</button>`;
|
|
1028
|
-
if(g.ahead>0)return `<button class="btn pri" data-a="push">Push</button>`;
|
|
1029
|
-
// None of its files changed and level with the remote: it was committed and pushed
|
|
1030
|
-
// somewhere else. With no record and no report, nothing says it was ever committed.
|
|
1031
|
-
return k||runDoc.impl?`<button class="btn" disabled>Committed & pushed</button>`:""}
|
|
1032
|
-
function confirmCommit(c,msg){const g=runDoc.git,foot=$("pfoot");
|
|
1033
|
-
foot.innerHTML=`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length===1?"":"s"} on ${esc(g.branch||"this branch")} and push to ${esc(g.remote)}?</div><div class="q dim">Only the files this run's report names.${g.others?` ${g.others} other changed file${g.others===1?" is":"s are"} left alone.`:""}</div><pre class="files">${esc(g.files.join("\n"))}</pre>${g.shared&&g.shared.length?`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${esc(g.shared.join("\n"))}</pre>`:""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${esc(msg||commitMsg(runDoc.impl)||c.title||c.text)}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit & push</button></div></div>`;
|
|
1034
|
-
const inp=foot.querySelector("textarea"),go=foot.querySelector("[data-a=s]");inp.focus();
|
|
1035
|
-
// Tall enough to show the whole message, up to half the window.
|
|
1036
|
-
const fit=()=>{inp.style.height="auto";inp.style.height=Math.min(inp.scrollHeight+2,innerHeight/2)+"px";$("clong").hidden=inp.value.split("\n")[0].length<=72};inp.oninput=fit;fit();
|
|
1037
|
-
foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
|
|
1038
|
-
go.onclick=()=>{if(!inp.value.trim()){toast("A commit message is required");return}commitPush(c,inp.value,go)};
|
|
1039
|
-
inp.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();go.click()}}}
|
|
1040
|
-
async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textContent="Pushing…";
|
|
1041
|
-
const r=await post("/commit",{dir:c.state,id:c.id,message,files:runDoc.git.files});if(r.ok)toast("Committed & pushed "+r.sha);
|
|
1042
|
-
$("pfoot").innerHTML="";if(openKey!==key(c))return;await openRun(c,false);
|
|
1043
|
-
// A refused message comes back for another try, beside the current file list.
|
|
1044
|
-
if(!r.ok&&message&&openKey===key(c)&&runDoc.git.files&&runDoc.git.files.length)confirmCommit(c,message)}
|
|
1045
|
-
async function toggleArchive(c,archived){const r=await post("/archive",c.kind==="idea"?{dir:c.state,text:c.text,archived}:{dir:c.state,id:c.id,archived});if(r.ok)toast(archived?"Archived":"Restored");close();load()}
|
|
1046
|
-
function close(){if(view!=="board")return;const k=openKey;stopDictation(true);termStop();openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
|
|
1047
|
-
$("pclose").onclick=close;$("scrim").onclick=close;
|
|
1048
|
-
// An idea is one inbox line, so a structured request arrives as one wall:
|
|
1049
|
-
// "Fix x Problem: … Scope: … Acceptance criteria: (1) … (2) …". Split it, for display
|
|
1050
|
-
// only, into the text before the first label and one part per label; inline (1) (2)
|
|
1051
|
-
// become a numbered list. Fewer than two labels is plain text and is left whole.
|
|
1052
|
-
function brief(t){
|
|
1053
|
-
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
1054
|
-
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
1055
|
-
const re=/(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,cut=[];let m;
|
|
1056
|
-
while((m=re.exec(t)))if(!/^\s+$/.test(m[1])||(!cut.length&&!m[2].includes(" ")))cut.push([m.index+m[1].length,m[2],re.lastIndex]);
|
|
1057
|
-
if(cut.length<2)return {head:t,parts:[]};
|
|
1058
|
-
// Inline (1) (2) … become a list; numbers that do not run 1, 2, 3 keep their own text.
|
|
1059
|
-
const parts=cut.map(([,label,from],i)=>{const body=t.slice(from,i+1<cut.length?cut[i+1][0]:t.length).trim(),n=body.split(/\s*\((\d+)\)\s+/);
|
|
1060
|
-
if(n.length<5)return {label,body};const its=[];for(let k=1;k<n.length;k+=2)its.push([n[k],n[k+1]]);
|
|
1061
|
-
const seq=its.every(([d],k)=>+d===k+1);
|
|
1062
|
-
return {label,body:(n[0]?n[0]+"\n":"")+its.map(([d,x],k)=>seq?`${k+1}. ${x}`:`- (${d}) ${x}`).join("\n")}});
|
|
1063
|
-
return {head:t.slice(0,cut[0][0]).trim()||`${parts[0].label}: ${t.slice(cut[0][2],cut[1][0]).trim()}`,parts}}
|
|
1064
|
-
|
|
1065
|
-
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
1066
|
-
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
1067
|
-
// (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
|
|
1068
|
-
function md(src,tick){
|
|
1069
|
-
const lines=src.split("\n"),out=[];let i=0,list=null;
|
|
1070
|
-
const inl=s=>esc(s).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank"><img src="$2" alt="$1"></a>').replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank">$1</a>');
|
|
1071
|
-
const endList=()=>{if(list){out.push(`</${list}>`);list=null}};
|
|
1072
|
-
while(i<lines.length){const l=lines[i];
|
|
1073
|
-
if(/^```/.test(l)){endList();const buf=[];i++;while(i<lines.length&&!/^```/.test(lines[i]))buf.push(lines[i++]);out.push(`<pre>${esc(buf.join("\n"))}</pre>`);i++;continue}
|
|
1074
|
-
const hm=/^(#{1,6})\s+(.*)$/.exec(l);if(hm){endList();out.push(`<h${Math.min(hm[1].length,3)}>${inl(hm[2])}</h${Math.min(hm[1].length,3)}>`);i++;continue}
|
|
1075
|
-
if(/^---+$/.test(l.trim())){endList();out.push("<hr>");i++;continue}
|
|
1076
|
-
if(/^\|/.test(l)){endList();const rows=[];while(i<lines.length&&/^\|/.test(lines[i]))rows.push(lines[i++]);const cells=r=>r.replace(/^\||\|$/g,"").split("|").map(c=>inl(c.trim()));out.push("<table>"+rows.filter(r=>!/^\|\s*-/.test(r)).map((r,n)=>`<tr>${cells(r).map(c=>n?`<td>${c}</td>`:`<th>${c}</th>`).join("")}</tr>`).join("")+"</table>");continue}
|
|
1077
|
-
const li=/^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(l);if(li){const t=/^\s*\d+\./.test(l)?"ol":"ul";if(list!==t){endList();out.push(`<${t}>`);list=t}const tk=tick&&/^(open|done|rejected):\s*(.*)$/.exec(li[1]);out.push(tk?`<li class="tk${tk[1]==="rejected"?" rej":""}"><label><input type="checkbox" tabindex="-1" aria-disabled="true" onclick="return false"${tk[1]==="done"?" checked":""} aria-label="${tk[1]}"><span>${inl(tk[2])}</span></label></li>`:`<li>${inl(li[1])}</li>`);i++;continue}
|
|
1078
|
-
if(!l.trim()){endList();i++;continue}
|
|
1079
|
-
endList();const buf=[l];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))buf.push(lines[i++]);out.push(`<p>${inl(buf.join(" "))}</p>`)}
|
|
1080
|
-
endList();return out.join("")}
|
|
1081
|
-
|
|
1082
|
-
// A building run's progress.md, one `- todo|doing|done: text` line per Work-list item.
|
|
1083
|
-
// prev: the statuses last drawn for this run; an item whose status moved gets .chg.
|
|
1084
|
-
function progress(src,prev){
|
|
1085
|
-
const items=[...src.matchAll(/^\s*-\s*(todo|doing|done):\s*(.*)$/gm)].map(m=>[m[1],m[2]]);if(!items.length)return "";
|
|
1086
|
-
const n=items.filter(t=>t[0]==="done").length,pct=Math.round(n/items.length*100),ic={done:"✓",doing:'<span class="pulse"></span>',todo:""};
|
|
1087
|
-
return `<div class="prog"><div class="ph"><b>${n} of ${items.length} done</b><span>${pct}%</span></div><div class="pbar"><i style="width:${pct}%"></i></div><ol>${items.map(([st,t],i)=>`<li class="p-${st}${prev&&prev[i]&&prev[i]!==st?" chg":""}"><span class="pi">${ic[st]}</span><span>${esc(t).replace(/`([^`]+)`/g,"<code>$1</code>")}</span></li>`).join("")}</ol></div>`}
|
|
1088
|
-
|
|
1089
|
-
// ---- settings, toast, notifications, keys
|
|
1090
|
-
// Rows in config order, grouped as the TUI groups them.
|
|
1091
|
-
const groups=rows=>{const m=new Map();for(const r of rows){if(!m.has(r.group))m.set(r.group,[]);m.get(r.group).push(r)}return [...m]};
|
|
1092
|
-
// The search survives the re-render each save does; the gear button starts it empty.
|
|
1093
|
-
let setQ="";
|
|
1094
|
-
// Settings and the Icebox fill the same panel after a fetch: a fill only lands if no other view
|
|
1095
|
-
// or fill started since, or a slow answer paints one page under the other's title.
|
|
1096
|
-
let pageGen=0;
|
|
1097
|
-
async function openSettings(){
|
|
1098
|
-
if(view!=="settings")return;const gen=++pageGen;
|
|
1099
|
-
stopDictation(true);
|
|
1100
|
-
// Coming from another page or a card, its body goes now, not when the fetch answers.
|
|
1101
|
-
if(!$("panel").classList.contains("open")||$("ptitle").textContent!=="Settings"){$("pbody").innerHTML=`<p class="snone">Loading…</p>`;$("psub").textContent=""}
|
|
1102
|
-
termStop();openKey=null;runDoc=null;$("pterm").hidden=true;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
1103
|
-
const cur=document.documentElement.dataset.theme||"system";
|
|
1104
|
-
const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
|
|
1105
|
-
if(gen!==pageGen)return;
|
|
1106
|
-
$("psub").textContent="Theme applies to this browser. Everything else is saved to your kaizen settings.";$("psub").title=cfg?cfg.file:"";
|
|
1107
|
-
// One control per row: a switch for on/off, one segment per value for a small number,
|
|
1108
|
-
// a select for a choice. The row's label names the control; its help sits under it.
|
|
1109
|
-
const ctl=(id,vals,on)=>{
|
|
1110
|
-
// The preset steps like a game's quick preset: ◁ ▷ go to the neighbouring tier,
|
|
1111
|
-
// wrapping, and never to custom, which only an edit to another row makes.
|
|
1112
|
-
if(id==="s-preset"){const t=vals.filter(v=>v!=="custom"),at=t.indexOf(on),go=d=>t[at<0?(d>0?0:t.length-1):(at+d+t.length)%t.length];
|
|
1113
|
-
return `<span class="car" id="${id}" role="group" aria-label="Preset"><button id="${id}-prev" data-v="${go(-1)}" aria-label="Previous preset: ${go(-1)}" title="${go(-1)}">◁</button><span aria-live="polite">${esc(on)}<i aria-hidden="true">${t.map(v=>`<u class="${v===on?"on":""}"></u>`).join("")}</i></span><button id="${id}-next" data-v="${go(1)}" aria-label="Next preset: ${go(1)}" title="${go(1)}">▷</button></span>`}
|
|
1114
|
-
if(vals.length===2&&vals.includes("true")&&vals.includes("false")){const yes=on==="true";return `<button class="chk${yes?" on":""}" id="${id}" data-v="${yes?"false":"true"}" role="switch" aria-checked="${yes}" title="${yes?"On":"Off"}"></button>`}
|
|
1115
|
-
if(vals.every(v=>/^\d+$/.test(v)))return `<span class="bar" id="${id}" role="group">${vals.map(v=>`<button data-v="${v}" class="${v===on?"on":""}" aria-pressed="${v===on}">${v}</button>`).join("")}</span>`;
|
|
1116
|
-
return `<select class="sel" id="${id}">${vals.map(v=>`<option value="${esc(v)}"${v===on?" selected":""}>${v===""?"—":esc(v)}</option>`).join("")}</select>`};
|
|
1117
|
-
const row=(label,control,hint,id,q)=>`<div class="srow" data-q="${esc(q.toLowerCase())}"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
|
|
1118
|
-
const hadFocus=document.activeElement?.id==="sq";
|
|
1119
|
-
const np=typeof Notification==="undefined"?null:Notification.permission;
|
|
1120
|
-
const ntHelp="A desktop notification when a run starts waiting on you."+(np?np==="denied"?" Blocked in this browser's site settings.":"":" Not supported in this browser.");
|
|
1121
|
-
const names=[...(cfg?groups(cfg.rows).map(([g])=>g):[]),"Browser"];
|
|
1122
|
-
$("pbody").innerHTML=`<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${esc(setQ)}"><div class="sjump" role="group" aria-label="Jump to section">${names.map(g=>`<button data-g="${esc(g)}">${esc(g)}</button>`).join("")}</div></div>
|
|
1123
|
-
${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>{const id="s-"+r.key.replace(/\./g,"-");return row(r.label,r.text?(r.folder?`<span class="fpick"><input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`:`<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false">`):ctl(id,r.values,r.value),esc(r.help),id,[r.label,r.help,r.key,g,...r.values].join(" "))}).join("")}`).join(""):`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`}
|
|
1124
|
-
<h3 class="sg">Browser</h3>${row("Theme",ctl("th",["system","light","dark"],cur),"Light or dark for this browser only; system follows your device.","th","theme light dark system browser")}
|
|
1125
|
-
${row("Notifications",`<button class="chk${np==="granted"?" on":""}" id="nt" role="switch" aria-checked="${np==="granted"}"${np&&np!=="denied"?"":" disabled"}></button>`,ntHelp,"nt","notifications "+ntHelp+" browser")}
|
|
1126
|
-
<p class="snone" id="snone" hidden></p>`;
|
|
1127
|
-
// Hides rows that do not match, then any group (and its chip) left with none.
|
|
1128
|
-
updBar();
|
|
1129
|
-
const heads=[...$("pbody").querySelectorAll(".sg")],chips=[...$("pbody").querySelectorAll(".sjump button")];
|
|
1130
|
-
const filter=()=>{const q=setQ.trim().toLowerCase();let any=false;
|
|
1131
|
-
heads.forEach((h,i)=>{let n=h.nextElementSibling,vis=false;for(;n&&n.matches(".srow");n=n.nextElementSibling){n.hidden=!!q&&!n.dataset.q.includes(q);vis||=!n.hidden}h.hidden=!vis;if(chips[i])chips[i].hidden=!vis;any||=vis});
|
|
1132
|
-
$("snone").hidden=any;$("snone").textContent=`No setting matches “${setQ.trim()}”.`};
|
|
1133
|
-
// Scrolled by measure: the pinned bar's height changes as the chips wrap.
|
|
1134
|
-
chips.forEach((b,i)=>b.onclick=()=>{const pb=$("pbody");pb.scrollTo({top:pb.scrollTop+heads[i].getBoundingClientRect().top-pb.querySelector(".sq").getBoundingClientRect().bottom-8,behavior:"smooth"})});
|
|
1135
|
-
$("sq").oninput=e=>{setQ=e.target.value;filter()};
|
|
1136
|
-
// Esc clears a search; with nothing to clear it closes the panel, as it does outside the field.
|
|
1137
|
-
$("sq").onkeydown=e=>{if(e.key!=="Escape")return;e.preventDefault();if(setQ){setQ="";e.target.value="";filter()}else close()};
|
|
1138
|
-
filter();if(hadFocus){const f=$("sq");f.focus();f.setSelectionRange(f.value.length,f.value.length)}
|
|
1139
|
-
$("th").onchange=e=>setTheme(e.target.value);
|
|
1140
|
-
$("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
|
|
1141
|
-
if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const f=document.activeElement?.id;const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast("Saved");await openSettings();if(f?.startsWith("s-preset-"))$(f)?.focus()};
|
|
1142
|
-
if(el.tagName==="SELECT"||el.tagName==="INPUT")el.onchange=async()=>{await save(el.value.trim());if(el.tagName==="INPUT")load()};else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v);
|
|
1143
|
-
// The system's own folder dialog, opened by the board: a page cannot read a real path.
|
|
1144
|
-
if(r.folder)$(el.id+"-pick").onclick=async()=>{const j=await post("/pick-folder",{});if(j.path){await save(j.path);load()}}})}
|
|
1145
|
-
// ---- views: the board, or Settings and the Icebox as pages in the docked panel, each with its own address
|
|
1146
|
-
let view="board";
|
|
1147
|
-
const markView=v=>document.querySelectorAll(".rail a.it").forEach(a=>a.dataset.view===v?a.setAttribute("aria-current","page"):a.removeAttribute("aria-current"));
|
|
1148
|
-
function go(v,push){pageGen++;if(v==="icebox")v="sources";if(!["settings","sources"].includes(v))v="board";const was=view;view=v;markView(v);document.body.classList.toggle("paged",v!=="board");document.body.classList.toggle("ice",v==="sources");
|
|
1149
|
-
if(push&&v!==was)history.pushState(null,"",v==="board"?location.pathname:"?view="+(v==="sources"?"icebox":v));
|
|
1150
|
-
const p=$("panel");
|
|
1151
|
-
if(v==="board"){if(was==="board")return;
|
|
1152
|
-
// Undocked without the slide: it was a page, not a drawer leaving.
|
|
1153
|
-
p.style.transition="none";p.classList.remove("open","docked");p.offsetWidth;p.style.transition="";close();return}
|
|
1154
|
-
p.classList.add("docked");if(v==="settings")openSettings();else openSources()}
|
|
1155
|
-
document.querySelectorAll(".rail a[data-view]").forEach(a=>{if(a.dataset.view==="notes")return;a.onclick=e=>{if(e.ctrlKey||e.metaKey||e.shiftKey||e.button)return;e.preventDefault();if(a.dataset.view==="settings")setQ="";go(a.dataset.view,true)}});
|
|
1156
|
-
document.querySelector(".rail .home").onclick=e=>{if(e.ctrlKey||e.metaKey||e.shiftKey||e.button)return;e.preventDefault();go("board",true)};
|
|
1157
|
-
addEventListener("popstate",()=>go(new URLSearchParams(location.search).get("view")));
|
|
1158
|
-
// The menu's theme switch while Settings shows: its Theme row follows.
|
|
1159
|
-
addEventListener("kz-theme",()=>{if(view==="settings")openSettings()});
|
|
1160
|
-
// ---- the icebox: requests gathered from request sources, read like an inbox, until
|
|
1161
|
-
// someone promotes them to ideas or dismisses them. The sources themselves sit in a
|
|
1162
|
-
// section below, folded. Same panel as settings.
|
|
1163
|
-
const SRC="Icebox";
|
|
1164
|
-
// What the page keeps across the rebuild every server change brings: the item whose
|
|
1165
|
-
// promote form is open and the one the cursor is on (both "<dir> <id>"), the checked
|
|
1166
|
-
// items, the source filter ("<dir> <url>"), whether the sources section is open, and
|
|
1167
|
-
// whether a bulk action is still posting.
|
|
1168
|
-
// The category filter, and whether the add form takes a link or a social account/tag. The
|
|
1169
|
-
// project filter is the board's own `shown`, so the board and the icebox narrow alike.
|
|
1170
|
-
// The reply being written to a gathered post: its item, the text, and whether it is out.
|
|
1171
|
-
// The reply style is per project: a promo voice in one need not leak into another's drafts.
|
|
1172
|
-
let iceReply=null;const iceStyle={};
|
|
1173
|
-
const rStyle=dir=>{if(!iceStyle[dir])try{iceStyle[dir]=localStorage.getItem("kz-reply-style:"+dir)}catch(e){}return iceStyle[dir]||"casual"};
|
|
1174
|
-
const STYLES={casual:"Casual",fun:"Fun",helpful:"Helpful",promo:"Promo"};
|
|
1175
|
-
let iceForm=null,iceCur=null,iceSrc="",iceCat="",srcKind="link",srcShow=null,srcAdd=false,iceBusy=false,iceNav=null;const iceSel=new Set();
|
|
1176
|
-
const PLAT={x:"X",tiktok:"TikTok",instagram:"Instagram",threads:"Threads"};
|
|
1177
|
-
const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
|
|
1178
|
-
const ago=at=>{const m=Math.round((Date.now()-Date.parse(at))/60000);return m<1?"just now":m<60?m+" min ago":m<1440?Math.round(m/60)+" h ago":Math.round(m/1440)+" d ago"};
|
|
1179
|
-
async function openSources(){
|
|
1180
|
-
if(view!=="sources")return;const gen=++pageGen;
|
|
1181
|
-
stopDictation(true);
|
|
1182
|
-
// What is half typed survives a refresh from the server.
|
|
1183
|
-
const typed=srcOpen()?[$("src-url")?.value||"",$("src-label")?.value||"",document.activeElement?.id,$("src-dir")?.value,$("src-cat")?.value||"",$("src-plat")?.value,$("src-q")?.value||""]:["",""];
|
|
1184
|
-
// So does a project picked on a row but not yet moved.
|
|
1185
|
-
const picked=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-mv]")].map(m=>[m.dataset.k,m.value]):[]);
|
|
1186
|
-
// And a promote form half filled, for the item it was typed on.
|
|
1187
|
-
// And which control of which inbox row had focus, by item key: row indices shift when a row goes.
|
|
1188
|
-
const fRow=srcOpen()&&document.activeElement?.closest?.(".ice-row"),fPick=fRow?[fRow.dataset.k,document.activeElement.id.replace(/-\d+$/,"")]:null;
|
|
1189
|
-
const iceDraft=srcOpen()&&$("ice-title")?[$("ice-title").dataset.k,$("ice-title").value,$("ice-details").value]:null;
|
|
1190
|
-
// And a category typed on a row but not yet saved.
|
|
1191
|
-
const cats=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-ct]")].map(m=>[m.dataset.k,m.value]):[]);
|
|
1192
|
-
if(!srcOpen())$("pbody").innerHTML=`<p class="snone">Loading…</p>`;
|
|
1193
|
-
termStop();openKey=null;runDoc=null;$("pterm").hidden=true;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=SRC;$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
1194
|
-
$("psub").textContent="Requests gathered from your sources wait here until you promote them to Ideas or dismiss them; nothing starts on its own.";$("psub").title="";
|
|
1195
|
-
const dir=all?null:S.dir,q=dir?"?dir="+encodeURIComponent(dir):"";
|
|
1196
|
-
const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
|
|
1197
|
-
if(gen!==pageGen||!srcOpen())return;
|
|
1198
|
-
const host=u=>{try{return new URL(u).hostname}catch{return u}};
|
|
1199
|
-
// The favicon of the site a link is on, asked of that site itself (Google's own static host for
|
|
1200
|
-
// Sheets and Docs, whose site icon is generic): nothing but the host reaches anyone. A social
|
|
1201
|
-
// platform's mark is drawn here instead: Meta's favicons are same-origin only.
|
|
1202
|
-
const fav=(u,fb,soc)=>{if(PLAT[soc])return `<span class="fav">${ico("b-"+soc)}</span>`;let h;try{const x=new URL(u);if(/^https?:$/.test(x.protocol))h=x.origin}catch{}
|
|
1203
|
-
const src=!h?"":u.includes("docs.google.com/spreadsheets")?"https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico":u.includes("docs.google.com/document")?"https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico":h+"/favicon.ico";
|
|
1204
|
-
return `<span class="fav">${src?`<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">`:""}${ico(fb)}</span>`};
|
|
1205
|
-
const srcType=s=>{if(s.social)return "social";const u=s.url;if(u.includes("docs.google.com/spreadsheets")||u.endsWith(".csv")||u.endsWith(".tsv"))return "sheet";if(u.includes("docs.google.com/document"))return "doc";return "link"};
|
|
1206
|
-
const list=d&&d.sources||[],ice=d&&d.icebox||[],G=d&&d.gatherer||{},chatOn=G.use==="chatgpt"&&G.connected;
|
|
1207
|
-
// A source belongs to one project, named on its row. Picking another only arms Move: a select
|
|
1208
|
-
// changes on every arrow key, and each change would have moved the source. The names are the picker's;
|
|
1209
|
-
// a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
|
|
1210
|
-
const opts=(on,name)=>(on&&!S.projects.some(p=>p.dir===on)?`<option value="${esc(on)}" selected>${esc(name||on)}</option>`:"")+S.projects.map(p=>`<option value="${esc(p.dir)}"${p.dir===on?" selected":""}>${esc(p.label)}</option>`).join("");
|
|
1211
|
-
const cards=list.map((s,i)=>{
|
|
1212
|
-
const st=srcType(s),title=s.label||host(s.url);
|
|
1213
|
-
// A social source is never read by kaizen, so its not-ok is the agent's turn, not a failure.
|
|
1214
|
-
const statusHtml=s.social&&(!s.last||!s.last.ok&&/gathered by your agent/i.test(s.last.note))?`<span class="src-badge dim">${ico("at")}${chatOn?"Gathered with ChatGPT":"Gathered by your agent"}</span>`:!s.last?`<span class="src-badge dim">Not gathered yet</span>`:s.last.ok?`<span class="src-badge ok"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`:`<span class="src-badge warn"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" stroke-width="1.8"/><path d="M8 5v3.5M8 11v.1" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>Failed: ${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
|
|
1215
|
-
return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st==="social"?esc(PLAT[s.social]||"social"):st}">${fav(s.url,st==="sheet"?"sheet":st==="doc"?"doc":st==="social"?"at":"link",s.social)}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts">${s.social?`<button class="btn ghost sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn?"Read this source with your ChatGPT plan":"Open your agent to run /kaizen gather for this project"}">${ico("refresh")} Gather</button>`:`<button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button>`}<button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${esc(s.dir+" "+s.url)}" value="${esc(s.category||"")}" placeholder="e.g. Feedback" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></div><div class="src-dest"><span>Project</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir,s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
|
|
1216
|
-
}).join("");
|
|
1217
|
-
const fails=(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
|
|
1218
|
-
const emptyState=`<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into the icebox, where you choose which become ideas. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>Public links</strong>TSV, CSV, or raw text links containing requests.</div><div class="src-empty-item"><strong>Social media</strong>A keyword, #hashtag or @account on X, TikTok, Instagram or Threads, read by your agent.</div></div></div>`;
|
|
1219
|
-
const social=srcKind==="social";
|
|
1220
|
-
const addOn=!list.length||srcAdd||[typed[0],typed[1],typed[4],typed[6]].some(Boolean);
|
|
1221
|
-
const addBox=`<div class="src-add" id="src-addbox"${addOn?"":" hidden"}><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social?" hidden":""}><span>Link</span><input class="sel" id="src-url" placeholder="https://docs.google.com/spreadsheets/d/… or any public link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social?"":" hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(PLAT).map(([k,v])=>`<option value="${k}">${v}</option>`).join("")}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social?"e.g. Mentions on X":"e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social?"e.g. Social":"e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir?"":`<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="hint">${social?(chatOn?"Your ChatGPT plan reads public posts when you press Gather on the source.":"Kaizen signs in nowhere. Your agent reads public posts when you press Gather on the source."):"Public links only. For private docs, run /kaizen gather."}</span><button class="btn pri" id="src-add">Add source</button></div></div>`;
|
|
1222
|
-
// The inbox: one row per request, by project when showing all, then by category,
|
|
1223
|
-
// narrowed to one source by its chip. Checked, open and cursor items are kept by key.
|
|
1224
|
-
const srcName=it=>{const s=list.find(x=>x.dir===it.dir&&x.url===it.source);return s?s.label||host(s.url):host(it.source)};
|
|
1225
|
-
const iceKey=it=>it.dir+" "+it.id,srcKey=it=>it.dir+" "+it.source;
|
|
1226
|
-
// Filters narrow in turn: project (the board's own picker, all projects only), then
|
|
1227
|
-
// category, then source; each chip counts what the filters before it leave.
|
|
1228
|
-
const catOf=it=>it.category||"Uncategorised",count=(xs,f)=>{const m=new Map();xs.forEach(it=>{const k=f(it);m.set(k,{it,n:(m.get(k)?.n||0)+1})});return m};
|
|
1229
|
-
const byProj=dir?new Map():count(ice,it=>it.dir);if(!dir)for(const pd of shown)if(!byProj.has(pd)){const p=showable().find(x=>x.dir===pd);if(p)byProj.set(pd,{it:{dir:pd,project:p.label},n:0})}
|
|
1230
|
-
const inProj=it=>dir||!shown.size||shown.has(it.dir),pv=ice.filter(inProj);
|
|
1231
|
-
const byCat=count(pv,catOf);if(iceCat&&!byCat.has(iceCat))iceCat="";
|
|
1232
|
-
const cv=pv.filter(it=>!iceCat||catOf(it)===iceCat);
|
|
1233
|
-
const bySrc=count(cv,srcKey);if(iceSrc&&!bySrc.has(iceSrc))iceSrc="";
|
|
1234
|
-
const keys=new Set(ice.map(iceKey));
|
|
1235
|
-
if(iceForm&&!keys.has(iceForm))iceForm=null;
|
|
1236
|
-
const groups=new Map();ice.forEach((it,i)=>{if(!inProj(it)||iceCat&&catOf(it)!==iceCat||iceSrc&&srcKey(it)!==iceSrc)return;const k=catOf(it);groups.has(k)||groups.set(k,[]);groups.get(k).push(i)});
|
|
1237
|
-
// Rows in the order they are drawn, which is the order j and k walk.
|
|
1238
|
-
const order=[...groups.values()].flat();
|
|
1239
|
-
// A checked request a filter hides is unchecked: a bulk action only ever acts on what shows.
|
|
1240
|
-
{const vis=new Set(order.map(i=>iceKey(ice[i])));[...iceSel].forEach(k=>vis.has(k)||iceSel.delete(k))}
|
|
1241
|
-
if(!order.some(i=>iceKey(ice[i])===iceCur))iceCur=order.length?iceKey(ice[order[0]]):null;
|
|
1242
|
-
const nSel=order.filter(i=>iceSel.has(iceKey(ice[i]))).length;
|
|
1243
|
-
// Filters are plain dropdowns, each shown only when it has a choice to make. The project one
|
|
1244
|
-
// is the board's own picker: one project, or all; several picked on the board read as such.
|
|
1245
|
-
const many=byProj.size>1,opt=(v,on,t)=>`<option value="${esc(v)}"${on?" selected":""}>${esc(t)}</option>`;
|
|
1246
|
-
const pick=(id,label,xs)=>`<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
|
|
1247
|
-
const chips=(many?pick("ice-fp","Project",[opt("",!shown.size,"All projects"),...(shown.size>1?[opt("*",true,shown.size+" projects")]:[]),...[...byProj].map(([k,{it}])=>opt(k,shown.size===1&&shown.has(k),it.project||k))]):"")
|
|
1248
|
-
+(byCat.size>1?pick("ice-fc","Category",[opt("",!iceCat,"All categories"),...[...byCat.keys()].map(k=>opt(k,iceCat===k,k))]):"")
|
|
1249
|
-
+(bySrc.size>1?pick("ice-fs","Source",[opt("",!iceSrc,"All sources"),...[...bySrc].map(([k,{it}])=>opt(k,iceSrc===k,srcName(it)))]):"");
|
|
1250
|
-
const bar=`<div class="ice-bar">${order.length?`<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel&&nSel===order.length?" checked":""}>`:""}<span class="count">${order.length<ice.length?`${order.length} of ${ice.length}`:ice.length} waiting</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length?"":" disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow??!list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
|
|
1251
|
-
const bulk=iceSel.size?`<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`:"";
|
|
1252
|
-
// The open item's form: a clean title for the idea's card and the person's own details;
|
|
1253
|
-
// what was gathered is added to the idea's notes by the server.
|
|
1254
|
-
const form=it=>`<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details</label><textarea class="sel" id="ice-details" rows="3" placeholder="What, for whom, why" aria-label="Details"></textarea><div class="ice-form-acts"><span class="hint">Source goes into the notes.</span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
|
|
1255
|
-
// A gathered post can be answered: Reply drafts, the box is the approval, Send reply posts that text.
|
|
1256
|
-
const replyBox=it=>{if(!it.post)return"";if(it.replied)return`<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
|
|
1257
|
-
const r=iceReply&&iceReply.k===iceKey(it)?iceReply:null;
|
|
1258
|
-
const style=d=>`<select class="sel" id="ice-rst" aria-label="Reply style" title="Promo names kaizen; Helpful may mention it when it fits; Casual and Fun never do"${d?" disabled":""}>${Object.entries(STYLES).map(([v,l])=>`<option value="${v}"${v===rStyle(it.dir)?" selected":""}>${l}</option>`).join("")}</select>`;
|
|
1259
|
-
if(!r)return`<div class="ice-form-acts"><span class="hint">You review it before it posts.</span>${G.connected?style(false):""}<button class="btn sm" id="ice-rp">Reply</button></div>`;
|
|
1260
|
-
if(r.wait)return`<div class="ice-form-acts"><span class="hint">Drafting…</span></div>`;
|
|
1261
|
-
return`<div class="ice-form"><label for="ice-reply" class="hint" style="margin:0">Your reply${r.note?` · ${esc(r.note)}`:""}</label><textarea class="sel" id="ice-reply" rows="3" maxlength="2000" aria-label="Your reply"${r.busy||r.again?" disabled":""}>${esc(r.text)}</textarea><span class="hint" id="ice-rn" style="margin:0">${r.again?"Writing another…":`${r.text.length} chars`}</span><div class="ice-form-acts"><span class="end"></span>${G.connected?`${style(r.busy||r.again)}<button class="btn ghost sm" id="ice-rg"${r.busy||r.again?" disabled":""}>Regenerate</button>`:""}<button class="btn ghost sm" id="ice-rc"${r.busy?" disabled":""}>Cancel</button><button class="btn pri sm" id="ice-rs"${r.busy||r.again||!r.text.trim()?" disabled":""}>${r.busy?"Sending…":"Send reply"}</button></div></div>`};
|
|
1262
|
-
const row=i=>{const it=ice[i],k=iceKey(it),open=iceForm===k;
|
|
1263
|
-
return `<div class="ice-row${iceCur===k?" cur":""}${iceSel.has(k)?" sel":""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k)?" checked":""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source,"link",list.find(x=>x.dir===it.dir&&x.url===it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many?`<span class="nm">${esc(it.project||"")}</span>`:""}${it.replied?`<span title="Replied ${esc(ago(it.replied))}">replied</span>`:""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button><button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button></span></span></div>`
|
|
1264
|
-
+(open?`<div class="ice-open">${it.notes?`<div class="ice-notes">${esc(it.notes)}</div>`:""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post?` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>`:""}</span>${replyBox(it)}${form(it)}</div>`:"")};
|
|
1265
|
-
const inbox=ice.length&&!order.length?`<div class="ice-zero"><h4>Nothing matches</h4><p>${ice.length} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`:ice.length?`<div class="ice-list">${[...groups].map(([g,ix])=>`<div class="ice-cat">${esc(g)}<i>${ix.length}</i></div>${ix.map(row).join("")}`).join("")}</div>`
|
|
1266
|
-
:`<div class="ice-zero"><h4>Inbox zero</h4><p>Nothing waiting. Gather all checks your sources for new requests; /kaizen discover in your agent sorts what comes in.</p></div>`;
|
|
1267
|
-
// The sources, in the right-hand drawer: shut until asked for, open by itself while there
|
|
1268
|
-
// are none until closed. Slid in only when opened; a rebuild while open shows it in place.
|
|
1269
|
-
const drawer=srcShow??!list.length,slide=drawer&&!$("src-sec")?.classList.contains("open");
|
|
1270
|
-
const sec=`<div class="src-scrim${drawer&&!slide?" on":""}" id="src-scrim"></div><aside class="src-sec${drawer&&!slide?" open":""}" id="src-sec" aria-label="Sources"${drawer?"":" inert"}><header><div class="t"><b>Sources</b><div class="sub">${d&&d.every&&d.every!=="off"?`Auto-check every ${esc(d.every)}`:"Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected?`ChatGPT${G.email?" "+esc(G.email):""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>`:`<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`}</div></div><div class="src-hda">${list.length?`<button class="btn sm" id="src-new" aria-expanded="${addOn}" aria-controls="src-addbox">${ico("plus")} Add</button>`:""}<button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></div></header><div class="body">${addBox}${list.length?`<div class="src-list">${cards}</div>`:emptyState}</div></aside>`;
|
|
1271
|
-
// First visit: offer the ChatGPT plan for social sources, or keep the agent.
|
|
1272
|
-
const onb=G.use==="ask"&&!G.connected?`<div class="cg-onb"><b>Gather social posts with your ChatGPT plan</b><p>Connect ChatGPT Plus or Pro and Gather reads X, TikTok, Instagram and Threads sources right here, counted against your plan, with no agent to open. Skip it and Gather keeps using your agent. You can change this in Settings.</p><div class="acts"><button class="btn ghost" id="cg-skip">Skip for now</button><button class="btn pri" id="cg-go">Continue with ChatGPT</button></div></div>`:"";
|
|
1273
|
-
$("pbody").innerHTML=d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">The icebox could not be read.</p>`:onb+bar+bulk+fails+inbox+sec;
|
|
1274
|
-
if(!d||d.error)return;
|
|
1275
|
-
if(iceDraft&&iceDraft[0]===iceForm&&$("ice-title")){$("ice-title").value=iceDraft[1];$("ice-details").value=iceDraft[2]}
|
|
1276
|
-
if($("src-add")){if(typed[5])$("src-plat").value=typed[5];$("src-q").value=typed[6]||"";$("src-url").value=typed[0];$("src-label").value=typed[1];$("src-cat").value=typed[4]||"";if(typed[3]!==undefined&&$("src-dir"))$("src-dir").value=typed[3]}
|
|
1277
|
-
if($("src-to-settings"))$("src-to-settings").onclick=()=>{setQ="sources";go("settings",true)};
|
|
1278
|
-
// The tab is opened inside the click, before the sign-in URL comes back: a popup blocker
|
|
1279
|
-
// lets only that through. The board hears of the result through /events.
|
|
1280
|
-
const connect=async()=>{const w=window.open("","_blank");const j=await post("/chatgpt",{op:"connect"});if(j.ok&&w)w.location=j.url;else{w?.close();if(j.ok)location.href=j.url}};
|
|
1281
|
-
for(const id of ["cg-go","cg-in"])if($(id))$(id).onclick=connect;
|
|
1282
|
-
if($("cg-skip"))$("cg-skip").onclick=async()=>{if((await post("/chatgpt",{op:"skip"})).ok){toast("Gather keeps using your agent");openSources()}};
|
|
1283
|
-
if($("cg-out"))$("cg-out").onclick=async()=>{if(!confirm("Disconnect ChatGPT? Gather goes back to your agent."))return;if((await post("/chatgpt",{op:"disconnect"})).ok){toast("ChatGPT disconnected");openSources()}};
|
|
1284
|
-
if(slide){$("src-sec").offsetWidth;$("src-sec").classList.add("open");$("src-scrim").classList.add("on")}
|
|
1285
|
-
const srcShut=()=>{srcShow=false;$("src-sec").classList.remove("open");$("src-scrim").classList.remove("on");$("src-sec").inert=true;$("src-tog").setAttribute("aria-expanded","false");$("src-tog").focus()};
|
|
1286
|
-
$("src-x").onclick=$("src-scrim").onclick=srcShut;
|
|
1287
|
-
if($("src-new"))$("src-new").onclick=()=>{const b=$("src-addbox");srcAdd=b.hidden;b.hidden=!srcAdd;$("src-new").setAttribute("aria-expanded",srcAdd);if(srcAdd)$(srcKind==="social"?"src-q":"src-url").focus()};$("src-sec").onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();srcShut()}};
|
|
1288
|
-
$("src-tog").onclick=()=>{if($("src-sec").classList.contains("open"))return srcShut();srcShow=true;openSources().then(()=>srcShow&&$("src-url")?.focus())};
|
|
1289
|
-
if($("ice-fs"))$("ice-fs").onchange=e=>{iceSrc=e.target.value;openSources()};
|
|
1290
|
-
if($("ice-fc"))$("ice-fc").onchange=e=>{iceCat=e.target.value;openSources()};
|
|
1291
|
-
if($("ice-fp"))$("ice-fp").onchange=e=>{const v=e.target.value;if(v==="*")return;shown=new Set(v?[v]:[]);setShown();openSources()};
|
|
1292
|
-
if($("ice-unf"))$("ice-unf").onclick=()=>{iceCat=iceSrc="";if(!dir)shown.clear();setShown();openSources()};
|
|
1293
|
-
$("pbody").querySelectorAll("[data-kind]").forEach(b=>b.onclick=()=>{if(srcKind===b.dataset.kind)return;srcKind=b.dataset.kind;openSources().then(()=>$(srcKind==="social"?"src-q":"src-url")?.focus())});
|
|
1294
|
-
const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
|
|
1295
|
-
$("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
|
|
1296
|
-
if(to&&[...m.options].some(o=>o.value===to))m.value=to;
|
|
1297
|
-
(m.onchange=()=>{b.disabled=m.value===s.dir})();
|
|
1298
|
-
b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(`Source moved. Requests already gathered stay in ${s.project||"the old project"}.`))});
|
|
1299
|
-
// The category saves on its button or Enter, never per keystroke.
|
|
1300
|
-
$("pbody").querySelectorAll("[data-ct]").forEach(m=>{const s=list[+m.dataset.ct],b=$("src-cs-"+m.dataset.ct),was=cats.get(m.dataset.k);
|
|
1301
|
-
if(was!==undefined)m.value=was;
|
|
1302
|
-
(m.oninput=()=>{b.disabled=m.value.trim()===(s.category||"")})();
|
|
1303
|
-
m.onkeydown=e=>{if(e.key==="Enter"&&!b.disabled)b.click()};
|
|
1304
|
-
b.onclick=()=>{b.disabled=true;act({op:"category",dir:s.dir,url:s.url,category:m.value.trim()},()=>toast(m.value.trim()?`New requests from ${s.label||host(s.url)} go under ${m.value.trim()}`:"Category cleared"))}});
|
|
1305
|
-
// The acting button is disabled as it is pressed, which drops focus to the page; it goes back to the cursor row.
|
|
1306
|
-
const focusCur=()=>{if(!document.activeElement||document.activeElement===document.body)$("pbody").querySelector(".ice-row.cur .ice-t")?.focus()};
|
|
1307
|
-
// Before rows go, the cursor steps to the next row that stays, else the one before.
|
|
1308
|
-
const stepPast=gone=>{const at=order.findIndex(i=>iceKey(ice[i])===iceCur);if(at<0||!gone.has(iceCur))return;
|
|
1309
|
-
const stay=i=>!gone.has(iceKey(ice[i])),n=order.slice(at+1).find(stay)??order.slice(0,at).reverse().find(stay);iceCur=n===undefined?null:iceKey(ice[n])};
|
|
1310
|
-
const iceAct=(op,it,msg,extra={})=>(async()=>{stepPast(new Set([iceKey(it)]));const j=await post("/icebox",{op,dir:it.dir,ids:[it.id],...extra});if(j.ok){if(op==="promote"&&iceForm===iceKey(it))iceForm=null;toast(msg);load()}if(srcOpen())openSources().then(focusCur)})();
|
|
1311
|
-
// Bulk: one dismiss per project; promote one request at a time, each its own idea
|
|
1312
|
-
// titled as gathered (one promote call with several ids would merge them into one).
|
|
1313
|
-
const bulkDo=async op=>{const its=ice.filter(it=>iceSel.has(iceKey(it)));if(!its.length)return;stepPast(new Set(its.map(iceKey)));iceBusy=true;$("ice-bp").disabled=$("ice-bd").disabled=true;let ok=0;
|
|
1314
|
-
try{if(op==="dismiss")for(const dd of new Set(its.map(it=>it.dir))){const ids=its.filter(it=>it.dir===dd).map(it=>it.id);if((await post("/icebox",{op,dir:dd,ids})).ok)ok+=ids.length}
|
|
1315
|
-
else for(const it of its)if((await post("/icebox",{op,dir:it.dir,ids:[it.id]})).ok)ok++}
|
|
1316
|
-
finally{iceBusy=false}
|
|
1317
|
-
iceSel.clear();if(ok)toast(`${ok} ${op==="promote"?"promoted to Ideas":"dismissed"}${ok<its.length?`, ${its.length-ok} not`:""}`);load();if(srcOpen())openSources().then(focusCur)};
|
|
1318
|
-
if(bulk){$("ice-bp").onclick=()=>bulkDo("promote");$("ice-bd").onclick=()=>bulkDo("dismiss");$("ice-bc").onclick=()=>{iceSel.clear();openSources()}}
|
|
1319
|
-
if($("ice-all"))$("ice-all").onchange=()=>{const k=order.map(i=>iceKey(ice[i]));if(nSel===order.length)k.forEach(x=>iceSel.delete(x));else k.forEach(x=>iceSel.add(x));openSources()};
|
|
1320
|
-
if($("ice-all"))$("ice-all").indeterminate=nSel>0&&nSel<order.length;
|
|
1321
|
-
$("pbody").querySelectorAll("[data-ck]").forEach(c=>c.onchange=()=>{const k=iceKey(ice[+c.dataset.ck]);iceCur=k;c.checked?iceSel.add(k):iceSel.delete(k);openSources()});
|
|
1322
|
-
// A row opens on its title or anywhere not a control; open shows what was gathered and the promote form.
|
|
1323
|
-
const toggle=i=>{const k=iceKey(ice[i]);iceCur=k;iceForm=iceForm===k?null:k;openSources().then(()=>{const t=$("ice-title");if(t&&iceForm===k){t.focus();t.select()}else $("ice-o-"+i)?.focus()})};
|
|
1324
|
-
$("pbody").querySelectorAll(".ice-row").forEach(r=>r.onclick=e=>{if(!e.target.closest("input,button"))toggle(+r.dataset.i)});
|
|
1325
|
-
$("pbody").querySelectorAll("[data-o]").forEach(b=>b.onclick=()=>toggle(+b.dataset.o));
|
|
1326
|
-
$("pbody").querySelectorAll("[data-pr]").forEach(b=>b.onclick=()=>{b.disabled=true;iceAct("promote",ice[+b.dataset.pr],"Promoted to Ideas")});
|
|
1327
|
-
$("pbody").querySelectorAll("[data-ds]").forEach(b=>b.onclick=()=>{b.disabled=true;iceAct("dismiss",ice[+b.dataset.ds],"Dismissed")});
|
|
1328
|
-
const open1=ice.find(it=>iceKey(it)===iceForm);
|
|
1329
|
-
if($("ice-rp"))$("ice-rp").onclick=async()=>{const it=open1,k=iceKey(it);iceReply={k,text:"",wait:true};openSources();
|
|
1330
|
-
const j=await post("/reply",{op:"draft",dir:it.dir,id:it.id,style:rStyle(it.dir)});if(!iceReply||iceReply.k!==k)return;
|
|
1331
|
-
iceReply={k,text:j.text||"",note:j.ok?j.note||"":"",wait:false};openSources().then(()=>$("ice-reply")?.focus())};
|
|
1332
|
-
if($("ice-rst"))$("ice-rst").onchange=e=>{const d=open1.dir;iceStyle[d]=e.target.value;try{localStorage.setItem("kz-reply-style:"+d,iceStyle[d])}catch(e){}};
|
|
1333
|
-
// Another draft in the picked style, told to differ from the one in the box; the box keeps its text until it lands.
|
|
1334
|
-
if($("ice-rg"))$("ice-rg").onclick=async()=>{const it=open1,r=iceReply;r.again=true;openSources();
|
|
1335
|
-
const j=await post("/reply",{op:"draft",dir:it.dir,id:it.id,style:rStyle(it.dir),avoid:r.text});if(iceReply!==r)return;
|
|
1336
|
-
r.again=false;if(j.text)r.text=j.text;r.note=j.ok?j.note||"":"";openSources().then(()=>$("ice-reply")?.focus())};
|
|
1337
|
-
if($("ice-reply")){const ta=$("ice-reply");ta.oninput=()=>{iceReply.text=ta.value;$("ice-rn").textContent=`${ta.value.length} chars`;$("ice-rs").disabled=!ta.value.trim()};
|
|
1338
|
-
ta.onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();$("ice-rc").click()}};
|
|
1339
|
-
$("ice-rc").onclick=()=>{iceReply=null;openSources()};
|
|
1340
|
-
$("ice-rs").onclick=async()=>{const it=open1,r=iceReply;r.busy=true;openSources();
|
|
1341
|
-
const j=await post("/reply",{op:"send",dir:it.dir,id:it.id,text:r.text});
|
|
1342
|
-
if(j.ok){iceReply=null;toast("Reply posted")}else if(j.sent)iceReply=null;else r.busy=false;
|
|
1343
|
-
load();if(srcOpen())openSources()}}
|
|
1344
|
-
|
|
1345
|
-
if($("ice-ok")){
|
|
1346
|
-
const ok=()=>{const title=$("ice-title").value.trim();if(!title){$("ice-title").focus();return}$("ice-ok").disabled=true;iceAct("promote",open1,"Promoted to Ideas",{text:title,details:$("ice-details").value})};
|
|
1347
|
-
$("ice-ok").onclick=ok;
|
|
1348
|
-
$("ice-cancel").onclick=()=>{iceForm=null;openSources()};
|
|
1349
|
-
$("ice-title").onkeydown=e=>{if(e.key==="Enter")ok();if(e.key==="Escape")$("ice-cancel").click()};
|
|
1350
|
-
$("ice-details").onkeydown=e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey))ok();if(e.key==="Escape")$("ice-cancel").click()};
|
|
1351
|
-
}
|
|
1352
|
-
// Keys, read by the page's keydown while the icebox shows. True when the key was used.
|
|
1353
|
-
iceNav=e=>{if(e.altKey||e.ctrlKey||e.metaKey)return false;
|
|
1354
|
-
const k=e.key,at=order.findIndex(i=>iceKey(ice[i])===iceCur),i=order[at];
|
|
1355
|
-
if(k==="Escape"){if($("src-sec")?.classList.contains("open")){srcShut();return true}if(iceForm){iceForm=null;openSources();return true}if(iceSel.size){iceSel.clear();openSources();return true}return false}
|
|
1356
|
-
if(at<0)return false;
|
|
1357
|
-
if(k==="j"||k==="k"){e.preventDefault();const n=order[Math.max(0,Math.min(order.length-1,at+(k==="j"?1:-1)))];iceCur=iceKey(ice[n]);
|
|
1358
|
-
$("pbody").querySelectorAll(".ice-row").forEach(r=>r.classList.toggle("cur",+r.dataset.i===n));$("pbody").querySelector(`.ice-row[data-i="${n}"]`)?.scrollIntoView({block:"nearest"});return true}
|
|
1359
|
-
const b=k==="o"?$("ice-o-"+i):k==="x"?$("ice-ck-"+i):k==="p"?(iceSel.size?$("ice-bp"):$("ice-pr-"+i)):k==="d"?(iceSel.size?$("ice-bd"):$("ice-ds-"+i)):null;
|
|
1360
|
-
if(!b||b.disabled)return false;e.preventDefault();b.click();return true};
|
|
1361
|
-
$("pbody").querySelectorAll("[data-chk]").forEach(b=>{const s=list[+b.dataset.chk];
|
|
1362
|
-
b.onclick=()=>{b.disabled=true;b.innerHTML=ico("refresh")+" Checking…";
|
|
1363
|
-
act({op:"gather",dir:s.dir,url:s.url},j=>{const r=j.results&&j.results[0];
|
|
1364
|
-
toast(r?(r.ok?`${s.label||host(s.url)}: ${r.note}${r.added?" in the icebox":""}`:`Failed: ${r.note}`):"Checked")})}});
|
|
1365
|
-
// A social source is read by the agent, so Gather opens one on /kaizen gather.
|
|
1366
|
-
$("pbody").querySelectorAll("[data-ag]").forEach(b=>{const s=list[+b.dataset.ag];
|
|
1367
|
-
b.onclick=()=>{b.disabled=true;if(chatOn)b.innerHTML=ico("refresh")+" Gathering…";
|
|
1368
|
-
act({op:"agent",dir:s.dir,url:s.url},j=>{const r=j.results&&j.results[0];toast(j.via==="chatgpt"?(r&&r.ok?`${s.label||host(s.url)}: ${r.note}${r.added?" in the icebox":""}`:`Failed: ${r?r.note:"no answer"}`):"/kaizen gather opened in your terminal: "+via(j.agent))})}});
|
|
1369
|
-
$("pbody").querySelectorAll("[data-rm]").forEach(b=>{const s=list[+b.dataset.rm];
|
|
1370
|
-
b.onclick=()=>{if(!confirm(`Remove ${s.label||host(s.url)}? What it gathered stays; added again later, it may bring back ones you deleted or dismissed.`))return;
|
|
1371
|
-
act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))}});
|
|
1372
|
-
// A row that went takes focus to the cursor row's title, never to whatever now holds its index.
|
|
1373
|
-
const ci=ice.findIndex(it=>iceKey(it)===iceCur);
|
|
1374
|
-
if(fPick){const at=ice.findIndex(it=>iceKey(it)===fPick[0]);(at>=0?$(fPick[1]+"-"+at):ci>=0?$("ice-o-"+ci):null)?.focus()}
|
|
1375
|
-
const was=!fPick&&typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-").replace("src-cs-","src-ct-")):was)?.focus();
|
|
1376
|
-
if($("src-add")){
|
|
1377
|
-
// A social source is a platform and what to follow; the server builds its link, and
|
|
1378
|
-
// nothing is gathered here: the agent reads it.
|
|
1379
|
-
$("src-add").onclick=()=>{const social=srcKind==="social",url=$("src-url").value.trim(),q=$("src-q").value.trim(),plat=$("src-plat").value;
|
|
1380
|
-
if(social?!q:!url){$(social?"src-q":"src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}
|
|
1381
|
-
$("src-add").disabled=true;$("src-add").textContent="Adding…";
|
|
1382
|
-
(async()=>{const label=$("src-label").value.trim()||(social?`${PLAT[plat]}: ${q}`:""),category=$("src-cat").value.trim();
|
|
1383
|
-
const j=await post("/sources",social?{op:"add",dir:to,platform:plat,query:q,label,category}:{op:"add",dir:to,url,label,category});
|
|
1384
|
-
if(j.ok&&social){$("src-q").value=$("src-label").value=$("src-cat").value="";toast("Source added. Click Gather on it to have your agent read its posts.");load()}
|
|
1385
|
-
else if(j.ok){$("src-url").value=$("src-label").value=$("src-cat").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
|
|
1386
|
-
toast(r?(r.ok?`Source added: ${r.note} in the icebox`:`Source added, but: ${r.note}`):"Source added");load()}
|
|
1387
|
-
else toast(j.error||"Could not add source");
|
|
1388
|
-
if(j.ok)srcAdd=false;if(srcOpen())openSources()})()};
|
|
1389
|
-
$("src-url").onkeydown=$("src-q").onkeydown=$("src-label").onkeydown=$("src-cat").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
|
|
1390
|
-
}
|
|
1391
|
-
if($("src-go"))$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").innerHTML=ico("refresh")+" Gathering…";
|
|
1392
|
-
act({op:"gather",dir:dir||undefined},j=>{const rs=j.results||[],soc=r=>list.some(s=>s.url===r.url&&s.social),n=rs.reduce((a,r)=>a+r.added,0),bad=rs.filter(r=>!r.ok&&!soc(r)).length,ag=rs.filter(soc).length;
|
|
1393
|
-
toast((n?`${n} new in the icebox`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:"")+(ag?`; ${ag} social for /kaizen gather`:""))})}}
|
|
1394
|
-
// With `report`, the toast carries a link to a prefilled GitHub bug form (/issue) and
|
|
1395
|
-
// stays long enough to click it.
|
|
1396
|
-
let tt;function toast(s,report){const t=$("toast");t.textContent=s;t.classList.toggle("rep",!!report);if(report){const a=document.createElement("a");a.href="/issue?"+new URLSearchParams(report);a.target="_blank";a.rel="noopener";a.textContent="Report";t.append(" ",a)}t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),report?8000:4000)}
|
|
1397
|
-
function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
|
|
1398
|
-
function notifyUser(body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification("kaizen",{body,icon:"/logo.png"})}catch(e){}}
|
|
1399
|
-
// The project picker: a button shaped like a select that opens a search field over the list.
|
|
1400
|
-
let PL=[];
|
|
1401
|
-
function list(){const q=$("projq").value.trim().toLowerCase();let g=0;
|
|
1402
|
-
$("projs").innerHTML=PL.map((p,i)=>p[0].toLowerCase().includes(q)?(p[2]&&!g++?`<div class="grp">In your projects folder</div>`:"")+`<button role="option" data-i="${i}" title="${esc(p[0])}">${esc(p[0])}</button>`:"").join("")||`<div class="grp">No match</div>`;
|
|
1403
|
-
const f=$("projs").querySelector("button");if(f)f.classList.add("on")}
|
|
1404
|
-
function pop(on){$("projpop").hidden=!on;$("proj").setAttribute("aria-expanded",on);if(on){$("projq").value="";list();$("projq").focus()}}
|
|
1405
|
-
$("proj").onclick=()=>pop($("projpop").hidden);
|
|
1406
|
-
$("projq").oninput=list;
|
|
1407
|
-
$("projq").onkeydown=e=>{const bs=[...$("projs").querySelectorAll("button")],i=bs.findIndex(b=>b.classList.contains("on"));
|
|
1408
|
-
if(e.key==="Escape"){pop(false);$("proj").focus()}
|
|
1409
|
-
else if(e.key==="Enter"){if(bs[i])bs[i].click()}
|
|
1410
|
-
else if((e.key==="ArrowDown"||e.key==="ArrowUp")&&bs[i]){e.preventDefault();const n=bs[(i+(e.key==="ArrowDown"?1:bs.length-1))%bs.length];bs[i].classList.remove("on");n.classList.add("on");n.scrollIntoView({block:"nearest"})}};
|
|
1411
|
-
$("projs").onclick=e=>{const b=e.target.closest("button");if(b){pop(false);pick(PL[b.dataset.i][1])}};
|
|
1412
|
-
document.addEventListener("click",e=>{if(!e.target.closest("#combo"))pop(false)});
|
|
1413
|
-
async function pick(v){
|
|
1414
|
-
if(v==="__find"){toast("Searching…");const j=await post("/find",{});if(j.ok)toast(j.added?`Found ${j.added} new project${j.added===1?"":"s"}`:"No new projects found")}
|
|
1415
|
-
else if(v==="all"){all=true;keep()}
|
|
1416
|
-
else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
|
|
1417
|
-
await load();if(view==="sources")openSources()}
|
|
1418
|
-
// The all-projects filter: tick the projects to show; none ticked shows every one. Kept per browser.
|
|
1419
|
-
let shown=new Set((()=>{try{return JSON.parse(localStorage.getItem("kz-show"))||[]}catch(e){return[]}})());
|
|
1420
|
-
// Known projects, plus any folder a card comes from that is not one (the global inbox).
|
|
1421
|
-
// New idea in All projects: the projects being shown, else every one.
|
|
1422
|
-
let newDir=null;
|
|
1423
|
-
// Same combo as the header's project picker: a button, then a searchable list.
|
|
1424
|
-
function ideaCombo(f){const ps=ideaDirs(),btn=f.querySelector("#newdir"),pp=f.querySelector("#ndc .menu-pop"),q=pp.querySelector("input"),box=f.querySelector("#nds");
|
|
1425
|
-
if(!ps.some(p=>p.dir===newDir))newDir=ps[0]?.dir??null;
|
|
1426
|
-
const name=()=>{btn.textContent=(ps.find(p=>p.dir===newDir)||{label:"Choose project"}).label};
|
|
1427
|
-
const draw=()=>{const t=q.value.trim().toLowerCase();box.innerHTML=ps.filter(p=>p.label.toLowerCase().includes(t)).map(p=>`<button type="button" role="option" data-d="${esc(p.dir)}" title="${esc(p.label)}" class="${p.dir===newDir?"on":""}">${esc(p.label)}</button>`).join("")||`<div class="grp">No match</div>`;
|
|
1428
|
-
box.querySelectorAll("button").forEach(b=>b.onclick=()=>{newDir=b.dataset.d;name();open(false);f.querySelector("#newidea").focus()})};
|
|
1429
|
-
const open=on=>{pp.hidden=!on;btn.setAttribute("aria-expanded",on);if(on){q.value="";draw();q.focus()}};
|
|
1430
|
-
btn.onclick=e=>{e.preventDefault();open(pp.hidden)};q.oninput=draw;q.onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();open(false)}if(e.key==="Enter"){e.preventDefault();box.querySelector("button")?.click()}};
|
|
1431
|
-
name()}
|
|
1432
|
-
function ideaDirs(){const sp=showable(),pick=sp.filter(p=>shown.has(p.dir));return pick.length?pick:sp}
|
|
1433
|
-
function showable(){const l=[...S.projects];for(const c of S.cards)if(!l.some(p=>p.dir===c.state))l.push({dir:c.state,label:c.where});return l}
|
|
1434
|
-
let SP=[];
|
|
1435
|
-
function showList(){const q=$("showq").value.trim().toLowerCase();SP=showable();
|
|
1436
|
-
$("shows").innerHTML=SP.map((p,i)=>p.label.toLowerCase().includes(q)?`<label title="${esc(p.label)}"><input type="checkbox" data-i="${i}" ${shown.has(p.dir)?"checked":""}>${esc(p.label)}</label>`:"").join("")||`<div class="grp">No match</div>`}
|
|
1437
|
-
function setShown(){try{localStorage.setItem("kz-show",JSON.stringify([...shown]))}catch(e){}render()}
|
|
1438
|
-
$("show").onclick=()=>{const on=$("showpop").hidden;$("showpop").hidden=!on;$("show").setAttribute("aria-expanded",on);if(on){$("showq").value="";showList();$("showq").focus()}};
|
|
1439
|
-
$("showq").oninput=showList;
|
|
1440
|
-
$("showq").onkeydown=e=>{if(e.key==="Escape"){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false);$("show").focus()}};
|
|
1441
|
-
// render() redraws the list; the ticked box keeps keyboard focus.
|
|
1442
|
-
$("shows").onchange=e=>{const i=e.target.dataset.i,d=SP[i]?.dir;if(!d)return;e.target.checked?shown.add(d):shown.delete(d);setShown();$("shows").querySelector(`[data-i="${i}"]`)?.focus()};
|
|
1443
|
-
$("showall").onclick=()=>{shown.clear();setShown()};
|
|
1444
|
-
document.addEventListener("click",e=>{if(!e.target.closest("#showc")){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false)}});
|
|
1445
|
-
$("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
|
|
1446
|
-
// A newer kaizen on npm: a dot on Settings, and an Update bar at the top of that page.
|
|
1447
|
-
// The server upgrades and starts a board on the new version, which replaces this one;
|
|
1448
|
-
// the hello with its new boot id reloads the page (listen()).
|
|
1449
|
-
let upd=null;
|
|
1450
|
-
fetch("/version").then(r=>r.json()).then(v=>{if(!v.update)return;upd=v;const a=document.querySelector('.rail a[data-view="settings"]');a.classList.add("upd");a.title=`Settings: kaizen ${v.latest} is available`;if(view==="settings")openSettings()}).catch(()=>{});
|
|
1451
|
-
function updBar(){if(!upd)return;const d=document.createElement("div");d.className="updbar";d.innerHTML=`<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><button class="btn pri" id="upd-go">Update kaizen</button>`;$("pbody").prepend(d);
|
|
1452
|
-
const b=$("upd-go");b.onclick=async()=>{if(!confirm(`Update kaizen to ${upd.latest}? The board restarts, and agents running inside cards stop.`))return;b.disabled=true;b.textContent="Updating…";
|
|
1453
|
-
const r=await post("/upgrade",{});if(!r.ok){b.disabled=false;b.textContent="Update kaizen";return}b.textContent="Restarting…";setTimeout(()=>location.reload(),20000)}}
|
|
1454
|
-
$("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
|
|
1455
|
-
document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
|
|
1456
|
-
document.onkeydown=e=>{
|
|
1457
|
-
// The icebox reads its own keys first; a checkbox there keeps them, for x then j.
|
|
1458
|
-
if(view==="sources"&&iceNav&&srcOpen()&&!$("help").classList.contains("on")&&!e.target.matches("input:not([type=checkbox]),select,textarea,[contenteditable]")&&!e.target.closest("#src-sec")&&iceNav(e))return;
|
|
1459
|
-
if(e.target.matches("input,select,textarea,[contenteditable]"))return;
|
|
1460
|
-
if(e.key==="?"){$("help").classList.toggle("on");return}
|
|
1461
|
-
if(e.key==="/"&&$("sq")&&$("panel").classList.contains("open")){e.preventDefault();$("sq").focus();return}
|
|
1462
|
-
if(e.key==="Escape"){$("help").classList.remove("on");if(mode){mode=null;render()}else close();return}
|
|
1463
|
-
if(e.key==="n"){if(view!=="board")go("board",true);mode="new";render()}
|
|
1464
|
-
if(e.key==="a"){all=!all;keep(all?null:S.dir);load()}
|
|
1465
|
-
const mv={ArrowUp:[0,-1],k:[0,-1],ArrowDown:[0,1],j:[0,1],ArrowLeft:[-1,0],h:[-1,0],ArrowRight:[1,0],l:[1,0]}[e.key];
|
|
1466
|
-
// In a run's panel: left/right between its tabs, up/down scroll it. Other panels keep the browser's arrows.
|
|
1467
|
-
const rc=mv&&openKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&S.cards.find(c=>key(c)===openKey&&c.kind==="run");
|
|
1468
|
-
if(rc&&$("ptabs").children.length){e.preventDefault();if(mv[0]){const ks=[...$("ptabs").children].map(b=>b.dataset.k).concat($("pterm").hidden?[]:["term"]),i=ks.indexOf(tab),t=ks[i<0?(mv[0]>0?0:ks.length-1):Math.max(0,Math.min(ks.length-1,i+mv[0]))];if(t!==tab){tab=t;openRun(rc,true)}}else $("pbody").scrollBy({top:mv[1]*40});return}
|
|
1469
|
-
if(mv&&!$("panel").classList.contains("open")&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.target===document.body||e.target.matches("#board .card"))){e.preventDefault();nav(...mv);return}
|
|
1470
|
-
// The open card, else the focused one.
|
|
1471
|
-
const ok=openKey||focusedKey(),oc=ok&&S.cards.find(c=>key(c)===ok);if(!oc)return;
|
|
1472
|
-
if(e.key==="e"&&oc.kind==="idea"){close();mode="edit";modeKey=key(oc);render()}
|
|
1473
|
-
if(e.key==="x"&&!["done","abandoned","starting"].includes(oc.status)){close();mode="reject";modeKey=key(oc);render()}
|
|
1474
|
-
if(e.key==="r"&&oc.kind==="idea"){close();mode="run";modeKey=key(oc);render()}};
|
|
1475
|
-
|
|
1476
|
-
// ---- live
|
|
1477
|
-
let boot=null;
|
|
1478
|
-
// Back from a terminal: what was committed or pushed there touched nothing the server watches.
|
|
1479
|
-
addEventListener("focus",()=>load(true));
|
|
1480
|
-
function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!iceBusy&&!$("src-go")?.disabled&&!$("src-add")?.disabled)openSources()}else if(ev.data==="reload")location.reload();else if(ev.data.startsWith("hello ")){const b=ev.data.slice(6);if(boot&&b!==boot)location.reload();boot=b}};es.onerror=()=>{es.close();setTimeout(listen,3000)}}
|
|
1481
|
-
// Back to the project picked last time, unless the URL says which view it wants. A
|
|
1482
|
-
// plain fetch: a project that is gone should fall back to all, not raise a toast.
|
|
1483
|
-
// A page named in the address shows from the first paint; it is filled once the board has loaded.
|
|
1484
|
-
const v0=(v=>v==="icebox"?"sources":v)(new URLSearchParams(location.search).get("view"));markView(["settings","sources"].includes(v0)?v0:"board");document.body.classList.toggle("paged",["settings","sources"].includes(v0));document.body.classList.toggle("ice",v0==="sources");
|
|
1485
|
-
(async()=>{let d;try{d=localStorage.getItem("kz-proj")}catch(e){}
|
|
1486
|
-
if(d&&!urlAll)try{if((await fetch("/cd",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({dir:d})})).ok)all=false;else keep()}catch(e){}
|
|
1487
|
-
try{const f=JSON.parse(sessionStorage.getItem("kz-form"));if(f)[mode,modeKey]=f}catch(e){}
|
|
1488
|
-
load().then(()=>{go(v0);deep()});listen()})();
|
|
1489
515
|
</script>
|
|
516
|
+
<!-- The board itself: web/board/*.ts, bundled by `bun run build:web`. -->
|
|
517
|
+
<script src="/board.js"></script>
|
|
1490
518
|
</body>
|
|
1491
519
|
</html>
|