@leftoff/cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "Joel"
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
27
27
  const MODEL_TIMEOUT_MS = 20000
28
28
 
29
29
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
30
- const MOD_VERSION = '0.8.3' // keep in step with .claude-plugin/plugin.json
30
+ const MOD_VERSION = '0.8.5' // keep in step with .claude-plugin/plugin.json
31
31
 
32
32
  const st = {
33
33
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",
package/ui/app.css CHANGED
@@ -1427,6 +1427,44 @@ a.cpr:hover { border-color: var(--line-strong); }
1427
1427
  .prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
1428
1428
  .prs-gh { margin-top: 6px; }
1429
1429
  .prs-foot { font-size: 12px; margin-top: 6px; }
1430
+ /* 0.8.5: PR filters (search, repo chips, state/check chips), the why, Quiet, details on tap */
1431
+ .prs-search { margin: 0 0 4px; }
1432
+ .prs-q { width: 100%; max-width: 480px; font-size: 16px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); }
1433
+ .prs-chips { margin-top: 8px; }
1434
+ .prs-chips .rchip.ck .rl::before { content: '◇ '; color: var(--faint); }
1435
+ .pr-why { color: var(--muted); }
1436
+ .prrow { cursor: pointer; }
1437
+ .prrow.open { border-color: var(--line-strong); }
1438
+ .prrow a, .prrow button, .prrow .pr-detail { cursor: auto; }
1439
+ .prrow a, .prrow button { cursor: pointer; }
1440
+ .prgroup.quiet > summary { list-style: none; cursor: pointer; }
1441
+ .prgroup.quiet > summary::-webkit-details-marker { display: none; }
1442
+ .prgroup.quiet > summary h2 { color: var(--muted); }
1443
+ .prgroup.quiet > summary h2::before { content: '▸ '; }
1444
+ .prgroup.quiet[open] > summary h2::before { content: '▾ '; }
1445
+ .prgroup.quiet .qhint { font-weight: 400; font-size: 12px; }
1446
+ .pr-detail { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; font-size: 13px; cursor: auto; }
1447
+ .pr-detail.loading, .pr-detail.none { color: var(--muted); }
1448
+ .pd-h { font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
1449
+ .pd-stats { text-transform: none; letter-spacing: 0; font-family: var(--mono); font-weight: 500; color: var(--muted); }
1450
+ .pd-add { color: var(--green); }
1451
+ .pd-del { color: var(--red); }
1452
+ .pd-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
1453
+ .pd-checks li { display: flex; gap: 7px; align-items: baseline; min-width: 0; }
1454
+ .pd-checks li a { color: var(--text); overflow-wrap: anywhere; }
1455
+ .pd-checks li.failed .pd-ic { color: var(--red); }
1456
+ .pd-checks li.pending .pd-ic { color: var(--yellow); }
1457
+ .pd-st { margin-left: auto; font-size: 12px; color: var(--muted); flex: none; }
1458
+ .pd-pass { color: var(--green); font-size: 12.5px; margin-top: 2px; }
1459
+ .pd-review { display: flex; flex-wrap: wrap; gap: 4px 10px; }
1460
+ .pd-text, .pd-clip { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
1461
+ .pd-body > summary { list-style: none; cursor: pointer; }
1462
+ .pd-body > summary::-webkit-details-marker { display: none; }
1463
+ .pd-body[open] > summary { display: none; }
1464
+ .pd-more { margin-left: 6px; }
1465
+ .pd-comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
1466
+ .pd-who { font-size: 12px; font-weight: 600; }
1467
+ .pd-cbody { color: var(--text2); overflow-wrap: anywhere; }
1430
1468
  @media (max-width: 639.98px) {
1431
1469
  .tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
1432
1470
  .prrow { flex-direction: column; gap: 8px; }
package/ui/app.js CHANGED
@@ -8,8 +8,8 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
8
8
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
9
9
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
10
10
  import { mdInline, renderSection } from './changelog-md.js'
11
- import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
12
- import { wantsSetupBanner } from './viewer.js'
11
+ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
12
+ import { wantsSetupBanner, setupCandidate, localLeftoffAnswers } from './viewer.js'
13
13
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
14
14
  const $app = document.getElementById('app')
15
15
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -214,7 +214,13 @@ function syncBanners(h) {
214
214
  out += '<div class="banner warn lowdisk" role="status"><div class="grow"><b>' + esc(d.text) + '</b><div class="fixline">fix: ' + esc(d.fix.text) + '</div></div>' + (d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '" title="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
215
215
  }
216
216
  const v = h.viewer || {}
217
- if (wantsSetupBanner({ viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() })) {
217
+ const who = { viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() }
218
+ // Ask this computer's own Leftoff once; the banner waits for the answer.
219
+ if (setupCandidate(who) && state.localHere === undefined) {
220
+ state.localHere = null
221
+ localLeftoffAnswers().then((here) => { state.localHere = here; if (!here) render() })
222
+ }
223
+ if (wantsSetupBanner({ ...who, here: state.localHere })) {
218
224
  const su = v.setup
219
225
  out += '<div class="banner info setup-mac" role="status"><div class="grow">This board is running on <b>' + esc(su.machine || 'another Mac') + '</b>. To track sessions on this computer too, run:<div class="joinline"><code class="cmd">' + esc(su.command) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(su.command) + '">Copy</button></div></div>' +
220
226
  '<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
@@ -933,30 +939,75 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
933
939
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
934
940
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
935
941
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
936
- const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
942
+ const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, view: 'mine', filter: {} }
943
+ // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
944
+ // tap repaints from what we have and reads again only when that is old.
945
+ function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen } }
946
+ function paintPrs(d) {
947
+ const q = document.getElementById('prs-q')
948
+ const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
949
+ $app.innerHTML = banners() + prsPageHtml(d, prsOpts(prsUi.view, prsUi.filter))
950
+ if (focused) { const n = document.getElementById('prs-q'); if (n) { n.focus(); try { n.setSelectionRange(caret, caret) } catch {} } }
951
+ }
952
+ // Just the chips and the list (typing in the search box keeps its focus).
953
+ function paintPrsBody() {
954
+ const el = document.getElementById('prs-body'), d = prsUi.data[prsUi.view]
955
+ if (el && d) el.innerHTML = prsBodyHtml(d, prsOpts(prsUi.view, prsUi.filter))
956
+ }
937
957
  async function viewPrs() {
938
958
  const params = (state.route && state.route.params) || new URLSearchParams()
939
959
  let view = params.get('view')
940
960
  if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
941
961
  view = view === 'team' ? 'team' : 'mine'
942
- const opts = { view, mayEdit: true, sessionAction: prSessionAction }
943
- if (!prsUi.data[view]) $app.innerHTML = banners() + prsPageHtml(null, opts)
962
+ prsUi.view = view
963
+ prsUi.filter = readPrFilter(params)
964
+ const cached = prsUi.data[view]
965
+ paintPrs(cached || null)
944
966
  const fresh = prsUi.fresh; prsUi.fresh = false
967
+ if (cached && !fresh && !cached.loading && !cached.error && Date.now() - (prsUi.at[view] || 0) < 15000) return
945
968
  let d
946
969
  try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
947
970
  catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
948
971
  if (!(state.route && state.route.name === 'prs')) return
949
- if (d && d.groups) {
972
+ if (d && (d.rows || d.groups)) {
950
973
  // Go / Resume on a row look the session up like a card does.
951
- for (const g of d.groups) for (const r of g.rows || []) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
974
+ for (const r of prRows(d)) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
952
975
  d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
953
976
  }
954
977
  prsUi.data[view] = d
955
- $app.innerHTML = banners() + prsPageHtml(d, opts)
978
+ prsUi.at[view] = Date.now()
979
+ if (prsUi.view === view) paintPrs(d)
956
980
  clearTimeout(prsUi.timer)
957
- if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
981
+ if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') { prsUi.at[view] = 0; render() } }, 2500) }
958
982
  else if (!(d && d.loading)) prsUi.polls = 0
959
983
  }
984
+ // Tap a PR: it opens in place; its details load once (cached ~5 min by the
985
+ // daemon that has gh, a little here too).
986
+ async function togglePr(url, { retry = false } = {}) {
987
+ if (!url) return
988
+ const was = prsUi.open.has(url)
989
+ if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
990
+ const repaint = () => {
991
+ const d = prsUi.data[prsUi.view]
992
+ const r = prRows(d).find((x) => x.pr.url === url)
993
+ const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
994
+ if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
995
+ }
996
+ const have = prsUi.details[url]
997
+ if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
998
+ prsUi.details[url] = { loading: true }
999
+ repaint()
1000
+ let res
1001
+ try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
1002
+ catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
1003
+ prsUi.details[url] = { ...res, at: Date.now() }
1004
+ if (state.route && state.route.name === 'prs') repaint()
1005
+ }
1006
+ function setPrsFilter(f) {
1007
+ const h = prsHash(prsUi.view, f)
1008
+ if (location.hash !== h) location.hash = h
1009
+ }
1010
+
960
1011
  function prSessionAction(s) {
961
1012
  if (s.live === 'closed' || s.status === 'ended') {
962
1013
  const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
@@ -2840,6 +2891,25 @@ document.addEventListener('click', (ev) => {
2840
2891
  location.hash = c.dataset.href
2841
2892
  })
2842
2893
 
2894
+ // 0.8.5: a tap anywhere on a PR row (not a link or button) opens it in place.
2895
+ document.addEventListener('click', (ev) => {
2896
+ const row = ev.target.closest('article.prrow[data-pr]')
2897
+ if (!row || ev.defaultPrevented || ev.target.closest('a, button, input, textarea, label, summary, details.pd-body, [data-action]')) return
2898
+ if (String(window.getSelection && window.getSelection()).trim()) return
2899
+ togglePr(row.dataset.pr)
2900
+ })
2901
+ document.addEventListener('toggle', (ev) => { if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open }, true)
2902
+ let prsQTimer = null
2903
+ document.addEventListener('input', (ev) => {
2904
+ if (ev.target.id !== 'prs-q') return
2905
+ prsUi.filter = { ...prsUi.filter, q: ev.target.value }
2906
+ paintPrsBody()
2907
+ // The link follows without a history entry per keystroke.
2908
+ clearTimeout(prsQTimer)
2909
+ prsQTimer = setTimeout(() => { const h = prsHash(prsUi.view, prsUi.filter); if (location.hash !== h) history.replaceState(null, '', h) }, 250)
2910
+ })
2911
+ document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
2912
+
2843
2913
  document.addEventListener('click', async (ev) => {
2844
2914
  const btn = ev.target.closest('[data-action]')
2845
2915
  if (!btn) return
@@ -2853,7 +2923,17 @@ document.addEventListener('click', async (ev) => {
2853
2923
  try {
2854
2924
  if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
2855
2925
  // PRs (0.6.9)
2856
- if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v === 'team' ? '#/prs?view=team' : '#/prs'; return }
2926
+ if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
2927
+ // 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
2928
+ if (action === 'prs-filter') {
2929
+ const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
2930
+ if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
2931
+ else if (k === 'state' || k === 'checks') f[k] = f[k] === v ? null : v
2932
+ return setPrsFilter(f)
2933
+ }
2934
+ if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
2935
+ if (action === 'pr-expand') return togglePr(btn.dataset.url)
2936
+ if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
2857
2937
  if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
2858
2938
  if (action === 'pr-unlink') {
2859
2939
  btn.disabled = true
@@ -3456,6 +3536,7 @@ let stream = null
3456
3536
  function connect() {
3457
3537
  if (stream) { try { stream.close() } catch {} }
3458
3538
  const es = stream = new EventSource('/v1/stream')
3539
+ es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
3459
3540
  for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
3460
3541
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
3461
3542
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
package/ui/prs.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // loads /v1/prs and /v1/sessions/:id and paints with these. Sessions and PRs
3
3
  // are many-to-many; a row shows every session it is linked to.
4
4
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
5
+ import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst } from './repos.js'
5
6
  const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
6
7
 
7
8
  export function shortAgo(iso, now = Date.now()) {
@@ -54,14 +55,17 @@ export function sessionPrsSection(s, { mayEdit = true } = {}) {
54
55
  return '<div class="prrow slim"><div class="pr-main">' +
55
56
  '<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
56
57
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
57
- '<div class="pr-meta">' + esc(p.repo || '') + (p.via ? ' · ' + esc(VIA[p.via] || p.via) : '') + '</div></div>' +
58
+ '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div></div>' +
58
59
  (mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
59
60
  }).join('') + '</div>'
60
61
  } else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
61
62
  if (mayEdit) h += '<form class="prlink" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false"><button type="submit" class="btn">Link</button></form>'
62
63
  return h
63
64
  }
64
- const VIA = { created: 'opened here', mentioned: 'mentioned', branch: 'this branch', manual: 'linked by hand' }
65
+ // 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
66
+ export const WHY = { created: 'created here', you: 'you mentioned it', branch: 'same branch', mentioned: 'same branch', manual: 'linked by you' }
67
+ const WHY_ORDER = ['manual', 'created', 'you', 'branch', 'mentioned']
68
+ export function whyText(r) { const v = r && (r.why || (Array.isArray(r.via) ? WHY_ORDER.find((k) => r.via.includes(k)) : r.via)); return v ? WHY[v] || '' : '' }
65
69
 
66
70
  // gh missing or signed out: the 0.6.5 status line with the fix to copy.
67
71
  export function ghLineHtml(g) {
@@ -83,27 +87,196 @@ function sessionLinks(r, opts) {
83
87
  }).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
84
88
  }
85
89
 
86
- // One PR row on the PRs tab.
87
- export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false } = {}) {
90
+ // One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
91
+ // (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
92
+ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null } = {}) {
88
93
  const p = r.pr || {}
89
94
  const url = safeUrl(p.url)
90
95
  const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
91
- (r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
96
+ (r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
97
+ const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
92
98
  const meta = team
93
- ? [p.author ? '@' + p.author : '', p.createdAt ? 'opened ' + shortAgo(p.createdAt, now) + ' ago' : '', p.branch || ''].filter(Boolean)
94
- : [p.repo, p.branch, p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''].filter(Boolean)
99
+ ? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
100
+ : [p.repo, p.branch, when].filter(Boolean)
101
+ const why = whyText(r)
95
102
  const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
96
- return '<article class="prrow" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
103
+ return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
97
104
  '<div class="pr-line1">' + line1 + '</div>' +
98
105
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
99
- '<div class="pr-meta">' + meta.map(esc).join(' · ') + '</div>' +
100
- sessionLinks(r, { sessionAction, showMachine }) + '</div>' +
101
- '<div class="pr-actions">' + (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
106
+ '<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + '</div>' +
107
+ sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
108
+ '<div class="pr-actions">' + '<button type="button" class="btn sm pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + open + '">' + (open ? 'Less ▴' : 'Details ▾') + '</button>' +
109
+ (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
102
110
  (unlinkTo ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(unlinkTo.id) + '" data-url="' + esc(p.url) + '" title="Unlink from this session">Unlink</button>' : '') + '</div></article>'
103
111
  }
104
112
 
105
- // The whole tab: Mine / Team switch, the gh line, groups.
106
- export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null } = {}) {
113
+ // ---------------------------------------------------------------- 0.8.5: details on tap
114
+
115
+ const REVIEW_STATE = { approved: 'approved', changes_requested: 'requested changes', commented: 'commented' }
116
+ const anyUrl = (u) => (/^https:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : '')
117
+ // Markdown-lite: paragraphs and list lines as plain text, links and code marks dropped to their words.
118
+ export function plainBody(md) {
119
+ return String(md || '').replace(/<!--[\s\S]*?-->/g, '').replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
120
+ .replace(/<\/?[a-z][^>]*>/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
121
+ .replace(/^[ \t]*[-*][ \t]+\[x\][ \t]+/gim, '☑ ').replace(/^[ \t]*[-*][ \t]+\[ \][ \t]+/gm, '☐ ').replace(/^[ \t]*[-*][ \t]+/gm, '• ').replace(/\n{3,}/g, '\n\n').trim()
122
+ }
123
+ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
124
+ if (!st || st.loading) return '<div class="pr-detail loading" aria-busy="true">Loading details…</div>'
125
+ if (!st.ok) return '<div class="pr-detail none">' + (st.code === 'no_gh' ? 'Details need gh on a Mac (none of yours has it signed in).' : 'Couldn’t load the details right now. <button type="button" class="linkish" data-action="pr-expand-retry" data-url="' + esc(p.url) + '">Try again</button>') + '</div>'
126
+ const d = st.detail || {}
127
+ let h = '<div class="pr-detail">'
128
+ const bad = (d.checkList || []).filter((c) => c.state !== 'passed')
129
+ const passed = (d.checkList || []).length - bad.length
130
+ if ((d.checkList || []).length) {
131
+ h += '<div class="pd-sec"><div class="pd-h">Checks</div>' + (bad.length ? '<ul class="pd-checks">' + bad.map((c) => {
132
+ const u = anyUrl(c.url)
133
+ const name = esc(c.name) + (c.workflow ? '<span class="faint"> · ' + esc(c.workflow) + '</span>' : '')
134
+ return '<li class="' + esc(c.state) + '"><span class="pd-ic" aria-hidden="true">' + (c.state === 'failed' ? '✕' : '◌') + '</span>' + (u ? '<a href="' + esc(u) + '" target="_blank" rel="noopener noreferrer">' + name + ' ↗</a>' : name) + '<span class="pd-st">' + (c.state === 'failed' ? 'failed' : 'running') + '</span></li>'
135
+ }).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
136
+ }
137
+ const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
138
+ const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
139
+ if (rv || who.length || (d.reviewRequested || []).length) {
140
+ h += '<div class="pd-sec"><div class="pd-h">Review</div><div class="pd-review">' + (rv ? '<span class="pr-review ' + esc(d.review || '') + '">' + esc(rv) + '</span>' : '') +
141
+ (who.length ? '<span>' + who.join(', ') + '</span>' : '') +
142
+ ((d.reviewRequested || []).length ? '<span class="faint">waiting on ' + d.reviewRequested.map((x) => esc('@' + x)).join(', ') + '</span>' : '') + '</div></div>'
143
+ }
144
+ const stats = [d.changedFiles != null ? d.changedFiles + ' file' + (d.changedFiles === 1 ? '' : 's') : '', d.additions != null ? '<span class="pd-add">+' + d.additions + '</span>' : '', d.deletions != null ? '<span class="pd-del">−' + d.deletions + '</span>' : ''].filter(Boolean)
145
+ const body = plainBody(d.body)
146
+ h += '<div class="pd-sec"><div class="pd-h">Description' + (stats.length ? ' <span class="pd-stats">' + stats.join(' ') + '</span>' : '') + '</div>' +
147
+ (body ? (body.length > 280 || body.split('\n').length > 5 ? '<details class="pd-body"><summary><span class="pd-clip">' + esc(body.split('\n').slice(0, 4).join('\n').slice(0, 280)) + '…</span><span class="linkish pd-more">more</span></summary><div class="pd-text">' + esc(body) + '</div></details>' : '<div class="pd-text">' + esc(body) + '</div>') : '<div class="faint">No description.</div>') + '</div>'
148
+ if ((d.comments || []).length) {
149
+ h += '<div class="pd-sec"><div class="pd-h">Latest comments' + (d.commentCount > d.comments.length ? ' <span class="faint">' + d.comments.length + ' of ' + d.commentCount + '</span>' : '') + '</div><ul class="pd-comments">' + d.comments.map((c) => {
150
+ const first = String(c.body || '').split('\n').filter((x) => x.trim()).slice(0, 2).join(' ')
151
+ return '<li><div class="pd-who">' + esc(c.author ? '@' + c.author : 'someone') + (c.at ? '<span class="faint"> · ' + esc(shortAgo(c.at, now)) + (shortAgo(c.at, now) === 'now' ? '' : ' ago') + '</span>' : '') + '</div><div class="pd-cbody">' + esc(plainBody(first).slice(0, 220)) + '</div></li>'
152
+ }).join('') + '</ul></div>'
153
+ }
154
+ return h + '</div>'
155
+ }
156
+
157
+ // ---------------------------------------------------------------- 0.8.5: filters
158
+
159
+ // The page's filter, from the hash (#/prs?view=team&repo=web&state=open&checks=failing&q=search).
160
+ export const PR_STATES = [['attention', 'Needs attention'], ['open', 'Open'], ['draft', 'Draft'], ['merged', 'Merged'], ['closed', 'Closed']]
161
+ export const PR_CHECKS = [['failing', 'Checks failing'], ['passing', 'Checks passing']]
162
+ export const QUIET_DAYS = 14
163
+ const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
164
+ export function readPrFilter(params) {
165
+ const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
166
+ const state = p.get('state'), checks = p.get('checks')
167
+ return {
168
+ repos: [...new Set(p.getAll('repo').flatMap(splitList))],
169
+ state: PR_STATES.some(([k]) => k === state) ? state : null,
170
+ checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
171
+ q: String(p.get('q') || '').slice(0, 200),
172
+ }
173
+ }
174
+ export function prsHash(view, f = {}) {
175
+ const parts = []
176
+ if (view === 'team') parts.push('view=team')
177
+ if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
178
+ if (f.state) parts.push('state=' + f.state)
179
+ if (f.checks) parts.push('checks=' + f.checks)
180
+ if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
181
+ return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
182
+ }
183
+ export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.checks || (f.q && f.q.trim())))
184
+
185
+ // Every row, flat: the daemon's `rows`, or an older daemon's groups.
186
+ export function prRows(d) {
187
+ if (!d) return []
188
+ if (Array.isArray(d.rows)) return d.rows
189
+ const seen = new Set(), out = []
190
+ for (const g of d.groups || []) for (const r of g.rows || []) if (r && r.pr && !seen.has(r.pr.url)) { seen.add(r.pr.url); out.push(r) }
191
+ return out.sort((a, b) => String(b.pr.updatedAt || '').localeCompare(String(a.pr.updatedAt || '')))
192
+ }
193
+ const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
194
+ export const isQuiet = (r, now = Date.now()) => { const t = lastAt(r.pr || {}); return !!t && now - t >= QUIET_DAYS * 864e5 }
195
+ export function prStateOf(r) { return r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') ? 'attention' : r.pr.state }
196
+ export function inPrState(r, state) { return !state || (state === 'attention' ? !!r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') : r.pr.state === state) }
197
+ export function inPrChecks(r, checks) {
198
+ const c = r.pr.checks || {}
199
+ if (!checks) return true
200
+ return checks === 'failing' ? c.failed > 0 : c.total > 0 && !c.failed && !c.pending
201
+ }
202
+ export function inPrSearch(r, q) {
203
+ const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
204
+ if (!words.length) return true
205
+ const p = r.pr
206
+ const hay = [p.title, p.branch, p.repo, p.author ? '@' + p.author : '', '#' + p.number, String(p.number)].filter(Boolean).join(' ').toLowerCase()
207
+ return words.every((w) => hay.includes(w) || (w.startsWith('#') && String(p.number) === w.slice(1)))
208
+ }
209
+ // The repo chip a PR belongs to: its GitHub "owner/name", labelled by name.
210
+ export function prRepoChips(rows, { countIn = null } = {}) {
211
+ return repoChips(rows.map((r) => ({ repoKey: r.pr.repo || 'no-repo', lastActivityAt: r.pr.updatedAt })), { countIn: countIn && countIn.map((r) => ({ repoKey: r.pr.repo || 'no-repo' })) })
212
+ }
213
+ export function inPrRepos(r, repos, labels = null) {
214
+ if (!repos || !repos.length) return true
215
+ const key = r.pr.repo || 'no-repo'
216
+ return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
217
+ }
218
+ export function filterPrRows(rows, f = {}, { skip = null } = {}) {
219
+ const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
220
+ return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state)) && (skip === 'checks' || inPrChecks(r, f.checks)) && inPrSearch(r, f.q))
221
+ }
222
+ // Groups, newest first in each; anything with no activity for 14+ days in Quiet.
223
+ export function groupPrRows(rows, { now = Date.now() } = {}) {
224
+ const g = Object.fromEntries(PR_STATES.map(([k]) => [k, []]))
225
+ const quiet = []
226
+ for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else (g[prStateOf(r)] || (g[prStateOf(r)] = [])).push(r) }
227
+ const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
228
+ const out = PR_STATES.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
229
+ return { groups: out, quiet: quiet.sort(by) }
230
+ }
231
+
232
+ function chipRow(label, chips, cls) {
233
+ return '<div class="repos prs-chips ' + cls + '" role="group" aria-label="' + esc(label) + '"><div class="track">' + chips.join('') + '</div></div>'
234
+ }
235
+ function fchip({ on, count, label, attrs, cls = '' }) {
236
+ return '<button type="button" class="rchip' + (cls ? ' ' + cls : '') + (on ? ' on' : '') + (count ? '' : ' zero') + '" ' + attrs + ' aria-pressed="' + on + '"' + (on && cls !== 'all' ? ' aria-label="' + esc('Remove filter ' + label) + '"' : '') + '><span class="rl">' + esc(label) + '</span><span class="rc">' + count + '</span>' + (on && cls !== 'all' ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>'
237
+ }
238
+
239
+ // The chip rows and the grouped list (repainted as you type in the search box).
240
+ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false } = {}) {
241
+ const f = { repos: [], state: null, checks: null, q: '', ...filter }
242
+ const all = prRows(d)
243
+ let h = ''
244
+ if (all.length) {
245
+ // Repos: All, then by latest activity; a chosen one always shows, first and removable.
246
+ const repoCount = filterPrRows(all, f, { skip: 'repos' })
247
+ let chips = prRepoChips(all, { countIn: repoCount })
248
+ for (const tok of f.repos) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0 })
249
+ chips = orderSelectedFirst(chips, f.repos)
250
+ if (chips.length > 1 || f.repos.length) {
251
+ h += chipRow('Filter by repo', [fchip({ on: !f.repos.length, count: repoCount.length, label: 'All', cls: 'all', attrs: 'data-action="prs-filter" data-k="repo" data-v=""' })]
252
+ .concat(chips.map((c) => fchip({ on: chipSelected(f.repos, c), count: c.count, label: c.label, attrs: 'data-action="prs-filter" data-k="repo" data-v="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc(c.key + ' · shift- or ⌘-click to pick several') + '"' }))), 'prs-repos')
253
+ }
254
+ const stCount = filterPrRows(all, f, { skip: 'state' }), ckCount = filterPrRows(all, f, { skip: 'checks' })
255
+ const sts = PR_STATES.map(([k, l]) => ({ k: 'state', v: k, l, n: stCount.filter((r) => inPrState(r, k)).length, on: f.state === k }))
256
+ const cks = PR_CHECKS.map(([k, l]) => ({ k: 'checks', v: k, l, n: ckCount.filter((r) => inPrChecks(r, k)).length, on: f.checks === k }))
257
+ const both = [...sts, ...cks]
258
+ const ordered = [...both.filter((x) => x.on), ...both.filter((x) => !x.on && (x.n || x.k === 'state'))]
259
+ h += chipRow('Filter by state and checks', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: x.k === 'checks' ? 'ck' : '', attrs: 'data-action="prs-filter" data-k="' + x.k + '" data-v="' + x.v + '"' })), 'prs-states')
260
+ }
261
+ const rows = filterPrRows(all, f)
262
+ const { groups, quiet } = groupPrRows(rows, { now })
263
+ const showMachine = !!d.multiMachine
264
+ const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
265
+ if (!all.length) {
266
+ h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, works on a branch with an open PR, or you paste a PR’s link in it.') + '</div>'
267
+ } else if (!rows.length) {
268
+ h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
269
+ } else {
270
+ for (const g of groups) h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
271
+ if (quiet.length) h += '<details class="prgroup quiet" data-group="quiet"' + (quietOpen || !groups.length ? ' open' : '') + '><summary><h2>Quiet <span class="count">' + quiet.length + '</span> <span class="faint qhint">no activity for ' + QUIET_DAYS + '+ days</span></h2></summary>' + quiet.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
272
+ }
273
+ if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
274
+ return h
275
+ }
276
+
277
+ // The whole tab: Mine / Team switch, the search box, the filters, the list.
278
+ export function prsPageHtml(d, opts = {}) {
279
+ const { view = 'mine', now = Date.now(), filter = {} } = opts
107
280
  let h = '<div class="pagehead"><div><h1>Pull requests</h1><div class="sub">' + (view === 'team' ? 'Every open PR in the repos you work in.' : 'PRs linked to your sessions.') + '</div></div></div>'
108
281
  h += '<div class="filters prs-filters"><div class="seg" role="group" aria-label="Whose PRs">' +
109
282
  [['mine', 'Mine'], ['team', 'Team']].map(([k, l]) => '<button type="button" data-action="prs-view" data-view="' + k + '" aria-pressed="' + (view === k) + '">' + l + '</button>').join('') +
@@ -111,17 +284,8 @@ export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
111
284
  if (!d) return h + '<div class="empty">Loading pull requests…</div>'
112
285
  if (d.error) return h + '<div class="banner warn"><span>' + esc(d.error) + '</span></div>'
113
286
  if (d.gh && d.gh.status !== 'ready') return h + ghLineHtml(d.gh)
114
- const groups = (d.groups || []).filter((g) => g.rows.length || (view === 'team' && g.failed))
115
- const showMachine = !!d.multiMachine
116
- if (!groups.length) {
117
- h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, mentions a PR, or works on a branch with an open PR.') + '</div>'
118
- } else {
119
- for (const g of groups) {
120
- h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>'
121
- if (g.failed && !g.rows.length) h += '<div class="hint">Couldn’t load this repo’s PRs right now.</div>'
122
- h += g.rows.map((r) => prRowHtml(r, { team: view === 'team', now, mayEdit, sessionAction, showMachine })).join('') + '</section>'
123
- }
124
- }
287
+ if (prRows(d).length || prFilterActive(filter)) h += '<div class="prs-search"><input type="search" id="prs-q" class="prs-q" placeholder="Search title, branch, #number, repo" aria-label="Search pull requests" autocapitalize="off" autocomplete="off" spellcheck="false" value="' + esc(filter.q || '') + '"></div>'
288
+ h += '<div id="prs-body">' + prsBodyHtml(d, opts) + '</div>'
125
289
  const foot = [d.servedBy && d.machine && d.servedBy !== d.machine ? 'From ' + d.servedBy + '’s gh' : '', d.loading ? 'Updating…' : d.updatedAt ? 'Updated ' + shortAgo(d.updatedAt, now) + (shortAgo(d.updatedAt, now) === 'now' ? '' : ' ago') : ''].filter(Boolean)
126
290
  if (foot.length) h += '<div class="prs-foot faint">' + esc(foot.join(' · ')) + '</div>'
127
291
  return h
package/ui/viewer.js CHANGED
@@ -12,8 +12,40 @@ export function isPhoneUa(ua, { maxTouchPoints = 0 } = {}) {
12
12
  return false
13
13
  }
14
14
 
15
- // "Set up this Mac too": only a desktop browser on another computer, until dismissed.
16
- export function wantsSetupBanner({ viewer, ua, maxTouchPoints = 0, dismissed = false } = {}) {
15
+ // "Set up this Mac too": only a desktop browser on another computer, until
16
+ // dismissed, and (0.8.4) only once this computer's own Leftoff didn't answer:
17
+ // `here` is false after localLeftoffAnswers() said no, true when it answered,
18
+ // null/undefined while not asked yet (no banner, so it never flashes).
19
+ export function setupCandidate({ viewer, ua, maxTouchPoints = 0, dismissed = false } = {}) {
17
20
  if (!viewer || !viewer.remote || !viewer.setup || dismissed) return false
18
21
  return !isPhoneUa(ua, { maxTouchPoints })
19
22
  }
23
+ export function wantsSetupBanner({ here, ...rest } = {}) {
24
+ return here === false && setupCandidate(rest)
25
+ }
26
+
27
+ // 0.8.4: does this computer already run Leftoff? Ask its daemon on the default
28
+ // port (GET /v1/here: an empty 204, presence only). Chrome's Local Network
29
+ // Access shows a permission prompt before a page may reach this computer's
30
+ // own ports; a banner isn't worth a prompt, so when the permission isn't
31
+ // already granted, don't ask (the daemon's tailnet check still applies).
32
+ export const LOCAL_PORT = 7429
33
+ export async function localLeftoffAnswers({ fetchImpl = (...a) => fetch(...a), permissions = (typeof navigator !== 'undefined' && navigator.permissions) || null, timeoutMs = 1500, port = LOCAL_PORT } = {}) {
34
+ if (await wouldPrompt(permissions)) return false
35
+ try {
36
+ const r = await fetchImpl('http://127.0.0.1:' + port + '/v1/here', { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) })
37
+ return r.status === 204 && r.headers.get('x-leftoff') === 'here'
38
+ } catch { return false }
39
+ }
40
+ // The permission names Chrome has used for it; a browser that knows neither has no such prompt.
41
+ async function wouldPrompt(permissions) {
42
+ if (!permissions || typeof permissions.query !== 'function') return false
43
+ let known = false
44
+ for (const name of ['loopback-network', 'local-network-access']) {
45
+ let s
46
+ try { s = await permissions.query({ name }) } catch { continue }
47
+ known = true
48
+ if (s && s.state === 'granted') return false
49
+ }
50
+ return known
51
+ }