@leftoff/cli 0.9.5 → 0.9.7

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/ui/app.js CHANGED
@@ -4,6 +4,7 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
5
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
+ import { mountWizard, shouldAutoOpen } from './wizard.js'
7
8
  import { changedKeys } from './settings-dirty.js'
8
9
  import { looksMd, stripMd, plainCp } from './cp-text.js'
9
10
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
@@ -12,7 +13,8 @@ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentRepl
12
13
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
13
14
  import { mdInline, renderSection } from './changelog-md.js'
14
15
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
15
- import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
16
+ import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
+ import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
16
18
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
17
19
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
18
20
  const $app = document.getElementById('app')
@@ -205,7 +207,7 @@ function banners() {
205
207
  const items = inc.slice(0, 2).flatMap((e) => e.sections.flatMap((s) => s.items.map((it) => ({ v: e.version, t: it })))).slice(0, 4)
206
208
  out += '<div class="banner info update" role="status"><div class="grow"><b>Leftoff ' + (u.remoteVersion ? 'v' + esc(u.remoteVersion) : esc(u.remoteSha || 'update')) + ' is available</b> · you have v' + esc(h.version) +
207
209
  (items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
208
- '<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
210
+ '<code class="cmd">' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(u.upgradeCommand || 'leftoff upgrade') + '">Copy</button></div>'
209
211
  }
210
212
  out += syncBanners(h)
211
213
  const mod = h.mod
@@ -310,7 +312,7 @@ function staleNote(machine) {
310
312
  function updateLine() {
311
313
  const h = state.health || {}
312
314
  const u = h.update || {}
313
- if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>leftoff upgrade</code>.'
315
+ if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>' + esc(u.upgradeCommand || 'leftoff upgrade') + '</code>.'
314
316
  if (u.error) return 'Last check couldn’t reach the repo (' + esc(u.error) + ').'
315
317
  if (u.checkedAt) return 'Up to date (v' + esc(h.version) + '), checked ' + esc(ago(u.checkedAt)) + '.'
316
318
  return 'Not checked yet.'
@@ -830,9 +832,10 @@ async function viewBoard() {
830
832
  // 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
831
833
  // focus, and matches among closed sessions are one tap away.
832
834
  function boardSearch(q, closedHits, n) {
833
- return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" placeholder="Find a session: title, repo, branch, folder or id" aria-label="Find a session" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
835
+ return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" enterkeyhint="search" placeholder="Find a session, or anything said in one" aria-label="Find a session, or search what was said in sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
834
836
  (q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
835
- (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
837
+ (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
838
+ ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
836
839
  }
837
840
  // A repaint from a live update (or the 30 s refresh) while you type in the
838
841
  // search box keeps the box focused, with what you typed and the caret.
@@ -862,7 +865,11 @@ function boardSearchWire() {
862
865
  if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
863
866
  }, 160)
864
867
  })
865
- q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
868
+ q.addEventListener('keydown', (ev) => {
869
+ if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
870
+ // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
871
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
872
+ })
866
873
  }
867
874
 
868
875
  // Closed (0.6.7): every ended session on every machine, newest first, with
@@ -1028,26 +1035,32 @@ function emptyFold(list) {
1028
1035
  '<div class="grid">' + list.map(card).join('') + '</div></details>'
1029
1036
  }
1030
1037
 
1031
- // Restore (v0.4.7): the sessions that were open before this machine restarted,
1038
+ // Restore (v0.4.7): the sessions that were open before this machine restarted
1039
+ // (0.9.7: or when the terminal quit),
1032
1040
  // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
1033
1041
  // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
1034
1042
  const restoreUi = { open: false, off: new Set(), result: null }
1035
1043
  function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
1036
1044
  function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
1045
+ // 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
1046
+ function restoreGate() {
1047
+ const lr = window.leftoffRelay, r = state.restore || {}
1048
+ return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
1049
+ phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
1050
+ }
1037
1051
  function restoreBanner() {
1038
1052
  const r = state.restore
1039
1053
  if (!r || !r.sessions || !r.sessions.length) return restoreUi.result ? restoreResult() : ''
1040
1054
  try { if (localStorage.getItem(restoreDismissKey(r)) === '1' && !restoreUi.open) return '' } catch {}
1041
1055
  const n = r.sessions.length
1042
- const what = r.restartKind === 'boot' ? (r.ghostty ? 'your Mac' : 'this machine') + ' restarted' : 'Leftoff restarted'
1043
1056
  const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
1044
- const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open before ' + what + '</b>' + esc(when)
1057
+ const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
1045
1058
  if (!restoreUi.open) {
1046
1059
  return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + head + '. Put them back, each in its own Ghostty tab.</span>' +
1047
1060
  '<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
1048
1061
  '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
1049
1062
  }
1050
- const local = state.viewerLocal !== false
1063
+ const gate = restoreGate()
1051
1064
  const picked = restorePicked().length
1052
1065
  const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
1053
1066
  '<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
@@ -1055,13 +1068,15 @@ function restoreBanner() {
1055
1068
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1056
1069
  '<div class="rs-list">' + rows + '</div>' +
1057
1070
  '<div class="rs-actions">' +
1058
- (local ? '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo"' + (picked ? '' : ' disabled') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>'
1059
- : '<button type="button" class="btn primary" disabled title="Restore opens Ghostty tabs on the Mac running Leftoff, so it only works from that Mac’s own browser (localhost)">Restore ' + picked + '</button>') +
1071
+ '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
1072
+ (gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1060
1073
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1061
1074
  '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
1062
- (local ? '' : '<span class="rs-note">This opens tabs on the Mac, so only its own browser can do it (or run <code>leftoff restore</code> there).</span>') +
1075
+ (gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1063
1076
  '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
1064
1077
  }
1078
+ // 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
1079
+ if (typeof window !== 'undefined') window.addEventListener('leftoff-route', () => { const el = restoreUi.open && document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner() })
1065
1080
  function restoreResult() {
1066
1081
  const x = restoreUi.result
1067
1082
  if (!x) return ''
@@ -1259,6 +1274,92 @@ async function viewSession(id) {
1259
1274
  if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1260
1275
  html += answeredList(s)
1261
1276
  $app.innerHTML = html
1277
+ aroundWire(s.id)
1278
+ }
1279
+ // ------------------------------------------------------------ search inside transcripts (0.9.6)
1280
+
1281
+ // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
1282
+ // overlap, run backwards or past the end are skipped).
1283
+ function markedHtml(text, marks) {
1284
+ const t = String(text || '')
1285
+ let h = '', at = 0
1286
+ for (const m of Array.isArray(marks) ? marks : []) {
1287
+ const a = Number(m && m[0]), b = Number(m && m[1])
1288
+ if (!(Number.isInteger(a) && Number.isInteger(b) && a >= at && b > a && b <= t.length)) continue
1289
+ h += esc(t.slice(at, a)) + '<mark>' + esc(t.slice(a, b)) + '</mark>'
1290
+ at = b
1291
+ }
1292
+ return h + esc(t.slice(at))
1293
+ }
1294
+ const txHref = (h, q) => '#/s/' + encodeURIComponent(h.sessionId) + (h.anchor != null ? '?hit=' + encodeURIComponent(h.anchor) + '&q=' + encodeURIComponent(q) : '')
1295
+ const txWho = (h) => h.role === 'assistant' ? 'Claude' : 'You'
1296
+ function txSub(h) {
1297
+ return [h.project && h.project.name ? projectLabel(h.project) : h.repoLabel, multi() || (h.machine && state.health && h.machine !== state.health.machine) ? h.machine : '', h.at ? ago(h.at) : '', h.hits > 1 ? h.hits + ' matches' : ''].filter(Boolean).join(' · ')
1298
+ }
1299
+ const nfmt = (n) => Number(n || 0).toLocaleString()
1300
+ // "Still indexing (n of m)", other Macs that weren't searched, or null.
1301
+ function txNotes(r) {
1302
+ const out = []
1303
+ const me = (r.machines || []).find((m) => m.self) || {}
1304
+ if (me.status === 'indexing') out.push('Still indexing transcripts' + (me.total ? ' (' + nfmt(me.done) + ' of ' + nfmt(me.total) + ')' : '') + '. Results so far.')
1305
+ if (me.status === 'off') out.push('Transcript search is off on this Mac.')
1306
+ for (const m of r.machines || []) {
1307
+ if (m.self) continue
1308
+ if (m.status === 'indexing') out.push(m.machine + ' is still indexing' + (m.total ? ' (' + nfmt(m.done) + ' of ' + nfmt(m.total) + ')' : '') + '.')
1309
+ else if (m.status === 'unreachable') out.push(m.machine + ' wasn’t searched (not reachable right now).')
1310
+ else if (m.status === 'needs_update') out.push(m.machine + ' wasn’t searched (it needs Leftoff 0.9.6).')
1311
+ }
1312
+ return out
1313
+ }
1314
+ // One hit as a result row (search page, board).
1315
+ function txHitHtml(h, q) {
1316
+ return '<a class="tx-hit" href="' + esc(txHref(h, q)) + '">' +
1317
+ '<span class="tx-title">' + esc(h.title || 'Untitled') + (h.endedAt ? '<span class="tx-tag">closed</span>' : '') + '</span>' +
1318
+ '<span class="tx-sub">' + esc(txSub(h)) + '</span>' +
1319
+ '<span class="tx-snip"><span class="tx-who ' + (h.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(h)) + '</span> ' + markedHtml(h.snippet, h.marks) + '</span></a>'
1320
+ }
1321
+ function txSectionHtml(r, q, { more = null } = {}) {
1322
+ if (!r) return '<h2 class="tx-h">In transcripts</h2><div class="tx-note">Searching transcripts…</div>'
1323
+ const notes = txNotes(r)
1324
+ let h = '<h2 class="tx-h">In transcripts' + (r.results.length ? ' <span class="tx-count">' + esc(r.results.length + (r.results.length === 1 ? ' session' : ' sessions')) + '</span>' : '') + '</h2>'
1325
+ const idx = notes.find((n) => /^Still indexing/.test(n))
1326
+ if (idx) h += '<div class="tx-note tx-indexing" role="status"><span class="spin" aria-hidden="true"></span>' + esc(idx) + '</div>'
1327
+ if (r.results.length) h += '<div class="tx-list">' + r.results.map((x) => txHitHtml(x, q)).join('') + '</div>'
1328
+ else if (!notes.some((n) => /^Still indexing/.test(n))) h += '<div class="tx-note tx-empty">Nothing you or Claude said matches “' + esc(q) + '”.</div>'
1329
+ for (const n of notes) if (n !== idx) h += '<div class="tx-note">' + esc(n) + '</div>'
1330
+ if (more) h += '<div class="tx-more"><a href="' + esc(more) + '">All results for “' + esc(q) + '” →</a></div>'
1331
+ return h
1332
+ }
1333
+ const txQuery = (q, limit) => api('GET', '/v1/search?in=transcripts&limit=' + limit + '&q=' + encodeURIComponent(q))
1334
+
1335
+ // The session page opened from a transcript hit: the message and the turns
1336
+ // around it, at the top, scrolled to.
1337
+ const aroundCache = new Map()
1338
+ async function aroundWire(id) {
1339
+ const r = state.route
1340
+ if (!r || r.name !== 's' || !r.params || r.params.get('hit') == null) return
1341
+ const anchor = r.params.get('hit'), q = r.params.get('q') || ''
1342
+ const key = id + '|' + anchor + '|' + q
1343
+ const head = document.querySelector('#app .pagehead')
1344
+ if (!head) return
1345
+ let box = document.getElementById('txAround')
1346
+ if (!box) { box = document.createElement('section'); box.id = 'txAround'; box.className = 'tx-around'; box.setAttribute('aria-label', 'Found in the transcript'); head.after(box) }
1347
+ const first = !aroundCache.has(key)
1348
+ let d = aroundCache.get(key)
1349
+ if (!d) {
1350
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript</div><div class="tx-note">Loading…</div>'
1351
+ try { d = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/around?anchor=' + encodeURIComponent(anchor) + '&q=' + encodeURIComponent(q)) } catch (e) { d = { error: e.message, messages: [] } }
1352
+ aroundCache.set(key, d)
1353
+ if (!document.getElementById('txAround')) return
1354
+ }
1355
+ const close = '<a class="icon x tx-close" href="#/s/' + encodeURIComponent(id) + '" aria-label="Close" title="Close">×</a>'
1356
+ if (!d.messages || !d.messages.length) { box.innerHTML = '<div class="tx-around-head">Found in the transcript' + close + '</div><div class="tx-note">' + esc(d.error ? 'Couldn’t read that part of the transcript: ' + d.error : 'That message isn’t in the index any more (the transcript changed).') + '</div>'; return }
1357
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript' + (q ? ' · “' + esc(q) + '”' : '') + close + '</div>' +
1358
+ d.messages.map((m) => '<div class="tx-msg ' + (m.role === 'assistant' ? 'a' : 'u') + (m.hit ? ' hit' : '') + '"' + (m.hit ? ' id="txHit" tabindex="-1"' : '') + '><div class="tx-msg-head"><span class="tx-who ' + (m.role === 'assistant' ? 'a' : 'u') + '">' + (m.role === 'assistant' ? 'Claude' : 'You') + '</span>' + (m.at ? '<span class="t" title="' + esc(clock(m.at)) + '">' + esc(ago(m.at)) + '</span>' : '') + '</div><div class="tx-msg-text">' + markedHtml(m.text, m.marks) + '</div></div>').join('')
1359
+ if (first) {
1360
+ const hit = document.getElementById('txHit')
1361
+ if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
1362
+ }
1262
1363
  }
1263
1364
  function cpTimeline(list, newestCp) {
1264
1365
  return list.map((cp) =>
@@ -2316,6 +2417,7 @@ async function viewSettings() {
2316
2417
  updates: '<div class="section">About</div>' +
2317
2418
  '<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
2318
2419
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
2420
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
2319
2421
  advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
2320
2422
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2321
2423
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -2887,12 +2989,36 @@ async function stAction(action, btn) {
2887
2989
  }
2888
2990
 
2889
2991
  async function viewSearch(q) {
2890
- const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
2891
- let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + ' sessions, ' + r.checkpoints.length + ' checkpoints</div></div></div>'
2892
- if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2893
- if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2894
- if (!r.sessions.length && !r.checkpoints.length) html += '<div class="empty mt">Nothing matched.</div>'
2895
- $app.innerHTML = html
2992
+ const form = '<form class="search-page-form" id="searchPageForm" role="search"><div class="set-search">' + icon('search') + '<input id="searchPageQ" type="search" enterkeyhint="search" placeholder="Search sessions and what was said in them" aria-label="Search" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div><button class="btn primary" type="submit">Search</button></form>'
2993
+ if (!q.trim()) { $app.innerHTML = '<div class="pagehead"><div><h1>Search</h1><div class="sub">Titles, checkpoints, and everything you and Claude said.</div></div></div>' + form; return searchPageWire() }
2994
+ const seq = (viewSearch.seq = (viewSearch.seq || 0) + 1)
2995
+ const [r, tx] = await Promise.all([api('GET', '/v1/search?q=' + encodeURIComponent(q)), txQuery(q, 30).catch(() => null)])
2996
+ if (seq !== viewSearch.seq) return
2997
+ const paint = (tx) => {
2998
+ let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + (r.sessions.length === 1 ? ' session, ' : ' sessions, ') + r.checkpoints.length + (r.checkpoints.length === 1 ? ' checkpoint' : ' checkpoints') + (tx ? ', ' + tx.results.length + ' in transcripts' : '') + '</div></div></div>' + form
2999
+ if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
3000
+ html += '<section class="tx-section">' + txSectionHtml(tx || { results: [], machines: [] }, q) + '</section>'
3001
+ if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
3002
+ if (!r.sessions.length && !r.checkpoints.length && tx && !tx.results.length && !txNotes(tx).length) html += '<div class="empty mt">Nothing matched.</div>'
3003
+ $app.innerHTML = html
3004
+ searchPageWire()
3005
+ }
3006
+ paint(tx)
3007
+ // still indexing: look again in a moment (results fill in as the index grows)
3008
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(async () => {
3009
+ if (seq !== viewSearch.seq || !state.route || state.route.name !== 'search' || document.activeElement === document.getElementById('searchPageQ')) return
3010
+ const again = await txQuery(q, 30).catch(() => null)
3011
+ if (again && seq === viewSearch.seq && state.route.name === 'search') { paint(again); if ((again.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === viewSearch.seq && state.route.name === 'search') render() }, 3000) }
3012
+ }, 2500)
3013
+ }
3014
+ function searchPageWire() {
3015
+ const f = document.getElementById('searchPageForm')
3016
+ if (!f) return
3017
+ f.addEventListener('submit', (e) => {
3018
+ e.preventDefault()
3019
+ const v = document.getElementById('searchPageQ').value.trim()
3020
+ location.hash = '#/search' + (v ? '?q=' + encodeURIComponent(v) : '')
3021
+ })
2896
3022
  }
2897
3023
 
2898
3024
  // ------------------------------------------------------------ lists (v0.5.1)
@@ -3033,6 +3159,7 @@ async function render() {
3033
3159
  else if (r.name === 'whats-new') await viewWhatsNew()
3034
3160
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3035
3161
  else if (r.name === 'search') await viewSearch(r.q || '')
3162
+ else if (r.name === 'setup') await viewSetup()
3036
3163
  else await viewBoard()
3037
3164
  } catch (e) {
3038
3165
  if (e.code === 'login_required') return showLogin()
@@ -3040,6 +3167,18 @@ async function render() {
3040
3167
  }
3041
3168
  }
3042
3169
 
3170
+ // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
3171
+ // it keeps its own step and fetches its own state.
3172
+ async function viewSetup() {
3173
+ if ($app.querySelector('.wz')) return
3174
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
3175
+ }
3176
+ // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
3177
+ async function firstRunSetup() {
3178
+ if (location.hash.replace(/^#\/?/, '')) return
3179
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
3180
+ }
3181
+
3043
3182
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
3044
3183
  // home-screen app always lands here first (it has its own cookie jar), so the
3045
3184
  // typed code is the main way in; the QR link is the other.
@@ -3280,6 +3419,21 @@ function searchGroups(q, r) {
3280
3419
  if ((r.files || []).length) groups.push(['Files', r.files.slice(0, 5).map((f) => ({ href: '#/s/' + encodeURIComponent(f.sessionId), title: f.name, sub: f.sessionTitle || '', icon: 'file-text' }))])
3281
3420
  if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: plainCp(cp.doing || cp.next) || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
3282
3421
  }
3422
+ // 0.9.6: what was said in sessions, below the rest of the results
3423
+ if (low && low.length >= 2) {
3424
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3425
+ if (!tx) groups.push(['In transcripts', [], '<span class="spin" aria-hidden="true"></span>Searching transcripts…'])
3426
+ else {
3427
+ const notes = txNotes(tx)
3428
+ const items = tx.results.slice(0, 6).map((h) => ({ href: txHref(h, q), title: h.title || 'Untitled', sub: txSub(h), snip: h, icon: 'message-square' }))
3429
+ const indexing = notes.find((n) => /^Still indexing/.test(n))
3430
+ // "still indexing" leads the section (what's below is partial); the rest follows the rows
3431
+ const lead = indexing ? '<span class="spin" aria-hidden="true"></span>' + esc(indexing) : ''
3432
+ const note = (!items.length && !indexing ? 'Nothing you or Claude said matches “' + esc(q) + '”.' : '') +
3433
+ notes.filter((n) => n !== indexing).map((n) => '<span class="tx-note-line">' + esc(n) + '</span>').join('')
3434
+ if (items.length || note || lead) groups.push(['In transcripts', items, note, lead])
3435
+ }
3436
+ }
3283
3437
  if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
3284
3438
  if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
3285
3439
  if (pages.length) groups.push(['Go to', pages.slice(0, low ? 4 : 8)])
@@ -3312,14 +3466,20 @@ function paintSearch() {
3312
3466
  searchUi.items = []
3313
3467
  let h = ''
3314
3468
  if (q) { searchUi.items.push({ href: '#/search?q=' + encodeURIComponent(q), title: 'All results for “' + q + '”', icon: 'search' }) }
3315
- for (const [name, items] of groups) { h += '<div class="cmdk-group" role="presentation">' + esc(name) + '</div>'; for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) } }
3316
- if (q) h = optHtml(searchUi.items[0], 0) + h
3469
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3317
3470
  if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
3318
- else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”.</div>'
3471
+ else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length && !(tx && tx.results.length)) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”' + (tx ? '' : ' by name') + '.</div>'
3472
+ for (const [name, items, note, lead] of groups) {
3473
+ h += '<div class="cmdk-group' + (name === 'In transcripts' ? ' tx-group' : '') + '" role="presentation">' + esc(name) + '</div>'
3474
+ if (lead) h += '<div class="cmdk-note tx-note tx-lead">' + lead + '</div>'
3475
+ for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) }
3476
+ if (note) h += '<div class="cmdk-note tx-note">' + note + '</div>'
3477
+ }
3478
+ if (q) h = optHtml(searchUi.items[0], 0) + h
3319
3479
  list.innerHTML = h
3320
3480
  // 0.9.0: Enter opens the best match (the first result); ↑ reaches "All results for …"
3321
3481
  setSearchActive(q && searchUi.items.length > 1 ? 1 : searchUi.items.length ? 0 : -1)
3322
- function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
3482
+ function optHtml(it, i) { return '<a class="cmdk-opt' + (it.snip ? ' tx-opt' : '') + '" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + (it.snip ? '<span class="co-snip"><span class="tx-who ' + (it.snip.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(it.snip)) + '</span> ' + markedHtml(it.snip.snippet, it.snip.marks) + '</span>' : '') + '</span></a>' }
3323
3483
  }
3324
3484
  function setSearchActive(i) {
3325
3485
  searchUi.active = i
@@ -3333,17 +3493,39 @@ async function runSearch() {
3333
3493
  if (q.length < 2) { paintSearch(); return }
3334
3494
  paintSearch()
3335
3495
  try {
3496
+ // 0.9.6: transcripts in parallel; names and checkpoints paint first
3497
+ const txp = txQuery(q, 6).then((tr) => { if (seq === searchUi.seq) { searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } }, () => { if (seq === searchUi.seq) { searchUi.tx = { q, r: { results: [], machines: [] } }; if (searchIsOpen()) paintSearchKeeping() } })
3336
3498
  const r = await api('GET', '/v1/search?light=1&limit=20&q=' + encodeURIComponent(q))
3337
3499
  if (seq !== searchUi.seq) return
3338
3500
  searchUi.last = { q, r }
3501
+ if (searchIsOpen()) paintSearchKeeping()
3502
+ await txp
3503
+ // still indexing: ask again while the palette stays open on the same words
3504
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3505
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3506
+ return
3339
3507
  } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [], prs: [], projects: [], lists: [], todos: [], files: [] } } }
3340
3508
  if (searchIsOpen()) paintSearch()
3341
3509
  }
3510
+ async function runSearchAgain(q, seq) {
3511
+ try { const tr = await txQuery(q, 6); if (seq !== searchUi.seq) return; searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } catch { return }
3512
+ const tx = searchUi.tx.r
3513
+ if ((tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3514
+ }
3515
+ // A repaint when results arrive keeps the row you had moved to (by its link).
3516
+ function paintSearchKeeping() {
3517
+ const was = searchUi.items[searchUi.active]
3518
+ const moved = searchUi.moved
3519
+ paintSearch()
3520
+ if (!moved || !was) return
3521
+ const i = searchUi.items.findIndex((it) => it.href === was.href && it.title === was.title)
3522
+ if (i >= 0) setSearchActive(i)
3523
+ }
3342
3524
  document.getElementById('searchBtn').addEventListener('click', () => openSearch())
3343
- document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3525
+ document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); searchUi.moved = false; paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3344
3526
  document.getElementById('q').addEventListener('keydown', (e) => {
3345
3527
  const n = searchUi.items.length
3346
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3528
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); searchUi.moved = true; if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3347
3529
  else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); document.getElementById('searchBtn').focus() }
3348
3530
  })
3349
3531
  document.getElementById('cmdk').addEventListener('click', (e) => {
@@ -3551,7 +3733,9 @@ document.addEventListener('click', async (ev) => {
3551
3733
  if (action === 'restore-copy') { const p = restorePicked(); if (!p.length) { toast('Pick at least one'); return } await copy(p.map((x) => x.command).join('\n')); return }
3552
3734
  if (action === 'restore-go') {
3553
3735
  const ids = restorePicked().map((x) => x.id)
3554
- if (!ids.length) return
3736
+ if (!ids.length) { toast('Pick at least one'); return }
3737
+ const gate = restoreGate()
3738
+ if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
3555
3739
  btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
3556
3740
  const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
3557
3741
  restoreUi.result = r.how === 'print' || r.failed ? r : null
@@ -4180,7 +4364,7 @@ let pending = null
4180
4364
  const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
4181
4365
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4182
4366
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4183
- function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name === 'settings') && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
4367
+ function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
4184
4368
  let stream = null
4185
4369
  function connect() {
4186
4370
  if (stream) { try { stream.close() } catch {} }
@@ -4230,7 +4414,7 @@ document.addEventListener('change', async (ev) => {
4230
4414
  if (ev.target.checked) restoreUi.off.delete(ev.target.value); else restoreUi.off.add(ev.target.value)
4231
4415
  const n = restorePicked().length
4232
4416
  const go = document.getElementById('restoreGo')
4233
- if (go) { go.disabled = !n; go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4417
+ if (go) { if (n && restoreGate().ok) go.removeAttribute('aria-disabled'); else go.setAttribute('aria-disabled', 'true'); go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
4234
4418
  const all = document.querySelector('[data-action="restore-all"]')
4235
4419
  if (all) all.textContent = n === (state.restore ? state.restore.sessions.length : 0) ? 'Select none' : 'Select all'
4236
4420
  return
@@ -4297,7 +4481,7 @@ document.addEventListener('pointerdown', (ev) => {
4297
4481
  if (m && !m.contains(ev.target)) m.open = false
4298
4482
  })
4299
4483
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
4300
- refreshHealth().then(render)
4484
+ refreshHealth().then(render).then(firstRunSetup)
4301
4485
  connect()
4302
4486
 
4303
4487
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
package/ui/direct.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
2
2
  // open on a Mac that runs Leftoff asks that Mac's own daemon over
3
- // http://127.0.0.1:7429 instead of through the relay; the address bar stays.
3
+ // http://127.0.0.1:7429 (0.9.7: or https://leftoff.localhost) instead of
4
+ // through the relay; the address bar stays.
4
5
  //
5
6
  // The request is the very envelope the relay would carry (ui/relay-crypto.js),
6
7
  // posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
@@ -21,7 +22,13 @@ import { LOCAL_PORT, isPhoneUa } from './viewer.js'
21
22
  export const DIRECT_PATH = '/v1/relay/direct'
22
23
  export const PROBE_EVERY_MS = 60000
23
24
  export const FOCUS_GAP_MS = 10000
25
+ // 0.9.7: Leftoff's own https://leftoff.localhost (`leftoff setup`, Caddy in
26
+ // front of 127.0.0.1:7429) is asked too: an https page may always call an
27
+ // https address, where Safari refuses plain http://127.0.0.1. Both go to the
28
+ // same daemon; the first that proves itself is used.
29
+ export const HTTPS_LOCAL = 'https://leftoff.localhost'
24
30
  export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
31
+ export function directUrls(port = LOCAL_PORT) { return [HTTPS_LOCAL + DIRECT_PATH, directUrl(port)] }
25
32
 
26
33
  export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
27
34
 
@@ -37,14 +44,18 @@ export function directMatch(info, { user, picked } = {}) {
37
44
  export function aborted(signal) { return !!(signal && signal.aborted) }
38
45
 
39
46
  export class DirectRoute {
40
- constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
47
+ constructor({ user, port = LOCAL_PORT, urls = null, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
41
48
  this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
49
+ this.urls = urls && urls.length ? urls.slice() : directUrls(port)
50
+ this.base = null // the address that answered last (one of urls)
42
51
  // a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
43
52
  this.never = isPhoneUa(ua, { maxTouchPoints })
44
53
  this.every = every; this.focusGap = focusGap
45
54
  this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
46
55
  }
47
- get url() { return directUrl(this.port) }
56
+ get url() { return this.base || this.urls[this.urls.length - 1] }
57
+ // 'https' (https://leftoff.localhost) or 'http' (127.0.0.1), once one answered
58
+ get via() { return this.base ? (this.base.startsWith('https:') ? 'https' : 'http') : null }
48
59
 
49
60
  // -> the machine (direct confirmed, not yet switched on) or null. Never throws.
50
61
  probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
@@ -53,15 +64,20 @@ export class DirectRoute {
53
64
  this.inflight = (async () => {
54
65
  this.checkedAt = this.now()
55
66
  const t0 = this.now()
56
- let info
57
- try {
58
- const r = await this.fetchImpl(this.url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
59
- if (r.status !== 200) return null
60
- info = await r.json()
61
- } catch { return null }
62
- const m = directMatch(info, { user: this.user, picked })
63
- if (!m) return null
64
- if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) return null }
67
+ // every address at once; the first that is this account's Leftoff wins
68
+ const ask = async (url) => {
69
+ const r = await this.fetchImpl(url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
70
+ if (r.status !== 200) throw new DirectError('http_' + r.status)
71
+ const m = directMatch(await r.json(), { user: this.user, picked })
72
+ if (!m) throw new DirectError('not_ours')
73
+ return { url, m }
74
+ }
75
+ let hit
76
+ try { hit = await firstOf(this.urls.map(ask)) } catch { return null }
77
+ const prev = this.base
78
+ this.base = hit.url
79
+ const m = hit.m
80
+ if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) { this.base = prev; return null } }
65
81
  this.latencyMs = Math.max(0, Math.round(this.now() - t0))
66
82
  return m
67
83
  })().finally(() => { this.inflight = null })
@@ -108,6 +124,15 @@ export function toResponse(out) {
108
124
  return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
109
125
  }
110
126
 
127
+ // Promise.any, for browsers and runtimes without it.
128
+ function firstOf(ps) {
129
+ return new Promise((resolve, reject) => {
130
+ let left = ps.length
131
+ if (!left) return reject(new DirectError('none'))
132
+ for (const p of ps) Promise.resolve(p).then(resolve, () => { if (--left === 0) reject(new DirectError('none')) })
133
+ })
134
+ }
135
+
111
136
  function timeoutSignal(ms) {
112
137
  if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
113
138
  const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
package/ui/icons.js CHANGED
@@ -45,7 +45,8 @@ export const ICONS = {
45
45
  "pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
46
46
  "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
47
47
  "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
48
- "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
48
+ "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
49
+ "message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
49
50
  }
50
51
 
51
52
  export function icon(name, cls = 'ic') {
@@ -0,0 +1,40 @@
1
+ // 0.9.7: may this board press Restore, and if not, what would make it work.
2
+ // Restore opens Ghostty tabs on the Mac the restore card belongs to, so the
3
+ // board must be on that Mac: its own browser on localhost, or your hosted
4
+ // board (<user>.leftoff.dev) open on it and talking to it over the direct path
5
+ // (ui/direct.js: http://127.0.0.1:7429 or https://leftoff.localhost). The
6
+ // daemon checks the same thing again (src/direct.js DIRECT_LOCAL); this only
7
+ // decides how the button looks and what the hint says. Plain logic, no DOM.
8
+ //
9
+ // local the page is that Mac's own board (viewer.local)
10
+ // hosted the page is the hosted board (window.leftoffRelay)
11
+ // route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
12
+ // machine the Mac the restore card belongs to
13
+ // phone a phone or tablet (never runs Leftoff)
14
+ // -> { ok, how: 'local' | 'direct' | null, hint: text with `commands` or null }
15
+ export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false } = {}) {
16
+ if (local && !hosted) return { ok: true, how: 'local', hint: null }
17
+ if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
18
+ const m = machine || 'the Mac'
19
+ if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
20
+ if (hosted) {
21
+ return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
22
+ }
23
+ return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
24
+ }
25
+
26
+ // The hint as HTML: escaped, `commands` in <code>.
27
+ export function hintHtml(hint, esc) {
28
+ return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
29
+ }
30
+
31
+ // 0.9.7: when the restore card's sessions were open, from GET /v1/restore:
32
+ // restartKind 'quit' (the terminal quit while the Mac stayed up; restartTerminal
33
+ // names it when known), 'boot' or 'daemon'. short: the CLI's one-liners.
34
+ // -> "when Ghostty quit" | "before your Mac restarted" | "before Leftoff restarted"
35
+ export function restoreWhen(r = {}, { short = false } = {}) {
36
+ if (r.restartKind === 'quit') return 'when ' + (r.restartTerminal || 'the terminal') + ' quit'
37
+ if (short) return 'before the restart'
38
+ if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
39
+ return 'before Leftoff restarted'
40
+ }