@leftoff/cli 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -4,11 +4,12 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
5
  import { selectHtml, comboHtml, closeMenu } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
- import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
7
+ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst } from './repos.js'
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
11
  import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
12
+ import { wantsSetupBanner } from './viewer.js'
12
13
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
13
14
  const $app = document.getElementById('app')
14
15
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -158,7 +159,7 @@ function where(s) {
158
159
  const branch = s.repo && s.repo.branch
159
160
  const label = s.repoLabel || s.repoName || 'no repo'
160
161
  return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
161
- (multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '') + modelChip(s)
162
+ (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<span class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</span>' })() : '') + modelChip(s)
162
163
  }
163
164
 
164
165
  // 0.6.8: a session that ran through Ollama (or another server) says so on its card.
@@ -187,6 +188,7 @@ function banners() {
187
188
  (items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
188
189
  '<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
189
190
  }
191
+ out += syncBanners(h)
190
192
  const mod = h.mod
191
193
  if (mod && mod.failed && mod.failed.length) {
192
194
  out += '<div class="banner warn" role="status"><b>Some mod commands didn\'t register</b> in the last Claude Code session' + (mod.machine ? ' on ' + esc(mod.machine) : '') + ': ' +
@@ -195,6 +197,59 @@ function banners() {
195
197
  return out
196
198
  }
197
199
 
200
+
201
+ // 0.8.3: a low disk on this Mac (before Syncthing stops), sync that has
202
+ // actually stopped somewhere (a Needs-style notice), and "Set up this Mac too"
203
+ // for a desktop browser on another computer.
204
+ function syncBanners(h) {
205
+ let out = ''
206
+ const sy = h.sync || {}
207
+ const d = sy.disk
208
+ const stopped = (sy.items || []).filter((i) => i.level === 'red' && /stopped|full/i.test(i.text))
209
+ if (stopped.length) {
210
+ const mine = d && d.level === 'red'
211
+ out += '<div class="banner needs-style sync-stopped" role="alert"><div class="grow">' + stopped.map((i) => '<div><b>' + esc(i.text) + '</b>' + (i.fix ? '<div class="fixline">fix: ' + esc(i.fix) + '</div>' : '') + '</div>').join('') + '</div>' + (mine && d.fix && d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
212
+ }
213
+ if (d && d.level === 'warn') {
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
+ }
216
+ const v = h.viewer || {}
217
+ if (wantsSetupBanner({ viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() })) {
218
+ const su = v.setup
219
+ 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
+ '<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
221
+ }
222
+ return out
223
+ }
224
+ const SETUP_KEY = 'lo.setupDismissed'
225
+ function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY) === '1' } catch { return false } }
226
+
227
+ // The board's sync chip: quiet when all's well, amber or red when Syncthing
228
+ // reports an error, a disk runs low, or another Mac has been away 15+ min.
229
+ function syncChip() {
230
+ const sy = (state.health && state.health.sync) || null
231
+ if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
232
+ return '<button type="button" class="chip syncchip ' + esc(sy.level) + '" data-action="sync-details" aria-expanded="' + !!state.syncOpen + '" title="Sync status · tap for details">' + '<span class="sdot" aria-hidden="true"></span>' + esc(sy.label) + '</button>'
233
+ }
234
+ function syncDetails() {
235
+ const sy = (state.health && state.health.sync) || null
236
+ if (!state.syncOpen || !sy || !(sy.items || []).length) return ''
237
+ return '<div class="syncdetails" role="region" aria-label="Sync details">' + sy.items.map((i) => '<div class="syncitem ' + esc(i.level) + '"><div><b>' + esc(i.text) + '</b></div>' + (i.fix ? '<div class="fixline">fix: ' + esc(i.fix) + '</div>' : '') + (i.cmd ? '<div class="joinline"><code class="cmd">' + esc(i.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(i.cmd) + '">Copy</button></div>' : '') + '</div>').join('') +
238
+ '<div class="hint">More in <a href="#/settings/sync">Settings → Sync</a> or <code>leftoff doctor</code>.</div></div>'
239
+ }
240
+ // Another Mac whose data is stale: "showing data as of N min ago".
241
+ function staleLine(sessions) {
242
+ const st = state.health && state.health.sync && state.health.sync.stale
243
+ if (!st) return ''
244
+ const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
245
+ return ms.length ? '<div class="stale-line faint">' + ms.map((m) => esc(m) + ': showing data as of ' + esc(st[m] ? ago(st[m]) : 'its last sync')).join(' · ') + '</div>' : ''
246
+ }
247
+ function staleNote(machine) {
248
+ const st = state.health && state.health.sync && state.health.sync.stale
249
+ if (!st || !machine || !(machine in st)) return ''
250
+ return st[machine] ? 'as of ' + ago(st[machine]) : 'not synced yet'
251
+ }
252
+
198
253
  function updateLine() {
199
254
  const h = state.health || {}
200
255
  const u = h.update || {}
@@ -227,6 +282,16 @@ async function viewWhatsNew() {
227
282
  }
228
283
  }
229
284
 
285
+ // A doc from the installed package (0.8.1): #/docs/security, read through
286
+ // GET /v1/docs/<name> (also from a phone, through the relay) and shown with the
287
+ // viewer's escaped Markdown.
288
+ async function viewDocPage(name) {
289
+ const d = await api('GET', '/v1/docs/' + encodeURIComponent(name))
290
+ $app.innerHTML = '<div class="pagehead"><div><h1>' + esc(d.title || name) + '</h1><div class="sub"><a href="#/settings/' + (/^agent-feed/.test(name) ? 'agents' : 'security') + '">← Settings</a></div></div></div>' +
291
+ '<div class="doc-page">' + docViewerBody(d) + '</div>'
292
+ window.scrollTo(0, 0)
293
+ }
294
+
230
295
  function wnCard(e, { current = false, coming = false } = {}) {
231
296
  return '<article class="wn-card' + (e.isNew ? ' is-new' : '') + (coming ? ' coming' : '') + '">' +
232
297
  '<header><span class="wn-ver">v' + esc(e.version) + '</span>' + (e.date ? '<span class="wn-date">' + esc(e.date) + '</span>' : '') +
@@ -371,8 +436,11 @@ function chipBar(chips, selected, allCount, emptyNote) {
371
436
  if (chips.length < 2 && !selected.length) return ''
372
437
  // A repo named in the link but with nothing here still gets a chip, so it can be cleared.
373
438
  for (const tok of selected) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0, missing: true })
439
+ // 0.8.3: a chosen repo always shows, first in the row, removable (✕), even
440
+ // with nothing in this view: a filter is never active out of sight.
441
+ chips = orderSelectedFirst(chips, selected)
374
442
  const { shown: row, more } = splitChips(chips, selected, 8)
375
- const chip = (c) => '<button type="button" class="rchip' + (chipSelected(selected, c) ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + chipSelected(selected, c) + '"><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span></button>'
443
+ const chip = (c) => { const on = chipSelected(selected, c); return '<button type="button" class="rchip' + (on ? ' on' : '') + (c.count ? '' : ' zero') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc((c.missing ? emptyNote + ' · ' : '') + (on ? 'tap to remove this filter · ' : '') + c.key + ' · shift- or ⌘-click to pick several') + '" aria-pressed="' + on + '"' + (on ? ' aria-label="' + esc('Remove filter ' + c.label) + '"' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span>' + (on ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>' }
376
444
  const all = '<button type="button" class="rchip all' + (selected.length ? '' : ' on') + '" data-action="repo-all" aria-pressed="' + !selected.length + '"><span class="rl">All</span><span class="rc">' + allCount + '</span></button>'
377
445
  let menu = ''
378
446
  if (more.length) {
@@ -386,8 +454,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
386
454
  async function viewBoard() {
387
455
  if (!state.health) await refreshHealth()
388
456
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
389
- const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
390
- state.sessions = sessions
457
+ const { sessions: all, viewer, closedCount, todoCount, parked = [] } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
458
+ // 0.8.2: parked sessions have their own section (any age), not a status group
459
+ const sessions = all.filter((s) => !s.parked)
460
+ state.sessions = all.concat(parked.filter((p) => !all.some((s) => s.id === p.id)))
391
461
  state.viewerLocal = viewer ? viewer.local !== false : true
392
462
  state.ghostty = viewer ? viewer.ghostty === true : false
393
463
  state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
@@ -420,11 +490,12 @@ async function viewBoard() {
420
490
  const inRepos = applyFilter(inSpace, { repos: f.repos })
421
491
  const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
422
492
  const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
423
- let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div></div>' +
493
+ let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
424
494
  '<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
425
495
  options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
426
496
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
427
497
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
498
+ html += syncDetails() + staleLine(sessions)
428
499
  html += restoreBanner()
429
500
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
430
501
  const needs = needsItems(sessions)
@@ -440,7 +511,7 @@ async function viewBoard() {
440
511
  return closedWire()
441
512
  }
442
513
  html += repoRow(inSpace, f)
443
- if (!shown.length && !empties.length) {
514
+ if (!shown.length && !empties.length && !parked.length) {
444
515
  html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
445
516
  } else if (!list.length && shown.length) {
446
517
  const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
@@ -450,6 +521,7 @@ async function viewBoard() {
450
521
  html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
451
522
  }
452
523
  html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
524
+ if (!filter) html += parkedSection(applyFilter(sp ? parked.filter((s) => s.space === sp) : parked, { repos: f.repos }), viewer || {})
453
525
  // 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
454
526
  if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
455
527
  $app.innerHTML = banners() + html
@@ -478,7 +550,7 @@ async function closedFetch(f, sp) {
478
550
  }
479
551
  async function closedSection(f, sp) {
480
552
  const r = await closedFetch(f, sp)
481
- return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
553
+ return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.allTotal != null ? r.allTotal : r.total, 'nothing closed yet') +
482
554
  '<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div>' +
483
555
  '<button type="button" class="chip docs-filter" data-action="closed-docs" aria-pressed="' + closedUi.docs + '" title="Only sessions that made docs, plans or artifacts">' + (closedUi.docs ? '✓ ' : '') + 'Docs &amp; artifacts</button></div>' +
484
556
  '<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
@@ -567,6 +639,49 @@ async function resumeOpen(btn, id) {
567
639
  }
568
640
  }
569
641
 
642
+ // Parked (0.8.2): sessions you left meaning to come back to (/cp-park, or Park
643
+ // on a session's page). Its own small section under the board, any age, not
644
+ // counted in Needs you. Resume is the main button once it's closed; while
645
+ // it's still open, Go. "Done with it" sends it to Closed.
646
+ function parkedSection(list, viewer) {
647
+ if (!list.length) return ''
648
+ return '<section class="parked-sec mt" aria-label="Parked"><h2 class="parked-h">Parked <span class="count">' + list.length + '</span></h2>' +
649
+ '<div class="grid">' + list.map((s) => parkedCard(s, viewer)).join('') + '</div></section>'
650
+ }
651
+ function parkedCard(s, viewer) {
652
+ const href = '#/s/' + encodeURIComponent(s.id)
653
+ const branch = s.repo && s.repo.branch
654
+ const label = s.repoLabel || s.repoName || 'no repo'
655
+ const p = s.parked || {}
656
+ const open = s.status !== 'ended' && s.live !== 'closed'
657
+ const cp = s.latestCheckpoint
658
+ const note = p.note || (cp && cp.next) || ''
659
+ const main = open ? goButton(s) : resumeButton(s, viewer)
660
+ return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
661
+ '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
662
+ '<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
663
+ '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
664
+ (s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
665
+ '<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
666
+ (note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
667
+ '<div class="actions-row">' + main +
668
+ (open ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>') +
669
+ '<button type="button" class="linkish park-done" data-action="unpark" data-id="' + esc(s.id) + '" title="No longer parked' + (open ? '' : '; it goes to Closed') + '">Done with it</button>' +
670
+ '</div></article>'
671
+ }
672
+ // The session page's Park row (0.8.2): any open session, Codex too, with an
673
+ // optional note that becomes the checkpoint's Next line. Parked: the note and Done.
674
+ function parkRow(s) {
675
+ if (s.agent) return ''
676
+ if (s.parked) return '<section class="park-row is-on" id="park"><div class="park-on"><span class="pill s-parked">parked</span> <span class="faint">' + esc(ago(s.parked.at)) + '</span>' + (s.parked.note ? ' <span class="park-note-inline">' + esc(s.parked.note) + '</span>' : '') + '</div>' +
677
+ '<button type="button" class="btn sm" data-action="unpark" data-id="' + esc(s.id) + '">Done with it</button></section>'
678
+ if (s.status === 'ended' || s.live === 'closed' || s.empty) return ''
679
+ return '<section class="park-row" id="park"><form class="park-form" data-park="' + esc(s.id) + '" autocomplete="off">' +
680
+ '<input type="text" name="note" maxlength="300" placeholder="Note for later (optional)" aria-label="Park note" enterkeyhint="done">' +
681
+ '<button type="submit" class="btn sm">Park</button></form>' +
682
+ '<div class="faint park-hint">Checkpoints it, and keeps it under Parked when it closes.' + (s.codingAgent === 'codex' ? '' : ' In the session: /cp-park [note]') + '</div></section>'
683
+ }
684
+
570
685
  // The folded row of empty sessions (v0.4.8), closed by default.
571
686
  function emptyFold(list) {
572
687
  if (!list.length) return ''
@@ -771,6 +886,7 @@ async function viewSession(id) {
771
886
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
772
887
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
773
888
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
889
+ html += parkRow(s)
774
890
  html += spacePicker(s, spv, 'session', s.id)
775
891
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
776
892
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
@@ -1582,6 +1698,8 @@ function settingsDots() {
1582
1698
  if ((st.knocked || []).length) add('sync', 'warn', 'A Mac asked to connect without the pairing proof')
1583
1699
  }
1584
1700
  if (st && st.run && st.run.state === 'failed') add('sync', 'warn', 'The last Syncthing step failed')
1701
+ const sy = state.health && state.health.sync
1702
+ if (sy && sy.level !== 'ok' && (sy.items || []).length) add('sync', 'warn', sy.items[0].text) // 0.8.3: low disk, a stop, a quiet Mac
1585
1703
  const peers = syncUi.peers && syncUi.peers.peers
1586
1704
  if (peers && peers.some((x) => x.trust === 'key_changed')) add('security', 'warn', 'A machine’s key changed')
1587
1705
  const ag = agentUi.data && agentUi.data.agents
@@ -1735,9 +1853,9 @@ async function viewSettings() {
1735
1853
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
1736
1854
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
1737
1855
  '<div class="section">Your data</div>' +
1738
- '<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '" target="_blank" rel="noopener">Security notes ↗</a></div>'),
1856
+ '<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '">Security notes</a></div>'),
1739
1857
  updates: '<div class="section">About</div>' +
1740
- '<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="' + REPO_URL + '" target="_blank" rel="noopener">GitHub ↗</a></div>' +
1858
+ '<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>' +
1741
1859
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
1742
1860
  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>' +
1743
1861
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
@@ -1812,8 +1930,10 @@ function syncSection(d, st) {
1812
1930
 
1813
1931
  // ------------------------------------------------------------ Settings → Agents (v0.5.4)
1814
1932
 
1815
- const REPO_URL = 'https://github.com/OneSillyApp/leftoff'
1816
- const AGENT_HOSTING_URL = REPO_URL + '/blob/main/docs/agent-feed-hosting.md'
1933
+ // 0.8.1: the repo is private, so About links to the public npm page and the
1934
+ // docs open in the board, served from the installed package (src/docs.js).
1935
+ const NPM_URL = 'https://www.npmjs.com/package/@leftoff/cli'
1936
+ const AGENT_HOSTING_URL = '#/docs/agent-feed-hosting'
1817
1937
  const agentUi = { data: null, err: null, busy: false }
1818
1938
  function agentStatusLine(a) {
1819
1939
  const st = a.status || {}
@@ -1839,7 +1959,7 @@ function agentsSectionHtml() {
1839
1959
  const local = d.viewer && d.viewer.local
1840
1960
  let h = '<div class="section dup" id="agents">Agents</div>' +
1841
1961
  '<div class="full hint">Work you do with an AI assistant outside Claude Code (Tab, and later others) shows up on the board like another machine. The agent publishes a private feed; ' + (d.agents.length ? 'one Mac pulls it every 2 minutes and the sessions sync to the others as Markdown in <code>agents/</code>.' : 'one Mac pulls it every 2 minutes. Its sessions, “needs you” items and lists show up read-only.') +
1842
- ' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '" target="_blank" rel="noopener noreferrer">How to host a feed ↗</a></div>'
1962
+ ' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '">How to host a feed</a></div>'
1843
1963
  h += '<div class="full agents">' + (d.agents.length ? d.agents.map((a) => agentRow(a, local)).join('') : '<div class="nocp">No agent feeds yet.</div>') + '</div>'
1844
1964
  if (local) {
1845
1965
  h += '<div class="full agentadd"><div class="ah"><strong>Add agent feed</strong></div>' +
@@ -2042,7 +2162,7 @@ async function syncAction(action, btn) {
2042
2162
  // pair another Mac with a one-time code, join with a code, status, stop. All
2043
2163
  // changes are loopback only; a phone sees the status and nothing else.
2044
2164
 
2045
- const SECURITY_URL = REPO_URL + '/blob/main/docs/SECURITY.md'
2165
+ const SECURITY_URL = '#/docs/security'
2046
2166
  const PROTECT = {
2047
2167
  syncthing: ['Device to device, encrypted in transit (TLS). No cloud copy.', 'Relays and discovery servers only see encrypted traffic and which devices connect.', 'Your board token, phone sign-ins and API keys stay on each Mac.', 'Only the Mac that uses your one-time code is accepted.'],
2048
2168
  icloud: ['A copy lives on Apple’s servers, under Apple’s terms.', 'With Advanced Data Protection on, iCloud Drive is end-to-end encrypted. Without it, Apple holds the keys.', 'API keys and machine keys stay on each Mac. The board token and phone sign-in records (hashes only) sync with the folder.', 'Choose “Keep Downloaded” so files stay on disk.'],
@@ -2053,7 +2173,7 @@ const PROTECT = {
2053
2173
  function protectHtml(kind, { open = false } = {}) {
2054
2174
  const items = PROTECT[kind] || PROTECT.other
2055
2175
  return '<details class="protect"' + (open ? ' open' : '') + '><summary>' + icon('check') + 'How your data is protected</summary><ul>' + items.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul>' +
2056
- '<a href="' + SECURITY_URL + '" target="_blank" rel="noopener">The full security notes</a></details>'
2176
+ '<a href="' + SECURITY_URL + '">The full security notes</a></details>'
2057
2177
  }
2058
2178
 
2059
2179
  const stUi = { data: null, panel: null, pair: null, joinCode: '', path: '', timer: null, busy: false, error: null, showLog: false }
@@ -2070,7 +2190,7 @@ function stSection(d) {
2070
2190
 
2071
2191
  function stStatusLine(d) {
2072
2192
  if (!d.installed) return '<span class="chip">not installed</span>'
2073
- if (!d.running) return '<span class="chip warn">installed · not running</span>'
2193
+ if (!d.running) return '<span class="chip warn">installed · not running</span>' + (d.fixes && d.fixes.notRunning ? '<span class="fixline">fix: ' + esc(d.fixes.notRunning) + '</span>' : '')
2074
2194
  const f = d.folder
2075
2195
  let h = '<span class="chip ok">' + icon('check') + 'running' + (d.version ? ' · ' + esc(d.version) : '') + '</span>'
2076
2196
  if (f) {
@@ -2085,7 +2205,7 @@ function stPeersHtml(d) {
2085
2205
  if (!d.folder) return ''
2086
2206
  if (!d.peers.length) return '<div class="hint">No other Mac yet.</div>'
2087
2207
  return '<div class="stpeers">' + d.peers.map((p) => '<div class="stpeer"><span class="chip machine remote">⌂ ' + esc(p.name) + '</span>' +
2088
- (p.connected ? '<span class="chip ok">' + icon('check') + 'connected' + (p.completion != null && p.completion < 100 ? ' · ' + esc(p.completion) + '%' : '') + '</span>' : p.notSharing ? '<span class="chip warn">not sharing Leftoff with this Mac yet</span>' : '<span class="chip">not connected' + (p.lastSeen ? ' · seen ' + esc(ago(p.lastSeen)) : '') + '</span>') + '</div>').join('') + '</div>'
2208
+ (p.connected ? '<span class="chip ok">' + icon('check') + 'connected' + (p.completion != null && p.completion < 100 ? ' · ' + esc(p.completion) + '%' : '') + '</span>' : p.notSharing ? '<span class="chip warn">not sharing Leftoff with this Mac yet</span>' : '<span class="chip">not connected' + (p.lastSeen ? ' · seen ' + esc(ago(p.lastSeen)) : '') + '</span>') + (!p.connected && !p.notSharing && d.fixes && d.fixes.peers && d.fixes.peers[p.name] ? '<div class="fixline">fix: ' + esc(d.fixes.peers[p.name]) + '</div>' : '') + '</div>').join('') + '</div>'
2089
2209
  }
2090
2210
 
2091
2211
  // A Mac that knocked during a pairing without the proof (never auto-accepted).
@@ -2156,7 +2276,7 @@ function stBoxHtml(d) {
2156
2276
  let h = '<div class="ststatus">' + stStatusLine(d) + (d.folder ? '<span class="chip mono">' + esc(d.folder.display) + '</span>' : '') + '</div>'
2157
2277
  if (d.folder) h += stPeersHtml(d)
2158
2278
  h += stKnockedHtml(d, local)
2159
- for (const e of (d.folder && d.folder.errors) || []) h += '<div class="warnbox">' + esc((e.path ? e.path + ': ' : '') + e.error) + '</div>'
2279
+ ;((d.folder && d.folder.errors) || []).forEach((e, i) => { const fx = d.fixes && d.fixes.errors && d.fixes.errors[i]; h += '<div class="warnbox">' + esc((e.path ? e.path + ': ' : '') + e.error) + (fx ? '<div class="fixline">fix: ' + esc(fx) + '</div>' : '') + '</div>' })
2160
2280
  if (!local) return h + '<div class="hint">Installing, pairing and joining happen on the Mac itself.</div>'
2161
2281
  const run = d.run && Date.now() - new Date(d.run.at).getTime() < 3600000 ? d.run : null
2162
2282
  const running = run && !['done', 'failed'].includes(run.state)
@@ -2448,6 +2568,7 @@ async function render() {
2448
2568
  else if (r.name === 'lists') await viewLists(r.arg)
2449
2569
  else if (r.name === 'settings') await viewSettings()
2450
2570
  else if (r.name === 'whats-new') await viewWhatsNew()
2571
+ else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
2451
2572
  else if (r.name === 'search') await viewSearch(r.q || '')
2452
2573
  else await viewBoard()
2453
2574
  } catch (e) {
@@ -2868,6 +2989,8 @@ document.addEventListener('click', async (ev) => {
2868
2989
  if (!confirm('Sign out every remote browser, the home-screen app included? Each needs a new QR code or code.')) return
2869
2990
  const r = await api('POST', '/v1/remote/revoke'); toast('Signed out ' + r.revoked + ' browser' + (r.revoked === 1 ? '' : 's')); return render()
2870
2991
  }
2992
+ if (action === 'dismiss-setup') { try { window.localStorage.setItem(SETUP_KEY, '1') } catch {} return render() }
2993
+ if (action === 'sync-details') { state.syncOpen = !state.syncOpen; return render() }
2871
2994
  if (action === 'dismiss-upgrade') { await api('POST', '/v1/changelog/dismiss'); await refreshHealth(); return render() }
2872
2995
  if (action === 'agent-add') return agentAdd(btn)
2873
2996
  if (action === 'agent-refresh') {
@@ -3001,7 +3124,12 @@ document.addEventListener('click', async (ev) => {
3001
3124
  return render()
3002
3125
  }
3003
3126
  if (action === 'go') return goClick(btn, id, s)
3004
- if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
3127
+ if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
3128
+ if (action === 'unpark') {
3129
+ btn.disabled = true
3130
+ try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast(r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
3131
+ return render()
3132
+ }
3005
3133
  if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
3006
3134
  // 0.7.0: only sessions with docs, plans or artifacts
3007
3135
  if (action === 'closed-docs') { closedUi.docs = !closedUi.docs; closedUi.limit = CLOSED_PAGE; btn.setAttribute('aria-pressed', String(closedUi.docs)); btn.textContent = (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts'; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
@@ -3246,6 +3374,14 @@ document.addEventListener('submit', async (ev) => {
3246
3374
  try { await api('POST', '/v1/todos', { text, sessionId: sid }); input.value = ''; input.blur(); toast('Added to your to-dos'); await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
3247
3375
  return
3248
3376
  }
3377
+ if (ev.target.dataset && ev.target.dataset.park) {
3378
+ ev.preventDefault()
3379
+ const input = ev.target.querySelector('input[name=note]')
3380
+ const note = input.value.trim()
3381
+ const b = ev.target.querySelector('button'); if (b) { b.disabled = true; b.textContent = 'Parking…' }
3382
+ try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(ev.target.dataset.park) + '/park', { note: note || null }, { timeoutMs: 90000 }); input.blur(); toast('Parked' + (r.checkpoint ? ' · Next: ' + r.checkpoint.next : ''), 6000); await render() } catch (e) { toast(e.message, 5000) } finally { if (b && b.isConnected) { b.disabled = false; b.textContent = 'Park' } }
3383
+ return
3384
+ }
3249
3385
  if (ev.target.dataset && ev.target.dataset.prLink) {
3250
3386
  ev.preventDefault()
3251
3387
  const input = ev.target.querySelector('input[name=ref]')
@@ -5,11 +5,14 @@
5
5
  //
6
6
  // Links: CHANGELOG.md ships inside the npm package and the repo is private,
7
7
  // so only an absolute https link becomes a real link (new tab). A relative
8
- // link (docs/relay.md, README.md#…) or any other scheme shows as its text.
8
+ // link (docs/relay.md, README.md#…) or any other scheme shows as its text,
9
+ // except a #/docs/<name> link to a doc the board itself serves (0.8.1).
9
10
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
10
11
 
11
12
  const httpsUrl = (u) => /^https:\/\/[^\s"'<>]+$/i.test(String(u || '').trim())
12
13
  const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel="noopener">' + inner + '</a>'
14
+ // 0.8.1: a link to one of the docs the board serves (#/docs/security) opens in the board.
15
+ const docLink = (u) => /^#\/docs\/[a-z-]+$/.test(String(u || '').trim())
13
16
 
14
17
  // One line of inline markdown -> HTML. Everything that isn't markup is escaped.
15
18
  export function mdInline(s, { links = true } = {}) {
@@ -23,7 +26,7 @@ export function mdInline(s, { links = true } = {}) {
23
26
  // [text](url "title"): a real link only for https; otherwise just the text.
24
27
  t = t.replace(/\[([^\]]+)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/g, (_, text, url) => {
25
28
  const inner = back(marks(text))
26
- return hold(links && httpsUrl(url) ? anchor(url, inner) : inner)
29
+ return hold(links && httpsUrl(url) ? anchor(url, inner) : links && docLink(url) ? '<a href="' + esc(url.trim()) + '">' + inner + '</a>' : inner)
27
30
  })
28
31
  t = marks(t)
29
32
  if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&amp;/g, '&'), url))
package/ui/repos.js CHANGED
@@ -21,11 +21,27 @@ export function repoKeyOf(s) {
21
21
  // Short labels: the folder name, and for folder names that collide, as many
22
22
  // parent folders as it takes to tell them apart ("colorGenie/expo" vs
23
23
  // "food_fight/expo"). keys -> Map(key -> label).
24
- export function labelRepos(keys) {
24
+ // 0.8.3: `names` (Map key -> name) is what a repo is called when that's not
25
+ // its folder (its origin remote's name); two repos with one name fall back to
26
+ // their folders. "/" and a bare home folder are "No repo", never "/" or an
27
+ // account name.
28
+ export const NO_REPO_LABEL = 'No repo'
29
+ export function isNoRepoDir(p, home = null) {
30
+ const s = String(p || '').replace(/\/+$/, '')
31
+ if (!s || s === '~' || p === '/' || /^[A-Za-z]:\\?$/.test(s)) return true
32
+ if (home && s === String(home).replace(/\/+$/, '')) return true
33
+ return /^\/(Users|home)\/[^/]+$/.test(s) || s === '/root' || s === '/var/root'
34
+ }
35
+ export function labelRepos(keys, { names = null } = {}) {
25
36
  const uniq = [...new Set([...keys].filter(Boolean))]
26
37
  const depth = new Map(uniq.map((k) => [k, 1]))
38
+ const nameOf = (k) => (names && names.get(k)) || null
39
+ const named = new Map()
40
+ for (const k of uniq) { const n = nameOf(k); if (n) named.set(n, (named.get(n) || 0) + 1) }
27
41
  const label = (k) => {
28
- if (k === 'no-repo') return 'no repo'
42
+ if (k === 'no-repo' || isNoRepoDir(k)) return NO_REPO_LABEL
43
+ const n = nameOf(k)
44
+ if (n && named.get(n) === 1 && depth.get(k) === 1 && !uniq.some((o) => o !== k && !nameOf(o) && baseOf(o) === n)) return n
29
45
  const s = segments(k)
30
46
  return s.length ? s.slice(-Math.min(depth.get(k), s.length)).join('/') : k
31
47
  }
@@ -168,6 +184,12 @@ export function inStatus(s, status) {
168
184
 
169
185
  // Which chips show in the row and which go behind "More ▾": the top `max` by
170
186
  // recent activity, plus any selected one that would otherwise be hidden.
187
+ // 0.8.3: chosen chips first, in the order chosen, then the rest as they were.
188
+ export function orderSelectedFirst(chips, selected = []) {
189
+ if (!selected || !selected.length) return chips
190
+ const sel = chips.filter((c) => chipSelected(selected, c))
191
+ return [...sel, ...chips.filter((c) => !sel.includes(c))]
192
+ }
171
193
  export function splitChips(chips, selected = [], max = 8) {
172
194
  if (chips.length <= max) return { shown: chips, more: [] }
173
195
  const isSel = (c) => chipSelected(selected, c)
package/ui/viewer.js ADDED
@@ -0,0 +1,19 @@
1
+ // 0.8.3: who's looking at the board. The daemon says whether the request came
2
+ // from another computer (a tailnet or LAN name, the relay); the browser tells
3
+ // a phone or tablet from a desktop by its user agent. Plain functions, no DOM.
4
+
5
+ // Phones and tablets (an iPad asking for the desktop site says "Macintosh"
6
+ // but has a touch screen: maxTouchPoints > 1).
7
+ export function isPhoneUa(ua, { maxTouchPoints = 0 } = {}) {
8
+ const u = String(ua || '')
9
+ if (/iPhone|iPod|Android.*Mobile|Mobile Safari|Windows Phone|BlackBerry|Opera Mini|IEMobile/i.test(u)) return true
10
+ if (/iPad|Android(?!.*Mobile)|Tablet|Silk/i.test(u)) return true
11
+ if (/Macintosh/.test(u) && maxTouchPoints > 1) return true
12
+ return false
13
+ }
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 } = {}) {
17
+ if (!viewer || !viewer.remote || !viewer.setup || dismissed) return false
18
+ return !isPhoneUa(ua, { maxTouchPoints })
19
+ }