@leftoff/cli 0.9.17-beta.7 → 0.9.17-beta.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.17-beta.7",
3
+ "version": "0.9.17-beta.8",
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": "One Silly App"
@@ -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.17-beta.7' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.17-beta.8' // 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.17-beta.7",
3
+ "version": "0.9.17-beta.8",
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
@@ -5102,3 +5102,33 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
5102
5102
  .ghost .ghost-k { flex: none; }
5103
5103
  .ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
5104
5104
  .ghost .ghost-k { align-self: center; }
5105
+
5106
+ /* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
5107
+ .hostedby { min-width: 0; }
5108
+ .hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
5109
+ .host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
5110
+ .host-row + .host-row { border-top: 1px solid var(--line); }
5111
+ .host-row.is-host { background: var(--bg2); }
5112
+ .host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
5113
+ .host-row.on .host-shape { color: var(--text); }
5114
+ .host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
5115
+ .host-sub { flex-basis: 100%; font-size: 12.5px; }
5116
+ .host-chip { font-weight: 600; }
5117
+ .pref-chip { border-style: dashed; }
5118
+ .host-make { flex: none; min-height: 44px; }
5119
+ .host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
5120
+ .hostedby > .hint { margin-top: 8px; }
5121
+ .host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
5122
+ .hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
5123
+ .hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
5124
+ .hp-opt.on::before { content: '✓ '; }
5125
+ .hp-opt:disabled { opacity: .55; cursor: default; }
5126
+ .hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5127
+ @media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
5128
+ .relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
5129
+ .relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
5130
+ .relay-host + .relay-host { border-top: 1px solid var(--line); }
5131
+ .relay-host-shape { width: 16px; text-align: center; flex: none; }
5132
+ .relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
5133
+ .relay-use { min-height: 44px; margin-left: auto; }
5134
+ @media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
package/ui/app.js CHANGED
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
34
34
  import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
35
35
  import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
36
36
  import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
37
+ import { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
37
38
  import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
38
39
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
39
40
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
@@ -2381,6 +2382,10 @@ async function viewSession(id) {
2381
2382
  // 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
2382
2383
  // each session, read before caughtUp folds its card; a new visit starts over.
2383
2384
  const openAway = new Map()
2385
+ // 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
2386
+ // while you were away reads at least "day" depth (ui/away.js), but its label says
2387
+ // how long it has really gone unseen: never more than the session's own age.
2388
+ function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
2384
2389
  function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
2385
2390
  function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
2386
2391
  function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
@@ -2555,7 +2560,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2555
2560
  const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
2556
2561
  // 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
2557
2562
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2558
- const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2563
+ const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2559
2564
  const wtOn = wtUi.sid === s.id
2560
2565
  const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
2561
2566
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
@@ -2597,7 +2602,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2597
2602
  side += answeredList(s)
2598
2603
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2599
2604
  const label = fsPeekWords(s)
2600
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2605
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2601
2606
  }
2602
2607
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2603
2608
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -4986,6 +4991,8 @@ function anywhereSection(r, adv = []) {
4986
4991
  if (r.enabled) {
4987
4992
  const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
4988
4993
  h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
4994
+ // 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
4995
+ h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
4989
4996
  if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
4990
4997
  // Pairing from a paired phone follows Trust my paired devices (0.6.6).
4991
4998
  const mayPair = v.mayPair != null ? !!v.mayPair : local
@@ -8265,6 +8272,18 @@ document.addEventListener('click', async (ev) => {
8265
8272
  refreshHealth().catch(() => {})
8266
8273
  return render()
8267
8274
  }
8275
+ // 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
8276
+ if (action === 'relay-host' || action === 'relay-host-pref') {
8277
+ const machine = btn.dataset.machine || null
8278
+ const prefer = action === 'relay-host-pref'
8279
+ btn.disabled = true
8280
+ let hz
8281
+ try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
8282
+ const now = hz && hz.host && hz.host.machine
8283
+ toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
8284
+ if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
8285
+ return render()
8286
+ }
8268
8287
  if (action === 'relay-user') {
8269
8288
  const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
8270
8289
  await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
@@ -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
+ }
@@ -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
- function pickedMac() { try { return localStorage.getItem(MAC_KEY) } catch { return null } }
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
- const want = pickedMac()
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) { good(gone); return toResponse(await openResponse(relay.key, env, await res.json())) }
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…', '<p class="relay-wait" role="status"><span class="relay-dot" aria-hidden="true"></span>' + esc(relay.mac || 'Your Mac') + ' may be restarting. This takes a few seconds.</p>')
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
- const order = [...new Set([lastGood(), relay.mac, st && st.default, ...online].filter((m) => m && online.includes(m)))]
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
  }