@leftoff/cli 0.6.5 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -142,7 +142,16 @@ function where(s) {
142
142
  const branch = s.repo && s.repo.branch
143
143
  const label = s.repoLabel || s.repoName || 'no repo'
144
144
  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>' +
145
- (multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '')
145
+ (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)
146
+ }
147
+
148
+ // 0.6.8: a session that ran through Ollama (or another server) says so on its card.
149
+ function modelChip(s) {
150
+ if (!s || !s.backend || !s.modelLabel) return ''
151
+ // the model name gives way first; "· Ollama" always shows
152
+ const i = s.modelLabel.lastIndexOf(' · ')
153
+ const inner = i > 0 ? '<span class="m">' + esc(s.modelLabel.slice(0, i)) + '</span><span class="via">· ' + esc(s.modelLabel.slice(i + 3)) + '</span>' : '<span class="m">' + esc(s.modelLabel) + '</span>'
154
+ return '<span class="chip model" title="' + esc((s.backend.kind === 'ollama' ? 'Runs through Ollama: ' : 'Runs on another server: ') + s.modelLabel) + '">' + inner + '</span>'
146
155
  }
147
156
 
148
157
  // Notices above the board: things that need the user's hand, from /v1/health.
@@ -306,6 +315,7 @@ const FILTERS = {
306
315
  waiting: { label: 'Waiting', cls: 's-waiting', statuses: ['waiting'] },
307
316
  dormant: { label: 'Dormant', cls: 's-dormant', statuses: ['dormant', 'stale'] },
308
317
  ended: { label: 'Ended today', cls: 's-ended', statuses: ['ended'] },
318
+ closed: { label: 'Closed', cls: 's-closed', statuses: ['ended'] },
309
319
  }
310
320
 
311
321
  // The board's filter: hash first, then the last repo choice from this browser.
@@ -360,9 +370,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
360
370
  async function viewBoard() {
361
371
  if (!state.health) await refreshHealth()
362
372
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
363
- const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
373
+ const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
364
374
  state.sessions = sessions
365
375
  state.viewerLocal = viewer ? viewer.local !== false : true
376
+ state.ghostty = viewer ? viewer.ghostty === true : false
366
377
  state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
367
378
  try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
368
379
  const today = new Date(); today.setHours(0, 0, 0, 0)
@@ -391,8 +402,8 @@ async function viewBoard() {
391
402
  const allShown = shown
392
403
  const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
393
404
  const inRepos = applyFilter(inSpace, { repos: f.repos })
394
- const count = (k) => inRepos.filter((s) => inStatus(s, k)).length
395
- const list = applyFilter(inSpace, f)
405
+ const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
406
+ const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
396
407
  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>' +
397
408
  '<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
398
409
  options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
@@ -405,6 +416,11 @@ async function viewBoard() {
405
416
  html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
406
417
  '<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '"><div class="n">' + count(k) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
407
418
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
419
+ if (filter === 'closed') {
420
+ html += await closedSection(f, sp)
421
+ $app.innerHTML = banners() + html
422
+ return closedWire()
423
+ }
408
424
  html += repoRow(inSpace, f)
409
425
  if (!shown.length && !empties.length) {
410
426
  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>'
@@ -417,6 +433,115 @@ async function viewBoard() {
417
433
  }
418
434
  html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
419
435
  $app.innerHTML = banners() + html
436
+ revealStat()
437
+ }
438
+
439
+ // Closed (0.6.7): every ended session on every machine, newest first, with
440
+ // a search box and Show more. Each card has its last checkpoint and Resume,
441
+ // which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
442
+ const CLOSED_PAGE = 30
443
+ const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
444
+ function closedQuery(f, sp) {
445
+ const qs = new URLSearchParams({ limit: String(closedUi.limit) })
446
+ if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
447
+ if (state.machine) qs.set('machine', state.machine)
448
+ if (f.repos.length) qs.set('repo', f.repos.join(','))
449
+ if (sp) qs.set('space', sp)
450
+ return '/v1/sessions/closed?' + qs
451
+ }
452
+ async function closedFetch(f, sp) {
453
+ const seq = ++closedUi.seq
454
+ const r = await api('GET', closedQuery(f, sp))
455
+ if (seq === closedUi.seq) closedUi.data = r
456
+ return closedUi.data
457
+ }
458
+ async function closedSection(f, sp) {
459
+ const r = await closedFetch(f, sp)
460
+ return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
461
+ '<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></div>' +
462
+ '<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
463
+ }
464
+ function closedListHtml(r, f, sp) {
465
+ if (!r.sessions.length) {
466
+ const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
467
+ return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
468
+ (f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
469
+ }
470
+ return '<div class="grid mt closed-grid">' + r.sessions.map((s) => closedCard(s, r.viewer || {})).join('') + '</div>' +
471
+ (r.more ? '<div class="closed-more"><button type="button" class="btn" data-action="closed-more">Show more <span class="faint">· ' + (r.total - r.sessions.length) + ' older</span></button></div>' : '<div class="closed-end faint">' + r.total + ' closed session' + (r.total === 1 ? '' : 's') + '</div>')
472
+ }
473
+ // Resume is a button only where it can run: a Mac's own browser, with Ghostty,
474
+ // for a session this Mac owns or one another Mac will open for it. Elsewhere
475
+ // (a phone, a remote browser) Copy resume is the way.
476
+ function resumeButton(s, viewer = {}) {
477
+ if (s.agent || !s.cwd) return ''
478
+ if (viewer.local === false || state.viewerLocal === false) return ''
479
+ const busy = closedUi.busy.has(s.id)
480
+ if (s.isLocal) {
481
+ if (!viewer.ghostty) return ''
482
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="Open it in a new Ghostty tab: claude --resume">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
483
+ }
484
+ if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
485
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
486
+ }
487
+ function closedCard(s, viewer) {
488
+ const href = '#/s/' + encodeURIComponent(s.id)
489
+ const branch = s.repo && s.repo.branch
490
+ const label = s.repoLabel || s.repoName || 'no repo'
491
+ return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
492
+ '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
493
+ '<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
494
+ '<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>' +
495
+ '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
496
+ '<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
497
+ cpBlock(s.latestCheckpoint, { showWhen: false }) +
498
+ '<div class="actions-row">' + resumeButton(s, viewer) +
499
+ '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
500
+ '<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
501
+ '</div></article>'
502
+ }
503
+ function closedRepaint() {
504
+ const el = document.getElementById('closedList')
505
+ if (!el || !closedUi.data) return
506
+ const f = state.boardFilter || { repos: [] }
507
+ el.innerHTML = closedListHtml(closedUi.data, f, state.boardSpace)
508
+ }
509
+ // On a phone the status counts are one row that scrolls: bring the picked one into view.
510
+ function revealStat() {
511
+ const on = document.querySelector('.stats .stat.on'), row = on && on.parentElement
512
+ if (row && row.scrollWidth > row.clientWidth) row.scrollLeft = Math.max(0, on.offsetLeft - row.offsetLeft - (row.clientWidth - on.offsetWidth) / 2)
513
+ }
514
+ function closedWire() {
515
+ revealStat()
516
+ const q = document.getElementById('closedQ')
517
+ if (!q) return
518
+ q.addEventListener('input', () => {
519
+ closedUi.q = q.value
520
+ closedUi.limit = CLOSED_PAGE
521
+ clearTimeout(closedUi.timer)
522
+ closedUi.timer = setTimeout(async () => {
523
+ try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) }
524
+ }, 180)
525
+ })
526
+ q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
527
+ }
528
+ // Resume, from a closed card or a session page. -> true when it opened.
529
+ async function resumeOpen(btn, id) {
530
+ if (closedUi.busy.has(id)) return false
531
+ closedUi.busy.add(id)
532
+ const idle = btn.innerHTML
533
+ btn.disabled = true; btn.textContent = 'Opening…'
534
+ try {
535
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
536
+ toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
537
+ return true
538
+ } catch (e) {
539
+ toast(e.message, 6000)
540
+ return false
541
+ } finally {
542
+ closedUi.busy.delete(id)
543
+ if (btn.isConnected) { btn.disabled = false; btn.innerHTML = idle }
544
+ }
420
545
  }
421
546
 
422
547
  // The folded row of empty sessions (v0.4.8), closed by default.
@@ -451,7 +576,7 @@ function restoreBanner() {
451
576
  const local = state.viewerLocal !== false
452
577
  const picked = restorePicked().length
453
578
  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') + '>' +
454
- '<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span></span></label>').join('')
579
+ '<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('')
455
580
  return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
456
581
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
457
582
  '<div class="rs-list">' + rows + '</div>' +
@@ -612,10 +737,10 @@ async function viewSession(id) {
612
737
  const spv = await api('GET', '/v1/spaces').catch(() => null)
613
738
  let html = '<a class="back" href="#/">← All sessions</a>'
614
739
  html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
615
- '<div class="actions">' + goButton(s) + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
740
+ '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
616
741
  html += '<div class="facts">' +
617
742
  fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
618
- fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.model || '—') + fact('Session id', s.id) +
743
+ 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) +
619
744
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
620
745
  fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
621
746
  html += spacePicker(s, spv, 'session', s.id)
@@ -636,7 +761,7 @@ async function viewSession(id) {
636
761
  if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
637
762
  else html += '<div class="timeline">' + s.checkpoints.map((cp) =>
638
763
  '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
639
- (cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + '</span>' : '') + '</div>' +
764
+ (cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
640
765
  cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
641
766
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
642
767
  (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
@@ -655,7 +780,7 @@ function spacePicker(s, spv, kind, key) {
655
780
  options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
656
781
  '<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
657
782
  }
658
- function fact(k, v) { return '<div class="fact"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
783
+ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
659
784
 
660
785
  // Timeline (0.4.3): the board's repo chips (same remembered pick), a session
661
786
  // filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
@@ -1040,7 +1165,7 @@ function syncProviderFields() {
1040
1165
  st.textContent = k.found ? '✓ key found' + (k.envSet ? ' ($' + k.env + ')' : ' (saved)') : 'no key yet'
1041
1166
  document.getElementById('f-key').placeholder = k.found ? 'paste a new key to replace it' : 'paste your key, or set $' + (k.env || '') + ' for the daemon'
1042
1167
  document.getElementById('f-key-hint').innerHTML = (f.key === 'ollama-cloud' ? 'Create one at <a href="https://ollama.com/settings/keys" target="_blank" rel="noopener">ollama.com/settings/keys</a>. ' : '') +
1043
- 'Saved to ' + esc(k.file || '.secret/') + ' (mode 0600), never config.json. $' + esc(k.env || '') + ' wins if the daemon sees it.'
1168
+ 'Saved in ' + esc(k.file || '.secret/') + ' on this Mac, never config.json.'
1044
1169
  }
1045
1170
  }
1046
1171
 
@@ -1123,9 +1248,12 @@ function summariesSection(c) {
1123
1248
  '<label>Checkpoint length</label><div class="lengths" role="radiogroup" aria-label="Checkpoint length">' + LENGTH_CHOICES.map((o) =>
1124
1249
  '<label class="lenopt"><input type="radio" name="f-length" value="' + o.key + '"' + (cur === o.key ? ' checked' : '') + '>' +
1125
1250
  '<span class="lo-t">' + esc(o.label) + (o.recommended ? ' <span class="lo-rec">default</span>' : '') + '</span><span class="lo-a">' + esc(o.approx) + '</span><span class="lo-b">' + esc(o.blurb) + '</span></label>').join('') + '</div>' +
1126
- '<div class="hint">In a session, <code>/cp --short</code> or <code>/cp --long</code> overrides it for one checkpoint. The board, the pane and the digest always use the first three lines.</div>' +
1127
- '<label>Example</label><div class="len-example" id="f-length-example" aria-live="polite">' + lengthExample(cur) + '</div>' +
1128
- '<div class="hint"><button type="button" class="linkish" data-action="length-try">Try the selected length on your latest session</button> <span class="faint">(runs the summarizer once; nothing is saved)</span></div>'
1251
+ '<div class="hint"><code>/cp --short</code> or <code>/cp --long</code> overrides it once.</div>'
1252
+ }
1253
+ function lengthExampleHtml(c) {
1254
+ const cur = (c.summary && c.summary.length) || 'informative'
1255
+ return '<label>Example</label><div class="len-example" id="f-length-example" aria-live="polite">' + lengthExample(cur) + '</div>' +
1256
+ '<div class="hint"><button type="button" class="linkish" data-action="length-try">Try it on your latest session</button> <span class="faint">(nothing is saved)</span></div>'
1129
1257
  }
1130
1258
  async function tryLength() {
1131
1259
  const k = ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative'
@@ -1137,7 +1265,7 @@ async function tryLength() {
1137
1265
  box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
1138
1266
  try {
1139
1267
  const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
1140
- box.innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') : '') + ' · not saved</span></div>' +
1268
+ box.innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
1141
1269
  cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
1142
1270
  } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
1143
1271
  }
@@ -1174,9 +1302,11 @@ function remoteSection(r) {
1174
1302
  : '<span class="chip">no sign-in needed</span>') + '</div>' +
1175
1303
  '<div class="hint">' + (r.requireToken ? 'A signed-in browser stays signed in for 30 days after its last visit.' : 'Anyone on your tailnet who can reach the URL gets the board (remoteRequireToken is off).') + '</div>'
1176
1304
  if (r.requireToken) {
1177
- if (local) {
1305
+ // A trusted remote browser may sign in another phone too (0.6.6, Trust my paired devices).
1306
+ const trusted = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
1307
+ if (local || trusted) {
1178
1308
  h += '<label>Sign in a phone</label><div class="remote-actions"><button type="button" class="btn primary" data-action="remote-link">' + icon('qr-code') + 'Show QR code</button>' +
1179
- (n ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
1309
+ (n && local ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
1180
1310
  '<div class="full signin" id="remoteLink" hidden></div>'
1181
1311
  } else {
1182
1312
  h += '<label>Home-screen app</label><div class="remote-actions"><button type="button" class="btn primary" data-action="remote-code">Get a code</button></div>' +
@@ -1212,7 +1342,7 @@ function signupBlock(r, su, local) {
1212
1342
 
1213
1343
  // Use from anywhere (v0.6.0): the hosted relay at <user>.leftoff.dev. The
1214
1344
  // switch, the name, the relay's state, pairing a phone, the paired devices.
1215
- function anywhereSection(r) {
1345
+ function anywhereSection(r, adv = []) {
1216
1346
  if (!r) return ''
1217
1347
  const v = r.viewer || {}
1218
1348
  const local = !!v.local
@@ -1220,27 +1350,33 @@ function anywhereSection(r) {
1220
1350
  const dom = esc(r.domain || 'leftoff.dev')
1221
1351
  const su = r.signup || { status: 'none' }
1222
1352
  let h = '<div class="section" id="anywhere">Use from anywhere</div>' +
1223
- '<div class="full hint">Your board at <b>' + esc(r.host || 'you.leftoff.dev') + '</b> on your phone or any browser, with no Tailscale and no open ports. Each Mac keeps one outbound connection to your relay. The board’s data is end-to-end encrypted between a paired browser and your Macs, so the relay passes along what it can’t read. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
1353
+ '<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
1224
1354
  // No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
1225
1355
  if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
1226
- h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>' +
1227
- '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
1356
+ h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
1357
+ // The name: up top until there is one, then Advanced (0.6.6).
1358
+ const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
1228
1359
  (local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
1229
- '<div class="hint">' + (r.user ? 'Your own subdomain. Your other Macs join on their own when you switch this on there, while a Mac that’s already on it is awake.' : 'Already claimed one on another Mac? Type it here and switch this on; that Mac vouches for this one while it’s awake.') + '</div>'
1360
+ '<div class="hint">' + (r.user ? 'Your other Macs join when you switch this on there.' : 'Claimed one on another Mac? Type it here and switch this on.') + '</div>'
1361
+ if (r.user) adv.push(nameRow); else h += nameRow
1230
1362
  if (r.enabled) {
1231
1363
  const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
1232
1364
  h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
1233
1365
  if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
1234
- if (local) h += '<label>Pair a phone</label><div class="remote-actions"><button type="button" class="btn primary" data-action="relay-pair"' + (r.connected ? '' : ' disabled') + '>' + icon('qr-code') + 'Pair a phone</button></div>' +
1366
+ // Pairing from a paired phone follows Trust my paired devices (0.6.6).
1367
+ const mayPair = v.mayPair != null ? !!v.mayPair : local
1368
+ if (mayPair) h += '<label>Pair a phone</label><div class="remote-actions"><button type="button" class="btn primary" data-action="relay-pair"' + (r.connected ? '' : ' disabled') + '>' + icon('qr-code') + 'Pair a phone</button></div>' +
1235
1369
  '<div class="full signin" id="relayPair" hidden></div>'
1370
+ else h += '<label>Pair a phone</label><div class="hint nomt" id="pairUntrusted">Turn on <a href="#/settings/security">Trust my paired devices</a> on a Mac to pair from here.</div>'
1236
1371
  }
1372
+ const mayRevoke = v.mayRevoke != null ? !!v.mayRevoke : local
1237
1373
  const list = r.list || []
1238
1374
  h += '<label>Paired devices</label><div class="devlist">' + (list.length ? list.map((d) =>
1239
1375
  '<div class="dev" data-device="' + esc(d.id) + '"><div class="dev-main"><b>' + esc(d.name) + '</b>' + (v.device === d.id ? ' <span class="chip">this one</span>' : '') +
1240
1376
  '<div class="faint">Paired ' + esc(fmtAgo(d.createdAt)) + ' on ' + esc(d.pairedOn || '?') + (d.lastSeenAt ? ' · used ' + esc(fmtAgo(d.lastSeenAt)) : '') + ' · reaches ' + esc((d.macs || []).join(', ')) + '</div></div>' +
1241
- (local ? '<button type="button" class="btn sm danger" data-action="relay-revoke" data-device="' + esc(d.id) + '" data-name="' + esc(d.name) + '">Revoke</button>' : '') + '</div>').join('')
1377
+ (mayRevoke ? '<button type="button" class="btn sm danger" data-action="relay-revoke" data-device="' + esc(d.id) + '" data-name="' + esc(d.name) + '"' + (v.device === d.id ? ' data-self="1"' : '') + '>Revoke</button>' : '') + '</div>').join('')
1242
1378
  : '<div class="faint">None yet.</div>') + '</div>' +
1243
- '<div class="hint">' + (local ? 'Revoking works at once: the relay refuses the device and your Macs throw away its key.' : 'Revoke devices from a Mac.') + ' Answering prompts from a phone follows <a href="#/settings/security">Privacy &amp; security</a>. Planned: a passkey as a second factor.</div>'
1379
+ '<div class="hint">' + (local ? 'Revoking works at once, from any paired device.' : 'Revoking works at once, this browser included.') + '</div>'
1244
1380
  return h
1245
1381
  }
1246
1382
  function fmtAgo(iso) {
@@ -1270,7 +1406,7 @@ function paintRelayPair(l) {
1270
1406
  box.innerHTML = '<div class="qr"><img alt="QR code: scan with the iPhone camera" width="220" height="220" src="data:image/svg+xml;base64,' + btoa(l.qrSvg) + '"></div>' +
1271
1407
  '<div class="signin-text"><div class="faint">Scan with your phone’s camera, or open this link on it:</div><code class="link">' + esc(l.link) + '</code>' +
1272
1408
  '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.link) + '">Copy link</button>' +
1273
- '<div class="faint gap">Or type this code at ' + esc(l.link.split('#')[0]) + ':</div><div class="code-big long" aria-label="Pairing code">' + esc(l.code) + '</div>' +
1409
+ '<div class="faint gap">Or type this code at ' + esc(l.link.split('#')[0]) + ':</div><div class="joinline"><div class="code-big long" aria-label="Pairing code">' + esc(l.code) + '</div><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.code) + '">Copy code</button></div>' +
1274
1410
  '<div class="expiry" id="relayExpiry" data-expires="' + esc(l.expiresAt) + '"></div>' +
1275
1411
  '<button type="button" class="linkish" data-action="relay-pair">New code</button></div>'
1276
1412
  const tick = () => {
@@ -1292,9 +1428,10 @@ function answeringSection(r, relay) {
1292
1428
  if (!r || !(r.enabled || anywhere)) return ''
1293
1429
  const local = r.via !== 'remote'
1294
1430
  const allow = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
1295
- return '<label for="f-remote-answers">Answering</label><label class="check"><input id="f-remote-answers" type="checkbox"' + (allow ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow answering permission prompts and questions from other devices</span></label>' +
1296
- '<div class="warnbox"><b>Think before turning this on.</b> Anyone signed in to this board over Tailscale, or using a browser you paired for Use from anywhere, could then approve what Claude runs on this machine, including commands that delete files or push code. Off, other devices still see what’s waiting and can’t answer it. ' +
1297
- (local ? 'Every answer is recorded in the session’s answers.md with where it came from.' : 'This can only be changed on the machine running Leftoff.') + '</div>'
1431
+ return '<label for="f-remote-answers">Paired devices</label><label class="check"><input id="f-remote-answers" type="checkbox"' + (allow ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Trust my paired devices</span></label>' +
1432
+ '<div class="hint">On: your phone and other paired browsers can answer prompts and pair new devices. Off: they can look, not touch.</div>' +
1433
+ '<div class="warnbox"><b>Think first.</b> A trusted device can approve what Claude runs here, including deleting files or pushing code.' +
1434
+ (local ? '' : ' Change this on a Mac.') + '</div>'
1298
1435
  }
1299
1436
 
1300
1437
  // The sign-in box under "Show QR code" / "Get a code": the QR image, the link,
@@ -1338,11 +1475,11 @@ function digestSettings(c) {
1338
1475
  const zoneNote = bz && zone && bz !== zone ? '<div class="hint warnish">This browser is in ' + esc(bz) + '. The digest runs on ' + esc(zone) + ' time' + (d.timeZone ? ' (set below)' : ', the zone of the machine running Leftoff') + '.</div>' : ''
1339
1476
  return '<div class="section dup" id="digest">Digest</div>' +
1340
1477
  '<label for="f-time">Time</label><div class="timerow"><input id="f-time" type="time" value="' + esc(d.time || '') + '"><span class="zone" id="f-zone">' + esc(zone) + (dg.zoneAbbr && dg.zoneAbbr !== zone ? ' (' + esc(dg.zoneAbbr) + ')' : '') + '</span></div>' +
1341
- '<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear the time to turn automatic digests off.</div>' + zoneNote +
1478
+ '<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear it to turn digests off.</div>' + zoneNote +
1342
1479
  '<label>Days</label><div class="days" role="group" aria-label="Digest days">' + DAY_KEYS.map(([k, l, full]) => '<button type="button" class="day" data-action="day-toggle" data-day="' + k + '" aria-pressed="' + days.includes(k) + '" title="' + full + '" aria-label="' + full + '">' + l + '</button>').join('') +
1343
1480
  '<span class="presets"><button type="button" class="linkish" data-action="days-preset" data-days="all">Every day</button> · <button type="button" class="linkish" data-action="days-preset" data-days="weekdays">Weekdays</button></span></div>' +
1344
- '<label for="f-skip">Quiet days</label><label class="check"><input id="f-skip" type="checkbox"' + (d.skipEmpty !== false ? ' checked' : '') + '><span>Skip days with no activity (no empty digest is written).</span></label>' +
1345
- '<label for="f-tz">Time zone</label>' + selectHtml({ id: 'f-tz', value: d.timeZone || '', options: [['', 'This machine’s (' + (dg.daemonZone || '?') + ')'], ...zones.map((z) => [z, z.replace(/_/g, ' ')])] })
1481
+ advanced('digest', '<label for="f-skip">Quiet days</label><label class="check"><input id="f-skip" type="checkbox"' + (d.skipEmpty !== false ? ' checked' : '') + '><span>Skip days with no activity (no empty digest is written).</span></label>' +
1482
+ '<label for="f-tz">Time zone</label>' + selectHtml({ id: 'f-tz', value: d.timeZone || '', options: [['', 'This machine’s (' + (dg.daemonZone || '?') + ')'], ...zones.map((z) => [z, z.replace(/_/g, ' ')])] }))
1346
1483
  }
1347
1484
 
1348
1485
  // Settings (v0.5.9): one section at a time. Desktop (≥900px) has a sidebar of
@@ -1417,6 +1554,12 @@ function settingsFilter() {
1417
1554
  }
1418
1555
  wrap.querySelectorAll('.set-hit').forEach((el) => el.classList.remove('set-hit'))
1419
1556
  if (q) for (const el of wrap.querySelectorAll('.set-pane:not([hidden]) label, .set-pane:not([hidden]) .section, .set-pane:not([hidden]) .sp-sub')) if (el.textContent.toLowerCase().includes(q)) el.classList.add('set-hit')
1557
+ // A match under Advanced opens it; clearing the search folds it back (unless you opened it).
1558
+ for (const d of wrap.querySelectorAll('details.set-adv')) {
1559
+ const inside = q && [...d.querySelectorAll('.set-advbody label, .set-advbody .section, .set-advbody .sp-sub')].some((el) => el.textContent.toLowerCase().includes(q))
1560
+ if (inside && !d.open) { d.dataset.auto = '1'; d.open = true }
1561
+ else if (!inside && d.dataset.auto) { delete d.dataset.auto; d.open = !!settingsUi.adv[d.dataset.adv] }
1562
+ }
1420
1563
  const none = wrap.querySelector('.set-nomatch')
1421
1564
  none.hidden = !!first
1422
1565
  none.textContent = first ? '' : 'Nothing in Settings matches “' + settingsUi.q.trim() + '”.'
@@ -1457,10 +1600,30 @@ function ghSection(g) {
1457
1600
  '<button type="button" class="linkish" data-action="gh-check">Check again</button></div>'
1458
1601
  if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
1459
1602
  h += '</div>'
1460
- if (!ok) h += '<div class="hint">' + (g.fix && g.fix.url ? 'Optional. Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this in Terminal.' : 'Optional. Run this in Terminal on this Mac.') + ' Without gh, cards just skip the pull request.</div>'
1603
+ if (!ok) h += '<div class="hint">' + (g.fix && g.fix.url ? 'Optional. Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this.' : 'Optional. Run this in Terminal on this Mac.') + '</div>'
1461
1604
  return h
1462
1605
  }
1463
1606
 
1607
+ // Advanced (0.6.6): each section shows what people change up top; the rest
1608
+ // folds under one closed Advanced per section. Same form, same Save; the
1609
+ // search opens a fold when something inside matches; opening one sticks.
1610
+ settingsUi.adv = settingsUi.adv || {}
1611
+ function advanced(key, html) {
1612
+ if (!html) return ''
1613
+ return '<details class="set-adv" data-adv="' + key + '"' + (settingsUi.adv[key] ? ' open' : '') + '><summary>Advanced</summary><div class="set-advbody">' + html + '</div></details>'
1614
+ }
1615
+ // Phone & remote: Use from anywhere up top; Tailscale under Advanced unless it's
1616
+ // the only way in (remote access on, the relay off).
1617
+ function phonePane(relay, remote, unavailable) {
1618
+ const adv = []
1619
+ const any = anywhereSection(relay, adv)
1620
+ const ts = remoteSection(remote) || unavailable('Couldn’t read remote access from Leftoff.')
1621
+ const tsOnly = !!(remote && remote.enabled) && !(relay && relay.enabled)
1622
+ if (!any) return ts
1623
+ if (tsOnly) return any + advanced('phone', adv.join('')) + '<div class="section">Over Tailscale</div>' + ts
1624
+ return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
1625
+ }
1626
+
1464
1627
  async function viewSettings() {
1465
1628
  const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
1466
1629
  state.relay = relay
@@ -1473,17 +1636,16 @@ async function viewSettings() {
1473
1636
  const h = state.health || {}
1474
1637
  const unavailable = (what) => '<div class="full hint set-empty">' + esc(what) + '</div>'
1475
1638
  const panes = {
1476
- general: '<div class="section">Checkpoints</div>' +
1477
- '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
1478
- '<div class="hint">A session with no activity this long gets a checkpoint.</div>' +
1479
- '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
1480
- '<div class="hint">No automatic checkpoint closer than this to the last one.</div>' +
1481
- '<div class="section">Needs you</div>' +
1482
- '<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a macOS notification when a session has waited on a permission prompt or question for ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
1483
- '<div class="hint">Answered sooner (in the terminal or here), no notification. With <code>terminal-notifier</code> installed (<code>brew install terminal-notifier</code>), clicking the notification jumps to the session.</div>' +
1484
- '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>' +
1639
+ general: '<div class="section">Needs you</div>' +
1640
+ '<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
1641
+ '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
1485
1642
  '<div class="section">Appearance</div>' +
1486
- '<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>',
1643
+ '<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
1644
+ advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
1645
+ '<div class="hint">No activity this long, and the session gets a checkpoint.</div>' +
1646
+ '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
1647
+ '<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
1648
+ '<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
1487
1649
  digest: digestSettings(c),
1488
1650
  spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
1489
1651
  sync: syncSection(sync, stUi.data) || unavailable('Couldn’t read the sync status from Leftoff.'),
@@ -1496,21 +1658,22 @@ async function viewSettings() {
1496
1658
  '<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
1497
1659
  '<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
1498
1660
  '<div class="hint" data-row="key" id="f-key-hint"></div>' +
1499
- summariesSection(c),
1500
- phone: (anywhereSection(relay) ? anywhereSection(relay) + '<div class="section">Over Tailscale</div>' : '') + (remoteSection(remote) || unavailable('Couldn’t read remote access from Leftoff.')) +
1501
- (remote && remote.enabled ? '<div class="full hint set-link">Answering prompts from your phone is in <a href="#/settings/security">Privacy &amp; security</a>.</div>' : ''),
1502
- security: '<div class="section" id="answering">Answering from other devices</div>' + (answeringSection(remote, relay) ||
1503
- '<div class="full hint">Other devices reach this board only once remote access or Use from anywhere is on (<a href="#/settings/phone">Phone &amp; remote</a>). Until then nothing can answer from elsewhere.</div>') +
1504
- machinesSection(peers) +
1505
- '<div class="section">Your data</div>' +
1506
- '<div class="full hint">Everything Leftoff writes is plain Markdown and JSON in your Leftoff folder. API keys, machine keys and the board token stay on each Mac. <a href="' + SECURITY_URL + '" target="_blank" rel="noopener">The full security notes ↗</a></div>',
1507
- updates: '<div class="section">Updates</div>' +
1508
- '<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 newer version once a day. Nothing installs on its own.</span></label>' +
1509
- '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
1510
- '<div class="section">About</div>' +
1661
+ summariesSection(c) +
1662
+ advanced('models', lengthExampleHtml(c)),
1663
+ phone: phonePane(relay, remote, unavailable),
1664
+ security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
1665
+ '<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone &amp; remote</a> is on.</div>') +
1666
+ // a changed machine key needs a click here, so Other machines stays up top then
1667
+ (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
1668
+ advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
1669
+ '<div class="section">Your data</div>' +
1670
+ '<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>'),
1671
+ updates: '<div class="section">About</div>' +
1511
1672
  '<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>' +
1512
- '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
1513
- '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>',
1673
+ '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
1674
+ 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>' +
1675
+ '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
1676
+ '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
1514
1677
  }
1515
1678
  const r = state.route || parseRoute()
1516
1679
  $app.innerHTML = '<div class="settings no-sec" id="settingsWrap">' +
@@ -1537,6 +1700,14 @@ async function viewSettings() {
1537
1700
  document.getElementById('f-model').value = same ? (c.summarizer.model || '') : ''
1538
1701
  syncProviderFields()
1539
1702
  })
1703
+ // Opening or closing Advanced by hand sticks across redraws (the search's own opening doesn't).
1704
+ document.getElementById('settingsWrap').addEventListener('toggle', (ev) => {
1705
+ const d = ev.target
1706
+ if (!d.classList || !d.classList.contains('set-adv')) return
1707
+ if (d.dataset.auto && d.open) return
1708
+ delete d.dataset.auto
1709
+ settingsUi.adv[d.dataset.adv] = d.open
1710
+ }, true)
1540
1711
  const f = document.getElementById('setFilter')
1541
1712
  f.addEventListener('input', () => { settingsUi.q = f.value; settingsFilter() })
1542
1713
  f.addEventListener('keydown', (ev) => {
@@ -2488,7 +2659,7 @@ document.addEventListener('click', async (ev) => {
2488
2659
  const action = btn.dataset.action
2489
2660
  const card = btn.closest('[data-id]')
2490
2661
  const id = btn.dataset.id || (card && card.dataset.id)
2491
- const s = state.sessions.find((x) => x.id === id)
2662
+ const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
2492
2663
  try {
2493
2664
  if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
2494
2665
  if (action === 'gh-check') {
@@ -2527,8 +2698,11 @@ document.addEventListener('click', async (ev) => {
2527
2698
  if (action === 'relay-signup-cancel') { await api('POST', '/v1/relay/signup/cancel'); stopSignupPoll(); return render() }
2528
2699
  if (action === 'relay-pair') { const l = await api('POST', '/v1/relay/pair'); paintRelayPair(l); return }
2529
2700
  if (action === 'relay-revoke') {
2530
- if (!confirm('Revoke ' + btn.dataset.name + '? It stops working at once and needs pairing again.')) return
2701
+ const self = btn.dataset.self === '1'
2702
+ if (!confirm(self ? 'Revoke this browser? It stops working at once and needs pairing again.' : 'Revoke ' + btn.dataset.name + '? It stops working at once and needs pairing again.')) return
2531
2703
  const r = await api('POST', '/v1/relay/devices/' + encodeURIComponent(btn.dataset.device) + '/revoke')
2704
+ // Revoked itself (0.6.6): the same screen a revoked browser gets.
2705
+ if ((self || r.self) && window.leftoffRelay && window.leftoffRelay.unpaired) return window.leftoffRelay.unpaired()
2532
2706
  toast('Revoked ' + btn.dataset.name + (r.relay === 'done' ? '' : ' (the relay hears it once this Mac reconnects)')); return render()
2533
2707
  }
2534
2708
  if (action === 'relay-mac') { if (window.leftoffRelay && btn.dataset.mac !== window.leftoffRelay.mac) window.leftoffRelay.pick(btn.dataset.mac); return }
@@ -2572,7 +2746,7 @@ document.addEventListener('click', async (ev) => {
2572
2746
  if (action === 'list-help') { listUi.help = !listUi.help; return render() }
2573
2747
  if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
2574
2748
  if (action === 'remote-link' || action === 'remote-code') {
2575
- const l = await api('POST', '/v1/remote/link')
2749
+ const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
2576
2750
  if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
2577
2751
  paintSignin(l); return
2578
2752
  }
@@ -2741,6 +2915,9 @@ document.addEventListener('click', async (ev) => {
2741
2915
  return render()
2742
2916
  }
2743
2917
  if (action === 'go') return goClick(btn, id, s)
2918
+ if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
2919
+ 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 }
2920
+ if (action === 'closed-clear') { closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
2744
2921
  if (action && action.startsWith('st-')) return stAction(action, btn)
2745
2922
  if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
2746
2923
  if (action === 'resume') {
@@ -2958,7 +3135,7 @@ document.addEventListener('submit', async (ev) => {
2958
3135
  ...(() => {
2959
3136
  const el = document.getElementById('f-remote-answers')
2960
3137
  if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
2961
- if (el.checked && !confirm('Allow approving prompts from other devices on your tailnet? Anyone signed in to the board there could approve commands on this machine.')) { el.checked = false; return {} }
3138
+ if (el.checked && !confirm('Trust your paired devices? They could approve commands on this machine and pair new devices.')) { el.checked = false; return {} }
2962
3139
  return { remote: { allowAnswers: el.checked } }
2963
3140
  })(),
2964
3141
  })