@leftoff/cli 0.9.8-beta.7 → 0.9.8-beta.9
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/CHANGELOG.md +2 -0
- package/bin/leftoff.js +2 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +87 -9
- package/ui/app.js +215 -4
- package/ui/index.html +3 -0
- package/ui/tabs.js +216 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,7 @@ What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The boar
|
|
|
9
9
|
- **Outside events**: CI or a webhook can post into a live session with this Mac's key (`leftoff live key`), signed through your leftoff.dev board.
|
|
10
10
|
- **Restore from your phone or another Mac**: it asks that Mac first, and nothing opens until someone there confirms.
|
|
11
11
|
- **Sidebar**: the sections move to a narrow rail on the left, with a count on Needs you. On phones they sit in a bar at the bottom, the rest under More.
|
|
12
|
+
- **Tabs**: sessions open in tabs next to the board. A click opens one italic preview tab that the next click replaces; it stays once you send a Live message, type there, or double-click it. On a phone, an Open row on the board and a swipe between open sessions. The layout follows the window's width, so iPad, Split View and folding keep your tabs.
|
|
12
13
|
- **Quick look**: hover Needs you (or long-press it on a phone) to see the five longest waits and Allow or Deny a permission prompt right there.
|
|
13
14
|
- **Pull to refresh** on phones: pull down from the top of any page to read it again.
|
|
14
15
|
- **Always allow here** on permission prompts, like Claude Code's own "don't ask again". It adds the exact rule (say `Bash(rm /tmp/x)`) to the project's `.claude/settings.local.json`; the ⓘ beside it shows which rule and where. Quick look calls it **Always**.
|
|
@@ -18,6 +19,7 @@ What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The boar
|
|
|
18
19
|
- **The Live badge pulses while Claude is working** on a turn, and stays still when the session is idle (no motion with Reduce Motion on).
|
|
19
20
|
- **Notifications on your phone.** Add the board to your iPhone's Home Screen (iOS 16.4+), tap **Turn on notifications** under More, and you get a notification when Claude needs your OK or a Live reply lands. The Mac running the session sends it, encrypted end to end. Any browser with Push works the same.
|
|
20
21
|
### Fixed
|
|
22
|
+
- `leftoff upgrade` asks npm for fresh package info, so a version published minutes ago installs instead of failing with "No matching version".
|
|
21
23
|
- Live chat: a message no longer stays on **Working** after Claude has answered. It settles when the turn ends, including a second message sent while Claude was busy ("Answered with the reply above"), and stuck ones clear when you update.
|
|
22
24
|
- Live chat: a message sent while an older one was stuck now gets its own reply instead of sitting on **Starting**, and each message shows the time it was sent instead of a frozen "just now".
|
|
23
25
|
- Live chat: **Jump to latest ↓** now shows whenever you've scrolled up, not only when something new arrives, and the chat stays at the bottom while Claude's reply grows.
|
package/bin/leftoff.js
CHANGED
|
@@ -218,7 +218,7 @@ Turn it off first: tailscale funnel --https=`+w+" off"),1;if((y.proxy||y.other)&
|
|
|
218
218
|
`).trim()}))}function wi(n=AM){try{return tg(vM.readFileSync(n,"utf8"))}catch{return[]}}function ki(n,e,t){return n.filter(s=>(!e||mt(s.version,e)>0)&&(!t||mt(s.version,t)<=0))}function av(n,e,t){return n.map(s=>({...s,isNew:(!t||mt(s.version,t)<=0)&&(!e||mt(s.version,e)>0)}))}function Td(n,{colors:e=null,width:t=78}={}){let s=e||{bold:i=>i,dim:i=>i,cyan:i=>i,green:i=>i},r=[],o=(i,a)=>{let l=String(i).replace(/\*\*(.+?)\*\*/g,"$1").split(/\s+/),c=[],u="";for(let d of l)(u+" "+d).trim().length>t-a.length?(c.push(u),u=d):u=(u+" "+d).trim();return u&&c.push(u),c.map((d,f)=>(f?a:"")+d)};for(let i of n){r.push(s.bold(s.cyan("v"+i.version))+(i.date?s.dim(" "+i.date):"")+(i.isNew?" "+s.green("new"):""));for(let a of i.sections){r.push(" "+s.bold(a.title));for(let l of a.items)r.push(" • "+o(l," ").join(`
|
|
219
219
|
`))}r.push("")}return r.join(`
|
|
220
220
|
`)}var AM,_d,Ed=K(()=>{Em();AM=ov.resolve(ov.dirname(xM(import.meta.url)),"..","CHANGELOG.md");_d=n=>eg(n)!==null});var wv={};at(wv,{CHECK_EVERY_MS:()=>og,DEFAULT_SOURCE:()=>ml,GITHUB_REPO:()=>ts,NPM_PACKAGE:()=>gt,PACKAGE_DIRS:()=>gl,REPO_DIR:()=>rr,STAMP_FILE:()=>rg,checkForUpdate:()=>wl,defaultCacheDir:()=>cg,fetchRemoteFiles:()=>yv,installInfo:()=>yl,isNpmBuild:()=>fv,isNpxCacheDir:()=>sg,lsRemote:()=>gv,normalizeSource:()=>bi,npmDistTags:()=>lg,npmLatest:()=>CM,npmPackageUrl:()=>Od,npmRegistry:()=>Cd,npmTarget:()=>pv,npmUpgradeCommand:()=>ag,packageManagerOf:()=>hv,readPkg:()=>dv,readStamp:()=>cv,registryPath:()=>uv,renamedSource:()=>oo,runGit:()=>Zt,sshFallback:()=>mv,writeStamp:()=>ig});import Si from"node:fs";import _M from"node:os";import ps from"node:path";import{execFile as TM}from"node:child_process";import{fileURLToPath as EM}from"node:url";function bi(n){let e=String(n||"").trim();if(!e)return ml;let t=e.match(/^(?:github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/);return t&&!e.includes("://")&&!e.startsWith("/")&&!e.startsWith(".")?oo("https://github.com/"+t[1]+"/"+t[2]+".git"):oo(e)}function oo(n){let e=String(n||"");for(let t of j0){let s=t.replace(/[.]/g,"\\."),r=new RegExp("^(https://github\\.com/|git@github\\.com:|ssh://git@github\\.com/)"+s+"(?:\\.git)?/?$","i"),o=e.match(r);if(o)return o[1]+ts+".git"}return e}function Zt(n,{cwd:e,timeoutMs:t=15e3,env:s=process.env}={}){return new Promise(r=>{let o={...s,GIT_TERMINAL_PROMPT:"0",GIT_ASKPASS:s.GIT_ASKPASS||"echo",SSH_ASKPASS:"echo",GIT_SSH_COMMAND:s.GIT_SSH_COMMAND||"ssh -o BatchMode=yes"};TM(s.CHECKPOINTS_GIT||"git",n,{cwd:e,timeout:t,env:o,maxBuffer:4*1024*1024},(i,a,l)=>{r({code:i?typeof i.code=="number"?i.code:-1:0,stdout:String(a||""),stderr:String(l||(i&&!a?i.message:"")||"")})})})}function cv(n=rr){try{return JSON.parse(Si.readFileSync(ps.join(n,rg),"utf8"))}catch{return null}}function ig(n,e=rr){let t={version:n.version||Q,sha:n.sha||null,source:n.source||null,at:n.at||new Date().toISOString()};return Si.writeFileSync(ps.join(e,rg),JSON.stringify(t,null,2)+`
|
|
221
|
-
`),t}function sg(n){return/[\\/]_npx[\\/]/.test(n)||/[\\/]\.npm[\\/]/.test(n)}function uv(n=gt){return n.startsWith("@")?"@"+n.slice(1).split("/").map(encodeURIComponent).join("%2f"):encodeURIComponent(n)}function Od(n=process.env,e=gt){return Cd(n)+"/"+uv(e)}function dv(n=rr){try{return JSON.parse(Si.readFileSync(ps.join(n,"package.json"),"utf8"))}catch{return null}}function fv(n=rr){let e=dv(n);return!!(e&&e.leftoffBuild==="npm")}function hv(n,e=process.env){if(e.LEFTOFF_PM)return e.LEFTOFF_PM;let t=String(n||"");return/[\\/]\.?pnpm[\\/]/i.test(t)||/[\\/]pnpm-global[\\/]/i.test(t)?"pnpm":/[\\/]\.bun[\\/]/.test(t)?"bun":"npm"}function ag(n,e=gt+"@latest"){let t=process.platform==="win32";return n==="pnpm"?[t?"pnpm.cmd":"pnpm","add","-g",e]:n==="bun"?["bun","add","-g",e]:[t?"npm.cmd":"npm","install","-g",e,"--no-fund","--no-audit","--loglevel=error"]}function Cd(n=process.env){return String(n.LEFTOFF_NPM_REGISTRY||n.npm_config_registry||"https://registry.npmjs.org").replace(/\/+$/,"")}async function lg({env:n=process.env,fetchImpl:e=fetch,timeoutMs:t=15e3}={}){let s=Od(n);try{let r=await e(s,{headers:{accept:"application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8"},signal:AbortSignal.timeout(t)});if(r.status===404)return{ok:!1,tags:{},error:gt+" isn’t on "+Cd(n)+" (404)"};if(!r.ok)return{ok:!1,tags:{},error:Cd(n)+" answered "+r.status};let o=await r.json(),i={...o&&o["dist-tags"]||{}};return!i.latest&&o&&o.version&&(i.latest=o.version),i.latest?{ok:!0,tags:i,error:null}:{ok:!1,tags:{},error:"no version in the registry answer"}}catch(r){return{ok:!1,tags:{},error:r.message}}}async function CM(n={}){let e=await lg(n);return e.ok?{ok:!0,version:e.tags.latest,error:null}:{ok:!1,version:null,error:e.error}}function pv(n,e={},t=null){let s=t||(_d(n)?"beta":"latest"),r=e.latest||null,o="latest";s==="beta"&&e.beta&&(!r||mt(e.beta,r)>0)&&(r=e.beta,o="beta");let i=!!r&&mt(r,n)>0;return{version:r,tag:o,available:i,command:o==="beta"?"leftoff upgrade --beta":"leftoff upgrade"}}async function yl({repoDir:n=rr,env:e=process.env,globalRoot:t}={}){let s={version:Q,sha:null,kind:"local",source:null,repoDir:n};if(Si.existsSync(ps.join(n,".git"))){s.kind="clone";let o=await Zt(["rev-parse","HEAD"],{cwd:n,env:e});o.code===0&&(s.sha=o.stdout.trim());let i=await Zt(["remote","get-url","origin"],{cwd:n,env:e});return s.source=i.code===0?i.stdout.trim():null,s}if(fv(n))return s.kind="npm",s.source="npm:"+gt,s.pm=hv(n,e),s.npx=sg(n),s;let r=cv(n);return r&&(s.sha=r.sha||null,s.source=r.source||null),sg(n)?s.kind="npx":(t?n.startsWith(t):/[\\/]node_modules[\\/](checkpoints|leftoff|@leftoff[\\/]cli)$/.test(n))&&(s.kind="global"),s}function mv(n){let e=String(n||"").match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/);return e?"git@github.com:"+e[1]+"/"+e[2]+".git":null}async function lv(n,{env:e,timeoutMs:t}){let s=await Zt(["ls-remote",n,"HEAD"],{env:e,timeoutMs:t}),r=s.code===0?(s.stdout.match(/^([0-9a-f]{40})\s+HEAD$/m)||[])[1]:null;return r?{ok:!0,sha:r,error:null,source:n}:{ok:!1,sha:null,error:ng(s.stderr)||"git ls-remote exited "+s.code,source:n}}async function gv(n,{env:e=process.env,timeoutMs:t=15e3}={}){let s=bi(n),r=await lv(s,{env:e,timeoutMs:t});if(r.ok)return r;let o=mv(s);if(!o)return r;let i=await lv(o,{env:e,timeoutMs:t});return i.ok?i:{ok:!1,sha:null,source:s,error:r.error+" (also tried "+o+": "+i.error+")"}}async function yv(n,{cacheDir:e=cg(),env:t=process.env,timeoutMs:s=3e4}={}){try{if(Si.mkdirSync(e,{recursive:!0}),!Si.existsSync(ps.join(e,"HEAD"))){let c=await Zt(["init","--bare","-q",e],{env:t});if(c.code!==0)return{ok:!1,error:ng(c.stderr)}}let r=await Zt(["fetch","-q","--depth=1",bi(n),"HEAD"],{cwd:e,env:t,timeoutMs:s});if(r.code!==0)return{ok:!1,error:ng(r.stderr)||"git fetch exited "+r.code};let o=(await Zt(["rev-parse","FETCH_HEAD"],{cwd:e,env:t})).stdout.trim()||null,i=await Zt(["show","FETCH_HEAD:CHANGELOG.md"],{cwd:e,env:t}),a=await Zt(["show","FETCH_HEAD:package.json"],{cwd:e,env:t}),l=null;try{l=JSON.parse(a.stdout).version||null}catch{}return{ok:!0,sha:o,version:l,changelog:i.code===0?i.stdout:null,error:null}}catch(r){return{ok:!1,error:r.message}}}function cg(n=process.env){return ps.join(n.XDG_CACHE_HOME||ps.join(_M.homedir(),".cache"),"checkpoints","remote.git")}async function wl({info:n,source:e,env:t=process.env,cacheDir:s,now:r=Date.now(),fetchFiles:o=!0,channel:i=null,fetchImpl:a=null}={}){let l=n||await yl({env:t});if(l.kind==="npm"){let h={checkedAt:new Date(r).toISOString(),source:"npm:"+gt,current:{version:l.version,sha:null},remote:{sha:null,version:null},available:!1,incoming:[],error:null},p=await lg({env:t,...a?{fetchImpl:a}:{}});if(!p.ok)return h.error=p.error,h;let m=pv(l.version,p.tags,i);return h.remote.version=m.version,h.remote.tag=m.tag,h.upgradeCommand=m.command,p.tags.latest&&p.tags.latest!==m.version&&(h.remote.latest=p.tags.latest),h.available=m.available,h}let c=bi(e||l.source||ml),u={checkedAt:new Date(r).toISOString(),source:c,current:{version:l.version,sha:l.sha},remote:{sha:null,version:null},available:!1,incoming:[],error:null},d=await gv(c,{env:t});if(!d.ok)return u.error=d.error,u;if(u.source=d.source,u.remote.sha=d.sha,l.sha&&l.sha===d.sha)return u;if(o){let h=await yv(u.source,{cacheDir:s||cg(t),env:t});h.ok?(u.remote.version=h.version,h.changelog&&(u.incoming=ki(tg(h.changelog),l.version,h.version).map(p=>({version:p.version,date:p.date,sections:p.sections})))):u.fetchError=h.error}let f=u.remote.version?mt(u.remote.version,l.version):null;return u.available=f===1||f===0&&!!l.sha||f===null&&!!l.sha,f===-1&&(u.available=!1),u}var rr,ml,rg,og,ng,gt,gl,kl=K(()=>{Rt();Ae();Ed();rr=ps.resolve(ps.dirname(EM(import.meta.url)),".."),ml="https://github.com/"+ts+".git",rg=".install.json",og=24*3600*1e3;ng=n=>String(n||"").trim().split(`
|
|
221
|
+
`),t}function sg(n){return/[\\/]_npx[\\/]/.test(n)||/[\\/]\.npm[\\/]/.test(n)}function uv(n=gt){return n.startsWith("@")?"@"+n.slice(1).split("/").map(encodeURIComponent).join("%2f"):encodeURIComponent(n)}function Od(n=process.env,e=gt){return Cd(n)+"/"+uv(e)}function dv(n=rr){try{return JSON.parse(Si.readFileSync(ps.join(n,"package.json"),"utf8"))}catch{return null}}function fv(n=rr){let e=dv(n);return!!(e&&e.leftoffBuild==="npm")}function hv(n,e=process.env){if(e.LEFTOFF_PM)return e.LEFTOFF_PM;let t=String(n||"");return/[\\/]\.?pnpm[\\/]/i.test(t)||/[\\/]pnpm-global[\\/]/i.test(t)?"pnpm":/[\\/]\.bun[\\/]/.test(t)?"bun":"npm"}function ag(n,e=gt+"@latest"){let t=process.platform==="win32";return n==="pnpm"?[t?"pnpm.cmd":"pnpm","add","-g",e]:n==="bun"?["bun","add","-g",e]:[t?"npm.cmd":"npm","install","-g",e,"--prefer-online","--no-fund","--no-audit","--loglevel=error"]}function Cd(n=process.env){return String(n.LEFTOFF_NPM_REGISTRY||n.npm_config_registry||"https://registry.npmjs.org").replace(/\/+$/,"")}async function lg({env:n=process.env,fetchImpl:e=fetch,timeoutMs:t=15e3}={}){let s=Od(n);try{let r=await e(s,{headers:{accept:"application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8"},signal:AbortSignal.timeout(t)});if(r.status===404)return{ok:!1,tags:{},error:gt+" isn’t on "+Cd(n)+" (404)"};if(!r.ok)return{ok:!1,tags:{},error:Cd(n)+" answered "+r.status};let o=await r.json(),i={...o&&o["dist-tags"]||{}};return!i.latest&&o&&o.version&&(i.latest=o.version),i.latest?{ok:!0,tags:i,error:null}:{ok:!1,tags:{},error:"no version in the registry answer"}}catch(r){return{ok:!1,tags:{},error:r.message}}}async function CM(n={}){let e=await lg(n);return e.ok?{ok:!0,version:e.tags.latest,error:null}:{ok:!1,version:null,error:e.error}}function pv(n,e={},t=null){let s=t||(_d(n)?"beta":"latest"),r=e.latest||null,o="latest";s==="beta"&&e.beta&&(!r||mt(e.beta,r)>0)&&(r=e.beta,o="beta");let i=!!r&&mt(r,n)>0;return{version:r,tag:o,available:i,command:o==="beta"?"leftoff upgrade --beta":"leftoff upgrade"}}async function yl({repoDir:n=rr,env:e=process.env,globalRoot:t}={}){let s={version:Q,sha:null,kind:"local",source:null,repoDir:n};if(Si.existsSync(ps.join(n,".git"))){s.kind="clone";let o=await Zt(["rev-parse","HEAD"],{cwd:n,env:e});o.code===0&&(s.sha=o.stdout.trim());let i=await Zt(["remote","get-url","origin"],{cwd:n,env:e});return s.source=i.code===0?i.stdout.trim():null,s}if(fv(n))return s.kind="npm",s.source="npm:"+gt,s.pm=hv(n,e),s.npx=sg(n),s;let r=cv(n);return r&&(s.sha=r.sha||null,s.source=r.source||null),sg(n)?s.kind="npx":(t?n.startsWith(t):/[\\/]node_modules[\\/](checkpoints|leftoff|@leftoff[\\/]cli)$/.test(n))&&(s.kind="global"),s}function mv(n){let e=String(n||"").match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/);return e?"git@github.com:"+e[1]+"/"+e[2]+".git":null}async function lv(n,{env:e,timeoutMs:t}){let s=await Zt(["ls-remote",n,"HEAD"],{env:e,timeoutMs:t}),r=s.code===0?(s.stdout.match(/^([0-9a-f]{40})\s+HEAD$/m)||[])[1]:null;return r?{ok:!0,sha:r,error:null,source:n}:{ok:!1,sha:null,error:ng(s.stderr)||"git ls-remote exited "+s.code,source:n}}async function gv(n,{env:e=process.env,timeoutMs:t=15e3}={}){let s=bi(n),r=await lv(s,{env:e,timeoutMs:t});if(r.ok)return r;let o=mv(s);if(!o)return r;let i=await lv(o,{env:e,timeoutMs:t});return i.ok?i:{ok:!1,sha:null,source:s,error:r.error+" (also tried "+o+": "+i.error+")"}}async function yv(n,{cacheDir:e=cg(),env:t=process.env,timeoutMs:s=3e4}={}){try{if(Si.mkdirSync(e,{recursive:!0}),!Si.existsSync(ps.join(e,"HEAD"))){let c=await Zt(["init","--bare","-q",e],{env:t});if(c.code!==0)return{ok:!1,error:ng(c.stderr)}}let r=await Zt(["fetch","-q","--depth=1",bi(n),"HEAD"],{cwd:e,env:t,timeoutMs:s});if(r.code!==0)return{ok:!1,error:ng(r.stderr)||"git fetch exited "+r.code};let o=(await Zt(["rev-parse","FETCH_HEAD"],{cwd:e,env:t})).stdout.trim()||null,i=await Zt(["show","FETCH_HEAD:CHANGELOG.md"],{cwd:e,env:t}),a=await Zt(["show","FETCH_HEAD:package.json"],{cwd:e,env:t}),l=null;try{l=JSON.parse(a.stdout).version||null}catch{}return{ok:!0,sha:o,version:l,changelog:i.code===0?i.stdout:null,error:null}}catch(r){return{ok:!1,error:r.message}}}function cg(n=process.env){return ps.join(n.XDG_CACHE_HOME||ps.join(_M.homedir(),".cache"),"checkpoints","remote.git")}async function wl({info:n,source:e,env:t=process.env,cacheDir:s,now:r=Date.now(),fetchFiles:o=!0,channel:i=null,fetchImpl:a=null}={}){let l=n||await yl({env:t});if(l.kind==="npm"){let h={checkedAt:new Date(r).toISOString(),source:"npm:"+gt,current:{version:l.version,sha:null},remote:{sha:null,version:null},available:!1,incoming:[],error:null},p=await lg({env:t,...a?{fetchImpl:a}:{}});if(!p.ok)return h.error=p.error,h;let m=pv(l.version,p.tags,i);return h.remote.version=m.version,h.remote.tag=m.tag,h.upgradeCommand=m.command,p.tags.latest&&p.tags.latest!==m.version&&(h.remote.latest=p.tags.latest),h.available=m.available,h}let c=bi(e||l.source||ml),u={checkedAt:new Date(r).toISOString(),source:c,current:{version:l.version,sha:l.sha},remote:{sha:null,version:null},available:!1,incoming:[],error:null},d=await gv(c,{env:t});if(!d.ok)return u.error=d.error,u;if(u.source=d.source,u.remote.sha=d.sha,l.sha&&l.sha===d.sha)return u;if(o){let h=await yv(u.source,{cacheDir:s||cg(t),env:t});h.ok?(u.remote.version=h.version,h.changelog&&(u.incoming=ki(tg(h.changelog),l.version,h.version).map(p=>({version:p.version,date:p.date,sections:p.sections})))):u.fetchError=h.error}let f=u.remote.version?mt(u.remote.version,l.version):null;return u.available=f===1||f===0&&!!l.sha||f===null&&!!l.sha,f===-1&&(u.available=!1),u}var rr,ml,rg,og,ng,gt,gl,kl=K(()=>{Rt();Ae();Ed();rr=ps.resolve(ps.dirname(EM(import.meta.url)),".."),ml="https://github.com/"+ts+".git",rg=".install.json",og=24*3600*1e3;ng=n=>String(n||"").trim().split(`
|
|
222
222
|
`).filter(Boolean).pop()||"";gt="@leftoff/cli",gl=[gt,"leftoff","checkpoints"]});import hn from"node:fs";import vi from"node:path";import OM from"node:crypto";function Nd(n){return!n||typeof n.toolName!="string"||!n.toolName?null:n.ruleContent!=null&&n.ruleContent!==""?n.toolName+"("+String(n.ruleContent)+")":n.toolName}function vv(n){let e=/^([A-Za-z_][\w-]*)(?:\((.*)\))?$/s.exec(String(n||""));return e?e[2]!=null?{toolName:e[1],ruleContent:e[2]}:{toolName:e[1]}:null}function MM(n){if(typeof n!="string")return null;let e=n.trim();if(!e||e.length>NM||/[\n\r`*()]/.test(e)||e.includes("$(")||e.includes("${")||/[\u2026\u22ef\u00b7]/.test(e)||/redacted/i.test(e))return null;let t=[],s="",r=null;for(let i=0;i<e.length;i++){let a=e[i],l=e[i+1];if(r){if(a==="\\"&&r==='"'){s+=a+(l||""),i++;continue}a===r&&(r=null),s+=a;continue}if(a==="\\"){if(l==null)return null;s+=a+l,i++;continue}if(a==="'"||a==='"'){r=a,s+=a;continue}if(a==="&"&&l==="&"||a==="|"&&l==="|"){t.push(s),s="",i++;continue}if(a===";"||a==="|"){t.push(s),s="";continue}s+=a}if(r)return null;t.push(s);let o=[];for(let i of t){let a=(i.match(Sv())||[]).join("");if(/[<>&]/.test(a)&&kv().test(i)||(i=i.replace(kv()," ").trim(),!i))return null;let l=i.replace(Sv(),'""');if(/[<>&{}]/.test(l)||/^[A-Za-z_][A-Za-z0-9_]*=/.test(l))return null;o.includes(i)||o.push(i)}return o.length&&o.length<=bv?o:null}function IM(n,e){let t=String(n||"");if(t==="Bash"){let s=MM(e&&e.command);return s?s.map(r=>({toolName:"Bash",ruleContent:r})):null}if(/^mcp__[\w-]+__[\w-]+$/.test(t))return[{toolName:t}];if(t==="WebFetch"&&e&&typeof e.url=="string"){let s;try{s=new URL(e.url)}catch{return null}return!/^https?:$/.test(s.protocol)||!s.hostname||/[*()]/.test(s.hostname)?null:[{toolName:"WebFetch",ruleContent:"domain:"+s.hostname}]}return null}function LM(n){if(!Array.isArray(n))return null;let e=[];for(let t of n)if(!(!t||t.type!=="addRules"||t.behavior==="deny"||t.behavior==="ask"||!Array.isArray(t.rules)))for(let s of t.rules){let r=Nd(s);r&&!/[\n\r]/.test(r)&&!e.some(o=>Nd(o)===r)&&e.push({toolName:s.toolName,...s.ruleContent!=null&&s.ruleContent!==""?{ruleContent:String(s.ruleContent)}:{}})}return e.length?e.slice(0,bv):null}function jM(n){try{let e=JSON.parse(String(n||""));return e&&typeof e=="object"&&!Array.isArray(e)?e:null}catch{return null}}function Md(n){if(!n||n.kind!=="permission")return null;let e=LM(n.suggestions);if(e)return{rules:e.map(Nd),source:"claude"};let t=n.input&&Object.keys(n.input).length?n.input:jM(n.preview),s=IM(n.tool,t);return s?{rules:s.map(Nd),source:"derived"}:null}function ms(n,e){let t=new Error(e);return t.status=409,t.code=n,t}function xv(n,e){if(!n||typeof n!="string"||!vi.isAbsolute(n))throw ms("no_folder","Leftoff doesn’t know this session’s project folder, so it can’t save the rule. Allow once instead.");let t;try{t=hn.statSync(n)}catch{t=null}if(!t||!t.isDirectory())throw ms("no_folder","The project folder "+n+" isn’t there, so the rule can’t be saved. Allow once instead.");let s=(Array.isArray(e)?e:[]).map(String).filter(Boolean);if(!s.length)throw ms("no_rule","No rule to save for this request.");let r=vi.join(n,".claude"),o=vi.join(r,"settings.local.json");try{hn.lstatSync(o).isSymbolicLink()&&(o=hn.realpathSync(o))}catch{}let i=null,a=420;try{i=hn.readFileSync(o,"utf8"),a=hn.statSync(o).mode&511}catch(p){if(p.code!=="ENOENT")throw ms("settings_unreadable","Couldn’t read "+gs+" ("+p.code+"). Nothing was changed.")}let l={};if(i!=null&&i.trim()){try{l=JSON.parse(i)}catch(p){throw ms("settings_malformed",gs+" in "+n+" isn’t valid JSON, so Leftoff left it alone. Fix it or Allow once. ("+p.message+")")}if(!l||typeof l!="object"||Array.isArray(l))throw ms("settings_malformed",gs+" in "+n+" isn’t a JSON object, so Leftoff left it alone. Allow once instead.")}let c=l.permissions==null?{}:l.permissions;if(!c||typeof c!="object"||Array.isArray(c))throw ms("settings_malformed",'"permissions" in '+gs+" isn’t an object, so Leftoff left it alone. Allow once instead.");let u=c.allow==null?[]:c.allow;if(!Array.isArray(u))throw ms("settings_malformed",'"permissions.allow" in '+gs+" isn’t a list, so Leftoff left it alone. Allow once instead.");let d=s.filter((p,m)=>!u.includes(p)&&s.indexOf(p)===m);if(!d.length)return{file:o,added:[],already:s,changed:!1};let f={...l,permissions:{...c,allow:[...u,...d]}};hn.mkdirSync(vi.dirname(o),{recursive:!0});let h=vi.join(vi.dirname(o),".settings.local.json."+process.pid+"."+OM.randomBytes(4).toString("hex")+".tmp");try{let p=hn.openSync(h,"w",a);try{hn.writeSync(p,JSON.stringify(f,null,2)+`
|
|
223
223
|
`),hn.fsyncSync(p)}finally{hn.closeSync(p)}hn.renameSync(h,o)}catch(p){try{hn.unlinkSync(h)}catch{}throw ms("settings_unwritable","Couldn’t write "+gs+" ("+(p.code||p.message)+"). Nothing was changed.")}return{file:o,added:d,already:s.filter(p=>!d.includes(p)),changed:!0}}var gs,NM,bv,kv,Sv,ug=K(()=>{gs=".claude/settings.local.json",NM=600,bv=6;kv=()=>/(^|\s)(?:[12]?>&[12]|&>\s*\/dev\/null|[12]?>\s*\/dev\/null)(?=\s|$)/g,Sv=()=>/'[^']*'|"(?:\\.|[^"\\])*"/g});import Tv from"node:crypto";import PM from"node:path";function dg(){return"rq_"+Tv.randomBytes(9).toString("base64url")}function Sl(n,e){let t=s=>Array.isArray(s)?"["+s.map(t).join(",")+"]":s&&typeof s=="object"?"{"+Object.keys(s).filter(r=>r!=="answers").sort().map(r=>JSON.stringify(r)+":"+t(s[r])).join(",")+"}":JSON.stringify(s===void 0?null:s);return Tv.createHash("sha256").update(String(n||"")+`
|
|
224
224
|
`+t(e||{})).digest("hex").slice(0,16)}function fg(n,e={}){let t=e&&typeof e=="object"?e:{};switch(n){case"Bash":return{verb:"Run",detail:yt(t.command,400),note:t.description?yt(t.description,160):null};case"Edit":case"MultiEdit":return{verb:"Edit",detail:yt(t.file_path,300),note:null};case"Write":return{verb:"Write",detail:yt(t.file_path,300),note:t.content?yt(String(t.content).split(`
|
|
@@ -512,7 +512,7 @@ leftoff live key [--new] the key for outside events (CI, webhooks), with a
|
|
|
512
512
|
# leftoff upgrade, `+new Date().toISOString()+(o?"":" (--verbose)")),n.finish)return LP({flags:n,out:e,env:t,c:r,say:a,ok:l,warn:c,fail:u,P:i});let d=s||await yl({env:t,globalRoot:t.CHECKPOINTS_NPM_GLOBAL_ROOT||void 0}),f=bi(n.source||t.CHECKPOINTS_UPDATE_SOURCE||(ee().updates||{}).source||d.source||ml);d.kind==="npm"&&(f="npm:"+gt),a(r.bold(Ge+" v"+d.version)+r.dim((d.kind==="npm"?" · installed from npm with "+d.pm+" at ":" ("+Tr(d.sha)+") · "+{clone:"a git clone at ",global:"installed globally at ",npx:"an npx copy at ",local:"installed at "}[d.kind])+d.repoDir));let h=n.beta&&OP(d);if(h)return i.warn(h),2;i.start("Checking for updates…");let p=d.kind==="npm"?n.beta?"beta":"latest":null,m=await wl({info:d,source:f,env:t,channel:p});if(m.error&&d.kind==="npm")return u("Couldn't reach the npm registry ("+Od(t)+"): "+m.error),1;if(m.error)return a(r.dim(" (git uses your own credentials; for a private repo, check `git ls-remote "+f+'` works, or set "updates": { "source": "git@github.com:'+ts+'.git" } in config.json)')),u("Couldn't reach "+f+": "+m.error),1;if(m.source&&m.source!==f&&(a(r.dim("· reached GitHub over SSH ("+m.source+")")),f=m.source),!(m.available||!d.sha&&m.remote.version&&mt(m.remote.version,d.version)>0)&&!n.force)return d.kind==="npm"&&_d(d.version)&&!n.beta?(i.done("Up to date: "+Ge+" "+d.version+", a beta"+(m.remote.version?" (latest is "+m.remote.version+")":"")),a(r.dim("Next beta: leftoff upgrade --beta")),0):(o?i.done("Up to date: "+Ge+" "+d.version):l("Up to date"+r.dim(" (remote "+Tr(m.remote.sha)+(m.remote.version?", v"+m.remote.version:"")+")")),0);let S=m.remote.version||Tr(m.remote.sha);if(i.done("Found "+S+" (you have "+d.version+")"),a(r.bold(r.cyan("Update available: "))+(m.remote.version?"v"+d.version+" → v"+m.remote.version:"")+(d.kind==="npm"?"":r.dim(" "+Tr(d.sha)+" → "+Tr(m.remote.sha)))),m.incoming.length?a(`
|
|
513
513
|
`+Td(m.incoming,{colors:r})):m.fetchError&&a(r.dim(" (couldn't read the new changelog: "+m.fetchError+")")),n.check)return a(r.dim("Run `"+(m.upgradeCommand||"leftoff upgrade")+"` to install it.")),0;i.start("Downloading "+S+"…");let y=d.repoDir;if(d.kind==="npm"){let b=ag(d.pm,gt+"@"+(m.remote.version||m.remote.tag||"latest"));a(r.dim("$ "+b.join(" ")));let x=await Lw(b[0],b.slice(1),{env:t,log:i.log});if(x.status!==0)return o||a((x.stderr||"").trim()),u(b.slice(0,3).join(" ")+" failed: "+((x.stderr||"").trim().split(`
|
|
514
514
|
`).pop()||"exit "+x.status)),1;let E=t.CHECKPOINTS_NPM_GLOBAL_ROOT||$_(t,d.pm);y=E&&Pt.existsSync(zt.join(E,gt,"package.json"))?zt.join(E,gt):d.repoDir;let O=null;try{O=JSON.parse(Pt.readFileSync(zt.join(y,"package.json"),"utf8")).version}catch{}if(m.remote.version&&O&&O!==m.remote.version)return u("npm installed "+O+", not "+m.remote.version+". npm may still be catching up; try again in a minute."),1;l("Installed "+gt+"@"+(O||m.remote.version||"latest")+r.dim(" → "+y))}else if(d.kind==="clone"){d.source&&oo(d.source)!==d.source&&(await Zt(["remote","set-url","origin",oo(d.source)],{cwd:d.repoDir,env:t})).code===0&&a(r.dim("· the repo is now "+ts+"; pointed origin at "+oo(d.source))),a(r.dim("$ git -C "+d.repoDir+" pull --ff-only"));let b=await Zt(["pull","--ff-only","-q"],{cwd:d.repoDir,env:t,timeoutMs:12e4});if(b.code!==0)return a(b.stderr.trim()),a(r.dim(" Local changes? Commit or stash them and run `leftoff upgrade` again.")),u("git pull failed: "+(b.stderr.trim().split(`
|
|
515
|
-
`).pop()||"exit "+b.code)),1;l("Pulled "+Tr(m.remote.sha))}else{let b=f.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([\w.-]+\/[\w.-]+?)(?:\.git)?$/),x=b?"github:"+b[1]:f;a(r.dim("$ npm install -g "+x));let E=await Lw(z_(),["install","-g",x,"--no-fund","--no-audit","--loglevel=error"],{env:t,log:i.log});if(E.status!==0)return o||a((E.stderr||"").trim()),u("npm install -g failed: "+((E.stderr||"").trim().split(`
|
|
515
|
+
`).pop()||"exit "+b.code)),1;l("Pulled "+Tr(m.remote.sha))}else{let b=f.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([\w.-]+\/[\w.-]+?)(?:\.git)?$/),x=b?"github:"+b[1]:f;a(r.dim("$ npm install -g "+x));let E=await Lw(z_(),["install","-g",x,"--prefer-online","--no-fund","--no-audit","--loglevel=error"],{env:t,log:i.log});if(E.status!==0)return o||a((E.stderr||"").trim()),u("npm install -g failed: "+((E.stderr||"").trim().split(`
|
|
516
516
|
`).pop()||"exit "+E.status)),1;let O=t.CHECKPOINTS_NPM_GLOBAL_ROOT||$_(t);y=O?TP(O):d.repoDir,l("Installed "+x+r.dim(" → "+y));try{Pt.existsSync(zt.join(y,".install.json"))||ig({sha:m.remote.sha,source:f,version:m.remote.version||void 0},y)}catch{}}i.done("Downloaded "+S),i.start("Updating the Claude Code plugin…");let k=null,v=Vi(t);if(d.kind==="npm")try{zy({from:y,env:t})}catch(b){c("Couldn't refresh the mod's files: "+b.message)}if(!v)c("`claude` isn't on PATH; update the mod later with: claude plugin marketplace update "+Vn+" && claude plugin update "+We);else{let b=oh(v,t);if(!b.installed&&b.legacyInstalled){a(r.dim("$ claude plugin marketplace update "+Vn));let x=As(v,["plugin","marketplace","update",Vn],{env:t});x.code!==0&&c("`claude plugin marketplace update "+Vn+"` failed: "+((x.stderr||x.stdout).trim().split(`
|
|
517
517
|
`).pop()||"exit "+x.code))}else if(!b.installed)a(r.dim("· The mod isn't installed through `claude plugin`; nothing to update there.")),k="skip";else{let x=!1;for(let E of[["plugin","marketplace","update",Vn],["plugin","update",We]]){a(r.dim("$ claude "+E.join(" ")));let O=await Lw(v,E,{env:{...t,CHECKPOINTS_INTERNAL:"1",LEFTOFF_INTERNAL:"1"},log:i.log,timeoutMs:12e4});if(O.status!==0){x=!0,c("`claude "+E.join(" ")+"` failed: "+((O.stderr||O.stdout).trim().split(`
|
|
518
518
|
`).pop()||"exit "+O.status));break}}l("Mod updated"+r.dim(" · restart open Claude Code sessions to load it")),x||(k="ok")}}o&&(k==="ok"?i.done("Updated the Claude Code plugin"):k==="skip"?i.done("Claude Code plugin: not installed with `claude plugin`, nothing to update"):i.stop());let T=[zt.join(y,"bin","checkpoints.js"),"upgrade","--finish","--from="+d.version,"--from-sha="+(d.sha||""),"--repo="+y];if(n["no-restart"]&&T.push("--no-restart"),!o){let b=Lh(process.execPath,T,{env:t,stdio:"inherit"});return b.status==null?1:b.status}T.push("--quiet","--log="+i.logFile);let g=Lh(process.execPath,T,{env:{...t,LEFTOFF_PROGRESS_TTY:e.isTTY&&!t.CI?"1":"0"},stdio:["ignore","inherit","pipe"],encoding:"utf8"});return g.stderr&&i.log(g.stderr.trimEnd()),g.status!==0?(i.fail("Finishing the upgrade failed (exit "+g.status+")"),g.status==null?1:g.status):0}var NP=n=>String(n).replace(/\x1b\[2m.*?\x1b\[22m/g,"").trim();function MP(n,e=3){let t=n.map(r=>{let o=r.sections.flatMap(a=>a.items),i=o.filter(a=>/^\*\*/.test(a));return i.length?i:o}),s=[];for(let r=0;s.length<e&&t.some(o=>o.length>r);r++)for(let o of t){if(s.length>=e||!o[r])continue;let i=o[r].match(/^\*\*(.+?)\*\*/),a=(i?i[1]:o[r]).replace(/[*`]/g,"").replace(/\s+/g," ").trim().replace(/[.:]$/,"");a.length>72&&(a=a.slice(0,71).replace(/\s+\S*$/,"")+"…"),s.push(a)}return s}function IP(n,e,t=Q){let s=[];for(let[r,o]of Object.entries(uo(n)))r!==e&&o.valid&&o.card.version&&mt(o.card.version,t)<0&&s.push(r+" ("+o.card.version+")");return s.sort()}async function LP({flags:n,out:e,env:t,c:s,say:r,ok:o,warn:i,fail:a,P:l}){let c=l.quiet,u=String(n.from||""),d=String(n.repo||rr),f=await yl({repoDir:d,env:t}),h=Z(),p=ht(h);if(p){let{cfg:y,applied:k}=_P(p,u,Q);if(k.length){Pt.writeFileSync(ve(h).config,JSON.stringify(y,null,2)+`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.9",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.8-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.9' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
3
|
+
"version": "0.9.8-beta.9",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -2141,10 +2141,10 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2141
2141
|
}
|
|
2142
2142
|
|
|
2143
2143
|
/* ------------------------------------------------------------ the sidebar rail (0.9.8)
|
|
2144
|
-
From
|
|
2144
|
+
From 700px up (0.9.8 tabs: was 820): a narrow rail on the left. The Leftoff mark at the top, then
|
|
2145
2145
|
the sections as an icon over a small label, the current one in a soft pill,
|
|
2146
2146
|
a red count on Needs you; search, What's new, Settings and this Mac's status
|
|
2147
|
-
(the Mac switcher) at the bottom. Under
|
|
2147
|
+
(the Mac switcher) at the bottom; 700–1099px it shows icons only. Under 700px: the mark, search and status in
|
|
2148
2148
|
a slim top row, and a bottom tab bar (Board, Needs you, Lists, PRs, More). */
|
|
2149
2149
|
:root { --rail-w: 84px; --rail-on: color-mix(in srgb, var(--accent) 13%, transparent); --bar-h: 58px; }
|
|
2150
2150
|
.tab-more { display: none; }
|
|
@@ -2153,7 +2153,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2153
2153
|
.moredot[hidden] { display: none; }
|
|
2154
2154
|
.iconbtn .lbl { display: none; }
|
|
2155
2155
|
.tabs a, .tab-more { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
|
2156
|
-
@media (min-width:
|
|
2156
|
+
@media (min-width: 700px) {
|
|
2157
2157
|
body { padding-left: calc(var(--rail-w) + env(safe-area-inset-left)); }
|
|
2158
2158
|
.top { position: fixed !important; top: 0; bottom: 0; left: 0; width: calc(var(--rail-w) + env(safe-area-inset-left)); z-index: 20; /* !important: the rail never joins the page flow */
|
|
2159
2159
|
flex-direction: column; flex-wrap: nowrap; align-items: stretch; justify-content: flex-start; gap: 6px; min-height: 0;
|
|
@@ -2181,11 +2181,11 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2181
2181
|
.newdot, .topdot { top: 8px; right: calc(50% - 15px); box-shadow: 0 0 0 2px var(--bg); }
|
|
2182
2182
|
.set-nav { top: 24px; }
|
|
2183
2183
|
}
|
|
2184
|
-
@media (min-width:
|
|
2184
|
+
@media (min-width: 700px) and (max-height: 640px) {
|
|
2185
2185
|
.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 46px; padding: 4px 2px; gap: 2px; }
|
|
2186
2186
|
.brand { padding-bottom: 4px; }
|
|
2187
2187
|
}
|
|
2188
|
-
@media (max-width:
|
|
2188
|
+
@media (max-width: 699.98px) {
|
|
2189
2189
|
.top { flex-wrap: nowrap; gap: 8px; min-height: 52px; padding-top: max(6px, env(safe-area-inset-top)); padding-bottom: 6px; background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
2190
2190
|
.brand { order: 1; }
|
|
2191
2191
|
.topright { order: 2; margin-left: auto; gap: 2px; }
|
|
@@ -2224,7 +2224,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2224
2224
|
.moremenu a.on { background: var(--rail-on); }
|
|
2225
2225
|
.moremenu a.on .ic { color: var(--accent); }
|
|
2226
2226
|
.moremenu .newdot, .moremenu .topdot { position: static; margin-left: auto; box-shadow: none; }
|
|
2227
|
-
@media (min-width:
|
|
2227
|
+
@media (min-width: 700px) { .moremenu { display: none !important; } }
|
|
2228
2228
|
/* Quick look on Needs you (0.9.8) */
|
|
2229
2229
|
.quicklook { position: fixed; z-index: 45; width: 360px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; overscroll-behavior: contain;
|
|
2230
2230
|
background: var(--card); color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 16px 40px rgba(15, 20, 35, .18), 0 2px 6px rgba(15, 20, 35, .08); padding: 12px 12px 8px; font-size: 13.5px; animation: lo-in .12s ease-out; }
|
|
@@ -2254,8 +2254,8 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2254
2254
|
.ql-foot { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 44px; padding: 0 6px; margin-top: 2px; color: var(--accent); font-weight: 600; text-decoration: none; border-radius: 8px; }
|
|
2255
2255
|
.ql-foot:hover { text-decoration: underline; }
|
|
2256
2256
|
.ql-foot:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2257
|
-
@media (pointer: coarse), (max-width:
|
|
2258
|
-
@media (max-width:
|
|
2257
|
+
@media (pointer: coarse), (max-width: 699.98px) { .ql-actions .btn { min-height: 44px; min-width: 64px; } .ql-main { min-height: 44px; justify-content: center; } }
|
|
2258
|
+
@media (max-width: 699.98px) { .quicklook { left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); width: auto; max-width: none; bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); max-height: calc(100vh - var(--bar-h) - 80px); } }
|
|
2259
2259
|
@media (prefers-reduced-motion: reduce) { .quicklook, .moremenu { animation: none; } }
|
|
2260
2260
|
|
|
2261
2261
|
/* Pull to refresh (phones, 0.9.8): the spinner comes down from under the top row */
|
|
@@ -2267,7 +2267,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2267
2267
|
.ptr-ring svg { width: 19px; height: 19px; }
|
|
2268
2268
|
.ptr.armed { color: var(--accent); }
|
|
2269
2269
|
.ptr.busy .ptr-ring { animation: spin .8s linear infinite; }
|
|
2270
|
-
@media (pointer: coarse) and (max-width:
|
|
2270
|
+
@media (pointer: coarse) and (max-width: 699.98px) { html, body { overscroll-behavior-y: contain; } }
|
|
2271
2271
|
@media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
|
|
2272
2272
|
@keyframes lo-pulse { 50% { opacity: .35; } }
|
|
2273
2273
|
|
|
@@ -2328,3 +2328,81 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2328
2328
|
.moremenu .push-slot { border-top: 1px solid var(--line); margin-top: 6px; padding: 12px 8px 6px; }
|
|
2329
2329
|
.moremenu:has(.push-slot:not([hidden])) { width: 270px; }
|
|
2330
2330
|
.form .push-slot { grid-column: 1 / -1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card2, transparent); margin-top: 4px; }
|
|
2331
|
+
|
|
2332
|
+
/* ------------------------------------------------------------ tabs for sessions (0.9.8)
|
|
2333
|
+
By width, never by device. Under 700px: no strip; an Open row on the board
|
|
2334
|
+
and "‹ 2 of 4 ›" on a session page (swipe works too). 700–1099px: the strip,
|
|
2335
|
+
the rail as icons. From 1100px: the full rail and the strip. */
|
|
2336
|
+
.tabstrip, .openrow, .tabpager { display: none; }
|
|
2337
|
+
.tabstrip[hidden], .openrow[hidden], .tabpager[hidden] { display: none !important; }
|
|
2338
|
+
.tab-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2339
|
+
.preview .tab-t, .tab-s.preview .tab-t, .ochip.preview .tab-t { font-style: italic; }
|
|
2340
|
+
.tab-live, .tab-need { flex: none; width: 8px; height: 8px; border-radius: 50%; position: relative; }
|
|
2341
|
+
.tab-live { background: var(--green); animation: live-pulse 1.6s ease-in-out infinite; }
|
|
2342
|
+
.tab-need { background: var(--red); }
|
|
2343
|
+
@media (prefers-reduced-motion: reduce) { .tab-live { animation: none; } }
|
|
2344
|
+
@media (min-width: 700px) {
|
|
2345
|
+
.tabstrip { display: flex; position: sticky; top: 0; z-index: 15; align-items: stretch; height: 44px; background: var(--bg); border-bottom: 1px solid var(--line); padding-top: env(safe-area-inset-top); }
|
|
2346
|
+
.tabstrip-in { flex: 1; min-width: 0; display: flex; align-items: stretch; }
|
|
2347
|
+
.tab-scroll { flex: 1; min-width: 0; display: flex; align-items: stretch; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain; }
|
|
2348
|
+
.tab-scroll::-webkit-scrollbar { display: none; }
|
|
2349
|
+
.tab { display: flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 12px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
|
2350
|
+
.tab .ic { width: 16px; height: 16px; flex: none; }
|
|
2351
|
+
.tab:hover { color: var(--text); }
|
|
2352
|
+
.tab:focus-visible, .tab-x:focus-visible, .tab-all:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2353
|
+
.tab-board { flex: none; border-right: 1px solid var(--line); }
|
|
2354
|
+
.tab-cell { position: relative; flex: 0 1 200px; min-width: 120px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
|
|
2355
|
+
.tab-s { flex: 1; min-width: 0; padding-right: 0; }
|
|
2356
|
+
.tab.on, .tab-cell.on { color: var(--text); background: var(--card); }
|
|
2357
|
+
.tab-board.on, .tab-cell.on { box-shadow: inset 0 -2px 0 var(--accent); }
|
|
2358
|
+
.tab-x { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; border-radius: 8px; }
|
|
2359
|
+
.tab-x .ic { width: 14px; height: 14px; }
|
|
2360
|
+
.tab-x { position: relative; }
|
|
2361
|
+
.tab-x::before { content: ''; position: absolute; left: 50%; top: 50%; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; background: transparent; transition: background .12s; }
|
|
2362
|
+
.tab-x .ic { position: relative; }
|
|
2363
|
+
.tab-x:hover { color: var(--text); background: none; }
|
|
2364
|
+
.tab-x:hover::before { background: color-mix(in srgb, var(--text) 9%, transparent); }
|
|
2365
|
+
.tab-keep .ic { width: 12px; height: 12px; }
|
|
2366
|
+
.tab-keep { opacity: 0; }
|
|
2367
|
+
.tab-cell:hover .tab-keep, .tab-keep:focus-visible, .tab-cell.on .tab-keep { opacity: 1; }
|
|
2368
|
+
.tab-cell.dragging { opacity: .5; }
|
|
2369
|
+
.tab-cell.drop-before { box-shadow: inset 2px 0 0 var(--accent); }
|
|
2370
|
+
.tab-all { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; border-left: 1px solid var(--line); background: var(--bg); color: var(--muted); cursor: pointer; }
|
|
2371
|
+
.tab-all .ic { width: 18px; height: 18px; }
|
|
2372
|
+
.tab-all:hover, .tab-all[aria-expanded="true"] { color: var(--text); background: var(--bg2); }
|
|
2373
|
+
.tab-all[hidden] { display: none; }
|
|
2374
|
+
}
|
|
2375
|
+
@media (min-width: 700px) and (pointer: coarse) { .tab-keep { opacity: 1; } }
|
|
2376
|
+
/* the rail as icons (700–1099px): the labels stay for screen readers */
|
|
2377
|
+
@media (min-width: 700px) and (max-width: 1099.98px) {
|
|
2378
|
+
:root { --rail-w: 64px; }
|
|
2379
|
+
.tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
2380
|
+
.tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 48px; padding: 6px 2px; }
|
|
2381
|
+
.navcount { left: calc(50% + 2px); }
|
|
2382
|
+
}
|
|
2383
|
+
/* phones (under 700px): the Open row on the board, ‹ N of M › on a session page */
|
|
2384
|
+
@media (max-width: 699.98px) {
|
|
2385
|
+
body[data-route="board"] .openrow { display: block; }
|
|
2386
|
+
body[data-route="s"] .tabpager { display: block; }
|
|
2387
|
+
.openrow { padding: 8px 0 0; }
|
|
2388
|
+
.openrow-h { display: flex; gap: 8px; align-items: baseline; padding: 0 max(16px, env(safe-area-inset-left)); font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--muted); }
|
|
2389
|
+
.openrow-chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px max(16px, env(safe-area-inset-left)) 6px; scroll-snap-type: x proximity; overscroll-behavior-x: contain; }
|
|
2390
|
+
.openrow-chips::-webkit-scrollbar { display: none; }
|
|
2391
|
+
.ochip-cell { flex: none; display: flex; align-items: stretch; max-width: 62vw; border: 1px solid var(--line); border-radius: 22px; background: var(--card); scroll-snap-align: start; }
|
|
2392
|
+
.ochip-cell.preview { border-style: dashed; }
|
|
2393
|
+
.ochip { display: flex; align-items: center; gap: 7px; min-height: 44px; min-width: 44px; padding: 0 14px; color: var(--text); text-decoration: none; font-size: 15px; max-width: 100%; min-width: 0; }
|
|
2394
|
+
.ochip-cell:not(.preview) .ochip { padding-right: 2px; }
|
|
2395
|
+
.ochip-x { flex: none; width: 44px; min-height: 44px; border: 0; background: none; color: var(--muted); display: flex; align-items: center; justify-content: center; padding: 0; border-radius: 22px; }
|
|
2396
|
+
.ochip-x .ic { width: 15px; height: 15px; }
|
|
2397
|
+
.tabpager { padding: 4px max(8px, env(safe-area-inset-left)) 0; }
|
|
2398
|
+
.pager-in { display: flex; align-items: center; gap: 6px; }
|
|
2399
|
+
.pager-b { flex: none; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 22px; color: var(--text); text-decoration: none; }
|
|
2400
|
+
.pager-b .ic { width: 22px; height: 22px; }
|
|
2401
|
+
.pager-b.off { opacity: .25; }
|
|
2402
|
+
.pager-b:not(.off):active { background: var(--bg2); }
|
|
2403
|
+
.pager-n { flex: 1; text-align: center; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
2404
|
+
.pager-keep { flex: none; min-height: 44px; }
|
|
2405
|
+
.swipe-next main { animation: lo-page-fwd .18s ease-out; }
|
|
2406
|
+
.swipe-prev main { animation: lo-page-back .18s ease-out; }
|
|
2407
|
+
}
|
|
2408
|
+
@media (prefers-reduced-motion: reduce) { .swipe-next main, .swipe-prev main { animation: none !important; } }
|
package/ui/app.js
CHANGED
|
@@ -19,9 +19,10 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
19
19
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
20
|
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
21
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
22
|
-
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses } from './live-view.js' // 0.9.8 Live link polish
|
|
22
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
|
|
23
23
|
import { statusView, elapsedText } from './live-status.js'
|
|
24
24
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
25
|
+
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, moveTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir } from './tabs.js' // 0.9.8 tabs for sessions
|
|
25
26
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
26
27
|
const $app = document.getElementById('app')
|
|
27
28
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
@@ -3380,6 +3381,7 @@ function parseRoute() {
|
|
|
3380
3381
|
async function render() {
|
|
3381
3382
|
const r = (state.route = parseRoute())
|
|
3382
3383
|
paintNav(r)
|
|
3384
|
+
tabsOnRoute(r)
|
|
3383
3385
|
try {
|
|
3384
3386
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
3385
3387
|
else if (r.name === 'waiting') await viewWaiting()
|
|
@@ -3394,6 +3396,7 @@ async function render() {
|
|
|
3394
3396
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3395
3397
|
else if (r.name === 'setup') await viewSetup()
|
|
3396
3398
|
else await viewBoard()
|
|
3399
|
+
tabsAfterRender()
|
|
3397
3400
|
} catch (e) {
|
|
3398
3401
|
if (e.code === 'login_required') return showLogin()
|
|
3399
3402
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3501,12 +3504,12 @@ function paintTopDot() {
|
|
|
3501
3504
|
}
|
|
3502
3505
|
|
|
3503
3506
|
// ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
|
|
3504
|
-
// From
|
|
3507
|
+
// From 700px up the sections sit in a rail on the left; under it, in a bottom
|
|
3505
3508
|
// bar with the rest under More. Hover the Needs you item (or long-press it on a
|
|
3506
3509
|
// phone, or press ↓/→ on it) for Quick look: the count and the five longest
|
|
3507
3510
|
// waits, with Allow / Deny on permission prompts (the Needs you page's own
|
|
3508
3511
|
// buttons, so the same checks apply and nothing is answered without a click).
|
|
3509
|
-
const railMode = () => window.matchMedia('(min-width:
|
|
3512
|
+
const railMode = () => window.matchMedia('(min-width: 700px)').matches
|
|
3510
3513
|
function paintNav(r = state.route || parseRoute()) {
|
|
3511
3514
|
const key = navKey(r.name)
|
|
3512
3515
|
document.querySelectorAll('.tabs a[data-route], .topright a[data-route], #moreMenu a[data-route]').forEach((a) => { const on = a.dataset.route === key; a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
|
|
@@ -3664,6 +3667,214 @@ function closeQuickLook(focusTab = false) {
|
|
|
3664
3667
|
window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
|
|
3665
3668
|
}
|
|
3666
3669
|
|
|
3670
|
+
// ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
|
|
3671
|
+
// The board is the first tab; a session opens as the one preview tab (italic)
|
|
3672
|
+
// that the next click replaces, and stays open once you send a Live message,
|
|
3673
|
+
// type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
|
|
3674
|
+
// middle-click opens it permanent straight away). Kept per device in
|
|
3675
|
+
// localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
|
|
3676
|
+
// (or a swipe) on a session page; from 700px the strip. A width change (fold,
|
|
3677
|
+
// Split View, rotation) only changes the CSS: the open set, the session and
|
|
3678
|
+
// where you were on the page stay.
|
|
3679
|
+
const tabsUi = { m: parseStored(localStorage.getItem(TABS_KEY)), active: null, route: null, permanent: null, html: { strip: '', row: '', pager: '' }, anchor: null, logs: new WeakMap(), quietUntil: 0, fetchT: 0, fetchedAt: 0, scrolls: {} }
|
|
3680
|
+
window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
|
|
3681
|
+
function setTabs(m) {
|
|
3682
|
+
if (m === tabsUi.m) return
|
|
3683
|
+
tabsUi.m = m
|
|
3684
|
+
try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
|
|
3685
|
+
}
|
|
3686
|
+
const tabSession = (id) => state.sessions.find((s) => s.id === id)
|
|
3687
|
+
const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
|
|
3688
|
+
function tabsOnRoute(r) {
|
|
3689
|
+
const id = r.name === 's' && r.arg ? String(r.arg) : null
|
|
3690
|
+
if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
|
|
3691
|
+
if (id) {
|
|
3692
|
+
const permanent = tabsUi.permanent === id
|
|
3693
|
+
setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
|
|
3694
|
+
}
|
|
3695
|
+
tabsUi.permanent = null
|
|
3696
|
+
const changed = tabsUi.active !== id || tabsUi.route !== r.name
|
|
3697
|
+
tabsUi.active = id; tabsUi.route = r.name
|
|
3698
|
+
document.body.dataset.route = r.name
|
|
3699
|
+
paintTabs()
|
|
3700
|
+
if (changed) scrollActiveTab()
|
|
3701
|
+
}
|
|
3702
|
+
function tabsAfterRender() {
|
|
3703
|
+
let m = tabsUi.m
|
|
3704
|
+
for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
|
|
3705
|
+
setTabs(m)
|
|
3706
|
+
paintTabs()
|
|
3707
|
+
const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
|
|
3708
|
+
tabsUi.restoreScroll = null
|
|
3709
|
+
if (back != null) window.scrollTo(0, back)
|
|
3710
|
+
noteAnchor()
|
|
3711
|
+
// the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
|
|
3712
|
+
if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
|
|
3713
|
+
}
|
|
3714
|
+
async function refreshTabSessions() {
|
|
3715
|
+
tabsUi.fetchedAt = Date.now()
|
|
3716
|
+
try {
|
|
3717
|
+
const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
|
|
3718
|
+
const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
|
|
3719
|
+
state.sessions = state.sessions.map((s) => (s.id !== tabsUi.active && fresh.has(s.id) ? fresh.get(s.id) : s)).concat([...fresh.values()].filter((s) => !state.sessions.some((x) => x.id === s.id)))
|
|
3720
|
+
paintTabs()
|
|
3721
|
+
} catch {}
|
|
3722
|
+
}
|
|
3723
|
+
function paintTabs() {
|
|
3724
|
+
const views = tabViews()
|
|
3725
|
+
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
3726
|
+
if (!strip) return
|
|
3727
|
+
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
3728
|
+
if (sh !== tabsUi.html.strip) {
|
|
3729
|
+
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
3730
|
+
const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
|
|
3731
|
+
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
3732
|
+
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
3733
|
+
if (had) { const el = strip.querySelector('[data-tab="' + CSS.escape(had) + '"], [data-tab-close="' + CSS.escape(had) + '"], [data-tab-keep="' + CSS.escape(had) + '"], #' + CSS.escape(had)); if (el) el.focus({ preventScroll: true }) }
|
|
3734
|
+
}
|
|
3735
|
+
strip.hidden = false
|
|
3736
|
+
const rh = openRowHtml(views, esc)
|
|
3737
|
+
if (rh !== tabsUi.html.row) { const c = row.querySelector('.openrow-chips'), l = c ? c.scrollLeft : 0; row.innerHTML = rh; tabsUi.html.row = rh; const nc = row.querySelector('.openrow-chips'); if (nc) nc.scrollLeft = l }
|
|
3738
|
+
row.hidden = !rh
|
|
3739
|
+
const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
|
|
3740
|
+
if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
|
|
3741
|
+
pager.hidden = !ph
|
|
3742
|
+
}
|
|
3743
|
+
function scrollActiveTab() {
|
|
3744
|
+
const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
|
|
3745
|
+
if (!el || !sc) return
|
|
3746
|
+
const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
|
|
3747
|
+
if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
|
|
3748
|
+
else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
|
|
3749
|
+
}
|
|
3750
|
+
function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3751
|
+
function closeTabNow(id) {
|
|
3752
|
+
const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
|
|
3753
|
+
setTabs(model)
|
|
3754
|
+
delete tabsUi.scrolls[id]
|
|
3755
|
+
if (tabsUi.active === String(id)) {
|
|
3756
|
+
const t = next && model.tabs.find((x) => x.id === next)
|
|
3757
|
+
if (t) tabsUi.restoreScroll = t.id
|
|
3758
|
+
location.hash = t ? tabView(t).href : '#/'
|
|
3759
|
+
} else paintTabs()
|
|
3760
|
+
}
|
|
3761
|
+
function goTab(href) {
|
|
3762
|
+
const id = sessionOfHash(href)
|
|
3763
|
+
if (id) tabsUi.restoreScroll = id
|
|
3764
|
+
if (location.hash === href) render(); else location.hash = href
|
|
3765
|
+
}
|
|
3766
|
+
// ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
|
|
3767
|
+
function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
|
|
3768
|
+
document.addEventListener('click', (e) => {
|
|
3769
|
+
const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
|
|
3770
|
+
if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
|
|
3771
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3772
|
+
e.preventDefault(); e.stopPropagation()
|
|
3773
|
+
if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
|
|
3774
|
+
if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
|
|
3775
|
+
}, true)
|
|
3776
|
+
document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
|
|
3777
|
+
document.addEventListener('auxclick', (e) => {
|
|
3778
|
+
if (e.button !== 1 || !e.target.closest) return
|
|
3779
|
+
const cell = e.target.closest('#tabStrip .tab-cell')
|
|
3780
|
+
if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
|
|
3781
|
+
const a = e.target.closest('a[href^="#/s/"]')
|
|
3782
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3783
|
+
e.preventDefault()
|
|
3784
|
+
if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3785
|
+
else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
|
|
3786
|
+
})
|
|
3787
|
+
document.addEventListener('click', (e) => {
|
|
3788
|
+
if (!e.target.closest) return
|
|
3789
|
+
const close = e.target.closest('[data-tab-close]')
|
|
3790
|
+
if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
|
|
3791
|
+
const keep = e.target.closest('[data-tab-keep]')
|
|
3792
|
+
if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
|
|
3793
|
+
const go = e.target.closest('[data-tab-go]')
|
|
3794
|
+
if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
|
|
3795
|
+
const all = e.target.closest('#tabAll')
|
|
3796
|
+
if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
|
|
3797
|
+
const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
|
|
3798
|
+
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
3799
|
+
})
|
|
3800
|
+
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
3801
|
+
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
3802
|
+
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
3803
|
+
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
3804
|
+
document.addEventListener('click', (e) => { if (e.target.closest && $app.contains(e.target) && e.target.closest('[data-action="live-send"], .live-box [data-action], .need [data-choice], [data-action="ask-answer"], [data-action="rename"]')) promoteActive() }, true)
|
|
3805
|
+
// Drag a tab to reorder
|
|
3806
|
+
{
|
|
3807
|
+
let dragId = null
|
|
3808
|
+
document.addEventListener('dragstart', (e) => { const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; dragId = c.dataset.tabId; c.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId) } catch {} })
|
|
3809
|
+
document.addEventListener('dragover', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; e.preventDefault(); document.querySelectorAll('#tabStrip .drop-before').forEach((x) => x.classList.remove('drop-before')); c.classList.add('drop-before') })
|
|
3810
|
+
document.addEventListener('drop', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (c) { e.preventDefault(); const to = indexOf(tabsUi.m, c.dataset.tabId); const from = indexOf(tabsUi.m, dragId); setTabs(moveTab(tabsUi.m, dragId, from < to ? to - 1 : to)); paintTabs() } })
|
|
3811
|
+
document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
|
|
3812
|
+
}
|
|
3813
|
+
// Phones: swipe left / right on a session page for the next / previous open session
|
|
3814
|
+
{
|
|
3815
|
+
let sw = null
|
|
3816
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
3817
|
+
const scrollsSideways = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 2 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true } return false }
|
|
3818
|
+
document.addEventListener('touchstart', (e) => {
|
|
3819
|
+
sw = null
|
|
3820
|
+
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
3821
|
+
const t = e.target
|
|
3822
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
3823
|
+
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
3824
|
+
}, { passive: true })
|
|
3825
|
+
document.addEventListener('touchend', (e) => {
|
|
3826
|
+
if (!sw || !e.changedTouches.length) return
|
|
3827
|
+
const p = e.changedTouches[0]
|
|
3828
|
+
const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
|
|
3829
|
+
sw = null
|
|
3830
|
+
if (!dir || (window.getSelection && String(window.getSelection()))) return
|
|
3831
|
+
const n = neighbours(tabsUi.m, tabsUi.active)
|
|
3832
|
+
const to = dir === 'next' ? n.next : n.prev
|
|
3833
|
+
if (!to) return
|
|
3834
|
+
const t = tabsUi.m.tabs.find((x) => x.id === to)
|
|
3835
|
+
document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
|
|
3836
|
+
document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
|
|
3837
|
+
goTab(tabView(t).href)
|
|
3838
|
+
}, { passive: true })
|
|
3839
|
+
}
|
|
3840
|
+
// A width change keeps your place: the element at the top of the page stays at
|
|
3841
|
+
// the top, and a Live chat that was at its newest stays there.
|
|
3842
|
+
function anchorY() {
|
|
3843
|
+
let y = 0
|
|
3844
|
+
for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
|
|
3845
|
+
if (!el) continue
|
|
3846
|
+
const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
|
|
3847
|
+
const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
|
|
3848
|
+
}
|
|
3849
|
+
return y
|
|
3850
|
+
}
|
|
3851
|
+
function noteAnchor() {
|
|
3852
|
+
if (Date.now() < tabsUi.quietUntil) return
|
|
3853
|
+
const y = anchorY() + 6, m = $app.getBoundingClientRect()
|
|
3854
|
+
if (m.bottom < y) { tabsUi.anchor = null; return }
|
|
3855
|
+
let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
|
|
3856
|
+
if (!el || !$app.contains(el) || el === $app) { const kids = [...$app.querySelectorAll('h1, h2, h3, p, li, .card, .fact, .lv-msg, section, div')]; el = kids.find((k) => { const r = k.getBoundingClientRect(); return r.bottom > y && r.height > 0 }) || null }
|
|
3857
|
+
tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
|
|
3858
|
+
}
|
|
3859
|
+
function keepPlace() {
|
|
3860
|
+
const a = tabsUi.anchor
|
|
3861
|
+
tabsUi.quietUntil = Date.now() + 250
|
|
3862
|
+
if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
|
|
3863
|
+
document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
|
|
3864
|
+
}
|
|
3865
|
+
{
|
|
3866
|
+
let raf = 0
|
|
3867
|
+
window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
|
|
3868
|
+
document.addEventListener('scroll', (e) => { const log = e.target; if (log && log.classList && log.classList.contains('live-log') && Date.now() >= tabsUi.quietUntil) tabsUi.logs.set(log, { bottom: log.scrollHeight - log.scrollTop - log.clientHeight < 24 }) }, true)
|
|
3869
|
+
let lay = layoutFor(innerWidth)
|
|
3870
|
+
window.addEventListener('resize', () => {
|
|
3871
|
+
keepPlace()
|
|
3872
|
+
const now = layoutFor(innerWidth)
|
|
3873
|
+
if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
|
|
3874
|
+
})
|
|
3875
|
+
document.body.dataset.layout = lay
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3667
3878
|
// Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
|
|
3668
3879
|
// past the line; the page reads its data again, the same as a live update (no
|
|
3669
3880
|
// full reload unless that fails). Never from a field, the Live box or a menu.
|
|
@@ -3672,7 +3883,7 @@ function closeQuickLook(focusTab = false) {
|
|
|
3672
3883
|
ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
|
|
3673
3884
|
ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
|
|
3674
3885
|
document.body.appendChild(ptr)
|
|
3675
|
-
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width:
|
|
3886
|
+
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
3676
3887
|
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
3677
3888
|
let pull = null, refreshing = false
|
|
3678
3889
|
const paint = (step) => {
|
package/ui/index.html
CHANGED
|
@@ -57,6 +57,9 @@
|
|
|
57
57
|
<div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
|
+
<nav class="tabstrip" id="tabStrip" aria-label="Open sessions" hidden></nav>
|
|
61
|
+
<div class="openrow" id="openRow" role="navigation" aria-label="Open sessions" hidden></div>
|
|
62
|
+
<div class="tabpager" id="tabPager" role="navigation" aria-label="Open sessions" hidden></div>
|
|
60
63
|
<main id="app"></main>
|
|
61
64
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
62
65
|
<script src="relay-client.js" type="module"></script>
|
package/ui/tabs.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Tabs for sessions (0.9.8). The board is always the first tab and can't be
|
|
2
|
+
// closed. Clicking a session opens it in ONE preview tab (its title in italics)
|
|
3
|
+
// that the next click replaces, VS Code style; it stays open (permanent, with a
|
|
4
|
+
// close ×) once you send a Live message in it, type there, choose Keep open or
|
|
5
|
+
// double-click the tab, and a middle-click or ⌘/Ctrl-click opens a session
|
|
6
|
+
// straight as permanent. The open set and its order are kept per device
|
|
7
|
+
// (localStorage) and survive a reload; a deep link opens as the preview.
|
|
8
|
+
//
|
|
9
|
+
// The layout follows the window's WIDTH, never the device: under 700px no strip
|
|
10
|
+
// (an Open row on the board, swipe or ‹ › between open sessions on a session
|
|
11
|
+
// page); 700–1099px the strip with the rail as icons; from 1100px the full rail
|
|
12
|
+
// and the strip. Pure functions over a plain model; app.js wires them.
|
|
13
|
+
|
|
14
|
+
export const TABS_KEY = 'cp.tabs'
|
|
15
|
+
export const MAX_TABS = 40
|
|
16
|
+
export const NARROW_MAX = 699.98 // < 700: phones, a foldable closed
|
|
17
|
+
export const WIDE_MIN = 1100 // ≥ 1100: iPad landscape, a Mac
|
|
18
|
+
|
|
19
|
+
// 'narrow' | 'medium' | 'wide' for a CSS width
|
|
20
|
+
export function layoutFor(width) {
|
|
21
|
+
const w = Number(width) || 0
|
|
22
|
+
return w < 700 ? 'narrow' : w < WIDE_MIN ? 'medium' : 'wide'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clean = (e) => ({ id: String(e.id), title: typeof e.title === 'string' ? e.title.slice(0, 200) : '', preview: !!e.preview, ...(typeof e.hash === 'string' && e.hash.startsWith('#/s/') ? { hash: e.hash } : {}) })
|
|
26
|
+
|
|
27
|
+
// Any stored or hand-made value → a valid model: string ids, no repeats, at most
|
|
28
|
+
// one preview (the last one wins), at most MAX_TABS.
|
|
29
|
+
export function normalize(raw) {
|
|
30
|
+
const list = raw && Array.isArray(raw.tabs) ? raw.tabs : []
|
|
31
|
+
const seen = new Set(), tabs = []
|
|
32
|
+
for (const e of list) {
|
|
33
|
+
if (!e || e.id == null || e.id === '' || seen.has(String(e.id))) continue
|
|
34
|
+
seen.add(String(e.id)); tabs.push(clean(e))
|
|
35
|
+
}
|
|
36
|
+
let pi = -1
|
|
37
|
+
tabs.forEach((t, i) => { if (t.preview) { if (pi >= 0) tabs[pi].preview = false; pi = i } })
|
|
38
|
+
return { tabs: tabs.slice(-MAX_TABS) }
|
|
39
|
+
}
|
|
40
|
+
export function emptyTabs() { return { tabs: [] } }
|
|
41
|
+
export function parseStored(text) {
|
|
42
|
+
try { return normalize(JSON.parse(text || 'null')) } catch { return emptyTabs() }
|
|
43
|
+
}
|
|
44
|
+
export function serialize(m) { return JSON.stringify({ v: 1, tabs: normalize(m).tabs }) }
|
|
45
|
+
|
|
46
|
+
export const indexOf = (m, id) => m.tabs.findIndex((t) => t.id === String(id))
|
|
47
|
+
export const isOpen = (m, id) => indexOf(m, id) >= 0
|
|
48
|
+
export const previewOf = (m) => m.tabs.find((t) => t.preview) || null
|
|
49
|
+
|
|
50
|
+
// Open a session. Already open: it just gets focus (permanent promotes a preview).
|
|
51
|
+
// Not open: as the preview, replacing the old preview in place; or, permanent,
|
|
52
|
+
// appended at the end.
|
|
53
|
+
export function openTab(m, id, { permanent = false, title, hash } = {}) {
|
|
54
|
+
const tabs = m.tabs.map((t) => ({ ...t }))
|
|
55
|
+
id = String(id)
|
|
56
|
+
const i = tabs.findIndex((t) => t.id === id)
|
|
57
|
+
const extra = { ...(title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') ? { hash } : {}) }
|
|
58
|
+
if (i >= 0) {
|
|
59
|
+
Object.assign(tabs[i], extra)
|
|
60
|
+
if (permanent) tabs[i].preview = false
|
|
61
|
+
return { tabs }
|
|
62
|
+
}
|
|
63
|
+
const e = { id, title: '', preview: !permanent, ...extra }
|
|
64
|
+
const pi = tabs.findIndex((t) => t.preview)
|
|
65
|
+
if (!permanent && pi >= 0) tabs[pi] = e
|
|
66
|
+
else tabs.push(e)
|
|
67
|
+
// too many: the oldest permanent tab that isn't this one goes
|
|
68
|
+
while (tabs.length > MAX_TABS) { const k = tabs.findIndex((t) => t.id !== id); tabs.splice(k, 1) }
|
|
69
|
+
return { tabs }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The preview becomes permanent (a Live message, typing there, Keep open, a double-click).
|
|
73
|
+
export function promote(m, id) {
|
|
74
|
+
const i = indexOf(m, id)
|
|
75
|
+
if (i < 0 || !m.tabs[i].preview) return m
|
|
76
|
+
return { tabs: m.tabs.map((t, k) => (k === i ? { ...t, preview: false } : t)) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keep a tab's title (shown while its session isn't loaded) and where in it you were.
|
|
80
|
+
export function remember(m, id, { title, hash } = {}) {
|
|
81
|
+
const i = indexOf(m, id)
|
|
82
|
+
if (i < 0) return m
|
|
83
|
+
const t = m.tabs[i]
|
|
84
|
+
const nt = { ...t, ...(title && title !== t.title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') && hash !== t.hash ? { hash } : {}) }
|
|
85
|
+
if (nt.title === t.title && nt.hash === t.hash) return m
|
|
86
|
+
return { tabs: m.tabs.map((x, k) => (k === i ? nt : x)) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Close a tab. When it was the one you're on, `next` is where to go: the
|
|
90
|
+
// neighbour on the right, else the one on the left, else the board (null).
|
|
91
|
+
export function closeTab(m, id, activeId = null) {
|
|
92
|
+
const i = indexOf(m, id)
|
|
93
|
+
if (i < 0) return { model: m, next: activeId == null ? null : String(activeId) }
|
|
94
|
+
const tabs = m.tabs.filter((_, k) => k !== i)
|
|
95
|
+
let next = activeId == null ? null : String(activeId)
|
|
96
|
+
if (String(id) === next) next = tabs.length ? (tabs[i] || tabs[i - 1]).id : null
|
|
97
|
+
return { model: { tabs }, next }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Move a tab to another position (drag on the strip).
|
|
101
|
+
export function moveTab(m, id, to) {
|
|
102
|
+
const i = indexOf(m, id)
|
|
103
|
+
if (i < 0) return m
|
|
104
|
+
const tabs = m.tabs.slice()
|
|
105
|
+
const [t] = tabs.splice(i, 1)
|
|
106
|
+
tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, t)
|
|
107
|
+
return { tabs }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Where a session sits among the open ones (the phone's "2 of 4", swipe and ‹ ›).
|
|
111
|
+
export function neighbours(m, id) {
|
|
112
|
+
const i = indexOf(m, id)
|
|
113
|
+
if (i < 0) return { index: 0, count: m.tabs.length, prev: null, next: null }
|
|
114
|
+
return { index: i + 1, count: m.tabs.length, prev: i > 0 ? m.tabs[i - 1].id : null, next: i < m.tabs.length - 1 ? m.tabs[i + 1].id : null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The session id a hash opens (#/s/<id>, #/s/<id>/live, #/s/<id>?hit=…), or null.
|
|
118
|
+
export function sessionOfHash(hash) {
|
|
119
|
+
const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
|
|
120
|
+
const p = h.split('/').filter(Boolean)
|
|
121
|
+
if (p[0] !== 's' || !p[1]) return null
|
|
122
|
+
try { return decodeURIComponent(p[1]) } catch { return p[1] }
|
|
123
|
+
}
|
|
124
|
+
// Where a tab goes back to: its own page, keeping /live (not a search hit).
|
|
125
|
+
export function tabHref(t) {
|
|
126
|
+
const base = '#/s/' + encodeURIComponent(t.id)
|
|
127
|
+
if (t.hash && sessionOfHash(t.hash) === t.id && t.hash.split('?')[0].endsWith('/live')) return base + '/live'
|
|
128
|
+
return base
|
|
129
|
+
}
|
|
130
|
+
export function keepHash(hash) { return String(hash || '').split('?')[0] }
|
|
131
|
+
|
|
132
|
+
// What a tab shows for its session: the title, the Live pulse while Claude is
|
|
133
|
+
// working (the Live chip's own signal), a dot when it needs you (the same as
|
|
134
|
+
// Needs you). `session` may be missing (not loaded yet): the kept title, no marks.
|
|
135
|
+
export function tabView(t, session, { title, working } = {}) {
|
|
136
|
+
const s = session || null
|
|
137
|
+
return {
|
|
138
|
+
id: t.id, preview: !!t.preview, href: tabHref(t),
|
|
139
|
+
title: (s && title ? title(s) : '') || t.title || 'Session',
|
|
140
|
+
working: !!(s && working && working(s)),
|
|
141
|
+
needs: !!(s && s.needsYou && !s.archived),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const X = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
|
|
146
|
+
const BOARD_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
|
|
147
|
+
const DOTS = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
|
|
148
|
+
const PREV = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6"/></svg>'
|
|
149
|
+
const NEXT = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg>'
|
|
150
|
+
|
|
151
|
+
// The marks after a title: a pulsing Live dot, or the red needs-you dot (both when both).
|
|
152
|
+
function marks(v, esc) {
|
|
153
|
+
return (v.working ? '<span class="tab-live" title="Claude is working"><span class="lo-vh">, Claude is working</span></span>' : '') +
|
|
154
|
+
(v.needs ? '<span class="tab-need" title="Needs you"><span class="lo-vh">, needs you</span></span>' : '')
|
|
155
|
+
}
|
|
156
|
+
function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
|
|
157
|
+
|
|
158
|
+
// The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
|
|
159
|
+
// The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
|
|
160
|
+
export function stripHtml(views, activeId, onBoard, esc) {
|
|
161
|
+
const board = '<a class="tab tab-board' + (onBoard ? ' on' : '') + '" href="#/" data-tab="board"' + (onBoard ? ' aria-current="true"' : '') + ' title="Board">' + BOARD_IC + '<span class="tab-t">Board</span></a>'
|
|
162
|
+
const tabs = views.map((v) => {
|
|
163
|
+
const on = v.id === activeId
|
|
164
|
+
return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" data-tab-id="' + esc(v.id) + '">' +
|
|
165
|
+
'<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' title="' + esc(v.title + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc(label(v)) + '" draggable="true">' +
|
|
166
|
+
'<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
|
|
167
|
+
(v.preview
|
|
168
|
+
? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" title="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg></button>'
|
|
169
|
+
: '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" title="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') +
|
|
170
|
+
'</div>'
|
|
171
|
+
}).join('')
|
|
172
|
+
return '<div class="tabstrip-in">' + board + '<div class="tab-scroll" role="list">' + tabs + '</div></div>' +
|
|
173
|
+
'<button type="button" class="tab-all" id="tabAll" title="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The menu behind ⋯ (picker.js's action menu): every open session, in order.
|
|
177
|
+
export function allTabsItems(views, activeId) {
|
|
178
|
+
return [{ label: 'Board', attrs: { 'data-tab-go': '#/' }, on: activeId == null }].concat(views.map((v) => ({
|
|
179
|
+
label: v.title + (v.preview ? ' (preview)' : ''), sub: [v.working ? 'Claude is working' : '', v.needs ? 'Needs you' : ''].filter(Boolean).join(' · ') || undefined,
|
|
180
|
+
on: v.id === activeId, attrs: { 'data-tab-go': v.href },
|
|
181
|
+
})))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The phone's Open row on the board: the same order, the same marks, the preview in italics.
|
|
185
|
+
export function openRowHtml(views, esc) {
|
|
186
|
+
if (!views.length) return ''
|
|
187
|
+
return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
|
|
188
|
+
'<span class="ochip-cell' + (v.preview ? ' preview' : '') + '"><a class="ochip' + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '" aria-label="' + esc(label(v)) + '"><span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
|
|
189
|
+
(v.preview ? '' : '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '" title="Close">' + X + '</button>') + '</span>').join('') + '</div>'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,
|
|
193
|
+
// and Keep open on a preview.
|
|
194
|
+
export function pagerHtml(m, id, views, esc) {
|
|
195
|
+
const n = neighbours(m, id)
|
|
196
|
+
if (!n.index || n.count < 2) {
|
|
197
|
+
const v = views.find((x) => x.id === String(id))
|
|
198
|
+
return v && v.preview ? '<div class="pager-in"><span class="pager-n faint">Preview</span><button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button></div>' : ''
|
|
199
|
+
}
|
|
200
|
+
const byId = (x) => views.find((v) => v.id === x)
|
|
201
|
+
const v = byId(String(id))
|
|
202
|
+
const btn = (to, dir) => {
|
|
203
|
+
const t = to ? byId(to) : null
|
|
204
|
+
return t ? '<a class="pager-b pager-' + dir + '" href="' + esc(t.href) + '" data-tab="' + esc(t.id) + '" data-pager="' + dir + '" aria-label="' + esc((dir === 'prev' ? 'Previous: ' : 'Next: ') + t.title) + '" title="' + esc(t.title) + '">' + (dir === 'prev' ? PREV : NEXT) + '</a>'
|
|
205
|
+
: '<span class="pager-b pager-' + dir + ' off" aria-hidden="true">' + (dir === 'prev' ? PREV : NEXT) + '</span>'
|
|
206
|
+
}
|
|
207
|
+
return '<div class="pager-in">' + btn(n.prev, 'prev') + '<span class="pager-n" aria-live="polite">' + n.index + ' of ' + n.count + '<span class="lo-vh"> open sessions</span></span>' +
|
|
208
|
+
(v && v.preview ? '<button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button>' : '') + btn(n.next, 'next') + '</div>'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A horizontal swipe on a phone's session page: which way, or null. Mostly
|
|
212
|
+
// sideways (twice as far across as down), at least 60px, quick (under 700ms).
|
|
213
|
+
export function swipeDir(dx, dy, ms) {
|
|
214
|
+
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy) || ms > 700) return null
|
|
215
|
+
return dx < 0 ? 'next' : 'prev'
|
|
216
|
+
}
|