@leftoff/cli 0.9.17-beta.7 → 0.9.17-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 +6 -0
- package/bin/leftoff.js +419 -419
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +107 -0
- package/ui/app.js +128 -36
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/hosted-by.js +50 -0
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/relay-client.js +96 -6
- package/ui/side-fold.js +124 -0
package/ui/fs-side.js
CHANGED
|
@@ -71,12 +71,12 @@ export function railHtml({ cpCount = 0, cu = true, away = '' } = {}, { esc, icon
|
|
|
71
71
|
// The whole panel. side/foot: the panel's markup (built by app.js from the
|
|
72
72
|
// session); label: the peek's one line; collapsed: the rail instead (desktop);
|
|
73
73
|
// sheet: the phone's sheet is raised.
|
|
74
|
-
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
74
|
+
export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, scrolled = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
|
|
75
75
|
const peek = '<div class="fs-peek">' +
|
|
76
76
|
'<button type="button" class="fs-peek-b" data-action="fs-sheet" aria-expanded="' + (sheet ? 'true' : 'false') + '" aria-controls="fsSide"><span class="fs-grip" aria-hidden="true"></span><span class="fs-peek-t">' + esc(label) + '</span>' + icon(sheet ? 'chevron-down' : 'chevron-up') + '</button>' +
|
|
77
77
|
(cu ? '<button type="button" class="btn fs-peek-cu' + (away ? ' primary' : '') + '" data-action="scu-open" data-id="' + esc(id) + '"' + (away ? ' aria-label="' + esc(away + ' · Catch me up') + '"' : '') + '>' + icon('coffee') + 'Catch me up</button>' : '') +
|
|
78
78
|
// 0.9.17 Walk me through it, beside Catch me up in the peek (its eye count when there's something to look at)
|
|
79
79
|
(wt ? '<button type="button" class="btn fs-peek-wt" data-action="wt-open" data-id="' + esc(id) + '" aria-label="' + esc('Walk me through it' + (look ? ', ' + look + ' to look closely at' : '')) + '" data-tip="Walk me through the code changes">' + icon('git-compare') + (look ? '<span class="fs-peek-n" aria-hidden="true">' + esc(String(look)) + '</span>' : '') + '</button>' : '') + '</div>'
|
|
80
|
-
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
80
|
+
return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
|
|
81
81
|
resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
|
|
82
82
|
}
|
package/ui/hosted-by.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 0.9.18 Hosted by (Settings → Use from anywhere): which of your machines serves
|
|
2
|
+
// <user>.leftoff.dev. The relay decides (relay/src/hosting.js): your preferred host
|
|
3
|
+
// whenever it's online, else the one already hosting, else an online one (Macs
|
|
4
|
+
// first). Here: each machine with its state by shape and word, the host marked,
|
|
5
|
+
// "Make host" per online machine, and Preferred (Automatic or a machine).
|
|
6
|
+
// Pure (no DOM), so the check and the unit tests render it directly.
|
|
7
|
+
const STATE = {
|
|
8
|
+
online: ['●', 'Online'],
|
|
9
|
+
not_answering: ['◐', 'Not answering'],
|
|
10
|
+
reconnecting: ['◌', 'Reconnecting'],
|
|
11
|
+
asleep: ['○', 'Offline'],
|
|
12
|
+
}
|
|
13
|
+
export function hostState(m) { return STATE[m && m.state] || (m && m.online ? STATE.online : STATE.asleep) }
|
|
14
|
+
const kind = (p) => (p === 'darwin' ? 'Mac' : p === 'linux' ? 'Linux' : p ? p : '')
|
|
15
|
+
|
|
16
|
+
export function hostedByHtml(h, { esc = (s) => String(s == null ? '' : s), canChange = true, viewing = null } = {}) {
|
|
17
|
+
if (!h || !Array.isArray(h.macs) || !h.macs.length) return ''
|
|
18
|
+
let out = '<label>Hosted by</label><div class="hostedby" id="hostedBy">'
|
|
19
|
+
if (!h.supported) {
|
|
20
|
+
out += '<div class="hint nomt">Your relay serves the board from the machine that connected last. Choosing a host needs a relay update.</div></div>'
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
const host = h.host || {}
|
|
24
|
+
const pref = host.preferred || null
|
|
25
|
+
out += '<ul class="hosts" role="list">'
|
|
26
|
+
for (const m of h.macs) {
|
|
27
|
+
const [shape, word] = hostState(m)
|
|
28
|
+
const isHost = m.machine === host.machine
|
|
29
|
+
const k = kind(m.platform)
|
|
30
|
+
out += '<li class="host-row' + (isHost ? ' is-host' : '') + (m.online ? ' on' : ' off') + '" data-machine="' + esc(m.machine) + '">' +
|
|
31
|
+
'<span class="host-shape" aria-hidden="true">' + shape + '</span>' +
|
|
32
|
+
'<span class="host-main"><b>' + esc(m.machine) + '</b>' + (isHost ? ' <span class="chip host-chip">Host</span>' : '') + (m.machine === pref ? ' <span class="chip pref-chip">Preferred</span>' : '') +
|
|
33
|
+
'<span class="faint host-sub">' + word + (k ? ' · ' + esc(k) : '') + (viewing && viewing === m.machine && !isHost ? ' · this browser' : '') + (m.serves === 0 ? ' · can’t open your paired browsers’ keys' : '') + '</span></span>'
|
|
34
|
+
if (canChange && !isHost) {
|
|
35
|
+
out += '<button type="button" class="btn sm host-make" data-action="relay-host" data-machine="' + esc(m.machine) + '"' + (m.online ? '' : ' disabled data-tip="' + esc(m.machine) + ' is offline: it can host once it’s online"') + '>Make host</button>'
|
|
36
|
+
}
|
|
37
|
+
out += '</li>'
|
|
38
|
+
}
|
|
39
|
+
out += '</ul>'
|
|
40
|
+
if (host.pending) out += '<div class="hint nomt host-pending">' + esc(host.pending.machine) + ' is back and takes over in a few seconds.</div>'
|
|
41
|
+
if (!host.machine) out += '<div class="hint nomt warnish">None of your machines is online. The first one to connect hosts.</div>'
|
|
42
|
+
// Preferred: a segmented choice, Automatic first
|
|
43
|
+
out += '<div class="host-pref" role="radiogroup" aria-label="Preferred host"><span class="host-pref-label">Preferred</span>'
|
|
44
|
+
const opt = (val, label, tip) => '<button type="button" role="radio" class="hp-opt' + ((pref || '') === val ? ' on' : '') + '" aria-checked="' + ((pref || '') === val ? 'true' : 'false') + '" data-action="relay-host-pref" data-machine="' + esc(val) + '" data-tip="' + esc(tip) + '"' + (canChange ? '' : ' disabled') + '>' + esc(label) + '</button>'
|
|
45
|
+
out += opt('', 'Automatic', 'Any online machine; a Mac before Linux, and the host stays put while it’s online')
|
|
46
|
+
for (const m of h.macs) out += opt(m.machine, m.machine, m.machine + ' hosts whenever it’s online; when it’s closed another machine takes over, and it takes back when it returns')
|
|
47
|
+
out += '</div>'
|
|
48
|
+
out += '<div class="hint">' + (pref ? esc(pref) + ' hosts whenever it’s online. ' : '') + 'When the host closes or loses its network, another online machine takes over within about half a minute. Phones and browsers stay paired.</div>'
|
|
49
|
+
return out + '</div>'
|
|
50
|
+
}
|
package/ui/live-view.js
CHANGED
|
@@ -168,7 +168,7 @@ export function senderLabel(m) {
|
|
|
168
168
|
if (!device && by.transport === 'local') device = 'this Mac'
|
|
169
169
|
if (!device && m.where) { // older entries: just the where line
|
|
170
170
|
const w = String(m.where)
|
|
171
|
-
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
|
|
171
|
+
device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || (w.match(/^from (.+?) · (?:via relay|direct)\b/) || [])[1] || null // 2026-10-29: "from iPhone · via relay"
|
|
172
172
|
}
|
|
173
173
|
let label = 'You' + (device ? ' · from ' + device : '')
|
|
174
174
|
const path = PATHS[by.transport] || null
|
package/ui/prs.js
CHANGED
|
@@ -93,6 +93,11 @@ export async function prCopyClick(btn, env = {}) {
|
|
|
93
93
|
export function prMoreHtml(p, open = false) {
|
|
94
94
|
return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
|
|
95
95
|
}
|
|
96
|
+
// 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
|
|
97
|
+
export function prGhOnlyHtml(p) {
|
|
98
|
+
const url = safeUrl(p.url)
|
|
99
|
+
return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
|
|
100
|
+
}
|
|
96
101
|
export function prActionsHtml(p, { unlinkId = null } = {}) {
|
|
97
102
|
const url = safeUrl(p.url), n = '#' + (p.number || '')
|
|
98
103
|
if (!url && !unlinkId) return ''
|
|
@@ -113,21 +118,25 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
|
|
|
113
118
|
const n = list.length + unchecked.length
|
|
114
119
|
let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
|
|
115
120
|
if (list.length) {
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
// 2026-10-29 one compact row each: the state (shape + word), #, the title on one line; tap the title for
|
|
122
|
+
// the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
|
|
123
|
+
h += '<div class="prlist pr-compact">' + list.map((p) => {
|
|
118
124
|
const isOpen = !!(open && open.has && open.has(p.url))
|
|
119
125
|
return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
120
|
-
'<div class="pr-
|
|
121
|
-
'<
|
|
122
|
-
'<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + prMoreHtml(p, isOpen) + '</div>' +
|
|
123
|
-
(isOpen ?
|
|
124
|
-
prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
|
|
126
|
+
'<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
|
|
127
|
+
'<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
|
|
128
|
+
(isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
|
|
129
|
+
(isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
|
|
125
130
|
}).join('') + '</div>'
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
// several refs that couldn't be checked fold into one "+4 not checked" row
|
|
133
|
+
if (unchecked.length === 1) h += '<div class="pr-unlist">' + uncheckedHtml(unchecked[0]) + '</div>'
|
|
134
|
+
else if (unchecked.length > 1) h += '<details class="pr-unfold"><summary class="pr-unsum">' + STATE_IC.unchecked + '<span>+' + unchecked.length + ' not checked</span></summary><div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div></details>'
|
|
128
135
|
if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
|
|
129
136
|
(mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
|
|
130
|
-
|
|
137
|
+
const form = '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
138
|
+
// 2026-10-29: with PRs already linked, the box waits behind one quiet "Link another PR…"
|
|
139
|
+
if (mayEdit) h += n ? '<details class="pr-linkd"><summary class="linkish">Link another PR…</summary>' + form + '</details>' : form
|
|
131
140
|
return h
|
|
132
141
|
}
|
|
133
142
|
// 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
|
package/ui/relay-client.js
CHANGED
|
@@ -58,7 +58,30 @@ async function loadDirect() {
|
|
|
58
58
|
const INIT_WAIT_MS = 1200 // how long the first paint waits for this Mac's own answer
|
|
59
59
|
const RELOAD_KEY = 'leftoff.direct.reloaded'
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// 0.9.18: a pick from the reconnecting screen ("Use laptop instead") lasts this tab only
|
|
62
|
+
const TEMP_KEY = 'leftoff.relay.mac.temp', REJECT_KEY = 'leftoff.relay.rejected', REJECT_MS = 10 * 60 * 1000
|
|
63
|
+
function pickedMac() { try { return localStorage.getItem(MAC_KEY) || sessionStorage.getItem(TEMP_KEY) } catch { return null } }
|
|
64
|
+
function tempPick(mac) { try { sessionStorage.setItem(TEMP_KEY, mac) } catch {} }
|
|
65
|
+
// Machines that just refused this browser's key (unknown_device / not_ready): skipped for a while,
|
|
66
|
+
// so the board doesn't keep going back to a host that can't open it (today's "Reconnecting" loop).
|
|
67
|
+
function rejected(now = Date.now()) {
|
|
68
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); return Object.keys(o).filter((m) => now - o[m] < REJECT_MS) } catch { return [] }
|
|
69
|
+
}
|
|
70
|
+
function reject(mac, now = Date.now()) {
|
|
71
|
+
if (!mac) return
|
|
72
|
+
try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); o[mac] = now; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } catch {}
|
|
73
|
+
}
|
|
74
|
+
function unreject(mac) { try { const o = JSON.parse(sessionStorage.getItem(REJECT_KEY) || '{}'); if (o[mac]) { delete o[mac]; sessionStorage.setItem(REJECT_KEY, JSON.stringify(o)) } } catch {} }
|
|
75
|
+
// Which machine this browser uses: your own pick while it's online, else the relay's host
|
|
76
|
+
// (relay/src/hosting.js), else the last one that answered, else any online one that hasn't
|
|
77
|
+
// just refused this browser. Pure, unit-tested.
|
|
78
|
+
export function pickMac({ picked = null, online = [], host = null, rejected = [], good = null } = {}) {
|
|
79
|
+
const ok = (m) => m && online.includes(m) && !rejected.includes(m)
|
|
80
|
+
if (picked && online.includes(picked)) return picked
|
|
81
|
+
if (ok(host)) return host
|
|
82
|
+
if (ok(good)) return good
|
|
83
|
+
return online.find((m) => !rejected.includes(m)) || host || null
|
|
84
|
+
}
|
|
62
85
|
function remember(mac) {
|
|
63
86
|
try { if (mac) localStorage.setItem(MAC_KEY, mac); else localStorage.removeItem(MAC_KEY) } catch {}
|
|
64
87
|
}
|
|
@@ -73,8 +96,7 @@ async function refreshStatus() {
|
|
|
73
96
|
const online = relay.status.macs.filter((m) => m.online).map((m) => m.machine)
|
|
74
97
|
// on this Mac's direct path the board stays on this Mac, whatever the relay sees
|
|
75
98
|
if (!onDirect()) {
|
|
76
|
-
|
|
77
|
-
relay.mac = want && online.includes(want) ? want : relay.status.default
|
|
99
|
+
relay.mac = pickMac({ picked: pickedMac(), online, host: relay.status.default, rejected: rejected(), good: lastGood() })
|
|
78
100
|
cookieMac(relay.mac)
|
|
79
101
|
}
|
|
80
102
|
return relay.status
|
|
@@ -252,8 +274,27 @@ async function viaRelay(body, init, retried) {
|
|
|
252
274
|
const gone = relay.mac
|
|
253
275
|
const env = await sealRequest(relay.key, { device: relay.device, machine: gone, body })
|
|
254
276
|
const res = await realFetch('/_relay/api', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(env), signal: init.signal, cache: 'no-store' })
|
|
255
|
-
if (res.status === 200) {
|
|
277
|
+
if (res.status === 200) {
|
|
278
|
+
good(gone); unreject(gone)
|
|
279
|
+
const out = toResponse(await openResponse(relay.key, env, await res.json()))
|
|
280
|
+
// 0.9.18: the host changed (made elsewhere, or a preferred machine came back): follow it
|
|
281
|
+
const host = res.headers.get('x-leftoff-host')
|
|
282
|
+
if (host && host !== relay.mac && relay.mac === gone && !pickedMac() && !onDirect() && /^[\w.-]{1,64}$/.test(host)) followHost(host)
|
|
283
|
+
return out
|
|
284
|
+
}
|
|
256
285
|
const j = await res.clone().json().catch(() => ({}))
|
|
286
|
+
if ((j.code === 'unknown_device' || j.code === 'not_ready') && !retried) {
|
|
287
|
+
// 0.9.18: this machine can't open this browser's key (a sandbox outside your synced
|
|
288
|
+
// folder, a Mac still starting): another online machine that can takes over
|
|
289
|
+
reject(gone)
|
|
290
|
+
await refreshStatus().catch(() => {})
|
|
291
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
292
|
+
}
|
|
293
|
+
if (res.status === 504 && j.code === 'timeout' && !retried) {
|
|
294
|
+
// the host stopped answering (a closed lid before the relay notices): ask who hosts now
|
|
295
|
+
await refreshStatus().catch(() => {})
|
|
296
|
+
if (relay.mac && relay.mac !== gone) { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: relay.mac, switched: true } })); return viaRelay(body, init, true) }
|
|
297
|
+
}
|
|
257
298
|
if (authVerdict(res.status, j.code) !== 'ok') {
|
|
258
299
|
// 0.9.17: the Mac that took over while yours restarted may not know this browser:
|
|
259
300
|
// stay with the one that does, and look again (never forget the key on this)
|
|
@@ -277,6 +318,16 @@ async function viaRelay(body, init, retried) {
|
|
|
277
318
|
return res
|
|
278
319
|
}
|
|
279
320
|
|
|
321
|
+
// The relay says another machine hosts now. Once per few seconds at most; the board
|
|
322
|
+
// hears leftoff-relay { switched } as it does after a failover.
|
|
323
|
+
let followedAt = 0
|
|
324
|
+
function followHost(host) {
|
|
325
|
+
if (Date.now() - followedAt < 5000) return
|
|
326
|
+
followedAt = Date.now()
|
|
327
|
+
relay.mac = host; cookieMac(host)
|
|
328
|
+
try { window.dispatchEvent(new CustomEvent('leftoff-relay', { detail: { mac: host, switched: true, followed: true } })) } catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
280
331
|
function patchedFetch(input, init) {
|
|
281
332
|
try {
|
|
282
333
|
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
|
|
@@ -433,7 +484,40 @@ function trouble(code) {
|
|
|
433
484
|
}
|
|
434
485
|
function reconnecting() {
|
|
435
486
|
if (document.querySelector('.relay-screen')) return
|
|
436
|
-
screen('Reconnecting to your board…',
|
|
487
|
+
screen('Reconnecting to your board…', reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rejected() }))
|
|
488
|
+
wirePicker()
|
|
489
|
+
}
|
|
490
|
+
// 0.9.18: the reconnecting screen says where each machine is (shape + word) and, when the
|
|
491
|
+
// host is out but another machine is online, that it's switching; with several online, a
|
|
492
|
+
// "Use <name> instead" button each (this tab only). Pure, unit-tested.
|
|
493
|
+
export function reconnectHtml({ status = null, mac = null, rejected = [] } = {}) {
|
|
494
|
+
const macs = (status && status.macs) || []
|
|
495
|
+
const online = macs.filter((m) => m.online).map((m) => m.machine)
|
|
496
|
+
const name = mac || 'Your Mac'
|
|
497
|
+
const hostOut = mac && (!online.includes(mac) || rejected.includes(mac))
|
|
498
|
+
const others = online.filter((m) => m !== mac && !rejected.includes(m))
|
|
499
|
+
const target = pickMac({ online: others, host: status && status.default, rejected })
|
|
500
|
+
let h = '<p class="relay-wait" role="status"><span class="relay-dot" aria-hidden="true"></span>'
|
|
501
|
+
if (hostOut && target) h += esc(name) + (online.includes(mac) ? ' can’t open this browser’s key.' : ' is offline.') + ' ' + esc(target) + ' is online: switching to it…</p>'
|
|
502
|
+
else h += esc(name) + ' may be restarting. This takes a few seconds.</p>'
|
|
503
|
+
if (macs.length > 1) {
|
|
504
|
+
h += '<ul class="relay-hosts" aria-label="Your machines">'
|
|
505
|
+
for (const m of macs) {
|
|
506
|
+
const st = m.online ? (rejected.includes(m.machine) ? ['◐', 'can’t open this browser’s key'] : ['●', 'online']) : m.state === 'reconnecting' ? ['◌', 'reconnecting'] : ['○', 'offline']
|
|
507
|
+
h += '<li class="relay-host ' + (m.online ? 'on' : 'off') + '"><span class="relay-host-shape" aria-hidden="true">' + st[0] + '</span><b>' + esc(m.machine) + '</b><span class="relay-host-word">' + st[1] + (m.machine === mac ? ' · was hosting' : '') + '</span>'
|
|
508
|
+
if (others.length > 1 && others.includes(m.machine)) h += '<button type="button" class="btn relay-use" data-mac="' + esc(m.machine) + '">Use ' + esc(m.machine) + ' instead</button>'
|
|
509
|
+
h += '</li>'
|
|
510
|
+
}
|
|
511
|
+
h += '</ul>'
|
|
512
|
+
}
|
|
513
|
+
return h
|
|
514
|
+
}
|
|
515
|
+
export function wirePicker() {
|
|
516
|
+
document.querySelectorAll('.relay-use').forEach((b) => b.addEventListener('click', () => {
|
|
517
|
+
const m = b.getAttribute('data-mac')
|
|
518
|
+
if (!m) return
|
|
519
|
+
stopTrouble(); tempPick(m); unreject(m); good(m); cookieMac(m); location.reload()
|
|
520
|
+
}))
|
|
437
521
|
}
|
|
438
522
|
// One sealed ping per candidate Mac (the one that last answered first): 'ok' (with
|
|
439
523
|
// the Mac), 'revoked', or 'retry'.
|
|
@@ -443,7 +527,10 @@ async function pingMacs() {
|
|
|
443
527
|
if (res0 && res0.status === 401) { const j = await res0.json().catch(() => ({})); return { verdict: authVerdict(401, j.code), code: j.code || 'not_paired' } }
|
|
444
528
|
if (res0 && res0.ok) { st = await res0.json().catch(() => null); if (st) relay.status = st }
|
|
445
529
|
const online = st ? st.macs.filter((m) => m.online).map((m) => m.machine) : []
|
|
446
|
-
|
|
530
|
+
// 0.9.18: the relay's host first, machines that just refused this browser last
|
|
531
|
+
const rej = rejected()
|
|
532
|
+
const order = [...new Set([st && st.default, lastGood(), relay.mac, ...online].filter((m) => m && online.includes(m)))].sort((a, b) => rej.includes(a) - rej.includes(b))
|
|
533
|
+
if (document.querySelector('.relay-screen') && !lostShown && !notPairedShown) { const w = document.querySelector('.relay-screen .relay-card'); if (w) { w.innerHTML = '<div class="relay-logo">Leftoff</div><h1>Reconnecting to your board…</h1>' + reconnectHtml({ status: relay.status, mac: relay.mac, rejected: rej }); wirePicker() } }
|
|
447
534
|
let last = { verdict: 'retry', code: online.length ? 'unknown_device' : 'mac_offline' }
|
|
448
535
|
for (const machine of order) {
|
|
449
536
|
try {
|
|
@@ -503,6 +590,9 @@ export const leftoffRelay = {
|
|
|
503
590
|
online(mac) { return !!mac && onlineMacs().includes(String(mac)) },
|
|
504
591
|
// Switch Macs: the board reloads from the other one.
|
|
505
592
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
593
|
+
// 0.9.18 Hosted by: the user's host changed (Settings): drop this browser's own pick and follow it.
|
|
594
|
+
useHost(mac) { remember(null); try { sessionStorage.removeItem(TEMP_KEY) } catch {} if (mac && mac !== relay.mac) { unreject(mac); cookieMac(mac); location.reload() } },
|
|
595
|
+
get pinned() { return pickedMac() },
|
|
506
596
|
// This browser revoked itself (0.6.6).
|
|
507
597
|
unpaired() { notPaired('self') },
|
|
508
598
|
}
|
package/ui/side-fold.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 2026-10-29 A calmer session panel (Joel: "very noisy and SUPER SCROLLY").
|
|
2
|
+
// The top stays open (the head, Catch me up / Walk, the newest checkpoint's
|
|
3
|
+
// Stopped + Next, PRs, To do); everything else is a one-line header with a
|
|
4
|
+
// count ("Files · 12"), closed until you open it. Open/closed is kept per
|
|
5
|
+
// device (localStorage lo-side-fold-v1), not per session. Pure builders,
|
|
6
|
+
// unit-tested (test/unit/calmside-1029.test.mjs); app.js wires them.
|
|
7
|
+
|
|
8
|
+
export const FOLD_KEY = 'lo-side-fold-v1'
|
|
9
|
+
|
|
10
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
11
|
+
|
|
12
|
+
// { key: true | false } in one key; anything unreadable is "nothing chosen yet"
|
|
13
|
+
export function createFoldStore(storage) {
|
|
14
|
+
const read = () => { try { const o = JSON.parse((storage && storage.getItem(FOLD_KEY)) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {} } catch { return {} } }
|
|
15
|
+
return {
|
|
16
|
+
open(key, dflt = false) { const v = read()[key]; return typeof v === 'boolean' ? v : !!dflt },
|
|
17
|
+
set(key, open) {
|
|
18
|
+
if (!key || !/^[\w.-]{1,40}$/.test(key)) return
|
|
19
|
+
const o = read(); o[key] = !!open
|
|
20
|
+
try { storage && storage.setItem(FOLD_KEY, JSON.stringify(o)) } catch {}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A section as one line: "Files · 12", a chevron; its body under it when open.
|
|
26
|
+
// body: the section's own markup (it keeps its id, so its painter still works).
|
|
27
|
+
export function foldHtml({ key, title, count = null, body = '', open = false, cls = '' }, { esc = escD } = {}) {
|
|
28
|
+
if (!body) return ''
|
|
29
|
+
const n = count == null || count === '' ? '' : '<span class="fs-fold-n"> · ' + esc(String(count)) + '</span>'
|
|
30
|
+
return '<details class="fs-fold' + (cls ? ' ' + cls : '') + '" data-fold="' + esc(key) + '"' + (open ? ' open' : '') + '>' +
|
|
31
|
+
'<summary class="fs-fold-h"><span class="fs-fold-t">' + esc(title) + n + '</span><svg class="fs-fold-c" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></summary>' +
|
|
32
|
+
'<div class="fs-fold-b">' + body + '</div></details>'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------ checkpoints
|
|
36
|
+
|
|
37
|
+
// A path as the reader knows it: relative to the session's repo / worktree root,
|
|
38
|
+
// else ~/ for the home folder. /Users/<u>/…/worktrees/<name>/src/a.js -> src/a.js
|
|
39
|
+
export function relPath(p, { roots = [], home = '' } = {}) {
|
|
40
|
+
let s = String(p == null ? '' : p).trim()
|
|
41
|
+
if (!s) return ''
|
|
42
|
+
const rs = roots.filter((r) => typeof r === 'string' && r.length > 1).map((r) => r.replace(/\/+$/, '')).sort((a, b) => b.length - a.length)
|
|
43
|
+
for (const r of rs) if (s === r) return '.'; else if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
44
|
+
// a worktree folder of a tool (…/worktrees/<name>/rest): the rest
|
|
45
|
+
const wt = /^(?:\/Users\/[^/]+|\/home\/[^/]+|~)\/(?:[^/]+\/)*?(?:\.?worktrees|\.claude\/worktrees)\/[^/]+\/(.+)$/.exec(s)
|
|
46
|
+
if (wt) return wt[1]
|
|
47
|
+
const h = String(home || '').replace(/\/+$/, '')
|
|
48
|
+
if (h && s.startsWith(h + '/')) return '~/' + s.slice(h.length + 1)
|
|
49
|
+
const mh = /^\/(?:Users|home)\/[^/]+\/(.+)$/.exec(s)
|
|
50
|
+
if (mh) return '~/' + mh[1]
|
|
51
|
+
return s
|
|
52
|
+
}
|
|
53
|
+
// The roots a session's paths are relative to (worktree first, then repo, then its folder)
|
|
54
|
+
export function rootsOf(s) {
|
|
55
|
+
const r = (s && s.repo) || {}
|
|
56
|
+
return [s && s.worktree && s.worktree.path, r.worktree, r.root, s && s.cwd].filter((x) => typeof x === 'string' && x.startsWith('/'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Files touched as chips: the first five, then "+N more" that opens the rest.
|
|
60
|
+
export const FILES_SHOWN = 5
|
|
61
|
+
export function filesTouchedHtml(files, { roots = [], home = '' } = {}, { esc = escD } = {}) {
|
|
62
|
+
const list = (Array.isArray(files) ? files : []).map((f) => String(f || '')).filter(Boolean)
|
|
63
|
+
if (!list.length) return ''
|
|
64
|
+
const chip = (f) => '<span class="chip mono" data-tip="' + esc(f) + '">' + esc(relPath(f, { roots, home })) + '</span>'
|
|
65
|
+
const first = list.slice(0, FILES_SHOWN), rest = list.slice(FILES_SHOWN)
|
|
66
|
+
return '<div class="files">' + first.map(chip).join('') +
|
|
67
|
+
(rest.length ? '<details class="files-more"><summary class="linkish">+' + rest.length + ' more</summary>' + rest.map(chip).join('') + '</details>' : '') + '</div>'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Text a checkpoint was written from that wasn't a prompt: Claude Code's
|
|
71
|
+
// wrappers (<task-notification>, <system-reminder>, a slash command's output…),
|
|
72
|
+
// the same list the mod skips (mod/hooks/core.js NOT_A_PROMPT).
|
|
73
|
+
const WRAPPER = /^\s*(?:Working on:\s*[“"]\s*)?(?:<(?:local-command-stdout|local-command-stderr|command-name|command-message|command-args|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook|task-notification|user-memory-input)\b|\[Request interrupted by user|Caveat: The messages below were generated)/
|
|
74
|
+
export function isWrapperCp(cp) {
|
|
75
|
+
if (!cp) return false
|
|
76
|
+
return WRAPPER.test(String(cp.doing || '')) || (!String(cp.doing || '').trim() && WRAPPER.test(String(cp.stoppedAt || '')))
|
|
77
|
+
}
|
|
78
|
+
// Junk: a wrapper checkpoint written within a few minutes of another one (the
|
|
79
|
+
// same moment twice). A lone one stays: it's all there is.
|
|
80
|
+
export const JUNK_NEAR_MS = 5 * 60e3
|
|
81
|
+
export function dropJunkCps(list) {
|
|
82
|
+
const all = Array.isArray(list) ? list : []
|
|
83
|
+
const t = (cp) => Date.parse((cp && cp.createdAt) || '')
|
|
84
|
+
return all.filter((cp) => !(isWrapperCp(cp) && all.some((o) => o !== cp && !isWrapperCp(o) && Math.abs(t(o) - t(cp)) <= JUNK_NEAR_MS)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ status history
|
|
88
|
+
|
|
89
|
+
// One small shape per change, the word and time in its tip (shape + word, never color alone).
|
|
90
|
+
export const STATUS_SHAPE = { needs_input: '▲', active: '●', background: '◐', waiting: '◆', dormant: '○', stale: '◌', ended: '■' }
|
|
91
|
+
export function statusStripHtml(changes, { label = {}, clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
92
|
+
const list = (Array.isArray(changes) ? changes : []).slice(-24)
|
|
93
|
+
if (!list.length) return ''
|
|
94
|
+
return '<div class="sstrip" role="list" aria-label="Status history">' + list.map((c) => {
|
|
95
|
+
const w = label[c.status] || c.status || ''
|
|
96
|
+
const tip = w + ' · ' + clock(c.at)
|
|
97
|
+
return '<span class="ss s-' + esc(c.status) + '" role="listitem" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(tip) + '">' + esc(STATUS_SHAPE[c.status] || '·') + '</span>'
|
|
98
|
+
}).join('') + '</div>'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------ answered from the board
|
|
102
|
+
|
|
103
|
+
// Where an answer came from, in the words of what actually happened. Older
|
|
104
|
+
// answers said "over Tailscale" for anything from another device, the relay
|
|
105
|
+
// included (src/answers.js whereFrom before 2026-10-29); they read right now.
|
|
106
|
+
export function whereWords(w) {
|
|
107
|
+
let s = String(w || '').trim()
|
|
108
|
+
if (!s) return ''
|
|
109
|
+
const old = /^over Tailscale(?: as (.+?))?(?: \(through the relay\))?(?: \(relay\))?(?: · (.+))?$/.exec(s)
|
|
110
|
+
if (old && /\(through the relay\)|\(relay\)/.test(s)) return 'from ' + (old[1] || 'a paired device') + ' · via relay' + (old[2] ? ' · ' + old[2] : '')
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
export const ANSWERED_SHOWN = 3
|
|
114
|
+
// The newest three, "Show all N" for the rest (a <details>, so it stays put through redraws)
|
|
115
|
+
export function answeredHtml(answers, { clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
116
|
+
const list = (Array.isArray(answers) ? answers : []).slice().reverse()
|
|
117
|
+
if (!list.length) return ''
|
|
118
|
+
const row = (a) => '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
|
|
119
|
+
'<span class="where">' + esc(whereWords(a.where)) + (a.delivered === false ? ' · not picked up' : '') + '</span></li>'
|
|
120
|
+
const first = list.slice(0, ANSWERED_SHOWN), rest = list.slice(ANSWERED_SHOWN)
|
|
121
|
+
return '<ul class="answered">' + first.map(row).join('') + '</ul>' +
|
|
122
|
+
(rest.length ? '<details class="answered-more"><summary class="linkish">Show all ' + list.length + '</summary><ul class="answered">' + rest.map(row).join('') + '</ul></details>' : '') +
|
|
123
|
+
'<div class="hint">Also in answers.md in this session’s folder.</div>'
|
|
124
|
+
}
|