@lovek4d/kickflip 0.4.0 → 0.5.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/CONTEXT.md +32 -16
- package/SKILL.md +8 -6
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/bin/board.mjs +50 -28
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +4 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +6 -89
- package/board/public/app-cards.js +19 -10
- package/board/public/app-core.js +1 -20
- package/board/public/app-reader.js +174 -0
- package/board/public/app-threads.js +49 -35
- package/board/public/app.html +2 -0
- package/board/public/app.js +28 -16
- package/board/public/hub.css +1 -0
- package/board/public/new-task.js +121 -0
- package/board/public/shell.js +4 -0
- package/board/public/sidebar.css +1 -0
- package/board/public/style.css +91 -3
- package/board/public/ui-cards.js +46 -21
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +47 -18
- package/board/public/ui-sidebar.js +9 -3
- package/board/public/ui-threads.js +25 -12
- package/board/public/ui.js +42 -12
- package/board/public/view-frame.js +1 -0
- package/board/public/view-questions.js +45 -16
- package/board/public/view-settings.js +38 -19
- package/board/server.mjs +20 -18
- package/board/store.mjs +15 -13
- package/board/worktree.mjs +20 -20
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +9 -12
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/readiness.mjs +2 -2
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/package.json +1 -1
- package/shared/owner-check.mjs +3 -0
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
package/board/finish.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
${
|
|
3
|
-
|
|
1
|
+
var N=Object.defineProperty;var u=(t,a)=>N(t,"name",{value:a,configurable:!0});import W from"node:fs";import x from"node:path";import{spawnSync as j}from"node:child_process";import*as n from"./store.mjs";import{builderRepo as M,git as b,joiningLand as v,landInTurn as A,passTests as E,syncBase as B,worktreesDir as D}from"./worktree.mjs";import{readPermissions as F}from"./permissions.mjs";import{launchBuilder as H}from"./headless.mjs";import{boardAt as C}from"../hub/boards.mjs";import{boardRepo as R}from"../hub/board-repo.mjs";const w=u(t=>{throw new n.BoardError(t)},"fail"),L=u(t=>typeof t=="string"&&t.trim()?t.trim():w("--text must be non-empty text"),"said");async function I(t,a,{text:r,ownerStep:o,check:e,repo:i,onWait:d}){const m=a.split("/")[1],p=await n.recordedWorktree(t,a),{repo:c,base:s,remote:l}=M(t,{repo:i??p?.repo}),h={path:p?.path??x.join(D(),m),branch:p?.branch??m,repo:x.resolve(c),base:s},f=await n.cardName(t,a),g=await n.readBoardSettings(t),$=l?R(C(t).route).github:null;if(l&&(!$||g.prs))return V(t,a,{...h,ref:f,text:L(r)});const T=await v(f,()=>P(t,a,{tree:h,ref:f,base:s,remote:l,repo:c,settings:g,text:r,ownerStep:o,check:e,onWait:d}));return T.joined?await n.isFinished(t,a)?{said:"landed and done",lines:[]}:I(t,a,{text:r,ownerStep:o,check:e,repo:i,onWait:d}):T}u(I,"finishCard");async function P(t,a,{tree:r,ref:o,base:e,remote:i,repo:d,settings:m,text:p,ownerStep:c,check:s,onWait:l}){await n.checkClose(t,a,p,c,s),await n.checkCallsVerified(t,a),i&&(B(r.repo,e),b(r.repo,"branch","--unset-upstream",r.branch));const h=u($=>n.appendThread(t,a,{text:`Waiting for the test slot: ${$} is running the suite`,kind:"status"}),"onWaitTests"),f=m.auto_merge||!!(await n.heldMerge(t,a))?.tapped;if(await A({...r,ref:o},{hold:!f,onWait:l,onWaitTests:h,sync:u(()=>i&&B(r.repo,e),"sync")}),!f){const $=await n.setItem(t,a,{mergeReady:r.branch,ownerStep:c,check:s});return await n.appendThread(t,a,{text:p}),{said:"verified; waits for your Merge",lines:$.lines}}const g=await n.setItem(t,a,{status:"done",text:p,ownerStep:c,check:s});return{said:c===void 0?"landed and done":`landed; waits on the owner step "${c.trim()}"`,lines:[...g.lines,...await q(t,a,d,e)]}}u(P,"landCard");async function Y(t,a,{launch:r=H}={}){(await n.heldMerge(t,a))?.tapped||w(`${a} isn't waiting on a Merge tap`);try{const{base:e}=M(t,{repo:(await n.recordedWorktree(t,a))?.repo}),i=u(d=>n.appendThread(t,a,{text:`Waiting for ${d} to land`,kind:"status"}),"onWait");return await I(t,a,{text:`Landed on ${e} on your Merge.`,onWait:i})}catch(e){if(!(e instanceof n.BoardError))throw e;const i=e.message.split(`
|
|
2
|
+
`)[0];if(/^rebasing .+ conflicts in |^tests failed in /.test(i)){await n.appendThread(t,a,{text:`Not merged yet: ${i}. A lander fixes it and lands it.`});try{await r(t,{key:a,model:await n.builderModel(t,a)},{lander:!0})}catch(d){await n.appendThread(t,a,{text:`Couldn't launch the lander headless (${d.message}); land it with board.mjs land ${a}.`})}}else await n.appendThread(t,a,{text:`Not merged: ${i}. Tap Merge again.`});throw e}}u(Y,"landHeld");async function q(t,a,r,o){if(!(await F(r)).pushMain)return[];const e=b(r,"push","--quiet","origin",o);if(e.ok)return[`pushed ${o} to origin`];const i=`${o} didn't reach origin: ${e.err}; push it with git -C ${r} push origin ${o}`;return await n.appendThread(t,a,{text:`Landed, but ${i}`}),[`landed, but ${i}`]}u(q,"pushBase");async function V(t,a,{path:r,branch:o,base:e,ref:i,text:d}){await n.checkCallsVerified(t,a),W.existsSync(r)||w(`no worktree at ${r}`),b(r,"status","--porcelain").out&&w(`${r} has uncommitted changes; commit them there first`),Number(b(r,"rev-list","--count",`refs/remotes/origin/${e}..HEAD`).out)===0&&w(`${o} has no commits beyond ${e}; nothing to send for review`),await E(r,{outcome:"nothing was pushed"});const m=b(r,"push","--quiet","origin",`HEAD:refs/heads/${o}`);m.ok||w(`could not push ${o} to origin: ${m.err}`);const{github:p}=R(C(t).route);if(!p){const g=await n.setItem(t,a,{pushed:o});return await n.appendThread(t,a,{text:d}),{said:`pushed ${o} for its merge`,lines:g.lines}}const{title:c}=await n.briefFacts(t,a),s=j("gh",["pr","create","--draft","--repo",p,"--base",e,"--head",o,"--title",`${i}: ${c}`,"--body",d],{cwd:r,encoding:"utf8"}),l=`${s.stdout||""}
|
|
3
|
+
${s.stderr||""}`,h=(s.status===0||/already exists/.test(l))&&/\/pull\/(\d+)/.exec(l)?.[1];h||w(`could not open a draft PR for ${o}: ${(s.stderr||s.error?.message||`gh exited ${s.status}`).trim().split(`
|
|
4
|
+
`)[0]}`);const f=await n.setItem(t,a,{pr:Number(h)});return await n.appendThread(t,a,{text:d}),{said:`draft PR #${h} opened`,lines:f.lines}}u(V,"sendForReview");export{I as finishCard,Y as landHeld};
|
package/board/headless.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)}d(
|
|
3
|
-
`)}d(
|
|
1
|
+
var q=Object.defineProperty;var d=(t,e)=>q(t,"name",{value:e,configurable:!0});import g from"node:fs";import A from"node:os";import $ from"node:path";import{spawn as O}from"node:child_process";import{fileURLToPath as L}from"node:url";import{claudeBin as N,inherited as P}from"../hub/sessions.mjs";import{boardAt as S}from"../hub/boards.mjs";import{readHubRules as F}from"../hub/rules.mjs";import{builderRepo as W}from"./worktree.mjs";import{appendThread as Y,briefFacts as D,readBoardRules as H}from"./store.mjs";import{modelName as M,plans as E}from"./domain.mjs";const _=L(new URL("./bin/board.mjs",import.meta.url)),b=d(t=>`node ${_} --site ${t}`,"cli"),K=d(t=>`git -c core.commentChar=';' rebase ${t}`,"rebase"),T=d(t=>t.length?["Rules from the hub and this board:",...t.map(e=>`- ${e}`),""]:[],"ruleLines"),j=d(({route:t,key:e,worktree:n,base:a})=>[`- With no unverified calls, done finishes the card itself: it rebases onto ${a}, reruns the full test suite, fast-forwards ${a} and closes the card (on a board with pull requests it pushes the branch and opens a draft PR instead; with Auto-merge off it stops before the fast-forward, and the card waits for the owner's Merge).`,`- On a conflict it undoes its rebase and names the files: run \`${K(a)}\` in ${n}, resolve each conflict keeping both sides' intent, continue with \`git -c core.commentChar=';' rebase --continue\`, rerun the tests, and run done again.`,"- On failing tests, fix them, commit, and run done again.","- While another card is landing, done waits its turn and then lands; leave it running.",`- Finishing never goes to the orchestrator. If done refuses for a reason you can't fix, record it with \`${b(t)} builder call ${e} "<why>"\` and run done again: with the call it hands the card back.`],"finishSteps"),B=d(({route:t,key:e})=>`\`${b(t)} builder done ${e} --text "<what you checked>"\``,"finishCommand");function U({route:t,key:e,ref:n,title:a,notes:c,worktree:i,base:s,rules:r=[]}){const o=b(t);return[`You are the headless builder for card ${n} (${e}, "${a}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person. Work only in ${i}. Never restart the hub or a board server, and never push.`,"",`1. Run \`${o} builder start ${e} --kind build\`. If it refuses, stop.`,`2. Report progress with \`${o} builder progress ${e} --phase reading|building|testing|checking|landing "<plain, 80 characters or fewer>"\`, at least every 10 minutes.`,`3. To ask the orchestrator something, run \`${o} builder ask ${e} "<question>"\` and carry on; read its replies with \`${o} builder asks ${e}\`. Never block on a reply.`,`4. Record each judgment call the notes leave open with \`${o} builder call ${e} "<call>"\` instead of guessing silently.`,"",c?`Spec: the card's notes, ${c}, all sections, where later sections override earlier ones.`:"The card has no notes file: build from its title, and record what you had to decide as calls.","",...T(r),"Write tests first, and run the full suite once, in the foreground, at the end. Where the repo keeps ADRs, write one named with a timestamp: docs/adr/YYYY-MM-DD-HHMM-<slug>.md.",`Commit with \`git -c core.commentChar=';' commit -F <file>\`, the subject "${n}: <what the card now does>".`,"",`To finish, run ${B({route:t,key:e})}.`,"- If you recorded a judgment call, it hands the card back for the orchestrator to verify your calls; stop there: the orchestrator's verification finishes the card.",...j({route:t,key:e,worktree:i,base:s}),"","Never push."].join(`
|
|
2
|
+
`)}d(U,"builderBrief");function X({route:t,key:e,ref:n,title:a,worktree:c,base:i,answers:s=[]}){const r=b(t);return[`You are the headless lander for card ${n} (${e}, "${a}") on the kickflip board /${t}. Its build is done and the orchestrator verified its judgment calls; it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${c}, never restart the hub or a board server, and never push.`,"",`1. Run \`${r} builder start ${e} --kind build --label Lander\`. If it refuses, stop.`,`2. Report progress with \`${r} builder progress ${e} --phase landing "<plain, 80 characters or fewer>"\`.`,...s.length?["3. The owner answered the builder's deferred asks. Where an answer changes the build, change it, with its tests, and commit:",...s.map(o=>`- ${o.ask} "${o.question}": ${o.answer}`)]:[],"",`To finish, run ${B({route:t,key:e})}.`,...j({route:t,key:e,worktree:c,base:i}),"","Never push."].join(`
|
|
3
|
+
`)}d(X,"landerBrief");function G({route:t,key:e,ref:n,title:a,notes:c,kind:i,repo:s,rules:r=[],text:o,files:u=[],question:l,note:p,choice:w}){const h=b(t),f=i==="grill";return[`You are the headless ${f?"grill":"prototype"} planner for card ${n} (${e}, "${a}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person: the owner answers on the board, and a later planner or builder picks up from their answers. Read and run in ${s}; write no output code there, never restart the hub or a board server, and never push.`,"",`1. Run \`${h} builder start ${e} --kind ${i}\`. If it refuses, stop.`,`2. Gather the context yourself: ${c?`the card's notes, ${c}, all sections, where later sections override earlier ones; `:"the card has no notes file yet; "}its thread, in the board site's data/threads folder, the repo's docs/adr and CONTEXT.md, and the cards its notes and thread link.`,`3. Report progress with \`${h} builder progress ${e} "<plain, 80 characters or fewer>"\` at least every 10 minutes.`,f?`4. Follow the grilling skill: find every decision the card leaves open, and post each as a question for the owner with \`${h} question add --file <json>\` on topic item:${e}, one per decision, your recommendation first.`:`4. Follow the prototype skill: record the prototype with \`${h} prototype add <topic> --card ${e} --take <K>:<label> \u2026\`, write one take per option, built from the board's real components fed fake data, and post one question whose url opens it with \`${h} question add --file <json>\` on topic item:${e}.`,...o?[` The owner asked for this ${f?"grill":"prototype"}, writing: "${o}"`]:[],...u.length?[` The owner attached: ${u.map(m=>m.path).join(", ")}.`]:[],...l?[` The owner asked for prototypes of question ${l}${p?`, noting "${p}"`:""}${w?`, leaning to option ${w}`:""}: draw that question's next round, its question naming round_of ${l}.`]:[],`5. Record what you learned in the card's notes with \`${h} item set ${e} --notes-file <file.md>\`, naming each answered question's id.`,"",...T(r),`To finish, run \`${h} builder done ${e} --text "<what you asked>"\`.`,"","Never push."].join(`
|
|
4
|
+
`)}d(G,"plannerBrief");const z=d(()=>process.env.KICKFLIP_CODEX||"codex","codexBin"),J=d(({kind:t,prompt:e,model:n,cwd:a})=>t==="codex"?["exec","--sandbox","danger-full-access","--cd",a,e]:["-p",e,"--permission-mode","auto","--model",n,"--output-format","stream-json","--verbose"],"agentArgs");function Q({kind:t="claude",bin:e=t==="codex"?z():N(),cwd:n,prompt:a,model:c,log:i}){g.mkdirSync($.dirname(i),{recursive:!0});const s=g.openSync(i,"a"),r=Object.fromEntries(Object.entries(process.env).filter(([o])=>!P(o)));return $.isAbsolute(e)&&(r.PATH=`${$.dirname(e)}:${r.PATH||""}`),new Promise((o,u)=>{const l=O(e,J({kind:t,prompt:a,model:c,cwd:n}),{cwd:n,env:r,detached:!0,stdio:["ignore",s,s]});l.once("spawn",()=>{g.closeSync(s),l.unref(),o({pid:l.pid,log:i})}),l.once("error",p=>{g.closeSync(s),u(p)})})}d(Q,"launchHeadless");const V=d(t=>{try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}},"pidAlive");async function ue(t,e,{launch:n,handBack:a,alive:c=V}){const i={};for(const[s,r]of Object.entries(t)){if(!e.has(s))continue;if(r.pid!==null&&c(r.pid)){i[s]=r;continue}const o={key:s,...r.kind?{kind:r.kind}:{},...r.model?{model:r.model}:{}};if(r.pid!==null&&r.relaunched){await a(o);continue}const{key:u,...l}=o;i[s]={...l,...await n(o),relaunched:r.pid!==null}}return i}d(ue,"reviveBuilders");const Z=d((t,e)=>$.join(A.homedir(),".claude","kickflip","logs",`${e}-${t}-${new Date().toISOString().replace(/[:.]/g,"-")}.log`),"logFile");async function pe(t,{key:e,kind:n="claude",model:a,text:c,files:i,question:s,note:r,choice:o},{lander:u=!1}={}){const l=await D(t,e),p=S(t)?.route??$.basename(t),w=[...F(),...await H(t)].map(I=>I.text),{base:h,repo:f}=W(t),m=E({kind:n}),v={...l,route:p,base:h,repo:f,rules:w,kind:n,text:c,files:i,question:s,note:r,choice:o},x=n==="codex"?"codex":a||"opus",y=m?"Planner":u?"Lander":"Builder",R=m?f:l.worktree,C=m?G(v):u?X(v):U(v),k=await Q({kind:m?"claude":n,cwd:R,prompt:C,model:x,log:Z(e.split("/")[1],y.toLowerCase())});return await Y(t,e,{text:`${y} launched on ${M({kind:n,model:x})} in ${R}.`,log:$.basename(k.log)}),k}d(pe,"launchBuilder");export{_ as BOARD_CLI,U as builderBrief,X as landerBrief,pe as launchBuilder,Q as launchHeadless,V as pidAlive,G as plannerBrief,ue as reviveBuilders};
|
package/board/merge3.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var A=Object.defineProperty;var d=(o,t)=>A(o,"name",{value:t,configurable:!0});import{isDeepStrictEqual as y}from"node:util";const p=d(o=>o!==null&&typeof o=="object"&&!Array.isArray(o),"isObject"),g=d(o=>Array.isArray(o)&&o.every(t=>p(t)&&typeof t.id=="string")&&new Set(o.map(t=>t.id)).size===o.length,"byId");function j(o,t,f){if(y(t,o))return f;if(y(f,o))return t;if(p(o)&&p(t)&&p(f)){const c={};for(const n of new Set([...Object.keys(f),...Object.keys(t)])){if(!(n in t)){(!(n in o)||!y(o[n],f[n]))&&(c[n]=f[n]);continue}if(!(n in f)){(!(n in o)||!y(o[n],t[n]))&&(c[n]=t[n]);continue}c[n]=j(o[n],t[n],f[n])}return c}if(g(o)&&g(t)&&g(f)){const c=d((r,u)=>r.find(i=>i.id===u),"find"),n=[];for(const r of f){const u=c(o,r.id),i=c(t,r.id);if(!i){(!u||!y(u,r))&&n.push(r);continue}n.push(u?j(u,i,r):i)}return[...n,...t.filter(r=>!c(f,r.id)&&!c(o,r.id))]}return t}d(j,"merge3");export{j as merge3};
|
package/board/on-host.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var g=Object.defineProperty;var t=(e,r)=>g(e,"name",{value:r,configurable:!0});import w from"node:os";import{execFile as p}from"node:child_process";import{promisify as h}from"node:util";import{tailscaleBin as x}from"../shared/tailscale.mjs";const y=h(p),I=t(e=>String(e||"").trim().replace(/^::ffff:/,""),"bare"),c=t(()=>Object.values(w.networkInterfaces()).flat().map(e=>e.address),"interfaceAddresses");async function u(){const e=x();if(!e)return null;try{const{stdout:r}=await y(e,["status","--json"],{timeout:5e3}),{Self:s,User:n}=JSON.parse(r);return{login:n?.[s.UserID]?.LoginName,addresses:s.TailscaleIPs||[]}}catch{return null}}t(u,"tailnetNode");const f=t(e=>{const r=e.headers["x-forwarded-for"];return I(r?String(r).split(",").at(-1):e.socket.remoteAddress)},"clientOf");async function j(e,r={addresses:c,node:u}){const s=e.headers["x-forwarded-for"],n=f(e);if(r.addresses().includes(n))return!0;const o=e.headers["tailscale-user-login"];if(!s||!o)return!1;const a=await r.node();return!!a&&a.login===o&&a.addresses.includes(n)}t(j,"fromHost");function S(e={addresses:c,node:u},{now:r=Date.now,retryMs:s=5e3}={}){let n=null,o=null,a=-1/0;const m=t(()=>n||r()-a<s?n:(o||=e.node().then(i=>(o=null,i?.login?n=i.login:a=r(),n)),o),"ownerLogin");return async i=>{if(e.addresses().includes(f(i)))return"owner";const l=i.headers["tailscale-user-login"];if(!l)return"other";const d=await m();return d?d===l?"owner":"other":"unread"}}t(S,"ownerGate");const B=S();export{j as fromHost,B as hubGate,S as ownerGate};
|
package/board/permissions.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var S=Object.defineProperty;var
|
|
2
|
-
`),await c.rename(
|
|
1
|
+
var S=Object.defineProperty;var r=(s,t)=>S(s,"name",{value:t,configurable:!0});import c from"node:fs/promises";import p from"node:path";import{BoardError as d}from"./store.mjs";const u=[{id:"pushMain",name:"Push main after a land",allows:"Pushes main to origin as each card lands, and lets the session push it.",rule:"Bash(git push origin main)"},{id:"pushTags",name:"Push release tags",allows:"Lets the session push a v* release tag.",rule:"Bash(git push origin v*)"},{id:"draftPr",name:"Open draft PRs",allows:"Lets the session open a PR as a draft.",rule:"Bash(gh pr create --draft *)"}];function f(s){if(typeof s!="string"||!p.isAbsolute(s))throw new d("This board has no repo folder recorded, so it has no settings.local.json to write. Set its repo first.");return p.join(s,".claude","settings.local.json")}r(f,"settingsFile");const m=r(s=>p.join(s,"data","permission-refusals.json"),"refusalsFile");async function y(s,t,i){let n;try{n=await c.readFile(s,"utf8")}catch(e){if(e.code==="ENOENT")return t;throw e}try{const e=JSON.parse(n);if(e&&typeof e=="object"&&!Array.isArray(e))return e}catch{}throw new d(`${i} isn\u2019t a JSON object, so the hub leaves it as it is. Fix it by hand first.`)}r(y,"readJSON");async function g(s,t){await c.mkdir(p.dirname(s),{recursive:!0});const i=`${s}.${process.pid}.${Date.now()}.tmp`;await c.writeFile(i,JSON.stringify(t,null,2)+`
|
|
2
|
+
`),await c.rename(i,s)}r(g,"writeJSON");const j=r(s=>Array.isArray(s.permissions?.allow)?s.permissions.allow:[],"allowOf");async function x(s){const t=j(await y(f(s),{},f(s)));return Object.fromEntries(u.map(i=>[i.id,t.includes(i.rule)]))}r(x,"readPermissions");async function A(s,t,i,n){const e=u.find(l=>l.id===i);if(!e||typeof n!="boolean")throw new d(`Send {id, on} with id one of ${u.map(l=>l.id).join(", ")} and on true or false.`);const o=f(s),a=await y(o,{},o);if(a.permissions!==void 0&&(typeof a.permissions!="object"||Array.isArray(a.permissions)||a.permissions===null))throw new d(`${o}\u2019s permissions isn\u2019t an object, so the hub leaves it as it is. Fix it by hand first.`);const w=j(a);w.includes(e.rule)!==n&&(a.permissions={...a.permissions,allow:n?[...w,e.rule]:w.filter(l=>l!==e.rule)},await g(o,a));const h=await b(t);return h[i]&&(delete h[i],await g(m(t),h)),x(s)}r(A,"setPermission");const b=r(s=>y(m(s),{},m(s)),"readRefusals");function O(s){return typeof s!="string"?null:u.find(t=>{const i=/^Bash\((.*)\)$/.exec(t.rule)[1];return new RegExp(`^${i.split("*").map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join(".*")}$`).test(s.trim())})||null}r(O,"permissionFor");async function F(s,t,i,n=new Date){if(i?.tool_name!=="Bash")return null;const e=O(i.tool_input?.command);if(!e||!(await x(s))[e.id])return null;const o=await b(t);return o[e.id]={command:i.tool_input.command,how:i.hook_event_name==="PermissionDenied"?"denied":"asked",at:n.toISOString()},await g(m(t),o),e.id}r(F,"recordRefusal");async function B(s,t){const[i,n]=await Promise.all([x(s),b(t)]);return{file:f(s),permissions:u.map(e=>({...e,on:i[e.id],refused:i[e.id]&&n[e.id]||null}))}}r(B,"permissionsView");export{u as PERMISSIONS,O as permissionFor,B as permissionsView,x as readPermissions,b as readRefusals,F as recordRefusal,A as setPermission,f as settingsFile};
|
package/board/public/app-asks.js
CHANGED
|
@@ -1,102 +1,19 @@
|
|
|
1
|
-
// New tasks on the board view:
|
|
1
|
+
// New tasks on the board view: bump, edit and delete for a New card or a
|
|
2
2
|
// pending thread message (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
|
|
3
3
|
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
|
-
const { $,
|
|
6
|
-
const {
|
|
5
|
+
const { $, state, toast } = B;
|
|
6
|
+
const { waitOn } = BoardUI;
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
const sheet = $('#sheet');
|
|
10
|
-
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
11
|
-
if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
|
|
12
|
-
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
13
|
-
BoardAttach.paint('ask', B.site.root);
|
|
14
|
-
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
15
|
-
if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
|
|
16
|
-
// ⌘↵ (Ctrl+↵ off a Mac) sends from any composer: the ask, a thread reply, an edit.
|
|
8
|
+
// ⌘↵ (Ctrl+↵ off a Mac) sends a thread reply or an edit from its box; the ask's own is new-task.js's.
|
|
17
9
|
B.on(B.root, 'keydown', (ev) => {
|
|
18
10
|
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
|
|
19
|
-
const form = ev.target instanceof HTMLTextAreaElement ? ev.target.closest('form[data-
|
|
11
|
+
const form = ev.target instanceof HTMLTextAreaElement ? ev.target.closest('form[data-reply], form.edit-form') : null;
|
|
20
12
|
if (!form) return;
|
|
21
13
|
ev.preventDefault();
|
|
22
14
|
form.requestSubmit();
|
|
23
15
|
});
|
|
24
|
-
|
|
25
|
-
B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
|
|
26
|
-
let stopFitting = null;
|
|
27
|
-
const stopFit = () => { stopFitting?.(); stopFitting = null; };
|
|
28
|
-
B.on(sheet, 'close', stopFit);
|
|
29
|
-
B.atUnmount(stopFit);
|
|
30
|
-
function openSheet() {
|
|
31
|
-
if (sheet.open) return;
|
|
32
|
-
stopFit();
|
|
33
|
-
stopFitting = BoardDialogs.fitSheet(sheet);
|
|
34
|
-
sheet.classList.remove('closing');
|
|
35
|
-
sheet.showModal();
|
|
36
|
-
$('#askText').focus();
|
|
37
|
-
$('#compose').setAttribute('aria-expanded', 'true');
|
|
38
|
-
}
|
|
39
|
-
function closeSheet() {
|
|
40
|
-
if (!sheet.open || sheet.classList.contains('closing')) return;
|
|
41
|
-
$('#compose').setAttribute('aria-expanded', 'false');
|
|
42
|
-
if (reduceMotion.matches) { sheet.close(); return; }
|
|
43
|
-
sheet.classList.add('closing');
|
|
44
|
-
B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
|
|
45
|
-
}
|
|
46
|
-
B.on($('#compose'), 'click', openSheet);
|
|
47
|
-
// n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
|
|
48
|
-
// nothing focused the key goes to <body>, outside the view, so this listens on the document. A key switch mode
|
|
49
|
-
// used (sidebar.js) comes defaultPrevented and doesn't count.
|
|
50
|
-
const TWICE_MS = 500;
|
|
51
|
-
let lastN = -Infinity;
|
|
52
|
-
B.on(document, 'keydown', (ev) => {
|
|
53
|
-
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
54
|
-
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
55
|
-
const field = ev.target instanceof Element && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
56
|
-
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
|
|
57
|
-
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
58
|
-
lastN = -Infinity;
|
|
59
|
-
ev.preventDefault();
|
|
60
|
-
openSheet();
|
|
61
|
-
});
|
|
62
|
-
B.on($('#sheetClose'), 'click', closeSheet);
|
|
63
|
-
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
64
|
-
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
65
|
-
// Discuss or Quick build, above Send: Discuss, the default, has the session grill or prototype the task at once; Quick
|
|
66
|
-
// build sends the ask with quick: true, and the session marks the card ready. Each send goes back to Discuss
|
|
67
|
-
// (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
68
|
-
const CHOICE_HELP = {
|
|
69
|
-
discuss: 'Discuss: Claude grills or prototypes it first, then waits for you.',
|
|
70
|
-
quick: 'Quick build: for changes you’re sure about; Claude marks it ready and builds.',
|
|
71
|
-
};
|
|
72
|
-
let quick = false;
|
|
73
|
-
function choose(isQuick) {
|
|
74
|
-
quick = isQuick;
|
|
75
|
-
$('#askDiscuss').setAttribute('aria-pressed', String(!quick));
|
|
76
|
-
$('#askQuick').setAttribute('aria-pressed', String(quick));
|
|
77
|
-
$('#askChoiceHelp').textContent = CHOICE_HELP[quick ? 'quick' : 'discuss'];
|
|
78
|
-
}
|
|
79
|
-
choose(false);
|
|
80
|
-
B.on(B.root, 'click', (ev) => {
|
|
81
|
-
const b = ev.target.closest('#askDiscuss, #askQuick');
|
|
82
|
-
if (b) choose(b.id === 'askQuick');
|
|
83
|
-
});
|
|
84
|
-
B.on($('#askForm'), 'submit', async (ev) => {
|
|
85
|
-
ev.preventDefault();
|
|
86
|
-
$('#askText').blur();
|
|
87
|
-
const text = $('#askText').value.trim();
|
|
88
|
-
const files = BoardAttach.files('ask', B.site.root);
|
|
89
|
-
if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
|
|
90
|
-
$('#askStatus').textContent = '';
|
|
91
|
-
try {
|
|
92
|
-
await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => B.send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
|
|
93
|
-
BoardAttach.clear('ask', B.site.root);
|
|
94
|
-
choose(false);
|
|
95
|
-
$('#askStatus').textContent = '';
|
|
96
|
-
closeSheet();
|
|
97
|
-
toast('Sent');
|
|
98
|
-
} catch { $('#askStatus').textContent = 'Could not send'; }
|
|
99
|
-
});
|
|
16
|
+
BoardNewTask.wire(B);
|
|
100
17
|
|
|
101
18
|
// A bump moves the New card to the top once the server has recorded it, button (its icon, or the prototype button
|
|
102
19
|
// that bumps it) waiting meanwhile (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
@@ -263,9 +263,9 @@ BoardView.part((B) => {
|
|
|
263
263
|
const CONFIRM = {
|
|
264
264
|
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
265
265
|
nudge: () => 'Nudge Claude to check this implementer?',
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
|
|
267
|
+
merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
|
|
268
|
+
: openPRs(it).length > 1
|
|
269
269
|
? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
|
|
270
270
|
: `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
|
|
271
271
|
'card-build': (it, ref) => (it.blockedBy?.length
|
|
@@ -294,21 +294,21 @@ BoardView.part((B) => {
|
|
|
294
294
|
const ACTION_BUTTONS = {
|
|
295
295
|
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
|
|
296
296
|
[BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
|
|
297
|
-
// Grill and Prototype each ask for a planner; both wait while either is under way.
|
|
298
|
-
[BUTTON.grill]: (it) => ['grill', 'prototype'].map((act) => `<button data-confirm="${act}" data-key="${esc(it.key)}"${it.grilling || it.prototyping ? ' disabled' : ''}>${act === 'grill' ? 'Grill' : 'Prototype'}</button>`).join(''),
|
|
299
297
|
};
|
|
300
298
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
301
299
|
|
|
302
300
|
// The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
|
|
303
301
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
304
302
|
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
305
|
-
// Its open questions asked since this browser last opened it
|
|
303
|
+
// Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
|
|
304
|
+
// questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
306
305
|
function card(it, band = {}) {
|
|
307
306
|
const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
|
|
307
|
+
const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
|
|
308
308
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
309
309
|
<summary class="work">
|
|
310
310
|
<span class="status ${it.lane}"></span>
|
|
311
|
-
<div class="title-wrap">${workLines(it, { unread
|
|
311
|
+
<div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
|
|
312
312
|
</summary>
|
|
313
313
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
314
314
|
</details>`;
|
|
@@ -450,7 +450,7 @@ BoardView.part((B) => {
|
|
|
450
450
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
451
451
|
// Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
|
|
452
452
|
// server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
453
|
-
const sent = {
|
|
453
|
+
const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
|
|
454
454
|
finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
|
|
455
455
|
if (sent) {
|
|
456
456
|
// Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
|
|
@@ -461,8 +461,17 @@ BoardView.part((B) => {
|
|
|
461
461
|
state.openItems.delete(key);
|
|
462
462
|
store.set('openItems', [...state.openItems]);
|
|
463
463
|
} : null;
|
|
464
|
-
|
|
465
|
-
|
|
464
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
|
|
465
|
+
// In Review, or no longer merging there, which is its refusal.
|
|
466
|
+
const pend = act === 'merge' ? `${key}:merge` : null;
|
|
467
|
+
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
468
|
+
try {
|
|
469
|
+
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
470
|
+
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
471
|
+
} catch (e) {
|
|
472
|
+
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
473
|
+
B.failed(e);
|
|
474
|
+
}
|
|
466
475
|
if (act === 'finish') B.syncDone(key);
|
|
467
476
|
return;
|
|
468
477
|
}
|
package/board/public/app-core.js
CHANGED
|
@@ -95,25 +95,6 @@ BoardView.part((B) => {
|
|
|
95
95
|
store.set('seenThreads', seen);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
|
|
99
|
-
// So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
|
|
100
|
-
const quiet = new Map();
|
|
101
|
-
function quieted(draftKey, ta) {
|
|
102
|
-
const until = quiet.get(draftKey) || 0;
|
|
103
|
-
if (until <= Date.now()) return false;
|
|
104
|
-
if (until !== Infinity) ta.value = '';
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
async function sendDraft(draftKey, ta, current, send) {
|
|
108
|
-
ta.blur();
|
|
109
|
-
quiet.set(draftKey, Infinity);
|
|
110
|
-
try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
|
|
111
|
-
store.set(draftKey, '');
|
|
112
|
-
const now = current();
|
|
113
|
-
if (now) now.value = '';
|
|
114
|
-
quiet.set(draftKey, Date.now() + 1000);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
98
|
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
118
99
|
const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
|
|
119
100
|
const svg = (body, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
@@ -124,5 +105,5 @@ BoardView.part((B) => {
|
|
|
124
105
|
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
125
106
|
const notice = dialogs.notice;
|
|
126
107
|
|
|
127
|
-
Object.assign(B, { steady, restorePlace, plural, seen, markSeen,
|
|
108
|
+
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, links, svg, confirm, notice });
|
|
128
109
|
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log →"
|
|
2
|
+
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
|
+
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
|
+
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
|
+
// A link in it closes it first, then opens through the shell.
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as lines, what Claude says and each
|
|
7
|
+
// tool call, with a raw toggle for the file as it is; any other log reads as its text. It is re-read every 3 seconds
|
|
8
|
+
// until the run's result arrives, and follows new lines while read at its end.
|
|
9
|
+
BoardView.part((B) => {
|
|
10
|
+
const { $, state } = B;
|
|
11
|
+
const { esc, markdown, linkContext } = BoardUI;
|
|
12
|
+
const sheet = $('#readerSheet');
|
|
13
|
+
const body = sheet.querySelector('.reader-body');
|
|
14
|
+
const tools = sheet.querySelector('.reader-tools');
|
|
15
|
+
const more = sheet.querySelector('.reader-new');
|
|
16
|
+
let opened = 0;
|
|
17
|
+
let pushed = false;
|
|
18
|
+
let pending = null;
|
|
19
|
+
let log = null;
|
|
20
|
+
let stopReading = null;
|
|
21
|
+
|
|
22
|
+
// → the log's lines, each a stream event or, for a line that isn't one, its text; null when no line is an event.
|
|
23
|
+
function streamOf(text) {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
if (lines.at(-1) === '') lines.pop();
|
|
26
|
+
const read = (line) => { try { const e = JSON.parse(line); return typeof e?.type === 'string' ? e : null; } catch { return null; } };
|
|
27
|
+
const events = lines.map(read);
|
|
28
|
+
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
29
|
+
}
|
|
30
|
+
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
+
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
+
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
+
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
+
// result have none; the raw view shows them.
|
|
35
|
+
function rowsOf(e) {
|
|
36
|
+
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
+
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
+
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
+
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
+
const content = e.message.content;
|
|
41
|
+
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
+
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
+
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
+
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
+
}
|
|
46
|
+
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
|
+
|
|
48
|
+
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
|
+
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
50
|
+
function drawTools() {
|
|
51
|
+
if (!log?.stream) { tools.innerHTML = ''; return; }
|
|
52
|
+
const word = log.finished ? 'finished' : log.following ? 'following' : 'paused';
|
|
53
|
+
tools.innerHTML = `<span class="reader-status${word === 'following' ? ' on' : ''}">${word}</span><button type="button" data-reader="raw" aria-pressed="${log.raw}">raw</button>`;
|
|
54
|
+
}
|
|
55
|
+
function drawMore() {
|
|
56
|
+
more.hidden = !log?.unseen;
|
|
57
|
+
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
|
+
}
|
|
59
|
+
function drawLog() {
|
|
60
|
+
const { text, stream, raw } = log;
|
|
61
|
+
body.innerHTML = !text ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
62
|
+
: !stream ? `<pre class="reader-text">${esc(text)}</pre>`
|
|
63
|
+
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
+
: stream.flatMap(rowsOf).map(rowHtml).join('');
|
|
65
|
+
drawTools();
|
|
66
|
+
drawMore();
|
|
67
|
+
}
|
|
68
|
+
function follow() {
|
|
69
|
+
log.following = true;
|
|
70
|
+
log.unseen = 0;
|
|
71
|
+
toEnd();
|
|
72
|
+
drawTools();
|
|
73
|
+
drawMore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readLog(n) {
|
|
77
|
+
let answer;
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
80
|
+
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
|
+
} catch { answer = null; }
|
|
82
|
+
if (n !== opened) return;
|
|
83
|
+
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
+
if (answer && answer.size !== log.size) {
|
|
85
|
+
const before = log.stream?.length ?? 0;
|
|
86
|
+
const stream = streamOf(answer.text);
|
|
87
|
+
Object.assign(log, { size: answer.size, text: answer.text, stream, finished: Boolean(stream?.some((e) => e.type === 'result')) });
|
|
88
|
+
if (!log.following) log.unseen += Math.max(0, (stream?.length ?? 0) - before);
|
|
89
|
+
drawLog();
|
|
90
|
+
if (log.following) toEnd();
|
|
91
|
+
}
|
|
92
|
+
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function show(title, ref) {
|
|
96
|
+
opened++;
|
|
97
|
+
stopReading?.();
|
|
98
|
+
log = null;
|
|
99
|
+
sheet.querySelector('#readerTitle').textContent = title;
|
|
100
|
+
sheet.querySelector('.reader-ref').textContent = ref;
|
|
101
|
+
tools.innerHTML = '';
|
|
102
|
+
more.hidden = true;
|
|
103
|
+
body.innerHTML = '<p class="muted">Loading…</p>';
|
|
104
|
+
sheet.showModal();
|
|
105
|
+
history.pushState({ ...history.state, idx: history.state.idx + 1, reader: true }, '', location.href);
|
|
106
|
+
pushed = true;
|
|
107
|
+
return opened;
|
|
108
|
+
}
|
|
109
|
+
function openLog(file) {
|
|
110
|
+
const n = show(`${file.split('-')[0]} log`, file);
|
|
111
|
+
log = { file, size: -1, text: '', stream: null, raw: false, following: true, unseen: 0, finished: false };
|
|
112
|
+
readLog(n);
|
|
113
|
+
}
|
|
114
|
+
async function openNotes(search) {
|
|
115
|
+
const wave = new URLSearchParams(search).has('wave');
|
|
116
|
+
const n = show('notes', '');
|
|
117
|
+
let html;
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(B.site.url(`api/notes${search}`), { cache: 'no-store' });
|
|
120
|
+
const answer = await r.json();
|
|
121
|
+
if (!r.ok) throw new Error(answer.error || `notes ${r.status}`);
|
|
122
|
+
if (n !== opened) return;
|
|
123
|
+
sheet.querySelector('.reader-ref').textContent = `${wave ? answer.title : answer.ref} notes`;
|
|
124
|
+
html = `<article class="plan reader-md">${markdown(answer.markdown, linkContext(state.board?.items || [], answer.key ?? null))}</article>`;
|
|
125
|
+
} catch (e) { html = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`; }
|
|
126
|
+
if (n === opened) body.innerHTML = html;
|
|
127
|
+
}
|
|
128
|
+
function close() {
|
|
129
|
+
if (!sheet.open) return;
|
|
130
|
+
sheet.close();
|
|
131
|
+
opened++;
|
|
132
|
+
stopReading?.();
|
|
133
|
+
if (pushed) { pushed = false; history.back(); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plainTap = (ev) => !ev.defaultPrevented && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey;
|
|
137
|
+
B.on(B.root, 'click', (ev) => {
|
|
138
|
+
if (!plainTap(ev)) return;
|
|
139
|
+
const a = ev.target.closest('a[href^="log.html?"], a[href^="notes.html?"]');
|
|
140
|
+
if (!a) return;
|
|
141
|
+
ev.preventDefault();
|
|
142
|
+
const href = a.getAttribute('href');
|
|
143
|
+
if (href.startsWith('log.html')) openLog(new URLSearchParams(href.slice(href.indexOf('?'))).get('file'));
|
|
144
|
+
else openNotes(href.slice(href.indexOf('?')));
|
|
145
|
+
});
|
|
146
|
+
B.on(sheet, 'click', (ev) => {
|
|
147
|
+
if (ev.target === sheet) { close(); return; }
|
|
148
|
+
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
|
+
if (act === 'close') close();
|
|
150
|
+
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
151
|
+
else if (act === 'end') follow();
|
|
152
|
+
const a = ev.target.closest('a[href]');
|
|
153
|
+
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
|
+
ev.preventDefault();
|
|
155
|
+
pending = new URL(a.getAttribute('href'), location.href).href;
|
|
156
|
+
close();
|
|
157
|
+
});
|
|
158
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); close(); });
|
|
159
|
+
B.on(body, 'scroll', () => {
|
|
160
|
+
if (!log?.stream) return;
|
|
161
|
+
log.following = atEnd();
|
|
162
|
+
if (log.following) log.unseen = 0;
|
|
163
|
+
drawTools();
|
|
164
|
+
drawMore();
|
|
165
|
+
});
|
|
166
|
+
// Back while it's open: the router has kept the board, as the entry under it holds the same URL.
|
|
167
|
+
B.on(window, 'popstate', () => {
|
|
168
|
+
if (pushed) { pushed = false; sheet.close(); opened++; stopReading?.(); }
|
|
169
|
+
if (!pending) return;
|
|
170
|
+
const href = pending;
|
|
171
|
+
pending = null;
|
|
172
|
+
if (!AppShell.open(href)) location.assign(href);
|
|
173
|
+
});
|
|
174
|
+
});
|