@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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/bin/leftoff.js +359 -323
- package/docs/SECURITY.md +3 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +28 -3
- package/package.json +1 -1
- package/ui/app.css +142 -5
- package/ui/app.js +213 -29
- package/ui/direct.js +37 -12
- package/ui/icons.js +2 -1
- package/ui/restore-gate.js +40 -0
- package/ui/wizard.js +158 -0
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
|
|
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>' : '') +
|
|
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) => {
|
|
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
|
|
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
|
|
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
|
-
|
|
1059
|
-
: '
|
|
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
|
-
(
|
|
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
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
if (
|
|
2895
|
-
|
|
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
|
-
|
|
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) + '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
const r = await this.fetchImpl(
|
|
59
|
-
if (r.status !== 200)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|