kaizen-agent 1.9.4 → 1.10.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 +11 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +48 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +46 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +90 -35
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +214 -2
- package/cli/state.ts +211 -14
- package/cli/web.ts +70 -14
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +1 -0
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +214 -49
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
package/web/board.html
CHANGED
|
@@ -108,7 +108,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
|
|
|
108
108
|
.chip.accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}.chip.accent i{background:var(--accent)}
|
|
109
109
|
.chip.ok{color:color-mix(in srgb,var(--ok) 80%,var(--ink));background:color-mix(in srgb,var(--ok) 14%,transparent)}
|
|
110
110
|
.chip.run{color:var(--run);background:color-mix(in srgb,var(--run) 14%,transparent)}.chip.run i{background:var(--run)}
|
|
111
|
-
.st.d-idea{color:var(--dim)}.st.d-run{color:var(--run)}.st.d-stall{color:var(--dim)}.st.d-wait{color:var(--warn)}.st.d-done{color:var(--ok)}.st.d-aband{color:var(--dim)}
|
|
111
|
+
.st.d-idea{color:var(--dim)}.st.d-start{color:var(--run)}.st.d-run{color:var(--run)}.st.d-stall{color:var(--dim)}.st.d-wait{color:var(--warn)}.st.d-done{color:var(--ok)}.st.d-aband{color:var(--dim)}
|
|
112
112
|
.d-run{color:var(--run)}.d-wait{color:var(--warn)}.d-done{color:var(--ok)}
|
|
113
113
|
.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;vertical-align:middle}
|
|
114
114
|
.d-run .pulse,.st.d-run svg{animation:pl 1.6s infinite}
|
|
@@ -136,6 +136,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
|
|
|
136
136
|
.notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
|
|
137
137
|
.inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
|
|
138
138
|
.inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
|
|
139
|
+
.inl.qa{max-height:60vh;overflow:auto}.inl.qa fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:4px}.inl.qa legend{font-size:13px;font-weight:500;padding:0;margin-bottom:4px}.inl.qa label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.qa label input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
|
|
139
140
|
.inl .q{font-size:14px;font-weight:500}.inl .q.dim{color:var(--dim);font-size:13px;font-weight:400}
|
|
140
141
|
.empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:12px;padding-block:60px;color:var(--dim);text-align:center}
|
|
141
142
|
.empty b{font-size:16px;font-weight:600;color:var(--ink)}.empty p{margin:0 0 6px;max-width:44ch}
|
|
@@ -164,11 +165,30 @@ aside nav button.on{color:var(--ink);box-shadow:inset 0 -2px var(--accent)}
|
|
|
164
165
|
aside .body{padding:24px;overflow:auto;flex:1}
|
|
165
166
|
aside .foot{padding:16px 24px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
166
167
|
aside .foot:empty{display:none}
|
|
168
|
+
/* The footer's message takes its own line; housekeeping sits left, the decision right. */
|
|
169
|
+
aside .foot .note{flex-basis:100%;color:var(--dim);font-size:12px;line-height:1.5}
|
|
170
|
+
/* Kept together and right-aligned even when it wraps under the rest. */
|
|
171
|
+
aside .foot .dec{margin-left:auto;display:flex;gap:8px}
|
|
172
|
+
/* Housekeeping folded away while a decision is on screen; opens upward from the footer. */
|
|
173
|
+
aside .foot .more{position:relative}.more>summary{list-style:none;cursor:pointer}.more>summary::-webkit-details-marker{display:none}
|
|
174
|
+
aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.menu-pop button:disabled{color:var(--dim);cursor:default;background:none}
|
|
167
175
|
.md h3{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:20px 0 8px}.md h3:first-child{margin-top:0}
|
|
168
176
|
.md p{margin:0 0 10px;max-width:62ch}.md ol,.md ul{display:block;padding:0 0 0 1.4em;margin:0 0 10px;list-style:revert}.md li{margin:3px 0}
|
|
169
177
|
.md .lnk a{color:var(--accent);text-underline-offset:3px}
|
|
170
178
|
.md .hit{outline:2px solid var(--accent);outline-offset:3px;border-radius:var(--r-s);transition:outline-color .6s}
|
|
171
179
|
.md li.tk{list-style:none;margin-left:-1.4em}.md li.tk label{display:flex;gap:8px;align-items:baseline}.md li.tk input{flex:none;margin:0;accent-color:var(--accent);position:relative;top:2px;cursor:default}.md li.rej{color:var(--dim);text-decoration:line-through}
|
|
180
|
+
/* A building run's progress.md: the work list as done, working on, to do. Only a
|
|
181
|
+
changed item animates (.chg); the bar slides from its last width. */
|
|
182
|
+
.prog{margin:0 0 18px}.prog .ph{display:flex;justify-content:space-between;font-size:13px;color:var(--dim);margin-bottom:6px}.prog .ph b{color:var(--ink)}
|
|
183
|
+
.pbar{height:6px;border-radius:3px;background:var(--line);overflow:hidden}.pbar i{display:block;height:100%;background:var(--accent);border-radius:3px;transition:width .26s cubic-bezier(.22,.61,.36,1)}
|
|
184
|
+
.md .prog ol{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:2px}
|
|
185
|
+
.prog li{display:flex;gap:10px;align-items:baseline;margin:0;padding:6px 8px;border-radius:var(--r-s);transition:background .12s,color .12s}
|
|
186
|
+
.prog .pi{flex:none;width:16px;height:16px;border-radius:50%;display:inline-grid;place-items:center;font-size:11px;position:relative;top:3px}
|
|
187
|
+
.prog .p-done{color:var(--dim)}.prog .p-done .pi{background:var(--ok);color:var(--bg-2)}
|
|
188
|
+
.prog .p-doing{background:color-mix(in srgb,var(--accent) 10%,transparent);font-weight:600}.prog .p-doing .pi{color:var(--accent);border:1.5px solid currentColor}.prog .p-doing .pulse{animation:pl 1.6s infinite}
|
|
189
|
+
.prog .p-todo{color:var(--dim)}.prog .p-todo .pi{border:1.5px solid var(--line)}
|
|
190
|
+
.prog li.chg{animation:fi .26s}.prog li.chg .pi{animation:pop .26s cubic-bezier(.22,.61,.36,1)}
|
|
191
|
+
@keyframes pop{from{transform:scale(.4)}}
|
|
172
192
|
.md pre{background:var(--bg);border:1px solid var(--line);border-radius:var(--r-s);padding:10px 12px;overflow-x:auto;margin:0 0 10px;font:12px/1.5 var(--mono)}
|
|
173
193
|
.md h1,.md h2{font-size:15px;font-weight:600;margin:20px 0 8px}.md h1:first-child,.md h2:first-child{margin-top:0}.md code{font:12px var(--mono);background:var(--bg);padding:1px 5px;border-radius:var(--r-s)}.md table{border-collapse:collapse;font-size:12px;margin:0 0 10px}.md td,.md th{border:1px solid var(--line);padding:4px 8px;text-align:left}.md hr{border:0;border-top:1px solid var(--line);margin:14px 0}
|
|
174
194
|
.find{border-bottom:1px solid var(--line);padding:10px 0;display:flex;gap:10px;align-items:flex-start}
|
|
@@ -182,6 +202,11 @@ aside .foot:empty{display:none}
|
|
|
182
202
|
.srow>:nth-child(2){grid-column:2;grid-row:1/span 2;align-self:center;justify-self:end}
|
|
183
203
|
.srow .hint{grid-column:1;margin:0;font-size:12px;color:var(--dim);line-height:1.45;max-width:46ch}
|
|
184
204
|
.srow input.sel{width:170px}
|
|
205
|
+
.srow[hidden],.sg[hidden],.sjump button[hidden]{display:none}
|
|
206
|
+
/* search and section chips: pinned to the top of the panel while the rows scroll under it */
|
|
207
|
+
.sq{position:sticky;top:-24px;z-index:1;background:var(--bg-2);padding:24px 0 12px;margin:-24px 0 4px;border-bottom:1px solid var(--line)}.sq input{width:100%;max-width:none}.sq+.sg{margin-top:16px}
|
|
208
|
+
.sjump{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.sjump button{padding:3px 10px;border:1px solid var(--line);border-radius:var(--r-pill);font-size:12px;color:var(--dim)}.sjump button:hover{background:var(--hover);color:var(--ink)}
|
|
209
|
+
.snone{color:var(--dim);margin-top:20px}
|
|
185
210
|
/* a switch: the same <button role="switch"> the save wiring binds to, drawn as a track and a thumb */
|
|
186
211
|
.chk{width:36px;height:20px;flex-shrink:0;border-radius:var(--r-pill);background:color-mix(in srgb,var(--dim) 40%,transparent);position:relative;transition:background .12s}
|
|
187
212
|
.chk::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--on-accent);box-shadow:var(--shadow-1);transition:transform .12s}
|
|
@@ -190,6 +215,11 @@ aside .foot:empty{display:none}
|
|
|
190
215
|
.bar{display:inline-flex;border:1px solid var(--line);border-radius:var(--r-s);overflow:hidden}
|
|
191
216
|
.bar button{min-width:30px;height:28px;font-size:13px;color:var(--dim);font-variant-numeric:tabular-nums}.bar button+button{border-left:1px solid var(--line)}
|
|
192
217
|
.bar button:hover{background:var(--hover);color:var(--ink)}.bar button.on,.bar button.on:hover{background:var(--accent);color:var(--on-accent)}.bar button:focus-visible{outline-offset:-2px;outline-color:var(--ink)}
|
|
218
|
+
/* the preset: a game's quick-preset carousel — arrows either side, the tier lit underneath */
|
|
219
|
+
.car{display:inline-grid;grid-template-columns:32px 96px 32px;align-items:center;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg-2)}
|
|
220
|
+
.car button{height:32px;color:var(--dim);font-size:12px}.car button:hover{background:var(--hover);color:var(--accent)}.car button:focus-visible{outline-offset:-2px}
|
|
221
|
+
.car span{display:flex;flex-direction:column;align-items:center;gap:3px;font-weight:600;text-transform:capitalize}
|
|
222
|
+
.car i{display:flex;gap:3px}.car u{width:14px;height:3px;border-radius:2px;background:var(--line)}.car u.on{background:var(--accent)}
|
|
193
223
|
.toast{position:fixed;z-index:12;left:28px;bottom:56px;max-width:calc(100% - 56px);background:var(--ink);color:var(--bg);border-radius:var(--r);padding:10px 16px;font-size:13px;box-shadow:var(--shadow-2);opacity:0;transition:opacity .2s;pointer-events:none}
|
|
194
224
|
.toast.on{opacity:1}
|
|
195
225
|
.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)}
|
|
@@ -214,6 +244,7 @@ aside .foot:empty{display:none}
|
|
|
214
244
|
<symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
|
|
215
245
|
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="19" cy="12" r="1.2" fill="currentColor"/></symbol>
|
|
216
246
|
<symbol id="s-idea" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/></symbol>
|
|
247
|
+
<symbol id="s-start" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/><path d="M6.5 5.5v5l4-2.5z" fill="currentColor" stroke="none"/></symbol>
|
|
217
248
|
<symbol id="s-run" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none"/></symbol>
|
|
218
249
|
<symbol id="s-stall" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none" opacity=".35"/></symbol>
|
|
219
250
|
<symbol id="s-wait" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 5v3.5M8 11v.1"/></symbol>
|
|
@@ -258,7 +289,7 @@ aside .foot:empty{display:none}
|
|
|
258
289
|
<div class="toast" id="toast" role="status"></div>
|
|
259
290
|
<div class="help" id="help"><div>
|
|
260
291
|
Optional. Everything here is also a button.<br>
|
|
261
|
-
<kbd>n</kbd>new idea<br><kbd>e</kbd>edit<br><kbd>x</kbd>reject / abandon<br><kbd>r</kbd>run<br><kbd>a</kbd>all projects<br><kbd>Esc</kbd>close<br><kbd>?</kbd>this list</div></div>
|
|
292
|
+
<kbd>←↓↑→</kbd><kbd>hjkl</kbd>move between cards<br><kbd>Enter</kbd>open<br><kbd>←→</kbd><kbd>hl</kbd>tabs, in an open run<br><kbd>↑↓</kbd><kbd>jk</kbd>scroll, in an open run<br><kbd>n</kbd>new idea<br><kbd>e</kbd>edit<br><kbd>x</kbd>reject / abandon<br><kbd>r</kbd>run<br><kbd>a</kbd>all projects<br><kbd>Esc</kbd>close<br><kbd>?</kbd>this list</div></div>
|
|
262
293
|
|
|
263
294
|
<script>
|
|
264
295
|
// The page never touches the filesystem. Everything comes from /state and the run
|
|
@@ -374,25 +405,38 @@ function draft(el,k){try{const v=sessionStorage.getItem(DK+k);if(v!=null)el.valu
|
|
|
374
405
|
function undraft(p){try{Object.keys(sessionStorage).forEach(k=>{if(k.startsWith(DK+p))sessionStorage.removeItem(k)})}catch(e){}}
|
|
375
406
|
// quiet: the server said something changed, nobody asked for a redraw.
|
|
376
407
|
async function load(quiet){
|
|
377
|
-
let r;try{r=await fetch("/state"+(all?"?all=1":""))}catch{toast(GONE);return}S=await r.json();
|
|
408
|
+
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();
|
|
378
409
|
{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}}
|
|
379
410
|
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)}
|
|
380
411
|
// A redraw under a form being typed in would reset its fields and its focus.
|
|
381
412
|
if(!(quiet&&mode&&$("board").querySelector(".inl input")))render();
|
|
382
|
-
if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(
|
|
413
|
+
if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(ta&&document.activeElement===ta)return;
|
|
414
|
+
if(!c){if(was&&was.kind==="idea")follow(was);return}if(runDoc)openRun(c,true,true);else if(c.kind==="idea")chat(c,c.notes,c.archived||c.status!=="idea")}}
|
|
415
|
+
// An idea's card goes when its run starts. Follow it to that run rather than keep
|
|
416
|
+
// offering "Run again": the newest run in its project whose request holds the idea's
|
|
417
|
+
// text, the rule boardCards() retires it by. No such run: close the panel.
|
|
418
|
+
// ponytail: checks the five newest runs, one /run fetch each; a run that took the idea
|
|
419
|
+
// further back than that closes the panel instead.
|
|
420
|
+
async function follow(idea){
|
|
421
|
+
const k=key(idea),norm=s=>s.toLowerCase().replace(/\s+/g," ").trim(),t=norm(idea.text);
|
|
422
|
+
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)){
|
|
423
|
+
const d=await fetch(`/run/${encodeURIComponent(r.id)}?dir=${encodeURIComponent(r.state)}`).then(x=>x.json()).catch(()=>null);
|
|
424
|
+
if(openKey!==k)return;if(norm(d?.request||"").includes(t)){open(r);return}}
|
|
425
|
+
if(openKey===k)close()}
|
|
383
426
|
// A tab outliving its server would otherwise swallow every click: say so.
|
|
384
427
|
const GONE="The board server is not running — start it with kaizen web (or the shortcut), then reload.";
|
|
385
428
|
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){toast(j.error||j.why||"That did not work");if(j.manual)console.log("run manually:",j.manual)}return j}
|
|
386
429
|
|
|
387
430
|
function card(c){
|
|
388
|
-
const k=key(c),el=document.createElement("div");el.className="card"+(k===openKey?" open":"")+(k===menuKey?" menu":"")+(c.archived?" archived":"");el.tabIndex=0;el.setAttribute("role","button");
|
|
431
|
+
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");
|
|
389
432
|
const st=ICON[c.status]||"idea",idt=c.kind==="idea"?"idea":c.text;
|
|
390
433
|
const chips=[];
|
|
391
434
|
if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(need(c.awaiting)||"needs you")}</span>`);
|
|
392
435
|
else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
|
|
393
436
|
if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
|
|
394
437
|
chips.push(...(c.tags||[]).map(t=>{const mark=MARKS[t]?`<svg><use href="#c-${t}"/></svg>`:"";return `<span class="chip${t==="full"||t==="lite"?" accent":""}">${mark}${esc(NAMES[t]||t)}</span>`}));
|
|
395
|
-
|
|
438
|
+
// A gathered idea's notes open with the stamp ingest() writes; its chip names the source.
|
|
439
|
+
const g=c.notes&&/^\[[^\]]*\] Gathered from (.+?)\. 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>`);
|
|
396
440
|
// Information only: the panel's button is where it is committed and pushed.
|
|
397
441
|
if(c.git){const t=c.git==="pushed"?"Committed & pushed":"Not committed & pushed yet";chips.push(`<span class="chip ${c.git==="pushed"?"ok":"warn"}" role="img" title="${t}" aria-label="${t}"><svg><use href="#c-git"/></svg></span>`)}
|
|
398
442
|
const pick=picking&&!archive&&c.column===4;
|
|
@@ -439,6 +483,8 @@ function render(){
|
|
|
439
483
|
// A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
|
|
440
484
|
if(mode&&mode!=="new"&&!S.cards.some(c=>key(c)===modeKey))mode=null;
|
|
441
485
|
if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
|
|
486
|
+
// The board is rebuilt below; the card that had focus gets it back.
|
|
487
|
+
const fk=focusedKey();
|
|
442
488
|
const cs=S.cards.filter(c=>c.archived===archive),b=$("board");b.innerHTML="";
|
|
443
489
|
const nArch=S.cards.filter(c=>c.archived).length;
|
|
444
490
|
const n=s=>cs.filter(c=>c.status===s).length;
|
|
@@ -484,18 +530,34 @@ function render(){
|
|
|
484
530
|
const seen=new Map();cs.forEach(c=>seen.set(c.status,1));
|
|
485
531
|
$("legend").innerHTML=[...seen.keys()].map(s=>`<span class="st d-${ICON[s]}" style="display:inline-grid;vertical-align:-2px"><svg><use href="#s-${ICON[s]}"/></svg></span> ${WORD[s]||s}`).join(" ");
|
|
486
532
|
stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
|
|
533
|
+
if(fk&&document.activeElement===document.body)cardEl(fk)?.focus({preventScroll:true});
|
|
487
534
|
}
|
|
535
|
+
const focusedKey=()=>document.activeElement?.closest?.("#board .card")?.dataset.k;
|
|
536
|
+
const cardEl=k=>k&&$("board").querySelector('.card[data-k="'+CSS.escape(k)+'"]');
|
|
537
|
+
// Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
|
|
538
|
+
// nearest column with cards, at the same row or its last card.
|
|
539
|
+
function nav(dx,dy){
|
|
540
|
+
const cols=[...$("board").querySelectorAll("section")].map(s=>[...s.querySelectorAll(".card")]).filter(l=>l.length);if(!cols.length)return;
|
|
541
|
+
const cur=document.activeElement.closest(".card");let ci=cols.findIndex(l=>l.includes(cur)),ri=0;
|
|
542
|
+
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)}}
|
|
543
|
+
const t=cols[ci][ri];t.focus({preventScroll:true});t.scrollIntoView({block:"nearest",inline:"nearest"})}
|
|
488
544
|
|
|
489
545
|
// ---- detail panel
|
|
490
|
-
|
|
546
|
+
// A run's tabs, its phases in board order. A card opens on its column's tab.
|
|
547
|
+
const TABS=[["Request","request"],["Notes","notes"],["Plan","plan"],["Backlog","backlog"],["Work","impl"],["Review","review"],["Preview","preview"]];
|
|
548
|
+
const tabOf=k=>TABS.findIndex(t=>t[1]===k);
|
|
549
|
+
// keepTab: a link already chose the tab.
|
|
550
|
+
function open(c,keepTab){
|
|
491
551
|
stopDictation(true); // the panel body is about to be replaced
|
|
492
552
|
fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
|
|
493
553
|
if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
|
|
494
|
-
const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn
|
|
554
|
+
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">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>`;
|
|
495
555
|
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}
|
|
556
|
+
if(!keepTab)tab=tabOf(c.status==="abandoned"?"plan":({1:"plan",2:"backlog",3:"review",4:"preview"})[c.column]||"request");
|
|
496
557
|
openRun(c,false)}
|
|
497
558
|
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
498
559
|
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)}
|
|
560
|
+
let nfocus=null;
|
|
499
561
|
function chat(c,src,ro){
|
|
500
562
|
const base=c.kind==="run"?`runs/${c.id}/`:"";
|
|
501
563
|
const fix=t=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent(base+p)})`);
|
|
@@ -506,37 +568,64 @@ function chat(c,src,ro){
|
|
|
506
568
|
if(ro)return;
|
|
507
569
|
const ta=$("pbody").querySelector("textarea"),files=[];
|
|
508
570
|
draft(ta,"n:"+key(c));
|
|
571
|
+
// Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
|
|
572
|
+
// so the note shows, and the new box takes focus back for the next note.
|
|
573
|
+
if(nfocus===key(c)){nfocus=null;ta.focus()}
|
|
509
574
|
{const m=mic([ta]);if(m)$("npick").before(m)}
|
|
510
575
|
const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
|
|
511
576
|
const add=f=>{files.push(f);show()};
|
|
512
577
|
$("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
|
|
513
578
|
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()}};
|
|
514
579
|
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)})));
|
|
515
|
-
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");load()}};
|
|
580
|
+
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()}};
|
|
516
581
|
$("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
|
|
517
582
|
$("pbody").scrollTop=0}
|
|
518
|
-
// The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself
|
|
583
|
+
// The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself,
|
|
584
|
+
// and its "### How to test" steps.
|
|
519
585
|
// "## Commit" is the message the builder wrote for the work, offered at commit and push.
|
|
520
586
|
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}
|
|
521
587
|
const preview=impl=>section(impl,"Preview");
|
|
522
588
|
const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
|
|
523
|
-
//
|
|
524
|
-
//
|
|
589
|
+
// A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
|
|
590
|
+
// each, "(recommended)" marking the default and "multi-select" in the question allowing
|
|
591
|
+
// several. A blank or other line ends a question's options. The format is pinned in
|
|
592
|
+
// spec-plan.md; a section with content in any other shape is null, none at all is [].
|
|
593
|
+
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[];
|
|
594
|
+
const lvl=/^#+/.exec(lines[at].trim())[0].length,out=[];let cur=null,said=false;
|
|
595
|
+
for(const raw of lines.slice(at+1)){const l=raw.trim(),h=/^#+(?=\s)/.exec(l);if(h&&h[0].length<=lvl)break;
|
|
596
|
+
if(!h&&l&&!/^```/.test(l)&&!/^none\.?$/i.test(l))said=true;
|
|
597
|
+
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}
|
|
598
|
+
const o=/^[-*]\s+(.+)$/.exec(l);
|
|
599
|
+
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()})}
|
|
600
|
+
else if(cur&&cur.options.length)cur=null}
|
|
601
|
+
const qs=out.filter(x=>x.options.length);return qs.length||!said?qs:null}
|
|
602
|
+
// A building run's Backlog tab opens with its progress, above the deferred items. The
|
|
603
|
+
// first draw of a run animates nothing; later ones animate what moved, the bar included.
|
|
604
|
+
let lastProg=null;
|
|
605
|
+
function showProgress(c){
|
|
606
|
+
const src=c.column===2&&runDoc.progress,prev=lastProg&&lastProg.id===c.id?lastProg:null,h=src?progress(src,prev&&prev.st):"";
|
|
607
|
+
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])};
|
|
608
|
+
$("pbody").innerHTML=h+(runDoc.backlog?"<h3>Backlog</h3>"+md(runDoc.backlog,true):"");
|
|
609
|
+
const bar=$("pbody").querySelector(".pbar i");if(prev&&prev.w!==lastProg.w){bar.style.width=prev.w+"%";bar.offsetWidth;bar.style.width=lastProg.w+"%"}}
|
|
610
|
+
// again: something changed on the server, so the doc is read afresh: a note sent, a plan
|
|
611
|
+
// written or a commit made elsewhere shows in nothing the page already holds. One /run
|
|
612
|
+
// fetch per change while a run panel is open; a tab switch reuses the doc.
|
|
525
613
|
async function openRun(c,refresh,again){
|
|
526
|
-
if(!refresh||!runDoc||runDoc.id!==c.id||again
|
|
614
|
+
if(!refresh||!runDoc||runDoc.id!==c.id||again){const d=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json());
|
|
527
615
|
// A file list being confirmed, or a push under way, keeps the doc it was drawn from.
|
|
528
|
-
if(refresh&&$("pfoot").querySelector(".inl,[data-busy]"))return;runDoc=d}
|
|
616
|
+
if(refresh&&$("pfoot").querySelector(".inl,[data-busy],.more[open]"))return;runDoc=d}
|
|
529
617
|
if(openKey!==key(c))return;
|
|
530
618
|
// A refresh while notes are being typed would wipe the textarea.
|
|
531
619
|
const ta=$("pbody").querySelector("textarea");if(refresh&&ta&&document.activeElement===ta)return;
|
|
532
620
|
$("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+need(c.awaiting):"");
|
|
533
|
-
const tabs=
|
|
621
|
+
const tabs=TABS;
|
|
534
622
|
$("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
|
|
535
623
|
[...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
|
|
536
624
|
const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
|
|
537
625
|
if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
|
|
538
626
|
chat(c,doc,ro);}
|
|
539
627
|
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>`;
|
|
628
|
+
if(k==="backlog")showProgress(c);
|
|
540
629
|
if(k==="review")fixForm(c);
|
|
541
630
|
// The notebook's notes that link here, each opening that note: above the run's own notes.
|
|
542
631
|
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>`);
|
|
@@ -544,13 +633,45 @@ async function openRun(c,refresh,again){
|
|
|
544
633
|
if(hit&&!refresh){if(hit.k===key(c))mark(hit);else hit=null}
|
|
545
634
|
const foot=$("pfoot");
|
|
546
635
|
// A refresh while the commit is being confirmed would wipe the message.
|
|
547
|
-
if(refresh&&foot.querySelector(".inl,[data-busy]"))return;
|
|
636
|
+
if(refresh&&foot.querySelector(".inl,[data-busy],.more[open]"))return;
|
|
548
637
|
const arch=gitBtn(c)+(c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`);
|
|
638
|
+
// A plan that asks questions takes their answers with the approval.
|
|
639
|
+
// One the board cannot read is said, so it is answered in the terminal instead of skipped.
|
|
640
|
+
const pq=c.status==="waiting"&&c.awaiting==="approvals.plan"?planQuestions(runDoc.plan):[],qs=pq||[];
|
|
549
641
|
if(c.archived)foot.innerHTML=arch;
|
|
550
|
-
else if(c.status==="waiting")
|
|
642
|
+
else if(c.status==="waiting"&&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"}.`:"";
|
|
643
|
+
// gitBtn's buttons restyled as menu rows.
|
|
644
|
+
const more=gitBtn(c).replace(/class="btn( pri)?"/g,"")+`<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
|
|
645
|
+
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><span class="dec"><button class="btn" data-a="revise"${sent?" disabled":""}>Revise</button><button class="btn pri" data-a="approve"${sent?" disabled":""}>Approve</button></span>`}
|
|
646
|
+
else if(c.status==="waiting")foot.innerHTML=`<span class="note">Waiting on you: ${esc(need(c.awaiting))}. Answer in the terminal running this run.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>`;
|
|
551
647
|
else if(c.status==="done"||c.status==="abandoned")foot.innerHTML=arch;
|
|
552
|
-
else foot.innerHTML
|
|
553
|
-
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}close();mode="reject";modeKey=key(c);render()})}
|
|
648
|
+
else foot.innerHTML=`<span class="note">${c.status==="stalled"?"No progress for a while. Resume it with <code>/kaizen run</code> in your agent, or abandon it.":"Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>`;
|
|
649
|
+
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}close();mode="reject";modeKey=key(c);render()})}
|
|
650
|
+
// Answering an approval from the panel opens your agent on the run, told the decision and,
|
|
651
|
+
// for a revise, the comment. Answered approvals are remembered outside the footer, which
|
|
652
|
+
// every refresh rebuilds, so a second press cannot open a second agent. Keyed by when the
|
|
653
|
+
// run last moved: the same approval reached again after a revise is a new question.
|
|
654
|
+
// The footer's More menu is a <details>: close it on a click anywhere else.
|
|
655
|
+
document.addEventListener("click",e=>{for(const d of document.querySelectorAll(".more[open]"))if(!d.contains(e.target))d.open=false});
|
|
656
|
+
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 reviewed. Approve to finish it, or revise with what to change."},decided=new Set(),answer=c=>key(c)+":"+c.awaiting+":"+c.moved;
|
|
657
|
+
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}:{})});
|
|
658
|
+
// A failed send keeps the footer, and with it a typed comment.
|
|
659
|
+
if(!r.ok){b.disabled=false;return}
|
|
660
|
+
decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+" in your terminal: "+r.agent.name);
|
|
661
|
+
if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
|
|
662
|
+
function confirmRevise(c){const foot=$("pfoot"),plan=c.awaiting==="approvals.plan";
|
|
663
|
+
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>`;
|
|
664
|
+
const inp=foot.querySelector("textarea");inp.focus();
|
|
665
|
+
foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
|
|
666
|
+
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)}}
|
|
667
|
+
// The plan's questions, answered with the approval: the recommended option preselected,
|
|
668
|
+
// a note for what the options miss. Each answer reaches the agent as one line.
|
|
669
|
+
function confirmAnswers(c,qs){const foot=$("pfoot");
|
|
670
|
+
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>`;
|
|
671
|
+
foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
|
|
672
|
+
foot.querySelector("[data-a=s]").onclick=e=>decide(c,e.target,undefined,qs.map((x,i)=>{
|
|
673
|
+
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();
|
|
674
|
+
return `${x.q} — ${picked}${note?"; "+note:""}`}))}
|
|
554
675
|
// A link from a note (?run= or ?idea=, with dir, tab, item) opens that card's panel;
|
|
555
676
|
// a finding or backlog item named is scrolled to and marked. Found by its text, since
|
|
556
677
|
// the rendered list does not carry the numbers.
|
|
@@ -561,29 +682,31 @@ function deep(){const p=new URLSearchParams(location.search),d=p.get("dir"),id=p
|
|
|
561
682
|
if(!c){toast(id?"That run is not on this board":"That idea is not on this board");return}
|
|
562
683
|
const m=/^(finding|backlog)-(\d+)$/.exec(p.get("item")||"");
|
|
563
684
|
// An item's tab is its own when the link does not name one.
|
|
564
|
-
const
|
|
685
|
+
const n=p.get("tab")||(m?(m[1]==="finding"?"review":"backlog"):""),t=tabOf(n==="work"?"impl":n);if(t>=0)tab=t;hit=m?{type:m[1],n:+m[2],k:key(c)}:null;open(c,t>=0)}
|
|
565
686
|
// What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
|
|
566
687
|
function mark(h){hit=null;const flat=t=>String(t).replace(/!?\[([^\]]*)\]\([^)\s]+\)/g,"$1").replace(/[`*]/g,"").replace(/\s+/g," ").trim();
|
|
567
688
|
const b=[...(runDoc.backlog||"").matchAll(/^\s*-\s*(?:open|done|rejected):\s*(.*)$/gm)];
|
|
568
689
|
const text=h.type==="finding"?(runDoc.findings||[]).find(f=>f.n===h.n)?.text:b[h.n-1]?.[1];
|
|
569
690
|
if(!text){toast("That item is not in this run");return}
|
|
570
|
-
const want=flat(text).slice(0,60),el=[...$("pbody").querySelectorAll("li,p")].find(e
|
|
691
|
+
const want=flat(text).slice(0,60),el=[...$("pbody").querySelectorAll("li,p")].find(e=>!e.closest(".prog")&&flat(e.textContent).includes(want));
|
|
571
692
|
if(!el)return;el.scrollIntoView({block:"center"});el.classList.add("hit");setTimeout(()=>el.classList.remove("hit"),2400)}
|
|
572
|
-
// Fix picked findings, under the review: open ones ticked, closed ones
|
|
573
|
-
// Unticked
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
693
|
+
// Fix picked findings, under the review: open ones ticked, closed and sent ones ticked but
|
|
694
|
+
// locked. Unticked and sent findings live outside the form, which every refresh rebuilds,
|
|
695
|
+
// keyed "run:number"; sent ones also survive a reload of this tab. A finding is one line,
|
|
696
|
+
// so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
|
|
697
|
+
const SK="kz-sent",fixOff=new Set(),fixSent=new Set((()=>{try{return JSON.parse(sessionStorage.getItem(SK))||[]}catch(e){return[]}})());
|
|
698
|
+
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));
|
|
699
|
+
if(c.archived||!fs.some(f=>!f.done)||!(c.status==="done"||c.awaiting==="findings"||c.awaiting==="approvals.review"))return;
|
|
578
700
|
const el=document.createElement("div");el.className="inl fix";
|
|
579
|
-
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
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
701
|
+
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>`:"");
|
|
702
|
+
if(!open.length)return $("pbody").appendChild(el);
|
|
703
|
+
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);
|
|
704
|
+
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)))};
|
|
705
|
+
el.onchange=e=>{const n=k(e.target.dataset.n);e.target.checked?fixOff.delete(n):fixOff.add(n);sync()};
|
|
706
|
+
allb.onclick=()=>{const none=picks().length===open.length;open.forEach(f=>none?fixOff.add(k(f.n)):fixOff.delete(k(f.n)));sync()};
|
|
584
707
|
go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
|
|
585
|
-
// Sent findings
|
|
586
|
-
if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+r.agent.name);nums.forEach(n=>
|
|
708
|
+
// Sent findings lock as sent, so a second press cannot open a second agent on them.
|
|
709
|
+
if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+r.agent.name);nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
|
|
587
710
|
sync();$("pbody").appendChild(el)}
|
|
588
711
|
// Commit and push, for a finished run in a project with a git remote. What the board
|
|
589
712
|
// did is recorded per run, so the settled label outlives later changes to the tree.
|
|
@@ -610,7 +733,7 @@ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textCo
|
|
|
610
733
|
// A refused message comes back for another try, beside the current file list.
|
|
611
734
|
if(!r.ok&&message&&openKey===key(c)&&runDoc.git.files&&runDoc.git.files.length)confirmCommit(c,message)}
|
|
612
735
|
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()}
|
|
613
|
-
function close(){stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render()}
|
|
736
|
+
function close(){const k=openKey;stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
|
|
614
737
|
$("pclose").onclick=close;$("scrim").onclick=close;
|
|
615
738
|
|
|
616
739
|
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
@@ -630,9 +753,18 @@ function md(src,tick){
|
|
|
630
753
|
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>`)}
|
|
631
754
|
endList();return out.join("")}
|
|
632
755
|
|
|
756
|
+
// A building run's progress.md, one `- todo|doing|done: text` line per Work-list item.
|
|
757
|
+
// prev: the statuses last drawn for this run; an item whose status moved gets .chg.
|
|
758
|
+
function progress(src,prev){
|
|
759
|
+
const items=[...src.matchAll(/^\s*-\s*(todo|doing|done):\s*(.*)$/gm)].map(m=>[m[1],m[2]]);if(!items.length)return "";
|
|
760
|
+
const n=items.filter(t=>t[0]==="done").length,pct=Math.round(n/items.length*100),ic={done:"✓",doing:'<span class="pulse"></span>',todo:""};
|
|
761
|
+
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>`}
|
|
762
|
+
|
|
633
763
|
// ---- settings, toast, notifications, keys
|
|
634
764
|
// Rows in config order, grouped as the TUI groups them.
|
|
635
765
|
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]};
|
|
766
|
+
// The search survives the re-render each save does; the gear button starts it empty.
|
|
767
|
+
let setQ="";
|
|
636
768
|
async function openSettings(){
|
|
637
769
|
stopDictation(true);
|
|
638
770
|
openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
|
|
@@ -642,18 +774,38 @@ async function openSettings(){
|
|
|
642
774
|
// One control per row: a switch for on/off, one segment per value for a small number,
|
|
643
775
|
// a select for a choice. The row's label names the control; its help sits under it.
|
|
644
776
|
const ctl=(id,vals,on)=>{
|
|
777
|
+
// The preset steps like a game's quick preset: ◁ ▷ go to the neighbouring tier,
|
|
778
|
+
// wrapping, and never to custom, which only an edit to another row makes.
|
|
779
|
+
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];
|
|
780
|
+
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>`}
|
|
645
781
|
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>`}
|
|
646
782
|
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>`;
|
|
647
783
|
return `<select class="sel" id="${id}">${vals.map(v=>`<option value="${esc(v)}"${v===on?" selected":""}>${v===""?"—":esc(v)}</option>`).join("")}</select>`};
|
|
648
|
-
const row=(label,control,hint,id)=>`<div class="srow"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
|
|
784
|
+
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>`;
|
|
785
|
+
const hadFocus=document.activeElement?.id==="sq";
|
|
649
786
|
const np=typeof Notification==="undefined"?null:Notification.permission;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
787
|
+
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.");
|
|
788
|
+
const names=[...(cfg?groups(cfg.rows).map(([g])=>g):[]),"Browser"];
|
|
789
|
+
$("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>
|
|
790
|
+
${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?`<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>`}
|
|
791
|
+
<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")}
|
|
792
|
+
${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")}
|
|
793
|
+
<p class="snone" id="snone" hidden></p>`;
|
|
794
|
+
// Hides rows that do not match, then any group (and its chip) left with none.
|
|
795
|
+
const heads=[...$("pbody").querySelectorAll(".sg")],chips=[...$("pbody").querySelectorAll(".sjump button")];
|
|
796
|
+
const filter=()=>{const q=setQ.trim().toLowerCase();let any=false;
|
|
797
|
+
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});
|
|
798
|
+
$("snone").hidden=any;$("snone").textContent=`No setting matches “${setQ.trim()}”.`};
|
|
799
|
+
// Scrolled by measure: the pinned bar's height changes as the chips wrap.
|
|
800
|
+
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"})});
|
|
801
|
+
$("sq").oninput=e=>{setQ=e.target.value;filter()};
|
|
802
|
+
// Esc clears a search; with nothing to clear it closes the panel, as it does outside the field.
|
|
803
|
+
$("sq").onkeydown=e=>{if(e.key!=="Escape")return;e.preventDefault();if(setQ){setQ="";e.target.value="";filter()}else close()};
|
|
804
|
+
filter();if(hadFocus){const f=$("sq");f.focus();f.setSelectionRange(f.value.length,f.value.length)}
|
|
653
805
|
const setTheme=t=>{if(t==="system")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;try{t==="system"?localStorage.removeItem("kz-theme"):localStorage.setItem("kz-theme",t)}catch(e){}};
|
|
654
806
|
$("th").onchange=e=>setTheme(e.target.value);
|
|
655
807
|
$("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
|
|
656
|
-
if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast("Saved");openSettings()};
|
|
808
|
+
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()};
|
|
657
809
|
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)})}
|
|
658
810
|
// ---- request sources: links kaizen reads for new ideas. Same panel as settings.
|
|
659
811
|
const SRC="Request sources";
|
|
@@ -673,24 +825,30 @@ async function openSources(){
|
|
|
673
825
|
const host=u=>{try{return new URL(u).hostname}catch{return u}};
|
|
674
826
|
const list=d&&d.sources||[];
|
|
675
827
|
// A source belongs to one project, named on its row. Picking another only arms Move: a select
|
|
676
|
-
// changes on every arrow key, and each change would have moved the source. The names are the picker's
|
|
677
|
-
|
|
678
|
-
const
|
|
828
|
+
// changes on every arrow key, and each change would have moved the source. The names are the picker's;
|
|
829
|
+
// a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
|
|
830
|
+
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("");
|
|
831
|
+
const rows=list.map((s,i)=>`<div class="srow"><label>${esc(s.label||host(s.url))}</label><button class="btn ghost" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(s.label||host(s.url))}">Remove</button><p class="hint" style="overflow-wrap:anywhere">${s.url.startsWith("https://")?`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(s.url)}</a>`:esc(s.url)}<br>${s.last?(s.last.ok?esc(s.last.note):`<span style="color:var(--ink);font-weight:500">Failed: ${esc(s.last.note)}</span>`)+" · "+ago(s.last.at):"Not gathered yet"}</p><p class="hint" style="margin-top:6px">Ideas go to <select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(s.label||host(s.url))}">${opts(s.dir,s.project)}</select> <button class="btn" id="src-mb-${i}" aria-label="Move ${esc(s.label||host(s.url))} to the chosen project" disabled>Move</button></p></div>`).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
|
|
679
832
|
$("pbody").innerHTML=(d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">Sources could not be read.</p>`:rows||`<p style="color:var(--dim)">No sources yet. Paste a link to a Google Sheet, a Doc, or a shared note.</p>`)
|
|
680
833
|
+`<h3 class="sg">Add a source</h3><div style="display:grid;gap:8px;margin-top:10px"><input class="sel" id="src-url" aria-label="Link" placeholder="https://docs.google.com/…" spellcheck="false" autocomplete="off" style="width:100%;max-width:none"><input class="sel" id="src-label" aria-label="Label (optional)" placeholder="Label (optional)" style="width:100%;max-width:none"><select class="sel" id="src-dir" aria-label="Project this source belongs to" style="width:100%;max-width:none">${dir?"":`<option value="">Choose the project it belongs to…</option>`}${opts(dir)}</select><div style="display:flex;gap:8px"><button class="btn pri" id="src-add">Add source</button><button class="btn" id="src-go"${list.length?"":" disabled"}>Gather now</button></div><p class="hint" style="font-size:12px;color:var(--dim);margin:0">${d&&d.every&&d.every!=="off"?`Checked every ${esc(d.every)} while the board is running.`:"Automatic checks are off."} Change it in Settings.</p></div>`;
|
|
681
834
|
$("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined)$("src-dir").value=typed[3];
|
|
682
835
|
$("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
|
|
683
836
|
if(to&&[...m.options].some(o=>o.value===to))m.value=to;
|
|
684
837
|
(m.onchange=()=>{b.disabled=m.value===s.dir})();
|
|
685
|
-
b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(
|
|
838
|
+
b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(`Source moved. Ideas already gathered stay in ${s.project||"the old project"}.`))});
|
|
686
839
|
// A Move button that went back to disabled cannot take focus; its select can.
|
|
687
840
|
const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was).focus();
|
|
688
841
|
// Every action names the project it acts on; only "gather everything" goes without one.
|
|
689
842
|
const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
|
|
690
|
-
$("src-add").onclick=()=>{const url=$("src-url").value.trim();if(!url){$("src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}
|
|
843
|
+
$("src-add").onclick=()=>{const url=$("src-url").value.trim();if(!url){$("src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}// A new source is read at once, so the user learns now whether the link works.
|
|
844
|
+
$("src-add").disabled=true;$("src-add").textContent="Adding…";
|
|
845
|
+
(async()=>{const j=await post("/sources",{op:"add",dir:to,url,label:$("src-label").value.trim()});
|
|
846
|
+
if(j.ok){$("src-url").value=$("src-label").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
|
|
847
|
+
toast(r?(r.ok?`Source added: ${r.note}`:`Source added, but: ${r.note}`):"Source added");load()}
|
|
848
|
+
if(srcOpen())openSources()})()};
|
|
691
849
|
$("src-url").onkeydown=$("src-label").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
|
|
692
850
|
$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").textContent="Gathering…";act({op:"gather",dir:dir||undefined},j=>{const n=(j.results||[]).reduce((a,r)=>a+r.added,0),bad=(j.results||[]).filter(r=>!r.ok).length;toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})};
|
|
693
|
-
$("pbody").querySelectorAll("[data-rm]").forEach(b=>b.onclick=()=>act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed")))}
|
|
851
|
+
$("pbody").querySelectorAll("[data-rm]").forEach(b=>b.onclick=()=>{const s=list[+b.dataset.rm];if(!confirm(`Remove ${s.label||host(s.url)}? Ideas it gathered stay; added again later, it may bring back ones you deleted.`))return;act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))})}
|
|
694
852
|
let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
|
|
695
853
|
function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
|
|
696
854
|
function notifyUser(body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification("kaizen",{body,icon:"/logo.png"})}catch(e){}}
|
|
@@ -714,17 +872,24 @@ async function pick(v){
|
|
|
714
872
|
else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
|
|
715
873
|
load()}
|
|
716
874
|
$("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
|
|
717
|
-
$("settings").onclick=openSettings;$("sources").onclick=openSources;
|
|
875
|
+
$("settings").onclick=()=>{setQ="";openSettings()};$("sources").onclick=openSources;
|
|
718
876
|
try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}
|
|
719
877
|
$("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
|
|
720
878
|
document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
|
|
721
879
|
document.onkeydown=e=>{
|
|
722
880
|
if(e.target.matches("input,select,textarea,[contenteditable]"))return;
|
|
723
881
|
if(e.key==="?"){$("help").classList.toggle("on");return}
|
|
882
|
+
if(e.key==="/"&&$("sq")&&$("panel").classList.contains("open")){e.preventDefault();$("sq").focus();return}
|
|
724
883
|
if(e.key==="Escape"){$("help").classList.remove("on");if(mode){mode=null;render()}else close();return}
|
|
725
884
|
if(e.key==="n"){mode="new";render()}
|
|
726
885
|
if(e.key==="a"){all=!all;keep(all?null:S.dir);load()}
|
|
727
|
-
const
|
|
886
|
+
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];
|
|
887
|
+
// In a run's panel: left/right between its tabs, up/down scroll it. Other panels keep the browser's arrows.
|
|
888
|
+
const rc=mv&&openKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&S.cards.find(c=>key(c)===openKey&&c.kind==="run");
|
|
889
|
+
if(rc&&$("ptabs").children.length){e.preventDefault();if(mv[0]){const t=Math.max(0,Math.min($("ptabs").children.length-1,tab+mv[0]));if(t!==tab){tab=t;openRun(rc,true)}}else $("pbody").scrollBy({top:mv[1]*40});return}
|
|
890
|
+
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}
|
|
891
|
+
// The open card, else the focused one.
|
|
892
|
+
const ok=openKey||focusedKey(),oc=ok&&S.cards.find(c=>key(c)===ok);if(!oc)return;
|
|
728
893
|
if(e.key==="e"&&oc.kind==="idea"){close();mode="edit";modeKey=key(oc);render()}
|
|
729
894
|
if(e.key==="x"&&!["done","abandoned","starting"].includes(oc.status)){close();mode="reject";modeKey=key(oc);render()}
|
|
730
895
|
if(e.key==="r"&&oc.kind==="idea"){close();mode="run";modeKey=key(oc);render()}};
|
|
@@ -733,7 +898,7 @@ document.onkeydown=e=>{
|
|
|
733
898
|
let boot=null;
|
|
734
899
|
// Back from a terminal: what was committed or pushed there touched nothing the server watches.
|
|
735
900
|
addEventListener("focus",()=>load(true));
|
|
736
|
-
function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!$("src-go")?.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)}}
|
|
901
|
+
function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!$("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)}}
|
|
737
902
|
// Back to the project picked last time, unless the URL says which view it wants. A
|
|
738
903
|
// plain fetch: a project that is gone should fall back to all, not raise a toast.
|
|
739
904
|
(async()=>{let d;try{d=localStorage.getItem("kz-proj")}catch(e){}
|