@leftoff/cli 0.0.0-stage → 0.6.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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/CHANGELOG.md +564 -0
  3. package/LICENSE.md +9 -0
  4. package/README.md +28 -2
  5. package/assets/sample-transcript.jsonl +12 -0
  6. package/bin/checkpoints.js +7 -0
  7. package/bin/leftoff.js +585 -0
  8. package/hooks/README.md +22 -0
  9. package/hooks/settings-hooks.example.json +82 -0
  10. package/mod/.claude-plugin/plugin.json +15 -0
  11. package/mod/hooks/core.js +1397 -0
  12. package/mod/hooks/hooks.json +4 -0
  13. package/mod/hooks/register.js +1006 -0
  14. package/package.json +45 -4
  15. package/templates/agent-feed-worker/README.md +45 -0
  16. package/templates/agent-feed-worker/src/index.js +69 -0
  17. package/templates/agent-feed-worker/wrangler.toml +20 -0
  18. package/ui/app.css +1282 -0
  19. package/ui/app.js +2979 -0
  20. package/ui/apple-touch-icon.png +0 -0
  21. package/ui/changelog-md.js +64 -0
  22. package/ui/favicon-32.png +0 -0
  23. package/ui/favicon-badge-32.png +0 -0
  24. package/ui/favicon-badge.svg +1 -0
  25. package/ui/favicon.ico +0 -0
  26. package/ui/favicon.svg +1 -0
  27. package/ui/go.js +68 -0
  28. package/ui/icon-192.png +0 -0
  29. package/ui/icon-512.png +0 -0
  30. package/ui/icon-maskable-512.png +0 -0
  31. package/ui/icons.js +41 -0
  32. package/ui/index.html +55 -0
  33. package/ui/manifest.webmanifest +37 -0
  34. package/ui/needs.js +197 -0
  35. package/ui/offline.css +11 -0
  36. package/ui/offline.html +24 -0
  37. package/ui/pair.html +31 -0
  38. package/ui/picker.js +294 -0
  39. package/ui/relay-client.js +187 -0
  40. package/ui/relay-crypto.js +155 -0
  41. package/ui/relay-pair.js +48 -0
  42. package/ui/repos.js +167 -0
  43. package/ui/settings-nav.js +23 -0
  44. package/ui/summary-examples.js +66 -0
  45. package/ui/sw.js +34 -0
  46. package/ui/theme.js +41 -0
  47. package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
package/ui/app.js ADDED
@@ -0,0 +1,2979 @@
1
+ // Leftoff web UI: plain JS, no build step, no CDN. Talks to the daemon's
2
+ // /v1 API on the same origin (auth rides the cookie set at GET /).
3
+ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
+ import { icon } from './icons.js'
5
+ import { selectHtml, comboHtml, closeMenu } from './picker.js'
6
+ import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
+ import { repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
8
+ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer } from './needs.js'
9
+ import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
10
+ import { mdInline, renderSection } from './changelog-md.js'
11
+ const $app = document.getElementById('app')
12
+ const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true }
13
+ const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
14
+ const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
15
+
16
+ const ORDER = [
17
+ ['needs_input', 'Needs you'],
18
+ ['active', 'Active'],
19
+ ['background', 'Background'],
20
+ ['waiting', 'Waiting'],
21
+ ['dormant', 'Dormant'],
22
+ ['stale', 'Stale'],
23
+ ['ended', 'Ended today'],
24
+ ]
25
+ const LABEL = { needs_input: 'needs you', active: 'active', background: 'background', waiting: 'waiting', dormant: 'dormant', stale: 'stale', ended: 'ended' }
26
+
27
+ // ------------------------------------------------------------ helpers
28
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
29
+
30
+ // Spaces (v0.5.8): Work / Personal / …, one remembered choice for the board and the digest.
31
+ const SPACE_KEY = 'lo.space'
32
+ const SPACE_WHY = { session: 'set for this session', repo: 'from its repo', agent: 'set for this agent', machine: 'its Mac’s default', default: 'its Mac’s name; pick a space in Settings → Spaces' }
33
+ function sortSpacesUi(list) { return [...new Set(list.filter(Boolean))].sort((a, b) => (a === 'Work' ? -1 : b === 'Work' ? 1 : a.localeCompare(b, 'en', { sensitivity: 'base' }) || a.localeCompare(b))) }
34
+ function spaceChip(s, { force = false } = {}) {
35
+ if (!s || !s.space || (!force && !state.showSpaces)) return ''
36
+ return '<span class="chip space" title="' + esc('Space: ' + s.space + ' (' + (SPACE_WHY[s.spaceSource] || '') + ')') + '">' + esc(s.space) + '</span>'
37
+ }
38
+ function readSpace(params) {
39
+ const v = params && params.get ? params.get('space') : null
40
+ if (v !== null && v !== undefined) return v || null
41
+ try { return window.localStorage.getItem(SPACE_KEY) || null } catch { return null }
42
+ }
43
+ function saveSpace(v) { try { if (v) window.localStorage.setItem(SPACE_KEY, v); else window.localStorage.removeItem(SPACE_KEY) } catch {} }
44
+ // The tab row: All, then each space with its count. action: the data-action for a click.
45
+ function spaceTabs(spaces, sel, counts, action, total) {
46
+ if (spaces.length < 2 && !sel) return ''
47
+ const b = (v, label, n) => '<button type="button" class="stab' + ((sel || '') === v ? ' on' : '') + '" role="tab" aria-selected="' + ((sel || '') === v) + '" data-action="' + action + '" data-space="' + esc(v) + '"><span class="sl">' + esc(label) + '</span>' + (n != null ? '<span class="rc">' + n + '</span>' : '') + '</button>'
48
+ return '<div class="spacetabs" role="tablist" aria-label="Spaces"><div class="track">' + b('', 'All', total) + spaces.map((sp) => b(sp, sp, counts ? counts.get(sp) || 0 : null)).join('') + '</div></div>'
49
+ }
50
+
51
+ async function api(method, path, body, { timeoutMs } = {}) {
52
+ const res = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin', ...(timeoutMs && typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? { signal: AbortSignal.timeout(timeoutMs) } : {}) })
53
+ const j = await res.json().catch(() => ({}))
54
+ if (!res.ok) { const e = new Error((j.error && j.error.message) || 'HTTP ' + res.status); e.code = j.error && j.error.code; e.status = res.status; throw e }
55
+ return j
56
+ }
57
+
58
+ function ago(iso) {
59
+ if (!iso) return '—'
60
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000)
61
+ if (s < 45) return 'just now'
62
+ if (s < 3600) return Math.round(s / 60) + 'm ago'
63
+ if (s < 86400) return Math.round(s / 3600) + 'h ago'
64
+ return Math.round(s / 86400) + 'd ago'
65
+ }
66
+ function idle(sec) {
67
+ if (sec == null) return ''
68
+ if (sec < 60) return 'idle <1m'
69
+ if (sec < 3600) return 'idle ' + Math.round(sec / 60) + 'm'
70
+ if (sec < 86400) return 'idle ' + Math.round(sec / 3600) + 'h'
71
+ return 'idle ' + Math.round(sec / 86400) + 'd'
72
+ }
73
+ function clock(iso) { return iso ? new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—' }
74
+ function toast(msg, ms = 2600) {
75
+ const t = document.getElementById('toast')
76
+ t.textContent = msg
77
+ t.classList.add('show')
78
+ clearTimeout(toast.t)
79
+ toast.t = setTimeout(() => t.classList.remove('show'), ms)
80
+ }
81
+ async function copy(text) {
82
+ try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
83
+ }
84
+ const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
85
+
86
+ const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
87
+
88
+ // The sections an informative / long checkpoint adds below the three lines (v0.4.6).
89
+ const EXTRA_TITLES = [['decisions', 'Key decisions'], ['questions', 'Open questions'], ['files', 'Files touched'], ['didntWork', 'What didn’t work'], ['recap', 'Recap'], ['nextSteps', 'Next steps']]
90
+ function hasExtras(cp) { return !!cp && EXTRA_TITLES.some(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])) }
91
+ function cpExtras(cp) {
92
+ let h = ''
93
+ for (const [k, title] of EXTRA_TITLES) {
94
+ const v = cp[k]
95
+ if (!v || (Array.isArray(v) && !v.length)) continue
96
+ h += '<div class="cpx-sec cpx-' + k + '"><div class="cpx-h">' + esc(title) + '</div>'
97
+ if (k === 'recap') h += '<p>' + fmtCode(v) + '</p>'
98
+ else if (k === 'files') h += '<ul>' + v.map((f) => '<li>' + (f.path ? '<code>' + esc(f.path) + '</code>' : '') + (f.path && f.why ? ' — ' : '') + fmtCode(f.why || '') + '</li>').join('') + '</ul>'
99
+ else h += '<' + (k === 'nextSteps' ? 'ol' : 'ul') + '>' + v.map((x) => '<li>' + fmtCode(x) + '</li>').join('') + '</' + (k === 'nextSteps' ? 'ol' : 'ul') + '>'
100
+ h += '</div>'
101
+ }
102
+ return h
103
+ }
104
+
105
+ // more: show the informative / long sections, folded (open: unfolded).
106
+ function cpBlock(cp, { showWhen = true, more = false, open = false } = {}) {
107
+ if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
108
+ const you = /^YOU:/i.test(cp.next || '')
109
+ const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
110
+ return '<dl class="cp">' +
111
+ '<dt>Doing</dt><dd>' + fmtCode(cp.doing) + '</dd>' +
112
+ '<dt>Stopped</dt><dd>' + fmtCode(cp.stoppedAt) + '</dd>' +
113
+ '<dt>Next</dt><dd class="next' + (you ? ' you' : '') + '">' + fmtCode(cp.next) + '</dd>' +
114
+ (showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) : '') + '</div>' : '') +
115
+ '</dl>' +
116
+ (more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp) + '</div></details>' : '')
117
+ }
118
+
119
+ // External agents (v0.5.4): the chip a feed's sessions wear, like a machine's.
120
+ function agentChip(a, { title } = {}) {
121
+ if (!a) return ''
122
+ return '<span class="chip machine agent' + (a.ok === false ? ' stale' : '') + '" title="' + esc(title || ('external agent · ' + (a.pullsHere ? 'pulled by this Mac' : 'pulled by ' + (a.puller || 'another Mac')) + (a.lastSuccessAt ? ' · last pull ' + ago(a.lastSuccessAt) : ''))) + '">' + esc(a.icon || '◆') + ' ' + esc(a.display || a.name) + '</span>'
123
+ }
124
+
125
+ function where(s) {
126
+ if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
127
+ const branch = s.repo && s.repo.branch
128
+ const label = s.repoLabel || s.repoName || 'no repo'
129
+ 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>' +
130
+ (multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '')
131
+ }
132
+
133
+ // Notices above the board: things that need the user's hand, from /v1/health.
134
+ function banners() {
135
+ const h = state.health || {}
136
+ let out = ''
137
+ const wn = h.whatsNew || {}
138
+ if (wn.upgradeNotice) {
139
+ out += '<div class="banner info" role="status"><span class="grow"><b>Updated to v' + esc(h.version) + '</b>' + (wn.upgradedFrom && /^\d/.test(wn.upgradedFrom) ? ' from v' + esc(wn.upgradedFrom) : '') + '. See what changed.</span>' +
140
+ '<a class="btn primary" href="#/whats-new">What’s new</a><button type="button" class="btn x" data-action="dismiss-upgrade" aria-label="Dismiss" title="Dismiss">✕</button></div>'
141
+ }
142
+ const u = h.update || {}
143
+ if (u.available) {
144
+ const inc = (state.updates && state.updates.incoming) || []
145
+ const items = inc.slice(0, 2).flatMap((e) => e.sections.flatMap((s) => s.items.map((it) => ({ v: e.version, t: it })))).slice(0, 4)
146
+ out += '<div class="banner info update" role="status"><div class="grow"><b>Leftoff ' + (u.remoteVersion ? 'v' + esc(u.remoteVersion) : esc(u.remoteSha || 'update')) + ' is available</b> · you have v' + esc(h.version) +
147
+ (items.length ? '<ul class="incoming">' + items.map((x) => '<li>' + mdInline(firstSentence(x.t)) + '</li>').join('') + '</ul>' : '') + '</div>' +
148
+ '<code class="cmd">leftoff upgrade</code><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div>'
149
+ }
150
+ const mod = h.mod
151
+ if (mod && mod.failed && mod.failed.length) {
152
+ 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) : '') + ': ' +
153
+ mod.failed.map((f) => '<code>/' + esc(f.want) + '</code> (' + esc(f.error || 'unknown error') + ')').join(', ') + '. Run <code>leftoff doctor</code> for details.</div>'
154
+ }
155
+ return out
156
+ }
157
+
158
+ function updateLine() {
159
+ const h = state.health || {}
160
+ const u = h.update || {}
161
+ if (u.available) return 'v' + esc(u.remoteVersion || '?') + ' is available: run <code>leftoff upgrade</code>.'
162
+ if (u.error) return 'Last check couldn’t reach the repo (' + esc(u.error) + ').'
163
+ if (u.checkedAt) return 'Up to date (v' + esc(h.version) + '), checked ' + esc(ago(u.checkedAt)) + '.'
164
+ return 'Not checked yet.'
165
+ }
166
+
167
+ // **bold**, `code`, [text](url) and bare links (ui/changelog-md.js); everything else escaped.
168
+ const mdInlineMain = (s) => mdInline(s)
169
+ function firstSentence(s) { const m = String(s).match(/^(.+?[.!?])(\s|$)/); return m ? m[1] : s }
170
+
171
+ async function viewWhatsNew() {
172
+ const cl = await api('GET', '/v1/changelog')
173
+ const u = (state.health && state.health.update) || {}
174
+ let upd = null
175
+ if (u.available) { try { upd = await api('GET', '/v1/updates') } catch {} }
176
+ let html = '<div class="pagehead"><div><h1>What’s new</h1><div class="sub">You’re on v' + esc(cl.current) + '. New since you last looked are marked.</div></div>' +
177
+ '<div class="actions"><button type="button" class="btn" data-action="check-updates">Check for updates</button></div></div>'
178
+ if (upd && upd.available && upd.incoming && upd.incoming.length) {
179
+ html += '<h2>Coming in the next update</h2><div class="wn">' + upd.incoming.map((e) => wnCard(e, { coming: true })).join('') + '</div>' +
180
+ '<div class="banner info mt"><span class="grow">Install it with <code>leftoff upgrade</code>. It updates the CLI and the mod and restarts the daemon.</span><button type="button" class="btn" data-action="copy-cmd" data-cmd="leftoff upgrade">Copy</button></div><h2>Installed</h2>'
181
+ }
182
+ html += '<div class="wn">' + cl.entries.map((e) => wnCard(e, { current: e.version === cl.current })).join('') + '</div>'
183
+ if (!cl.entries.length) html += '<div class="empty">No changelog in this install.</div>'
184
+ $app.innerHTML = html
185
+ if (cl.entries.some((e) => e.isNew) || cl.upgradeNotice) {
186
+ try { await api('POST', '/v1/changelog/seen', { version: cl.current }); refreshHealth() } catch {}
187
+ }
188
+ }
189
+
190
+ function wnCard(e, { current = false, coming = false } = {}) {
191
+ return '<article class="wn-card' + (e.isNew ? ' is-new' : '') + (coming ? ' coming' : '') + '">' +
192
+ '<header><span class="wn-ver">v' + esc(e.version) + '</span>' + (e.date ? '<span class="wn-date">' + esc(e.date) + '</span>' : '') +
193
+ (e.isNew ? '<span class="pill new">New</span>' : '') + (current ? '<span class="pill here">Installed</span>' : '') + (coming ? '<span class="pill soon">Available</span>' : '') + '</header>' +
194
+ e.sections.map(renderSection).join('') +
195
+ '</article>'
196
+ }
197
+
198
+ // Open or closed, for real (v0.4): the daemon checks Claude Code's pid on this
199
+ // machine every 30 s; other machines' sessions go by their heartbeat.
200
+ function liveDot(s) {
201
+ const term = s.terminal ? ' in ' + s.terminal.label : ''
202
+ let cls = 'unknown', tip = 'Open or closed: unknown (hooks-only capture records no process)'
203
+ if (s.live === 'open') {
204
+ cls = 'open'
205
+ tip = s.liveVia === 'pid' ? 'Open · Claude Code is running' + term + (s.tty ? ' (' + s.tty.replace('/dev/', '') + ')' : '') : 'Open · heartbeat from ' + s.machine + ' (can\'t check its process from here)'
206
+ } else if (s.live === 'closed') {
207
+ cls = 'closed'
208
+ tip = 'Closed ' + ago(s.closedAt) + (s.liveVia === 'pid' ? ' · its Claude Code process exited' : s.liveVia === 'heartbeat' ? ' · no heartbeat' : '')
209
+ }
210
+ return '<span class="live ' + cls + '" title="' + esc(tip) + '" aria-label="' + esc(tip) + '"></span>'
211
+ }
212
+ function liveNote(s) {
213
+ if (s.live === 'closed') return 'closed ' + ago(s.closedAt)
214
+ if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
215
+ return s.live === 'open' ? 'open' : ''
216
+ }
217
+ function goButton(s) {
218
+ if (s.live === 'closed') return ''
219
+ const j = s.jump || {}
220
+ // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
221
+ if (!s.isLocal) {
222
+ if (!j.peer) return ''
223
+ const label = 'Go on ' + s.machine + ' ↗'
224
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go only works from a Mac’s own browser">' + esc(label) + '</button>'
225
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
226
+ const st = goState.get(s.id)
227
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
228
+ }
229
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
230
+ if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
231
+ const st = goState.get(s.id)
232
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" title="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
233
+ }
234
+
235
+ // The short reason a cross-machine button is grey (the full one is its tooltip).
236
+ function peerWhy(j) {
237
+ const c = j.code
238
+ return c === 'not_allowed' ? 'not allowed on ' + j.peer : c === 'unreachable' ? j.peer + ' isn’t reachable' : c === 'key_changed' ? j.peer + '’s key changed' : c === 'no_key' ? j.peer + ' needs Leftoff 0.5.2' : c === 'checking' ? 'checking ' + j.peer + '…' : c === 'no_route' ? 'no route to ' + j.peer : c === 'off' ? 'off here' : (j.reason || '').replace(/^runs on [^:]+: /, '')
239
+ }
240
+
241
+ const AGENT_STATUS = { active: 'working on it', waiting: 'waiting', done: 'done' }
242
+ const plain = (t) => '<span class="plain">' + esc(t) + '</span>'
243
+ function agentCpBlock(cp) {
244
+ if (!cp) return '<div class="nocp">No checkpoint in the feed yet.</div>'
245
+ return '<dl class="cp">' + '<dt>Latest</dt><dd>' + plain(cp.summary) + '</dd>' + (cp.done ? '<dt>Done</dt><dd>' + plain(cp.done) + '</dd>' : '') +
246
+ (cp.next ? '<dt>Next</dt><dd class="next">' + plain(cp.next) + '</dd>' : '') + '<dt>When</dt><dd class="faint">' + esc(ago(cp.createdAt)) + '</dd></dl>'
247
+ }
248
+ function agentListChips(s) {
249
+ return (s.lists || []).map((l) => '<a class="chip listchip' + (l.done === l.total ? ' done' : '') + '" href="' + listHref(l.ref) + '" title="' + esc(l.name) + ' (read-only)">' + icon('list-checks') + esc(l.progress) + ' <span class="ln">' + esc(l.name) + '</span></a>').join('')
250
+ }
251
+ function agentCard(s) {
252
+ const a = s.agent || {}
253
+ const n = s.needsYou
254
+ const href = '#/s/' + encodeURIComponent(s.id)
255
+ return '<article class="card agentcard ' + esc(s.status) + '" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
256
+ '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled') + '</a></div>' + pill(s.status) + '</div>' +
257
+ '<div class="meta">' + spaceChip(s) + where(s) + '<span>' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + '</span><span>·</span><span>updated ' + esc(ago(s.lastActivityAt)) + '</span></div>' +
258
+ agentListChips(s) + (n ? cardBadge(n) : '') + agentCpBlock(s.latestCheckpoint) +
259
+ '<a class="cplink" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session') + ' →</a>' +
260
+ '<div class="actions-row">' + (n ? agentReplyButton(a, 'btn') : '') +
261
+ '<span class="ro">read-only · from ' + esc(a.display || a.name) + '’s feed</span></div></article>'
262
+ }
263
+
264
+ function card(s) {
265
+ if (s.agent) return agentCard(s)
266
+ const endedNote = s.live === 'closed' ? '' : s.status === 'ended' ? 'ended ' + ago(s.endedAt) : idle(s.idleSec)
267
+ const ln = liveNote(s)
268
+ const nb = s.needsYou ? cardBadge(s.needsYou) : ''
269
+ return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
270
+ '<div class="row1"><div class="title">' + liveDot(s) +
271
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
272
+ '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
273
+ '</div>' + pill(s.status) + '</div>' +
274
+ '<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
275
+ (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
276
+ listChips(s) + nb + cpBlock(s.latestCheckpoint) +
277
+ '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
278
+ '<div class="actions-row">' + goButton(s) +
279
+ '<button type="button" class="btn" data-action="resume">Copy resume</button>' +
280
+ (s.isLocal ? '<button type="button" class="btn" data-action="checkpoint">Checkpoint now</button>' +
281
+ '<button type="button" class="btn" data-action="pin">' + (s.pinned ? 'Unpin' : 'Pin') + '</button>' +
282
+ '<button type="button" class="btn" data-action="archive">Archive</button>' : '<span class="ro">' + (s.peer && s.peer.ok ? 'lives on ' + esc(s.machine) + ' · answering and Go work through it' : 'read-only here · lives on ' + esc(s.machine)) + '</span>') +
283
+ '</div></article>'
284
+ }
285
+
286
+ // ------------------------------------------------------------ views
287
+
288
+ const FILTERS = {
289
+ needs: { label: 'Needs you', cls: 's-needs_input', statuses: ['needs_input'] },
290
+ active: { label: 'Active', cls: 's-active', statuses: ['active', 'background'] },
291
+ waiting: { label: 'Waiting', cls: 's-waiting', statuses: ['waiting'] },
292
+ dormant: { label: 'Dormant', cls: 's-dormant', statuses: ['dormant', 'stale'] },
293
+ ended: { label: 'Ended today', cls: 's-ended', statuses: ['ended'] },
294
+ }
295
+
296
+ // The board's filter: hash first, then the last repo choice from this browser.
297
+ function boardFilter() {
298
+ const params = (state.route && state.route.params) || new URLSearchParams()
299
+ const f = readFilter(params, window.localStorage)
300
+ // A bare #/ restored from localStorage: put it in the address bar so the
301
+ // view is linkable as it stands.
302
+ if (f.from === 'storage') history.replaceState(null, '', hashFor(f))
303
+ state.boardFilter = f
304
+ return f
305
+ }
306
+ function setBoardFilter(next) {
307
+ const f = { repos: next.repos || [], status: next.status || null }
308
+ saveRepos(window.localStorage, f.repos)
309
+ history.replaceState(null, '', hashFor(f))
310
+ state.route = parseRoute()
311
+ return render()
312
+ }
313
+ // The board's filter link, or the same on the Needs you page (#/waiting?repo=…).
314
+ function hashFor(f) {
315
+ let h = filterHash(f)
316
+ if (state.boardSpace) h += (h.includes('?') ? '&' : '?') + 'space=' + encodeURIComponent(state.boardSpace)
317
+ return state.route && state.route.name === 'waiting' ? h.replace(/^#\//, '#/waiting') : h
318
+ }
319
+
320
+ // The repo chips under the stats: All, then repos by most recent activity,
321
+ // the top 8 in the row and the rest behind More ▾ with a filter box.
322
+ function repoRow(shown, f) {
323
+ const statusOnly = applyFilter(shown, { status: f.status })
324
+ return chipBar(repoChips(shown, { countIn: statusOnly }), f.repos, statusOnly.length, 'no sessions today')
325
+ }
326
+
327
+ // The repo chip row, shared by the board and the timeline: All, the top 8,
328
+ // and More ▾ with a filter box. chips: repoChips() output.
329
+ function chipBar(chips, selected, allCount, emptyNote) {
330
+ if (chips.length < 2 && !selected.length) return ''
331
+ // A repo named in the link but with nothing here still gets a chip, so it can be cleared.
332
+ 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 })
333
+ const { shown: row, more } = splitChips(chips, selected, 8)
334
+ 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>'
335
+ 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>'
336
+ let menu = ''
337
+ if (more.length) {
338
+ menu = '<details class="repomore"' + (state.moreOpen ? ' open' : '') + '><summary class="rchip">More ▾ <span class="rc">' + more.length + '</span></summary><div class="menu" role="menu">' +
339
+ '<input type="search" class="morefilter" placeholder="Filter repos…" aria-label="Filter repos" value="' + esc(state.moreQuery || '') + '">' +
340
+ '<div class="opts">' + more.map((c) => '<button type="button" class="opt' + (chipSelected(selected, c) ? ' on' : '') + '" data-action="repo" data-repo="' + esc(c.label) + '" data-key="' + esc(c.key) + '" data-search="' + esc((c.label + ' ' + c.key).toLowerCase()) + '" title="' + esc(c.key) + '"' + (state.moreQuery && !(c.label + ' ' + c.key).toLowerCase().includes(state.moreQuery.toLowerCase()) ? ' hidden' : '') + '><span class="rl">' + esc(c.label) + '</span><span class="rc">' + c.count + '</span></button>').join('') + '</div></div></details>'
341
+ }
342
+ return '<div class="repos" role="group" aria-label="Filter by repo"><div class="track">' + all + row.map(chip).join('') + '</div>' + menu + '</div>'
343
+ }
344
+
345
+ async function viewBoard() {
346
+ if (!state.health) await refreshHealth()
347
+ if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
348
+ const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
349
+ state.sessions = sessions
350
+ state.viewerLocal = viewer ? viewer.local !== false : true
351
+ state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
352
+ try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
353
+ const today = new Date(); today.setHours(0, 0, 0, 0)
354
+ const recent = sessions.filter((s) => !(s.status === 'ended' && Date.parse(s.endedAt || 0) < today.getTime()))
355
+ // v0.4.8: sessions with nothing in them yet (no prompt, name or checkpoint,
356
+ // not a resumed conversation) fold into one "N empty sessions" row. One
357
+ // appears on the board as itself after its first prompt. Pinned ones, and
358
+ // ones that need you, always show.
359
+ const isFolded = (s) => s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking)
360
+ const shown = recent.filter((s) => !isFolded(s))
361
+ const empties = recent.filter(isFolded)
362
+ const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
363
+ // grouped Needs you → Active → Background → Waiting → Dormant → Stale → Ended today; pinned first within a group
364
+ shown.sort((a, b) => (rank[a.status] - rank[b.status]) || (b.pinned - a.pinned) || (Date.parse(b.lastActivityAt || 0) - Date.parse(a.lastActivityAt || 0)))
365
+ // Status AND repo (v0.4.2). The repo choice lives in the hash (linkable)
366
+ // and in localStorage (survives a bare #/); the status choice in the hash.
367
+ const f = boardFilter()
368
+ const filter = f.status
369
+ // Spaces (v0.5.8): All / Work / Personal …, ahead of status and repo.
370
+ const spaces = sortSpacesUi(recent.map((s) => s.space))
371
+ state.showSpaces = spaces.length > 1
372
+ let sp = readSpace((state.route && state.route.params) || new URLSearchParams())
373
+ if (sp && !spaces.includes(sp)) sp = null
374
+ state.boardSpace = sp
375
+ const spCounts = new Map(spaces.map((x) => [x, shown.filter((s) => s.space === x).length]))
376
+ const allShown = shown
377
+ const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
378
+ const inRepos = applyFilter(inSpace, { repos: f.repos })
379
+ const count = (k) => inRepos.filter((s) => FILTERS[k].statuses.includes(s.status)).length
380
+ const list = applyFilter(inSpace, f)
381
+ 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>' +
382
+ '<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
383
+ options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
384
+ '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
385
+ '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
386
+ html += restoreBanner()
387
+ // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
388
+ const needs = needsItems(sessions)
389
+ html += needsStrip(needs, needOpts())
390
+ html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
391
+ '<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>'
392
+ html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
393
+ html += repoRow(inSpace, f)
394
+ if (!shown.length && !empties.length) {
395
+ 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>'
396
+ } else if (!list.length && shown.length) {
397
+ const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
398
+ html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
399
+ ' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
400
+ } else if (list.length) {
401
+ html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
402
+ }
403
+ html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
404
+ $app.innerHTML = banners() + html
405
+ }
406
+
407
+ // The folded row of empty sessions (v0.4.8), closed by default.
408
+ function emptyFold(list) {
409
+ if (!list.length) return ''
410
+ const n = list.length
411
+ return '<details class="emptyfold mt" id="emptyFold"' + (state.emptyOpen ? ' open' : '') + '>' +
412
+ '<summary><span class="ef-n">' + n + '</span> ' + (n === 1 ? 'empty session' : 'empty sessions') +
413
+ '<span class="ef-hint">open, no prompt yet · each shows up here as itself after its first prompt</span></summary>' +
414
+ '<div class="grid">' + list.map(card).join('') + '</div></details>'
415
+ }
416
+
417
+ // Restore (v0.4.7): the sessions that were open before this machine restarted,
418
+ // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
419
+ // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
420
+ const restoreUi = { open: false, off: new Set(), result: null }
421
+ function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
422
+ function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
423
+ function restoreBanner() {
424
+ const r = state.restore
425
+ if (!r || !r.sessions || !r.sessions.length) return restoreUi.result ? restoreResult() : ''
426
+ try { if (localStorage.getItem(restoreDismissKey(r)) === '1' && !restoreUi.open) return '' } catch {}
427
+ const n = r.sessions.length
428
+ const what = r.restartKind === 'boot' ? (r.ghostty ? 'your Mac' : 'this machine') + ' restarted' : 'Leftoff restarted'
429
+ const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
430
+ const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open before ' + what + '</b>' + esc(when)
431
+ if (!restoreUi.open) {
432
+ return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + head + '. Put them back, each in its own Ghostty tab.</span>' +
433
+ '<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
434
+ '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
435
+ }
436
+ const local = state.viewerLocal !== false
437
+ const picked = restorePicked().length
438
+ 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') + '>' +
439
+ '<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('')
440
+ 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>' +
441
+ '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
442
+ '<div class="rs-list">' + rows + '</div>' +
443
+ '<div class="rs-actions">' +
444
+ (local ? '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo"' + (picked ? '' : ' disabled') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>'
445
+ : '<button type="button" class="btn primary" disabled title="Restore opens Ghostty tabs on the Mac running Leftoff, so it only works from that Mac’s own browser (localhost)">Restore ' + picked + '</button>') +
446
+ '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
447
+ '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
448
+ (local ? '' : '<span class="rs-note">This opens tabs on the Mac, so only its own browser can do it (or run <code>leftoff restore</code> there).</span>') +
449
+ '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
450
+ }
451
+ function restoreResult() {
452
+ const x = restoreUi.result
453
+ if (!x) return ''
454
+ if (x.how === 'print') {
455
+ return '<div class="banner warn rs-result" role="alert"><div class="grow"><b>Couldn’t open Ghostty tabs</b>: ' + esc(x.message) + '. Run these yourself, one per tab:' +
456
+ '<pre class="rs-cmds">' + esc(x.sessions.map((s) => s.command).join('\n')) + '</pre></div><button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(x.sessions.map((s) => s.command).join('\n')) + '">Copy</button></div>'
457
+ }
458
+ const bad = x.sessions.filter((s) => !s.ok)
459
+ if (!bad.length) return ''
460
+ return '<div class="banner warn rs-result" role="alert"><div class="grow"><b>' + bad.length + ' didn’t open</b>:<pre class="rs-cmds">' + esc(bad.map((s) => s.command + ' # ' + (s.error || '')).join('\n')) + '</pre></div></div>'
461
+ }
462
+
463
+ // #/waiting (v0.4.4): everything waiting on you, longest wait first, the board's
464
+ // repo chips, and what happened to recent requests while you were away.
465
+ async function viewWaiting() {
466
+ const [{ sessions, viewer }, needs] = await Promise.all([api('GET', '/v1/sessions?limit=200'), api('GET', '/v1/needs?recent=20')])
467
+ state.sessions = sessions
468
+ state.viewerLocal = viewer ? viewer.local !== false : true
469
+ state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
470
+ const all = needsItems(sessions)
471
+ const f = boardFilter()
472
+ const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
473
+ const items = all.filter((n) => shownSessions.includes(n.session))
474
+ let html = '<div class="pagehead"><div><h1>Needs you</h1><div class="sub">Permission prompts and questions waiting on you, longest wait first. Answer here, or Go to the terminal.' +
475
+ (state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
476
+ html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
477
+ if (!items.length) html += '<div class="empty mt"><p><strong>Nothing waiting on you.</strong></p><p>When a session asks for permission or asks you a question, it shows up here and at the top of the board.' + (f.repos.length && all.length ? ' (' + all.length + ' in other repos. <button type="button" class="linkish" data-action="clear-filters">Show all</button>)' : '') + '</p></div>'
478
+ else html += '<section class="needs-strip quiet mt" aria-label="Waiting"><div class="needs-list">' + items.map((n) => needPanel(n, needOpts())).join('') + '</div></section>'
479
+ const recent = needs.recent || []
480
+ html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
481
+ (recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
482
+ (r.session.repoLabel ? '<span class="chip mono">' + esc(r.session.repoLabel) + '</span>' : '') +
483
+ '<span class="out-' + esc(r.state) + '">' + esc(r.outcome) + '</span><span class="what">' + esc(String(r.what || '').slice(0, 200)) + '</span>' +
484
+ (r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
485
+ : '<div class="nocp">Nothing yet since the daemon started.</div>') +
486
+ '<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
487
+ $app.innerHTML = banners() + html
488
+ }
489
+
490
+ // The nav's count, the page title and the favicon dot: visible from a background tab.
491
+ function paintNeedsCount(n) {
492
+ const c = document.getElementById('needsCount')
493
+ if (c) { c.hidden = !n; c.textContent = n > 99 ? '99+' : String(n) }
494
+ const tab = document.getElementById('needsTab')
495
+ if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
496
+ document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
497
+ // The tab icon gets a red dot while something waits (leftoff-favicon-badge,
498
+ // v0.4.5); the PNG is for browsers without SVG favicons.
499
+ const fav = document.getElementById('favicon')
500
+ const png = document.getElementById('favicon-png')
501
+ if (fav && paintNeedsCount.last !== !!n) {
502
+ paintNeedsCount.last = !!n
503
+ fav.href = n ? 'favicon-badge.svg' : 'favicon.svg'
504
+ if (png) png.href = n ? 'favicon-badge-32.png' : 'favicon-32.png'
505
+ }
506
+ }
507
+
508
+ const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
509
+ function needOpts(extra = {}) {
510
+ return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
511
+ }
512
+
513
+ // ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
514
+ // typed answer, the Reply box) presses that box's button, the same click as the
515
+ // mouse: same request, same rate limit and Origin check. Plain Enter still types
516
+ // a newline in the Reply box. Held keys repeat; only the first press counts, and
517
+ // a second press within 1.5 s of a sent one is ignored (v0.4.6).
518
+ document.addEventListener('keydown', (ev) => {
519
+ if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
520
+ const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
521
+ const need = box && box.closest('.need')
522
+ if (!need) return
523
+ ev.preventDefault()
524
+ if (ev.repeat || ev.isComposing) return
525
+ const btn = need.querySelector('[data-submit-for="' + box.dataset.submit + '"]')
526
+ if (!btn || btn.disabled) return
527
+ const last = Number(need.dataset.kbdAt || 0)
528
+ if (Date.now() - last < 1500) return
529
+ need.dataset.kbdAt = String(Date.now())
530
+ btn.click()
531
+ })
532
+ // Settings → Spaces: Enter in the repo field adds the override instead of saving Settings
533
+ // (the suggestion list takes Enter first while one is highlighted).
534
+ document.addEventListener('keydown', (ev) => {
535
+ if (ev.key !== 'Enter' || ev.defaultPrevented || !(ev.target && ev.target.closest && ev.target.closest('#f-space-repo'))) return
536
+ ev.preventDefault()
537
+ const b = document.querySelector('[data-action="space-repo-add"]')
538
+ if (b && !b.disabled) b.click()
539
+ })
540
+ // Settings → Agents: Enter in the add form adds the feed instead of saving Settings.
541
+ document.addEventListener('keydown', (ev) => {
542
+ if (ev.key !== 'Enter' || !(ev.target && ev.target.matches && ev.target.matches('[data-agent-field]'))) return
543
+ ev.preventDefault()
544
+ const b = document.querySelector('[data-action="agent-add"]')
545
+ if (b && !b.disabled) b.click()
546
+ })
547
+ function answeredList(s) {
548
+ const list = (s.answers || []).slice().reverse()
549
+ if (!list.length) return ''
550
+ return '<h2>Answered from the board <span class="count">' + list.length + '</span></h2><ul class="answered">' + list.map((a) =>
551
+ '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
552
+ '<span class="where">' + esc(a.where || '') + (a.delivered === false ? ' · not picked up' : '') + '</span></li>').join('') + '</ul>' +
553
+ '<div class="hint">Also in answers.md in this session’s folder.</div>'
554
+ }
555
+
556
+ // External agents (v0.5.4): a feed session's page. Everything is the feed's
557
+ // text, escaped; links are https only (the daemon drops anything else).
558
+ async function viewAgentSession(s) {
559
+ const a = s.agent || {}
560
+ const home = httpsOnly(a.url)
561
+ const name = esc(a.display || a.name)
562
+ const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
563
+ let html = '<a class="back" href="#/">← All sessions</a>'
564
+ html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
565
+ '<div class="actions">' + agentReplyButton(a) + '</div></div>'
566
+ // v0.5.6: agent.url only links the name; the reply affordance and its
567
+ // words come from the feed (agent.reply, agent.replyHint), or nothing.
568
+ html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
569
+ if (s.needsYou) html += '<h2>Needs you</h2><div class="needs">' + agentNeedPanel(s.needsYou, s) + '</div>'
570
+ const lists = s.lists || []
571
+ if (lists.length) {
572
+ let full = []
573
+ try { full = (await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists')).lists || [] } catch {}
574
+ html += '<h2>Lists <span class="count">' + full.length + '</span></h2><div class="lists">' + full.map((l) => listCard(l, { session: false })).join('') + '</div>'
575
+ }
576
+ const links = (s.links || []).filter((k) => httpsOnly(k.url))
577
+ if (links.length) html += '<h2>Links</h2><ul class="agent-links">' + links.map((k) => '<li><a href="' + esc(httpsOnly(k.url)) + '" target="_blank" rel="noopener noreferrer">' + esc(k.label) + ' ↗</a> <span class="faint mono">' + esc(new URL(k.url).host) + '</span></li>').join('') + '</ul>'
578
+ const cps = s.checkpoints || []
579
+ html += '<h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
580
+ html += cps.length ? '<div class="agent-cps">' + cps.map((c) => '<article class="cpcard"><div class="cphead"><span class="mono faint">' + esc(clock(c.createdAt)) + '</span></div>' + agentCpBlock(c) + '</article>').join('') + '</div>' : '<div class="nocp">No checkpoints in the feed yet.</div>'
581
+ html += '<div class="facts">' + fact('Agent', (a.display || a.name) + ' (feed “' + a.name + '”)') + (s.project ? fact('Project', s.project) : '') + fact('Started', clock(s.startedAt)) + fact('Updated', clock(s.lastActivityAt)) + (s.file ? fact('File', s.file) : '') + '</div>'
582
+ html += spacePicker(s, await api('GET', '/v1/spaces').catch(() => null), 'agent', a.name)
583
+ $app.innerHTML = banners() + html
584
+ }
585
+
586
+ async function viewSession(id) {
587
+ if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
588
+ const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
589
+ state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
590
+ const branch = s.repo && s.repo.branch
591
+ const spv = await api('GET', '/v1/spaces').catch(() => null)
592
+ let html = '<a class="back" href="#/">← All sessions</a>'
593
+ 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>' +
594
+ '<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>'
595
+ html += '<div class="facts">' +
596
+ 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)) +
597
+ 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) +
598
+ fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
599
+ fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
600
+ html += spacePicker(s, spv, 'session', s.id)
601
+ if (s.needsYou) html += '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>'
602
+ if (s.statusChanges && s.statusChanges.length) {
603
+ html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
604
+ }
605
+ // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
606
+ if (s.lists && s.lists.length) {
607
+ const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
608
+ state.listFull = {}
609
+ for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
610
+ }
611
+ state.listsMayEdit = state.mayAnswer !== false
612
+ html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
613
+ html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
614
+ const newestCp = s.checkpoints.reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
615
+ if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
616
+ else html += '<div class="timeline">' + s.checkpoints.map((cp) =>
617
+ '<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>' +
618
+ (cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + '</span>' : '') + '</div>' +
619
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
620
+ (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
621
+ (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
622
+ '</div>').join('') + '</div>'
623
+ html += answeredList(s)
624
+ $app.innerHTML = html
625
+ }
626
+ // Space of one session (or, on an agent's page, of that agent's feed): its
627
+ // own override, or back to the repo / Mac default (v0.5.8).
628
+ function spacePicker(s, spv, kind, key) {
629
+ if (!spv) return ''
630
+ const own = kind === 'session' ? s.spaceSource === 'session' : s.spaceSource === 'agent'
631
+ const opts = sortSpacesUi([...(spv.spaces || []), s.space])
632
+ return '<div class="spacepick"><label for="f-space-one">Space</label>' +
633
+ selectHtml({ id: 'f-space-one', value: own ? s.space : '', attrs: { 'data-space-kind': kind, 'data-space-key': key },
634
+ options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
635
+ '<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>'
636
+ }
637
+ function fact(k, v) { return '<div class="fact"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
638
+
639
+ // Timeline (0.4.3): the board's repo chips (same remembered pick), a session
640
+ // filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
641
+ const TRIGGER_FILTERS = { manual: { label: 'Manual', test: (t) => t === 'manual' }, auto: { label: 'Automatic', test: (t) => t !== 'manual' && t !== 'session_end' }, closed: { label: 'Closed', test: (t) => t === 'session_end' } }
642
+ function timelineHash({ repos = [], session = null, trigger = null } = {}) {
643
+ const h = filterHash({ repos }).replace(/^#\//, '')
644
+ const parts = h ? [h.replace(/^\?/, '')] : []
645
+ if (session) parts.push('session=' + encodeURIComponent(session))
646
+ if (trigger) parts.push('trigger=' + encodeURIComponent(trigger))
647
+ return '#/timeline' + (parts.length ? '?' + parts.join('&') : '')
648
+ }
649
+ async function viewTimeline() {
650
+ const params = (state.route && state.route.params) || new URLSearchParams()
651
+ const f = readFilter(params, window.localStorage)
652
+ const session = params.get('session') || null
653
+ const trigger = TRIGGER_FILTERS[params.get('trigger')] ? params.get('trigger') : null
654
+ if (f.from === 'storage') history.replaceState(null, '', timelineHash({ repos: f.repos, session, trigger }))
655
+ state.tlFilter = { repos: f.repos, session, trigger }
656
+ const [{ checkpoints }, { sessions }] = await Promise.all([api('GET', '/v1/checkpoints?limit=1000'), api('GET', '/v1/sessions?limit=500&archived=1')])
657
+ const byId = new Map(sessions.map((x) => [x.id, x]))
658
+ // each checkpoint gets its session's repo, so it filters like the board
659
+ const items = checkpoints.map((cp) => { const se = byId.get(cp.sessionId) || {}; return { cp, id: cp.sessionId, repoKey: se.repoKey || cp.repo || 'no-repo', repoLabel: se.repoLabel || cp.repo || 'no repo', repoName: cp.repo, lastActivityAt: cp.createdAt, title: cp.sessionTitle || se.title } })
660
+ const trig = (x) => !trigger || TRIGGER_FILTERS[trigger].test(x.cp.trigger)
661
+ const inRepos = applyFilter(items, { repos: f.repos })
662
+ const list = inRepos.filter((x) => (!session || x.id === session) && trig(x))
663
+ const chips = repoChips(items, { countIn: items.filter((x) => (!session || x.id === session) && trig(x)) })
664
+ // sessions to pick from: those with checkpoints in the chosen repos
665
+ const sessOpts = []
666
+ for (const x of inRepos) if (!sessOpts.some((o) => o.id === x.id)) sessOpts.push({ id: x.id, title: x.title || 'Untitled', repo: x.repoLabel })
667
+ const cur = session ? (byId.get(session) || {}).title || (items.find((x) => x.id === session) || {}).title || session.slice(0, 8) : null
668
+ let html = '<div class="pagehead"><div><h1>Timeline</h1><div class="sub">Every checkpoint across sessions, newest first. Click a session’s name to see only its checkpoints.</div></div>' +
669
+ (session ? '<div class="actions"><a class="btn" href="#/s/' + encodeURIComponent(session) + '">Open session →</a></div>' : '') + '</div>'
670
+ html += chipBar(chips, f.repos, items.filter((x) => (!session || x.id === session) && trig(x)).length, 'no checkpoints')
671
+ html += '<div class="tlbar">' +
672
+ '<div class="tlsel">' + selectHtml({ id: 'tlSession', label: 'Session', value: session || '',
673
+ options: [['', 'All sessions (' + sessOpts.length + ')'], ...sessOpts.map((o) => ({ value: o.id, label: o.title, sub: f.repos.length === 1 ? '' : o.repo }))] }) + '</div>' +
674
+ (session ? '<button type="button" class="pillx" data-action="tl-session" data-session="" title="Show every session">× session: ' + esc(cur) + '</button>' : '') +
675
+ '<div class="seg" role="group" aria-label="Kind of checkpoint">' + [['', 'All']].concat(Object.entries(TRIGGER_FILTERS).map(([k, v]) => [k, v.label])).map(([k, l]) => '<button type="button" data-action="tl-trigger" data-trigger="' + k + '" aria-pressed="' + ((trigger || '') === k) + '">' + l + '</button>').join('') + '</div></div>'
676
+ if (!checkpoints.length) html += '<div class="empty mt">No checkpoints yet.</div>'
677
+ else if (!list.length) html += '<div class="empty mt">No checkpoints match. <button type="button" class="linkish" data-action="tl-clear">Show everything</button></div>'
678
+ else html += '<div class="timeline mt">' + list.slice(0, 300).map(({ cp, repoLabel, repoKey }) =>
679
+ '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="' + esc(timelineHash({ repos: f.repos, session: cp.sessionId, trigger })) + '" title="Only this session’s checkpoints">' + esc(cp.sessionTitle || 'Untitled') + '</a>' +
680
+ '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(repoLabel) + '" data-key="' + esc(repoKey) + '" title="' + esc(repoKey + ' · show only this repo') + '">' + esc(repoLabel) + (cp.branch ? ' · ' + esc(cp.branch) : '') + '</button><span class="trig">' + esc(cp.trigger) + '</span></div>' + cpBlock(cp, { showWhen: false, more: true }) + '</div>').join('') + '</div>'
681
+ $app.innerHTML = html
682
+ }
683
+
684
+ function renderMd(md, { spaces = [] } = {}) {
685
+ const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
686
+ const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
687
+ const out = []
688
+ let inList = false, inSec = false
689
+ // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
690
+ // other "## " headings (Lists, Top of mind) are whole-folder sections.
691
+ const openSec = (h) => {
692
+ if (inSec) out.push('</section>')
693
+ inSec = true
694
+ if (spaces.includes(h)) {
695
+ out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
696
+ '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
697
+ } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
698
+ }
699
+ for (const line of body.split('\n')) {
700
+ if (/^- /.test(line)) {
701
+ if (!inList) { out.push('<ul>'); inList = true }
702
+ // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
703
+ const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
704
+ // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
705
+ const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
706
+ out.push('<li' + (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + '>' + li); continue
707
+ }
708
+ if (/^\s{2,}\S/.test(line) && inList) {
709
+ // "Stopped at: … Next: …" is two lines, each with its label.
710
+ const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
711
+ if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
712
+ else out.push('<span class="sub">' + inline(t) + '</span>')
713
+ continue
714
+ }
715
+ if (inList) { out.push('</ul>'); inList = false }
716
+ if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
717
+ else if (/^## /.test(line)) { if (spaces.length) openSec(line.slice(3).trim()); else out.push('<h2>' + inline(line.slice(3)) + '</h2>') }
718
+ else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
719
+ else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
720
+ }
721
+ if (inList) out.push('</ul>')
722
+ if (inSec) out.push('</section>')
723
+ return out.join('\n')
724
+ }
725
+
726
+ // "2026-10-10" → "Sat Oct 10" for the phone's past-digests strip (v0.5.7).
727
+ function shortDay(iso) {
728
+ const d = new Date(iso + 'T12:00:00')
729
+ return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
730
+ }
731
+
732
+ // Digest (v0.5.8): one digest a day for the whole folder. The strip has one
733
+ // pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
734
+ // labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
735
+ // repo chips, which only count the picked space.
736
+ const digestUi = { standups: {}, date: null, open: null }
737
+ async function viewDigest(date, machine) {
738
+ const [{ digests }, spv] = await Promise.all([api('GET', '/v1/digests'), api('GET', '/v1/spaces').catch(() => null)])
739
+ const byDay = new Map()
740
+ for (const d of digests) { if (!byDay.has(d.date)) byDay.set(d.date, []); byDay.get(d.date).push(d) }
741
+ const days = [...byDay.keys()]
742
+ const lead = (day) => byDay.get(day)[0] // shared first, then this Mac's, then others (the daemon's order)
743
+ const pickD = date ? (digests.find((d) => d.date === date && (machine ? d.machine === machine : true)) || { date, machine: machine || null }) : days[0] ? lead(days[0]) : null
744
+ const pick = pickD && pickD.date
745
+ let html = '<div class="pagehead"><div><h1>Daily digest</h1><div class="sub">Where you left off, one digest a day for every Mac in the folder, ' + (state.cfg && state.cfg.digest && state.cfg.digest.time ? 'made each morning at ' + esc(state.cfg.digest.time) : 'made on demand') + ' and saved as markdown.</div></div>' +
746
+ '<div class="actions"><a class="btn" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a><button type="button" class="btn primary" data-action="gen-digest">' + icon('refresh-cw') + 'Generate now</button></div></div>'
747
+ // When the next automatic one comes, and a failed one, visibly (v0.4.2).
748
+ try { await refreshHealth() } catch {}
749
+ const dg = (state.health && state.health.digest) || {}
750
+ const when = (iso) => { if (!iso) return null; const t = new Date(iso); const today = new Date(); const tom = new Date(); tom.setDate(tom.getDate() + 1); const hm = t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); return t.toDateString() === today.toDateString() ? (Date.parse(iso) <= Date.now() + 30000 ? 'in a few seconds' : 'today at ' + hm) : t.toDateString() === tom.toDateString() ? 'tomorrow at ' + hm : clock(iso) }
751
+ const dayName = { sun: 'Sunday', mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday' }[dg.todayDay] || 'today'
752
+ const todayLine = dg.todayNote === 'no activity' ? 'No activity today (' + dayName + '), so no digest was written. ' : dg.todayNote === 'not a digest day' ? dayName + ' isn’t a digest day (' + esc(dg.daysLabel || '') + '). ' : /^waiting for /.test(dg.todayNote || '') ? 'Today’s comes from ' + esc(dg.first) + '; this Mac makes it if there’s none by ' + esc(when(dg.nextAt) || '') + '. ' : ''
753
+ const who = dg.order && dg.order.length > 1 ? ' Made by ' + esc(dg.order[0]) + ' (first in line), or the next Mac awake ' + esc(dg.graceMin) + ' min later.' : ''
754
+ const nextLine = todayLine + (dg.time ? 'Next automatic digest: ' + esc(when(dg.nextAt) || 'at ' + dg.time) + ' · ' + esc(dg.daysLabel || 'Every day') + ', ' + esc(dg.label || dg.time) + '.' + who : 'Automatic digests are off (<a href="#/settings/digest">Settings → Digest</a>).')
755
+ if (dg.lastError) html += '<div class="banner warn" role="status"><span class="grow"><b>The last digest failed</b> (' + esc(ago(dg.lastError.at)) + '): ' + esc(dg.lastError.message) + '. Generate now tries again; <code>leftoff doctor</code> shows it too.</span></div>'
756
+ html += '<div class="digest-wrap"><nav class="dates digest-dates" aria-label="Past digests">' + (days.length ? days.map((day) => {
757
+ const d = lead(day)
758
+ const on = pick === day
759
+ const label = d.shared ? '' : d.machine
760
+ return '<a href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" class="' + (on ? 'on' : '') + '" title="' + esc(day + (d.shared ? '' : ' · ' + d.machine + '’s own digest (from before one digest a day)')) + '"><span class="d-iso">' + day + '</span><span class="d-short">' + esc(shortDay(day)) + '</span>' + (label ? ' <span class="m">' + esc(label) + '</span>' : '') + '</a>'
761
+ }).join('') : '<span class="nocp">None yet</span>') + '</nav>'
762
+ let spaces = []
763
+ if (pick) {
764
+ try {
765
+ const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
766
+ const fm = d.meta || {}
767
+ const madeAt = fm.generated_at
768
+ spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
769
+ const whose = d.shared ? (d.by ? ' by ' + esc(d.by) : '') : ''
770
+ const legacy = !d.shared && d.machine ? '<span class="chip machine">⌂ ' + esc(d.machine) + '</span> ' + esc(d.machine) + '’s own digest, from before one digest a day. ' : ''
771
+ const others = (d.others || []).filter((x) => x.machine || x.shared)
772
+ const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
773
+ const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
774
+ html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
775
+ '<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
776
+ } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
777
+ } else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
778
+ $app.innerHTML = html + '</div>'
779
+ const params = (state.route && state.route.params) || new URLSearchParams()
780
+ state.digestRepos = readFilter(params, window.localStorage).repos
781
+ state.digestSpaces = spaces
782
+ let sp = readSpace(params)
783
+ state.digestSpace = sp && spaces.includes(sp) ? sp : null
784
+ if (digestUi.date !== pick) { digestUi.standups = {}; digestUi.open = null; digestUi.date = pick }
785
+ paintDigestFilter()
786
+ // Prefetch each space's standup text, so a tap copies at once (iOS only
787
+ // lets a page write the clipboard inside the tap itself).
788
+ if (pick && spaces.length && !(pickD && pickD.machine)) for (const x of spaces) if (!digestUi.standups[x]) api('GET', '/v1/standup?' + new URLSearchParams({ space: x, date: pick })).then((r) => { digestUi.standups[x] = r.text }).catch(() => {})
789
+ }
790
+
791
+ // The digest's filters: space tabs (v0.5.8), then repo chips (v0.4.2) from
792
+ // the repos in the picked space; a pick hides the other items and any
793
+ // section left empty. Shares the board's remembered repo and space choices.
794
+ function paintDigestFilter() {
795
+ const box = document.getElementById('digestRepos')
796
+ if (!box) return
797
+ const spaces = state.digestSpaces || []
798
+ const sp = state.digestSpace
799
+ const secs = [...document.querySelectorAll('.md section')]
800
+ for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
801
+ const tabsBox = document.getElementById('digestSpaces')
802
+ if (tabsBox) {
803
+ const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
804
+ tabsBox.innerHTML = spaceTabs(spaces, sp, counts, 'digest-space', document.querySelectorAll('.md section.space-sec li[data-repo]').length)
805
+ }
806
+ const visibleLi = (li) => !li.closest('section') || !li.closest('section').hidden
807
+ const items = [...document.querySelectorAll('.md li[data-repo]')].filter(visibleLi)
808
+ const counts = new Map()
809
+ for (const li of items) counts.set(li.dataset.repo, (counts.get(li.dataset.repo) || 0) + 1)
810
+ const sel = (state.digestRepos || []).filter((tok) => [...counts.keys()].some((r) => repoMatches({ repoKey: r, repoLabel: r }, tok)))
811
+ const keep = (r) => !sel.length || sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
812
+ for (const li of document.querySelectorAll('.md li[data-repo]')) li.hidden = !keep(li.dataset.repo)
813
+ for (const ul of document.querySelectorAll('.md ul')) {
814
+ const h = ul.previousElementSibling
815
+ const any = [...ul.children].some((li) => !li.hidden)
816
+ ul.hidden = !any
817
+ if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
818
+ }
819
+ if (sel.length) for (const sec of secs) if (sec.classList.contains('space-sec') && !sec.hidden && ![...sec.querySelectorAll('li[data-repo]')].some((li) => !li.hidden)) sec.hidden = true
820
+ if (counts.size < 2) { box.innerHTML = ''; return }
821
+ const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
822
+ box.innerHTML = '<div class="track"><button type="button" class="rchip all' + (sel.length ? '' : ' on') + '" data-action="digest-repo" data-repo=""><span class="rl">All</span><span class="rc">' + items.length + '</span></button>' +
823
+ [...counts.entries()].map(([r, n]) => '<button type="button" class="rchip' + (on(r) ? ' on' : '') + '" data-action="digest-repo" data-repo="' + esc(r) + '" title="' + esc(r) + '" aria-pressed="' + on(r) + '"><span class="rl">' + esc(r) + '</span><span class="rc">' + n + '</span></button>').join('') + '</div>'
824
+ }
825
+
826
+ // "Copy for standup" (v0.5.8): the clipboard when the browser allows it
827
+ // (inside the tap), else the text selected in a box to copy by hand. Either
828
+ // way the text shows under the section's heading.
829
+ async function standupCopy(btn) {
830
+ const space = btn.dataset.space
831
+ const sec = btn.closest('section')
832
+ let text = digestUi.standups[space]
833
+ let copied = false
834
+ if (text) copied = await clipWrite(text)
835
+ else {
836
+ btn.disabled = true
837
+ try { text = (await api('GET', '/v1/standup?' + new URLSearchParams({ space, date: digestUi.date || '' }))).text; digestUi.standups[space] = text } catch (e) { btn.disabled = false; return toast(e.message, 5000) }
838
+ btn.disabled = false
839
+ copied = await clipWrite(text)
840
+ }
841
+ if (!sec) return
842
+ let box = sec.querySelector('.standup-out')
843
+ if (!box) { box = document.createElement('div'); box.className = 'standup-out'; sec.querySelector('.space-head').after(box) }
844
+ box.innerHTML = '<div class="so-head"><span class="' + (copied ? 'ok' : '') + '">' + (copied ? icon('check') + 'Copied for Slack' : 'Select all and copy') + '</span><button type="button" class="linkish" data-action="standup-close">Close</button></div>' +
845
+ '<textarea readonly rows="' + Math.min(14, text.split('\n').length + 1) + '" aria-label="' + esc(space) + ' standup">' + esc(text) + '</textarea>'
846
+ const ta = box.querySelector('textarea')
847
+ if (!copied) { ta.focus(); ta.select(); try { ta.setSelectionRange(0, text.length) } catch {} ; try { if (document.execCommand('copy')) box.querySelector('.so-head span').innerHTML = icon('check') + 'Copied for Slack' } catch {} }
848
+ digestUi.open = space
849
+ }
850
+ async function clipWrite(text) {
851
+ try { if (navigator.clipboard && window.isSecureContext !== false) { await navigator.clipboard.writeText(text); return true } } catch {}
852
+ return false
853
+ }
854
+
855
+ // ------------------------------------------------------------ catch me up (v0.5.0)
856
+ //
857
+ // Pick repos and a window, get one recap: a short summary on top, then per
858
+ // repo and session what got done, where it stopped, what's next, open
859
+ // questions, and what's waiting on you now (answered right here, like Needs
860
+ // you). Every catch-up is saved to catchups/ in the folder and listed on the left.
861
+
862
+ const cuUi = { open: false, since: 'last', from: '', to: '', repos: null, machines: null, opts: null, making: false, err: null }
863
+
864
+ function cuWhen(iso) { return iso ? new Date(iso).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—' }
865
+ function cuSince() { return cuUi.since === 'custom' ? (cuUi.from || '') : cuUi.since }
866
+
867
+ async function cuLoadOptions() {
868
+ const qs = new URLSearchParams()
869
+ const since = cuSince()
870
+ if (since) qs.set('since', since)
871
+ if (cuUi.since === 'custom' && cuUi.to) qs.set('to', cuUi.to)
872
+ try { cuUi.opts = await api('GET', '/v1/catchups/options?' + qs.toString()); cuUi.err = null } catch (e) { cuUi.err = e.message }
873
+ }
874
+
875
+ function cuPicker() {
876
+ const o = cuUi.opts
877
+ const winBtns = ((o && o.windows) || [{ key: 'last', label: 'Since I last caught up' }, { key: '24h', label: 'Last 24 hours' }, { key: 'workday', label: 'Since the last workday' }, { key: '7d', label: 'Last 7 days' }, { key: 'custom', label: 'Custom…' }])
878
+ .map((w) => '<button type="button" class="cu-win" data-action="cu-win" data-win="' + esc(w.key) + '" aria-pressed="' + (cuUi.since === w.key) + '"' + (w.from ? ' title="from ' + esc(cuWhen(w.from)) + '"' : '') + '>' + esc(w.label) + '</button>').join('')
879
+ let h = '<section class="cu-picker" aria-label="New catch-up"><div class="cu-row"><div class="cu-k">When</div><div class="cu-wins" role="group" aria-label="Window">' + winBtns + '</div></div>'
880
+ if (cuUi.since === 'custom') {
881
+ h += '<div class="cu-row"><div class="cu-k"></div><div class="cu-custom"><label>From <input type="datetime-local" id="cuFrom" value="' + esc(cuUi.from) + '"></label><label>To <input type="datetime-local" id="cuTo" value="' + esc(cuUi.to) + '" placeholder="now"></label></div></div>'
882
+ }
883
+ if (cuUi.err) h += '<div class="cu-row"><div class="cu-k"></div><div class="cu-err">' + esc(cuUi.err) + '</div></div>'
884
+ if (o && !cuUi.err) {
885
+ h += '<div class="cu-row"><div class="cu-k"></div><div class="cu-winline">' + esc(o.window.label) + ': ' + esc(cuWhen(o.window.from)) + ' → ' + esc(cuWhen(o.window.to)) + (o.window.fallback === 'no earlier catch-up' ? ' · no earlier catch-up yet' : '') + '</div></div>'
886
+ const repos = o.repos || []
887
+ const sel = cuUi.repos || new Set(repos.map((r) => r.key))
888
+ h += '<div class="cu-row"><div class="cu-k">Repos</div><div class="cu-repos">'
889
+ if (!repos.length) h += '<span class="faint">No sessions with activity in this window.</span>'
890
+ else {
891
+ h += '<button type="button" class="linkish" data-action="cu-repos-all">' + (sel.size === repos.length ? 'None' : 'All') + '</button>'
892
+ h += repos.map((r) => '<label class="cu-repo' + (sel.has(r.key) ? ' on' : '') + '" title="' + esc(r.key) + '"><input type="checkbox" data-cu-repo="' + esc(r.key) + '"' + (sel.has(r.key) ? ' checked' : '') + '><span class="rl">' + esc(r.label) + '</span><span class="rc">' + r.count + '</span>' + (r.needsYou ? '<span class="cu-needdot" title="' + r.needsYou + ' waiting on you"></span>' : '') + '</label>').join('')
893
+ }
894
+ h += '</div></div>'
895
+ const ms_ = o.allMachines || []
896
+ if (ms_.length > 1) {
897
+ const msel = cuUi.machines || new Set(ms_)
898
+ h += '<div class="cu-row"><div class="cu-k">Machines</div><div class="cu-repos">' + ms_.map((m) => '<label class="cu-repo' + (msel.has(m) ? ' on' : '') + '"><input type="checkbox" data-cu-machine="' + esc(m) + '"' + (msel.has(m) ? ' checked' : '') + '><span class="rl">' + esc(m) + '</span></label>').join('') + '</div></div>'
899
+ }
900
+ }
901
+ const nRepos = o ? (cuUi.repos ? cuUi.repos.size : (o.repos || []).length) : 0
902
+ h += '<div class="cu-row cu-go"><div class="cu-k"></div><div><button type="button" class="btn primary" data-action="cu-make"' + (cuUi.making || (o && !nRepos) || cuUi.err ? ' disabled' : '') + '>' + icon('coffee') + (cuUi.making ? 'Catching you up…' : 'Catch me up') + '</button>' +
903
+ '<button type="button" class="btn" data-action="cu-close">Cancel</button>' +
904
+ (cuUi.making ? '<span class="faint cu-wait">Reading checkpoints' + (state.health && state.health.summarizer && state.health.summarizer !== 'heuristic' ? ', then the summary from ' + esc(state.health.summarizer) : '') + '…</span>' : '') + '</div></div></section>'
905
+ return h
906
+ }
907
+
908
+ // The live session for each id in this catch-up (for Go, Copy resume and Needs you).
909
+ function cuLive(id) { return (state.sessions || []).find((s) => s.id === id) || null }
910
+
911
+ function cuSessionCard(x) {
912
+ const s = cuLive(x.id)
913
+ const isAgent = !!(x.agent || String(x.id || '').startsWith('agent:'))
914
+ const mdInline = isAgent ? esc : mdInlineMain // feed text is plain text, never links or markup
915
+ const li = (arr) => '<ul>' + arr.map((d) => '<li>' + mdInline((d)) + '</li>').join('') + '</ul>'
916
+ let h = '<article class="cu-s" data-id="' + esc(x.id || '') + '">'
917
+ h += '<div class="cu-sh">' + (s ? liveDot(s) : '') + (x.id ? '<a class="cu-st" href="#/s/' + encodeURIComponent(x.id) + '">' + esc(x.title) + '</a>' : '<span class="cu-st">' + esc(x.title) + '</span>') + '</div>'
918
+ if (x.line) h += '<div class="cu-line">' + esc(x.line) + (s && s.live === 'open' ? ' · <span class="livenote open">open now</span>' : '') + '</div>'
919
+ const rows = []
920
+ if (x.needsYou) rows.push(['Needs you', '<span class="cu-need">' + mdInline((x.needsYou)) + '</span>'])
921
+ if (x.done.length) rows.push(['Done', x.done.length === 1 ? mdInline((x.done[0])) : li(x.done)])
922
+ if (x.stoppedAt) rows.push([isAgent ? 'Latest' : 'Stopped at', mdInline((x.stoppedAt))])
923
+ if (x.lastReply) rows.push(['Since then', mdInline((x.lastReply))])
924
+ if (x.next) rows.push(['Next', mdInline((x.next)) + (x.nextSteps.length ? li(x.nextSteps) : '')])
925
+ if (x.questions.length) rows.push(['Open questions', li(x.questions)])
926
+ h += '<dl class="cu-dl">' + rows.map(([k, v]) => '<dt>' + esc(k) + '</dt><dd>' + v + '</dd>').join('') + '</dl>'
927
+ if (isAgent) return h + '<div class="actions-row">' + agentReplyButton((s && s.agent) || x.agent, 'btn') + (x.id ? '<a class="btn" href="#/s/' + encodeURIComponent(x.id) + '">Session →</a>' : '') + '<span class="ro">read-only · from an agent’s feed</span></div></article>'
928
+ h += '<div class="actions-row">' + (s ? goButton(s) : '') + (x.resume ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(x.resume) + '">Copy resume</button>' : '') +
929
+ (x.id ? '<a class="btn" href="#/s/' + encodeURIComponent(x.id) + '">Session →</a>' : '') + '</div>'
930
+ return h + '</article>'
931
+ }
932
+
933
+ async function viewCatchup(name) {
934
+ if (!state.health) await refreshHealth()
935
+ const [{ catchups }, sv, lv] = await Promise.all([api('GET', '/v1/catchups'), api('GET', '/v1/sessions?limit=300').catch(() => ({ sessions: [] })), api('GET', '/v1/lists').catch(() => null)])
936
+ state.sessions = sv.sessions || []
937
+ state.lists = lv ? lv.lists : []
938
+ if (lv) state.listsMayEdit = lv.mayEdit !== false
939
+ if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.mayAnswer = sv.viewer.mayAnswer !== false }
940
+ if (name === 'new' || (!catchups.length && !name)) { if (!cuUi.open) { cuUi.open = true; cuUi.repos = null; cuUi.machines = null; cuUi.opts = null } }
941
+ if (cuUi.open && !cuUi.opts && !cuUi.err) await cuLoadOptions()
942
+ const pick = name && name !== 'new' ? name : (catchups[0] && catchups[0].name)
943
+ let html = '<div class="pagehead"><div><h1>Catch me up</h1><div class="sub">What happened across your sessions while you were away: what got done, where each one stopped, and what’s waiting on you.</div></div>' +
944
+ '<div class="actions">' + (cuUi.open ? '' : '<button type="button" class="btn primary" data-action="cu-open">' + icon('coffee') + 'New catch-up</button>') + '</div></div>'
945
+ if (cuUi.open) html += cuPicker()
946
+ html += '<div class="digest-wrap cu-wrap"><nav class="dates cu-list" aria-label="Past catch-ups">' + (catchups.length ? catchups.map((c) => '<a href="#/catchup/' + esc(c.name) + '" class="' + (c.name === pick ? 'on' : '') + '"><span class="cu-ln">' + esc(cuWhen(c.generatedAt)) + '</span><span class="m">' + esc(c.label || '') + ' · ' + (c.sessions == null ? '?' : c.sessions) + '</span></a>').join('') : '<span class="nocp">None yet</span>') + '</nav>'
947
+ if (pick) {
948
+ let c = null
949
+ try { c = await api('GET', '/v1/catchups/' + encodeURIComponent(pick)) } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
950
+ if (c) html += cuArticle(c)
951
+ } else if (!cuUi.open) html += '<div class="empty"><p><strong>No catch-ups yet.</strong></p><p>Pick repos and a window and get one recap. From a terminal: <code>leftoff catchup</code>.</p></div>'
952
+ else html += '<div></div>'
953
+ html += '</div>'
954
+ $app.innerHTML = banners() + html
955
+ }
956
+
957
+ function cuArticle(c) {
958
+ const p = c.parsed, m = p.meta
959
+ const ids = new Set([...(m.sessionIds || []), ...p.repos.flatMap((r) => r.sessions.map((x) => x.id))].filter(Boolean))
960
+ const nRepos = p.repos.length, nS = p.repos.reduce((a, r) => a + r.sessions.length, 0)
961
+ let h = '<article class="md cu" data-name="' + esc(c.name) + '"><h1>' + esc((m.window && m.window.label) || m.title || 'Catch-up') + '</h1>'
962
+ h += '<div class="digest-meta">' + esc(cuWhen(m.window && m.window.from)) + ' → ' + esc(cuWhen(m.window && m.window.to)) + ' · ' + nS + ' session' + (nS === 1 ? '' : 's') + ' in ' + nRepos + ' repo' + (nRepos === 1 ? '' : 's') +
963
+ (m.machines && m.machines.length > 1 ? ' · ' + esc(m.machines.join(', ')) : '') + ' · summary by ' + esc(m.model === 'heuristic' ? 'Leftoff (no model)' : m.model || '?') +
964
+ ' · <button type="button" class="linkish" data-action="copy-cmd" data-cmd="' + esc(c.markdown) + '">Copy as Markdown</button></div>'
965
+ h += '<ul class="cu-summary">' + p.summary.map((b) => '<li>' + mdInline((b)) + '</li>').join('') + '</ul>'
966
+ // Needs you, live: what is waiting right now in these sessions, answerable here.
967
+ const live = (state.sessions || []).filter((s) => ids.has(s.id) && s.needsYou)
968
+ if (live.length) h += '<h2>Needs you now (' + live.length + ')</h2><div class="needs cu-needs">' + live.map((s) => needPanel({ ...s.needsYou, session: s }, needOpts())).join('') + '</div>'
969
+ else if (p.needsYou.length) h += '<h2>Was waiting on you (' + p.needsYou.length + ')</h2><ul>' + p.needsYou.map((n) => '<li><a href="#/s/' + encodeURIComponent(n.id || '') + '"><strong>' + esc(n.title) + '</strong></a> <span class="faint">' + esc(n.repo) + '</span><span class="sub">' + (String(n.id || '').startsWith('agent:') ? esc(n.needsYou) : mdInline((n.needsYou))) + '</span></li>').join('') + '</ul>'
970
+ // Lists (v0.5.1): still open, with live progress when the list is still in the folder
971
+ const so = (p.stillOpen || []).map((x) => ({ ...x, live: (state.lists || []).find((l) => l.ref === x.ref) || null })).filter((x) => !x.live || (!x.live.archived && x.live.done < x.live.total))
972
+ if (so.length) {
973
+ h += '<h2>Still open (' + so.length + ')</h2><div class="lists cu-lists">' + so.map((x) => x.live ? listCard({ ...x.live, items: x.live.items.filter((it) => !it.done) }, { full: false }) :
974
+ '<div class="listcard"><div class="lhead"><span class="lname">' + esc(x.name) + '</span><span class="lprog">' + esc(x.done + '/' + x.total) + '</span></div><ul class="checklist">' + x.next.map((it) => '<li><label><input type="checkbox" disabled><span class="n">' + esc(it.n) + '</span><span class="t">' + (String(x.ref || '').startsWith('~') ? esc(it.text) : mdInline(it.text)) + '</span></label></li>').join('') + '</ul></div>').join('') + '</div>'
975
+ }
976
+ for (const r of p.repos) h += '<h2>' + esc(r.label) + ' <span class="faint">(' + r.sessions.length + ')</span></h2>' + r.sessions.map(cuSessionCard).join('')
977
+ if (!nS) h += '<p class="faint">No sessions with activity in this window.</p>'
978
+ return h + '</article>'
979
+ }
980
+
981
+ const PROVIDERS = [
982
+ ['auto', 'Auto', 'claude CLI if found, else an Anthropic key, else no model'],
983
+ ['claude', 'Claude (your Claude Code login)', 'runs claude -p; no key'],
984
+ ['ollama', 'Ollama (local)', 'free and private; runs on this machine'],
985
+ ['ollama-cloud', 'Ollama cloud (API key)', 'hosted models on ollama.com; nothing to download'],
986
+ ['openai-compatible', 'LM Studio / OpenAI-compatible', 'any local /v1/chat/completions server'],
987
+ ['anthropic', 'Anthropic API key', 'Claude Haiku over the API'],
988
+ ['heuristic', 'No model', 'last prompt + last reply; works offline'],
989
+ ]
990
+ const PROVIDER_FIELDS = {
991
+ auto: { model: true, base: false, key: null, modelPh: 'haiku', basePh: '' },
992
+ claude: { model: true, base: false, key: null, modelPh: 'haiku · sonnet', basePh: '' },
993
+ ollama: { model: true, base: true, key: null, modelPh: 'qwen2.5:3b', basePh: 'http://localhost:11434' },
994
+ 'ollama-cloud': { model: true, base: true, key: 'ollama-cloud', modelPh: 'gpt-oss:20b', basePh: 'https://ollama.com' },
995
+ 'openai-compatible': { model: true, base: true, key: null, modelPh: 'qwen2.5-7b-instruct', basePh: 'http://localhost:1234/v1' },
996
+ anthropic: { model: true, base: false, key: 'anthropic', modelPh: 'haiku · sonnet · claude-haiku-4-5', basePh: '' },
997
+ heuristic: { model: false, base: false, key: null, modelPh: '', basePh: '' },
998
+ }
999
+ const THEME_ICONS = {
1000
+ light: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
1001
+ dark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
1002
+ system: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 20h8M12 16v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
1003
+ }
1004
+
1005
+ function syncProviderFields() {
1006
+ const prov = document.getElementById('f-mode').value
1007
+ const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
1008
+ const c = state.cfg || {}
1009
+ const show = (id, on) => document.querySelectorAll('[data-row="' + id + '"]').forEach((el) => { el.hidden = !on })
1010
+ show('model', f.model); show('base', f.base && (prov === 'openai-compatible' || !!(c.summarizer && c.summarizer.baseUrl && String(c.summarizer.provider || '').toLowerCase() === prov))); show('key', !!f.key)
1011
+ document.getElementById('f-base').placeholder = f.basePh
1012
+ if (f.model) loadModels(prov)
1013
+ const info = PROVIDERS.find((p) => p[0] === prov)
1014
+ document.getElementById('f-mode-hint').textContent = (info ? info[2] : '') + (prov === (c.summarizer && c.summarizer.provider || 'auto') ? ' · now using: ' + c.summarizerResolved : '')
1015
+ if (f.key) {
1016
+ const k = (c.keys && c.keys[f.key]) || {}
1017
+ const st = document.getElementById('f-keystate')
1018
+ st.className = 'keystate ' + (k.found ? 'ok' : 'no')
1019
+ st.textContent = k.found ? '✓ key found' + (k.envSet ? ' ($' + k.env + ')' : ' (saved)') : 'no key yet'
1020
+ document.getElementById('f-key').placeholder = k.found ? 'paste a new key to replace it' : 'paste your key, or set $' + (k.env || '') + ' for the daemon'
1021
+ 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>. ' : '') +
1022
+ 'Saved to ' + esc(k.file || '.secret/') + ' (mode 0600), never config.json. $' + esc(k.env || '') + ' wins if the daemon sees it.'
1023
+ }
1024
+ }
1025
+
1026
+ // Settings → Models & summaries → Model (v0.4.3): the same short card list for every
1027
+ // provider; local Ollama suggestions download from here (curated ones only).
1028
+ state.pulls = {}
1029
+ async function loadModels(prov) {
1030
+ const box = document.getElementById('f-models-list')
1031
+ if (!box) return
1032
+ box.dataset.provider = prov
1033
+ try {
1034
+ const d = await api('GET', '/v1/models?provider=' + encodeURIComponent(prov))
1035
+ if (box.dataset.provider !== prov) return
1036
+ state.models = d
1037
+ for (const c of d.choices) if (c.pull) state.pulls[c.tag] = c.pull
1038
+ paintModels()
1039
+ } catch (e) { box.innerHTML = '<div class="nocp">Couldn’t list models: ' + esc(e.message) + '</div>' }
1040
+ }
1041
+ function pullLine(pl) {
1042
+ if (!pl) return ''
1043
+ if (pl.error) return '<div class="pull err">Download failed: ' + esc(pl.error) + '</div>'
1044
+ if (pl.cancelled) return '<div class="pull">Download cancelled.</div>'
1045
+ if (pl.done) return ''
1046
+ const pct = Math.round((pl.fraction || 0) * 100)
1047
+ const mbs = pl.bps ? ' · ' + (pl.bps / 1e6).toFixed(1) + ' MB/s' : ''
1048
+ const of = pl.total ? ' · ' + (pl.completed / 1e9).toFixed(2) + ' of ' + (pl.total / 1e9).toFixed(1) + ' GB' : ''
1049
+ return '<div class="pull"><div class="pbar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="' + pct + '"><i data-pct="' + pct + '"></i></div>' +
1050
+ '<div class="ptext">' + pct + '%' + of + mbs + (pl.use ? ' · will switch to it when done' : '') + ' <button type="button" class="linkish" data-action="pull-cancel" data-tag="' + esc(pl.tag) + '">Cancel</button></div></div>'
1051
+ }
1052
+ function paintModels() {
1053
+ const box = document.getElementById('f-models-list')
1054
+ const d = state.models
1055
+ if (!box || !d) return
1056
+ const cur = document.getElementById('f-model').value
1057
+ const isCur = (c) => cur ? (c.tag === cur || c.tag === cur + ':latest' || cur === c.tag + ':latest') : c.current
1058
+ const onAny = d.choices.some(isCur)
1059
+ const card = (c) => {
1060
+ const pl = state.pulls[c.tag]
1061
+ const pulling = pl && !pl.done && !pl.error && !pl.cancelled
1062
+ const installed = c.installed || (pl && pl.done)
1063
+ const needsDl = d.provider === 'ollama' && !installed
1064
+ const side = d.provider !== 'ollama' ? '' : installed ? '<span class="badge ok">✓ on this machine</span>'
1065
+ : pulling ? '' : c.canDownload ? '<button type="button" class="btn small" data-action="pull-model" data-tag="' + esc(c.tag) + '" data-use="1">' + icon('download') + 'Download &amp; use</button>' : '<span class="badge">not downloaded</span>'
1066
+ return '<div class="mcard' + (isCur(c) ? ' on' : '') + (needsDl ? ' needs-dl' : '') + '" role="radio" tabindex="0" aria-checked="' + !!isCur(c) + '" data-action="pick-model" data-tag="' + esc(c.tag) + '"' + (needsDl ? ' data-needs-dl="1"' : '') + '>' +
1067
+ '<span class="dot" aria-hidden="true"></span><span class="mmain"><span class="mname">' + esc(c.name) + (c.recommended ? ' <span class="badge acc">Suggested</span>' : '') + '</span>' +
1068
+ (c.note ? '<span class="mnote">' + esc(c.note) + '</span>' : '') + (c.hint ? '<span class="mhint">' + esc(c.hint) + '</span>' : '') + pullLine(pl) + '</span>' +
1069
+ '<span class="mside">' + side + '</span></div>'
1070
+ }
1071
+ const canStart = d.status && d.status.canStart && state.viewerLocal !== false
1072
+ const st = d.status ? '<div class="mstatus ' + (d.status.ok ? 'ok' : 'warn') + '">' + esc(d.status.text) +
1073
+ (canStart ? ' <button type="button" class="btn small" data-action="ollama-start">' + icon('play') + 'Start Ollama</button>' : '') + '</div>' : ''
1074
+ const other = cur && !onAny ? cur : ''
1075
+ box.innerHTML = st + d.choices.map(card).join('') + (d.choices.length ? '' : '<div class="nocp">No models found.</div>') +
1076
+ '<details class="other"' + (other ? ' open' : '') + '><summary>Other model…</summary><input id="f-model-other" placeholder="' + esc((PROVIDER_FIELDS[d.provider] || {}).modelPh || 'model name') + '" value="' + esc(other) + '" spellcheck="false" aria-label="Other model name"><span class="hint">Any model name this provider accepts. Overrides the cards above.</span></details>'
1077
+ // widths through the CSSOM: the page's CSP has no inline style attributes
1078
+ box.querySelectorAll('.pbar i[data-pct]').forEach((i) => { i.style.width = i.dataset.pct + '%' })
1079
+ }
1080
+ function onPull(v) {
1081
+ const was = state.pulls[v.tag]
1082
+ state.pulls[v.tag] = v
1083
+ if (state.route && state.route.name === 'settings') {
1084
+ if (v.done && (!was || !was.done) && v.use) { const m = document.getElementById('f-model'); if (m) m.value = v.tag; const sel = document.getElementById('f-mode'); if (sel) sel.value = 'ollama' }
1085
+ paintModels()
1086
+ }
1087
+ if (v.done && (!was || !was.done)) toast(v.use ? 'Downloaded ' + v.tag + '. Checkpoints now summarize with it.' : 'Downloaded ' + v.tag, 6000)
1088
+ if (v.error && (!was || !was.error)) toast('Couldn’t download ' + v.tag + ': ' + v.error, 10000)
1089
+ }
1090
+
1091
+ // Settings → Models & summaries (v0.4.6): how long a checkpoint is, with the example for
1092
+ // the length picked, and a try-out on a real session (generated, not saved).
1093
+ function lengthExample(k) {
1094
+ const o = LENGTH_CHOICES.find((x) => x.key === k) || LENGTH_CHOICES[1]
1095
+ const ex = SUMMARY_EXAMPLES[o.key]
1096
+ return '<div class="ex-head">Example · ' + esc(o.label) + ' <span class="faint">' + esc(o.approx) + '</span></div>' + cpBlock(ex, { showWhen: false }) +
1097
+ (hasExtras(ex) ? '<div class="cpx">' + cpExtras(ex) + '</div>' : '')
1098
+ }
1099
+ function summariesSection(c) {
1100
+ const cur = (c.summary && c.summary.length) || 'informative'
1101
+ return '<div class="section" id="summaries">Summaries</div>' +
1102
+ '<label>Checkpoint length</label><div class="lengths" role="radiogroup" aria-label="Checkpoint length">' + LENGTH_CHOICES.map((o) =>
1103
+ '<label class="lenopt"><input type="radio" name="f-length" value="' + o.key + '"' + (cur === o.key ? ' checked' : '') + '>' +
1104
+ '<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>' +
1105
+ '<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>' +
1106
+ '<label>Example</label><div class="len-example" id="f-length-example" aria-live="polite">' + lengthExample(cur) + '</div>' +
1107
+ '<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>'
1108
+ }
1109
+ async function tryLength() {
1110
+ const k = ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative'
1111
+ const box = document.getElementById('f-length-example')
1112
+ let list = state.sessions.length ? state.sessions : ((await api('GET', '/v1/sessions?limit=50').catch(() => ({ sessions: [] }))).sessions || [])
1113
+ list = list.filter((x) => x.isLocal !== false).sort((a, b) => String(b.lastActivityAt || '').localeCompare(String(a.lastActivityAt || '')))
1114
+ const s = list[0]
1115
+ if (!s) { toast('No session yet to try it on'); return }
1116
+ box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
1117
+ try {
1118
+ const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
1119
+ 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>' +
1120
+ cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
1121
+ } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
1122
+ }
1123
+
1124
+ // Am I the iPhone home-screen app (or another installed app window)?
1125
+ const standalone = () => !!(window.navigator.standalone || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches))
1126
+ const isIOS = () => /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
1127
+
1128
+ // Add to Home Screen, in the words iOS uses (v0.4.6).
1129
+ function homeScreenSteps(local) {
1130
+ return '<ol class="steps">' +
1131
+ (local ? '<li>Scan the QR code with the iPhone camera. Safari opens the board, signed in.</li>' : '') +
1132
+ '<li>In Safari, tap <b>Share</b> <span class="faint">(the square with the arrow)</span>, then <b>Add to Home Screen</b>, then <b>Add</b>.</li>' +
1133
+ '<li>Open <b>Leftoff</b> from the home screen. It keeps its own sign-in, separate from Safari, so it asks for a code once: ' + (local ? 'type the 6-character code shown here.' : 'tap “Get a code” below in Safari first, then type it in the app.') + '</li>' +
1134
+ '<li>That’s it for 30 days; each visit moves the 30 days on.</li></ol>'
1135
+ }
1136
+
1137
+ // Settings → Remote access: the tailnet URL, signing a phone in (a QR code and a
1138
+ // typed code, v0.4.6), add to home screen, who's signed in, answering.
1139
+ function remoteSection(r) {
1140
+ if (!r) return ''
1141
+ const local = r.via !== 'remote'
1142
+ let h = '<div class="section dup" id="remote">Remote access</div>'
1143
+ if (!r.enabled) {
1144
+ return h + '<label>Status</label><div><span class="chip">Off</span></div>' +
1145
+ '<div class="hint full2">To open the board from your phone over your tailnet, run <code>leftoff tailscale on</code> on this machine: it sets up Tailscale Serve (https, tailnet only), checks the address and prints a QR code to scan. ' +
1146
+ 'No Tailscale? <code>ssh -N -L ' + esc(location.port || '7429') + ':127.0.0.1:' + esc(location.port || '7429') + ' you@this-machine</code></div>'
1147
+ }
1148
+ const n = r.sessions.length
1149
+ h += '<label>URL</label><div class="remote-urls">' + r.urls.map((u) => '<span class="chip mono">' + esc(u) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(u) + '">Copy</button>').join('') + '</div>' +
1150
+ '<div class="hint">Tailscale Serve, https, inside your tailnet only (Funnel is never used). The iPhone needs the Tailscale app, connected.</div>' +
1151
+ '<label>Signed in</label><div>' + (r.requireToken
1152
+ ? esc(n + ' browser' + (n === 1 ? '' : 's')) + (r.via === 'remote' ? ' · <span class="chip">this one</span>' : '')
1153
+ : '<span class="chip">no sign-in needed</span>') + '</div>' +
1154
+ '<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>'
1155
+ if (r.requireToken) {
1156
+ if (local) {
1157
+ 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>' +
1158
+ (n ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
1159
+ '<div class="full signin" id="remoteLink" hidden></div>'
1160
+ } else {
1161
+ 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>' +
1162
+ '<div class="hint">' + (standalone() ? 'You’re in the home-screen app, signed in.' : 'The home-screen app has its own sign-in. Get a code here, then type it in the app.') + '</div>' +
1163
+ '<div class="full signin" id="remoteLink" hidden></div>'
1164
+ }
1165
+ h += '<label>Add to Home Screen</label><div class="howto">' + homeScreenSteps(local) + '</div>'
1166
+ }
1167
+ return h
1168
+ }
1169
+
1170
+ // Claim <name>.leftoff.dev from Settings (0.6.1): name + email, then a link
1171
+ // to press Confirm on. Plain inputs and buttons only (no native select).
1172
+ function signupBlock(r, su, local) {
1173
+ const dom = esc(r.domain || 'leftoff.dev')
1174
+ if (su.status === 'pending') {
1175
+ return '<label>Claim your name</label><div class="signup-wait" role="status" aria-live="polite">' +
1176
+ '<div class="sw-head"><span class="spin" aria-hidden="true"></span><b>Check your email</b></div>' +
1177
+ '<div>We sent a link to <b>' + esc(su.email || '') + '</b>. Open it and press <b>Confirm ' + esc(su.host || '') + '</b>. This page finishes on its own.</div>' +
1178
+ '<div class="faint">The link works for 30 minutes.' + (su.claimed ? ' Your leftoff.dev claim carries over.' : '') + '</div>' +
1179
+ (local ? '<div class="remote-actions"><button type="button" class="btn sm" data-action="relay-signup-resend" data-user="' + esc(su.user || '') + '" data-email="' + esc(su.email || '') + '">Send a new link</button><button type="button" class="linkish" data-action="relay-signup-cancel">Use a different name or email</button></div>' : '') +
1180
+ '</div>'
1181
+ }
1182
+ const bad = su.status === 'expired' ? 'That link expired. Send a new one.' : su.status === 'replaced' ? 'A newer link was sent; use the newest email.' : ''
1183
+ const dis = local ? '' : ' disabled'
1184
+ return '<label for="f-signup-user">Claim your name</label><div class="signup">' +
1185
+ '<div class="anyuser"><input id="f-signup-user" value="' + esc(su.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32" aria-describedby="signupHint"' + dis + '><span class="faint mono">.' + dom + '</span></div>' +
1186
+ '<input id="f-signup-email" type="email" inputmode="email" placeholder="you@example.com" autocomplete="email" spellcheck="false" autocapitalize="none" aria-label="Email for the confirmation link"' + dis + '>' +
1187
+ '<div class="remote-actions"><button type="button" class="btn primary" data-action="relay-signup"' + dis + '>' + icon('mail') + 'Email me a link</button></div>' +
1188
+ (bad ? '<div class="hint warnish">' + esc(bad) + '</div>' : '') + '<div class="hint warnish" id="signupErr" hidden></div>' +
1189
+ '</div><div class="hint" id="signupHint">' + (local ? 'Use your board from anywhere: we email you a link, you press Confirm, and this Mac is on <b>yourname.' + dom + '</b>. Free during the beta. Or in a terminal: <code>leftoff relay signup yourname</code>.' : 'Claim a name from a Mac.') + '</div>'
1190
+ }
1191
+
1192
+ // Use from anywhere (v0.6.0): the hosted relay at <user>.leftoff.dev. The
1193
+ // switch, the name, the relay's state, pairing a phone, the paired devices.
1194
+ function anywhereSection(r) {
1195
+ if (!r) return ''
1196
+ const v = r.viewer || {}
1197
+ const local = !!v.local
1198
+ const dis = local ? '' : ' disabled'
1199
+ const dom = esc(r.domain || 'leftoff.dev')
1200
+ const su = r.signup || { status: 'none' }
1201
+ let h = '<div class="section" id="anywhere">Use from anywhere</div>' +
1202
+ '<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>'
1203
+ // No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
1204
+ if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
1205
+ 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>' +
1206
+ '<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>' +
1207
+ (local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
1208
+ '<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>'
1209
+ if (r.enabled) {
1210
+ 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>'
1211
+ h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
1212
+ 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>'
1213
+ 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>' +
1214
+ '<div class="full signin" id="relayPair" hidden></div>'
1215
+ }
1216
+ const list = r.list || []
1217
+ h += '<label>Paired devices</label><div class="devlist">' + (list.length ? list.map((d) =>
1218
+ '<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>' : '') +
1219
+ '<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>' +
1220
+ (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('')
1221
+ : '<div class="faint">None yet.</div>') + '</div>' +
1222
+ '<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>'
1223
+ return h
1224
+ }
1225
+ function fmtAgo(iso) {
1226
+ const t = Date.parse(iso || ''); if (!t) return 'never'
1227
+ const m = Math.round((Date.now() - t) / 60000)
1228
+ return m < 2 ? 'just now' : m < 60 ? m + ' min ago' : m < 36 * 60 ? Math.round(m / 60) + ' h ago' : Math.round(m / 1440) + ' days ago'
1229
+ }
1230
+ let relayPairTimer = null
1231
+ // While a signup link is out, ask the daemon every 2 s whether it was confirmed.
1232
+ let signupTimer = null
1233
+ function stopSignupPoll() { if (signupTimer) clearInterval(signupTimer); signupTimer = null }
1234
+ function relaySignupPoll() {
1235
+ if (signupTimer) return
1236
+ signupTimer = setInterval(async () => {
1237
+ if (!document.querySelector('.signup-wait')) return stopSignupPoll()
1238
+ let st; try { st = await api('GET', '/v1/relay/signup') } catch { return }
1239
+ if (st.status === 'pending') return
1240
+ stopSignupPoll()
1241
+ if (st.status === 'confirmed') toast('✓ ' + st.host + ' is yours. Pair your phone next.')
1242
+ render()
1243
+ }, 2000)
1244
+ }
1245
+ function paintRelayPair(l) {
1246
+ const box = document.getElementById('relayPair')
1247
+ if (!box) return
1248
+ box.hidden = false; box.classList.remove('expired')
1249
+ 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>' +
1250
+ '<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>' +
1251
+ '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.link) + '">Copy link</button>' +
1252
+ '<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>' +
1253
+ '<div class="expiry" id="relayExpiry" data-expires="' + esc(l.expiresAt) + '"></div>' +
1254
+ '<button type="button" class="linkish" data-action="relay-pair">New code</button></div>'
1255
+ const tick = () => {
1256
+ const el = document.getElementById('relayExpiry')
1257
+ if (!el) { clearInterval(relayPairTimer); relayPairTimer = null; return }
1258
+ const left = Date.parse(el.dataset.expires) - Date.now()
1259
+ if (left <= 0) { el.textContent = 'Expired. Make a new one.'; el.classList.add('warn'); box.classList.add('expired'); clearInterval(relayPairTimer); relayPairTimer = null; return }
1260
+ el.textContent = 'Works once · expires in ' + Math.floor(left / 60000) + ':' + String(Math.floor(left / 1000) % 60).padStart(2, '0')
1261
+ }
1262
+ if (relayPairTimer) clearInterval(relayPairTimer)
1263
+ relayPairTimer = setInterval(tick, 1000); tick()
1264
+ }
1265
+
1266
+ // Answering prompts from another device (v0.4.4): off unless turned on here, on
1267
+ // this machine. Shown in Settings → Privacy & security since 0.5.9, only while
1268
+ // remote access is on (as before).
1269
+ function answeringSection(r, relay) {
1270
+ const anywhere = !!(relay && (relay.enabled || (relay.list || []).length))
1271
+ if (!r || !(r.enabled || anywhere)) return ''
1272
+ const local = r.via !== 'remote'
1273
+ const allow = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
1274
+ 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>' +
1275
+ '<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. ' +
1276
+ (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>'
1277
+ }
1278
+
1279
+ // The sign-in box under "Show QR code" / "Get a code": the QR image, the link,
1280
+ // the code, and a live countdown to its expiry.
1281
+ let signinTimer = null
1282
+ function paintSignin(l) {
1283
+ const box = document.getElementById('remoteLink')
1284
+ if (!box) return
1285
+ box.hidden = false
1286
+ const code = '<div class="code-big" aria-label="Sign-in code">' + esc(l.code) + '</div>'
1287
+ box.innerHTML = (l.qrSvg ? '<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>' : '') +
1288
+ '<div class="signin-text">' +
1289
+ (l.qrSvg ? '<div class="faint">Scan with the iPhone camera, or open:</div><code class="link">' + esc(l.url) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.url) + '">Copy link</button>' +
1290
+ '<div class="faint gap">Home-screen app? Type this code in it:</div>' : '<div class="faint">Type this code in the home-screen app:</div>') + code +
1291
+ '<div class="expiry" id="signinExpiry" data-expires="' + esc(l.expiresAt) + '"></div>' +
1292
+ '<button type="button" class="linkish" data-action="' + (l.qrSvg ? 'remote-link' : 'remote-code') + '">New ' + (l.qrSvg ? 'QR code and code' : 'code') + '</button></div>'
1293
+ const tick = () => {
1294
+ const el = document.getElementById('signinExpiry')
1295
+ if (!el) { clearInterval(signinTimer); signinTimer = null; return }
1296
+ const left = Date.parse(el.dataset.expires) - Date.now()
1297
+ if (left <= 0) { el.textContent = 'Expired. Make a new one.'; el.classList.add('warn'); box.classList.add('expired'); clearInterval(signinTimer); signinTimer = null; return }
1298
+ const m = Math.floor(left / 60000), sec = Math.floor(left / 1000) % 60
1299
+ el.textContent = 'Works once · expires in ' + m + ':' + String(sec).padStart(2, '0') + ' (at ' + new Date(el.dataset.expires).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + ')'
1300
+ }
1301
+ if (signinTimer) clearInterval(signinTimer)
1302
+ signinTimer = setInterval(tick, 1000)
1303
+ tick()
1304
+ }
1305
+
1306
+ // Settings → Digest (v0.4.3): time with its zone, days, skip quiet days, zone override.
1307
+ const DAY_KEYS = [['mon', 'M', 'Monday'], ['tue', 'T', 'Tuesday'], ['wed', 'W', 'Wednesday'], ['thu', 'T', 'Thursday'], ['fri', 'F', 'Friday'], ['sat', 'S', 'Saturday'], ['sun', 'S', 'Sunday']]
1308
+ function browserZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return '' } }
1309
+ function digestSettings(c) {
1310
+ const d = c.digest || {}
1311
+ const dg = (state.health && state.health.digest) || {}
1312
+ const days = Array.isArray(d.days) && d.days.length ? d.days : DAY_KEYS.map(([k]) => k)
1313
+ const zone = dg.timeZone || d.timeZone || dg.daemonZone || ''
1314
+ const bz = browserZone()
1315
+ let zones = []
1316
+ try { zones = Intl.supportedValuesOf('timeZone') } catch {}
1317
+ 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>' : ''
1318
+ return '<div class="section dup" id="digest">Digest</div>' +
1319
+ '<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>' +
1320
+ '<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear the time to turn automatic digests off.</div>' + zoneNote +
1321
+ '<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('') +
1322
+ '<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>' +
1323
+ '<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>' +
1324
+ '<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, ' ')])] })
1325
+ }
1326
+
1327
+ // Settings (v0.5.9): one section at a time. Desktop (≥900px) has a sidebar of
1328
+ // sections with the chosen one on the right; a phone gets an iOS-style list
1329
+ // that drills into a section with a back link. Every section has its own link
1330
+ // (#/settings/spaces) that survives a reload; the old ones (#/settings?spaces,
1331
+ // ?sync, ?remote…) land on the same section. All fields stay in one form, so
1332
+ // Save still saves every section, exactly as before; switching sections never
1333
+ // redraws the form, so nothing you typed is lost.
1334
+ const settingsUi = { q: '' }
1335
+ const wideSettings = () => window.matchMedia('(min-width: 900px)').matches
1336
+ function settingsKey(r) { return r ? settingsKeyFor(r.arg, r.params ? [...r.params.keys()] : []) : null }
1337
+ // The old links (#/settings?spaces) become the new form in the address bar, without a reload.
1338
+ function canonSettingsHash(r) {
1339
+ const k = settingsKey(r)
1340
+ if (k && (r.arg !== k || [...r.params.keys()].length)) { try { history.replaceState(null, '', '#/settings/' + k) } catch {} }
1341
+ }
1342
+ const settingsSec = (k) => SETTINGS.find((x) => x.key === k) || SETTINGS[0]
1343
+
1344
+ // Small dots on a section: what used to show at the top of the page.
1345
+ function settingsDots() {
1346
+ const dots = {}
1347
+ const add = (k, level, why) => { const d = dots[k] || (dots[k] = { level, why: [] }); if (level === 'warn') d.level = 'warn'; d.why.push(why) }
1348
+ const st = stUi.data
1349
+ if (st && st.installed) {
1350
+ if (!st.running) add('sync', 'warn', 'Syncthing is installed but not running')
1351
+ else if (st.folder) {
1352
+ if (st.folder.isCurrent === false) add('sync', 'warn', 'Syncthing shares another folder than the one Leftoff uses')
1353
+ if ((st.folder.errors || []).length) add('sync', 'warn', 'Syncthing reports a folder error')
1354
+ for (const p of st.peers || []) if (p.notSharing) add('sync', 'warn', p.name + ' isn’t sharing the folder yet'); else if (!p.connected && (!p.lastSeen || Date.now() - Date.parse(p.lastSeen) > 864e5)) add('sync', 'warn', p.name + ' hasn’t connected for over a day')
1355
+ }
1356
+ if ((st.knocked || []).length) add('sync', 'warn', 'A Mac asked to connect without the pairing proof')
1357
+ }
1358
+ if (st && st.run && st.run.state === 'failed') add('sync', 'warn', 'The last Syncthing step failed')
1359
+ const peers = syncUi.peers && syncUi.peers.peers
1360
+ if (peers && peers.some((x) => x.trust === 'key_changed')) add('security', 'warn', 'A machine’s key changed')
1361
+ const ag = agentUi.data && agentUi.data.agents
1362
+ if (ag && ag.some((a) => a.status && a.status.error)) add('agents', 'warn', 'A feed couldn’t be pulled')
1363
+ const h = state.health || {}
1364
+ if (h.digest && h.digest.lastError) add('digest', 'warn', 'The last digest failed')
1365
+ if (h.update && h.update.available) add('updates', 'info', 'Update available: v' + (h.update.remoteVersion || '?'))
1366
+ const sp = spacesUi.data
1367
+ if (sp && (sp.machines || []).some((m) => !m.set)) add('spaces', 'info', 'A Mac has no space yet')
1368
+ return dots
1369
+ }
1370
+ function paintSettingsDots() {
1371
+ const wrap = document.getElementById('settingsWrap')
1372
+ if (!wrap) return
1373
+ const dots = settingsDots()
1374
+ for (const a of wrap.querySelectorAll('.set-nav a[data-sec]')) {
1375
+ const d = dots[a.dataset.sec], el = a.querySelector('.sdot')
1376
+ el.hidden = !d
1377
+ el.className = 'sdot' + (d ? ' ' + d.level : '')
1378
+ el.title = d ? d.why.join(' · ') : ''
1379
+ el.textContent = d ? d.why.join('. ') : ''
1380
+ }
1381
+ }
1382
+
1383
+ // Search inside settings: section names, their key words, and every label in them.
1384
+ function settingsFilter() {
1385
+ const wrap = document.getElementById('settingsWrap')
1386
+ if (!wrap) return null
1387
+ const q = settingsUi.q.trim().toLowerCase()
1388
+ let first = null
1389
+ for (const sec of SETTINGS) {
1390
+ const a = wrap.querySelector('.set-nav a[data-sec="' + sec.key + '"]')
1391
+ const pane = wrap.querySelector('.set-pane[data-sec="' + sec.key + '"]')
1392
+ const labels = pane ? [...pane.querySelectorAll('label, .section, .sp-sub, .ah strong, summary')].map((el) => el.textContent.toLowerCase()) : []
1393
+ const hit = !q || (sec.label + ' ' + sec.words).toLowerCase().includes(q) || labels.some((t) => t.includes(q))
1394
+ a.parentElement.hidden = !hit
1395
+ if (hit && !first) first = sec.key
1396
+ }
1397
+ wrap.querySelectorAll('.set-hit').forEach((el) => el.classList.remove('set-hit'))
1398
+ 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')
1399
+ const none = wrap.querySelector('.set-nomatch')
1400
+ none.hidden = !!first
1401
+ none.textContent = first ? '' : 'Nothing in Settings matches “' + settingsUi.q.trim() + '”.'
1402
+ return first
1403
+ }
1404
+
1405
+ // Show one section without redrawing the form.
1406
+ function showSettingsSection(r) {
1407
+ const wrap = document.getElementById('settingsWrap')
1408
+ if (!wrap) return
1409
+ canonSettingsHash(r)
1410
+ closeMenu()
1411
+ const asked = settingsKey(r)
1412
+ const sec = settingsSec(asked || 'general')
1413
+ wrap.classList.toggle('has-sec', !!asked)
1414
+ wrap.classList.toggle('no-sec', !asked)
1415
+ for (const p of wrap.querySelectorAll('.set-pane')) p.hidden = p.dataset.sec !== sec.key
1416
+ for (const a of wrap.querySelectorAll('.set-nav a[data-sec]')) { const on = a.dataset.sec === sec.key; a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') }
1417
+ document.getElementById('setTitle').textContent = sec.label
1418
+ document.getElementById('setSub').textContent = sec.sub
1419
+ // Save shows where a section has fields that Save writes (Privacy & security has none while remote access is off)
1420
+ const pane = wrap.querySelector('.set-pane[data-sec="' + sec.key + '"]')
1421
+ document.getElementById('setSave').hidden = !(sec.save && pane && pane.querySelector('input:not([type="hidden"]):not([data-agent-field]), lo-select, .day'))
1422
+ document.title = (asked || wideSettings() ? sec.label + ' · ' : '') + 'Settings · Leftoff'
1423
+ settingsFilter()
1424
+ if (!wideSettings() || wrap.getBoundingClientRect().top < 0) window.scrollTo(0, 0)
1425
+ }
1426
+
1427
+ async function viewSettings() {
1428
+ const [c, , remote, sync, peers, st, , , relay] = 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)])
1429
+ state.relay = relay
1430
+ syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
1431
+ state.cfg = c
1432
+ const sm = c.summarizer || {}
1433
+ const cur = String(sm.provider || sm.mode || 'auto').toLowerCase()
1434
+ const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
1435
+ const h = state.health || {}
1436
+ const unavailable = (what) => '<div class="full hint set-empty">' + esc(what) + '</div>'
1437
+ const panes = {
1438
+ general: '<div class="section">Checkpoints</div>' +
1439
+ '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
1440
+ '<div class="hint">A session with no activity this long gets a checkpoint.</div>' +
1441
+ '<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
1442
+ '<div class="hint">No automatic checkpoint closer than this to the last one.</div>' +
1443
+ '<div class="section">Needs you</div>' +
1444
+ '<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>' +
1445
+ '<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>' +
1446
+ '<div class="section">Appearance</div>' +
1447
+ '<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>',
1448
+ digest: digestSettings(c),
1449
+ spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
1450
+ sync: syncSection(sync, stUi.data) || unavailable('Couldn’t read the sync status from Leftoff.'),
1451
+ agents: '<div class="full-box" id="agentsBox">' + agentsSectionHtml() + '</div>',
1452
+ models: '<div class="section">Summarizer</div>' +
1453
+ '<label for="f-mode">Provider</label>' + selectHtml({ id: 'f-mode', value: cur, options: PROVIDERS.map(([v, label]) => [v, label]) }) +
1454
+ '<div class="hint" id="f-mode-hint"></div>' +
1455
+ '<label data-row="model">Model</label><div data-row="model" class="models" id="f-models-list" role="radiogroup" aria-label="Model"><div class="nocp">Loading…</div></div>' +
1456
+ '<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
1457
+ '<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
1458
+ '<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>' +
1459
+ '<div class="hint" data-row="key" id="f-key-hint"></div>' +
1460
+ summariesSection(c),
1461
+ phone: (anywhereSection(relay) ? anywhereSection(relay) + '<div class="section">Over Tailscale</div>' : '') + (remoteSection(remote) || unavailable('Couldn’t read remote access from Leftoff.')) +
1462
+ (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>' : ''),
1463
+ security: '<div class="section" id="answering">Answering from other devices</div>' + (answeringSection(remote, relay) ||
1464
+ '<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>') +
1465
+ machinesSection(peers) +
1466
+ '<div class="section">Your data</div>' +
1467
+ '<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>',
1468
+ updates: '<div class="section">Updates</div>' +
1469
+ '<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>' +
1470
+ '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
1471
+ '<div class="section">About</div>' +
1472
+ '<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>' +
1473
+ '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
1474
+ '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>',
1475
+ }
1476
+ const r = state.route || parseRoute()
1477
+ $app.innerHTML = '<div class="settings no-sec" id="settingsWrap">' +
1478
+ '<aside class="set-nav"><div class="set-navhead"><h1>Settings</h1></div>' +
1479
+ '<div class="set-search">' + icon('search') + '<input id="setFilter" type="search" placeholder="Search settings" aria-label="Search settings" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(settingsUi.q) + '"></div>' +
1480
+ '<nav aria-label="Settings sections"><ul>' + SETTINGS.map((x) => '<li><a href="#/settings/' + x.key + '" data-sec="' + x.key + '">' + icon(x.icon) + '<span class="set-lbl">' + esc(x.label) + '</span><span class="sdot" hidden></span>' + icon('chevron-right', 'ic set-chev') + '</a></li>').join('') + '</ul></nav>' +
1481
+ '<p class="set-nomatch" hidden></p></aside>' +
1482
+ '<div class="set-main"><div class="set-head"><a class="set-back" href="#/settings">' + icon('chevron-left') + 'Settings</a><h2 id="setTitle"></h2><div class="sub" id="setSub"></div></div>' +
1483
+ '<form class="form" id="settingsForm" autocomplete="off">' +
1484
+ SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
1485
+ '<div class="full set-save" id="setSave"><span class="faint">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit">' + icon('check') + 'Save</button></div></form></div></div>'
1486
+ showSettingsSection(r)
1487
+ paintSettingsDots()
1488
+ syncProviderFields()
1489
+ // Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
1490
+ for (const id of ['f-signup-user', 'f-signup-email']) { const el = document.getElementById(id); if (!el) continue; el.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const b = document.querySelector('[data-action="relay-signup"]'); if (b) b.click() } }); el.addEventListener('input', () => { const er = document.getElementById('signupErr'); if (er) er.hidden = true }) }
1491
+ if (document.querySelector('.signup-wait')) relaySignupPoll()
1492
+ for (const el of document.querySelectorAll('input[name="f-length"]')) el.addEventListener('change', () => { const ex = document.getElementById('f-length-example'); if (ex) ex.innerHTML = lengthExample(el.value) })
1493
+ if (syncUi.step) paintSyncWizard()
1494
+ stSchedule()
1495
+ document.getElementById('f-mode').addEventListener('change', () => {
1496
+ const c = state.cfg || {}
1497
+ const same = document.getElementById('f-mode').value === String((c.summarizer && c.summarizer.provider) || 'auto').toLowerCase()
1498
+ document.getElementById('f-model').value = same ? (c.summarizer.model || '') : ''
1499
+ syncProviderFields()
1500
+ })
1501
+ const f = document.getElementById('setFilter')
1502
+ f.addEventListener('input', () => { settingsUi.q = f.value; settingsFilter() })
1503
+ f.addEventListener('keydown', (ev) => {
1504
+ if (ev.key === 'Escape' && f.value) { ev.preventDefault(); f.value = ''; settingsUi.q = ''; settingsFilter() }
1505
+ if (ev.key === 'Enter') { ev.preventDefault(); const k = settingsFilter(); if (k) location.hash = '#/settings/' + k }
1506
+ })
1507
+ }
1508
+
1509
+ // ------------------------------------------------------------ sync and other machines (v0.5.2)
1510
+
1511
+ const syncUi = { step: null, pick: null, custom: '', plan: null, data: null, peers: null, error: null, moving: null }
1512
+
1513
+ function syncSection(d, st) {
1514
+ if (!d) return ''
1515
+ const local = d.viewer && d.viewer.local
1516
+ const cur = d.current || {}
1517
+ let h = '<div class="section dup" id="sync">Sync</div>' +
1518
+ '<label>Folder</label><div class="syncnow"><span class="chip mono">' + esc(cur.display || '') + '</span>' +
1519
+ (cur.syncedBy ? '<span class="chip ok">' + icon('check') + 'in ' + esc(cur.syncedBy.label) + '</span>' : '<span class="chip">not in a synced folder</span>') + '</div>' +
1520
+ '<div class="hint">' + (cur.syncedBy ? 'Every Mac that uses this folder shows up on one board. Each writes only its own files, so they never collide.' : 'Put the folder in Syncthing, Dropbox or iCloud Drive and every Mac you use shows up on one board.') + '</div>'
1521
+ h += stSection(st)
1522
+ const stOwns = st && st.folder && st.folder.isCurrent
1523
+ if (local) h += '<label>' + (st ? 'Other sync' : 'Set up') + '</label><div><button type="button" class="btn' + (cur.syncedBy || st ? '' : ' primary') + '" data-action="sync-open">' + icon('folder-sync') + (cur.syncedBy ? 'Move or join another folder' : st ? 'Use iCloud, Dropbox or another folder' : 'Set up syncing') + '</button></div>'
1524
+ else h += '<label>Set up</label><div class="hint">Moving the folder is done on the Mac itself (its own browser, or <code>leftoff move</code>).</div>'
1525
+ h += '<div class="full" id="syncWizard"' + (syncUi.step ? '' : ' hidden') + '></div>'
1526
+ if (cur.syncedBy && d.join && !stOwns) h += '<label>Other Macs</label><div class="joinline"><code class="cmd">' + esc(d.join.installed) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.join.installed) + '">Copy</button></div>' +
1527
+ '<div class="hint">Run it on each other Mac once the folder has synced there. Not installed yet: <code>' + esc(d.join.fresh) + '</code></div>'
1528
+ for (const b of d.backups || []) {
1529
+ h += '<label>Old folder</label><div class="backup"><span class="chip mono">' + esc(b.display) + '</span>' + (local ? '<button type="button" class="btn sm" data-action="sync-delete-backup" data-path="' + esc(b.path) + '">Delete it…</button>' : '') + '</div>' +
1530
+ '<div class="hint">Kept from the move' + (b.at ? ' on ' + esc(clock(b.at)) : '') + '. Leftoff never deletes it on its own; delete it once the board looks right.</div>'
1531
+ }
1532
+ return h
1533
+ }
1534
+
1535
+ // ------------------------------------------------------------ Settings → Agents (v0.5.4)
1536
+
1537
+ const REPO_URL = 'https://github.com/OneSillyApp/leftoff'
1538
+ const AGENT_HOSTING_URL = REPO_URL + '/blob/main/docs/agent-feed-hosting.md'
1539
+ const agentUi = { data: null, err: null, busy: false }
1540
+ function agentStatusLine(a) {
1541
+ const st = a.status || {}
1542
+ const when = (t) => (t ? ago(t) : 'never')
1543
+ if (st.error) return '<span class="chip warn">' + esc(st.error.code === 'unreachable' ? 'unreachable' : st.error.code === 'invalid' ? 'invalid feed' : st.error.code === 'unauthorized' ? 'key refused' : 'error') + '</span> <span>' + esc(st.error.message) + '</span>' +
1544
+ '<div class="faint">Last success: ' + esc(when(st.lastSuccessAt)) + ' · tried ' + esc(when(st.lastAttemptAt)) + '</div>' +
1545
+ (st.error.details && st.error.details.length ? '<details class="agent-details"><summary>Details</summary><ul>' + st.error.details.map((d) => '<li class="mono">' + esc(d) + '</li>').join('') + '</ul></details>' : '')
1546
+ if (st.ok) return '<span class="chip ok">' + icon('check') + 'pulled ' + esc(when(st.lastSuccessAt)) + '</span> <span>' + esc(a.sessions) + ' session' + (a.sessions === 1 ? '' : 's') + (a.needsYou ? ' · ' + esc(a.needsYou) + ' waiting on you' : '') + '</span>'
1547
+ return '<span class="chip">not pulled yet</span>'
1548
+ }
1549
+ function agentRow(a, local) {
1550
+ const who = a.pullsHere ? 'pulled by this Mac every ' + esc(Math.round((agentUi.data && agentUi.data.pollSec || 120) / 60)) + ' min' : a.puller ? 'pulled by ' + esc(a.puller) + (a.hasKey ? '' : ' (its key is on that Mac)') : 'not pulled'
1551
+ return '<div class="agentrow" data-agent="' + esc(a.name) + '">' +
1552
+ '<div class="ah">' + agentChipHtml(a) + '<span class="mono faint">' + esc(a.name) + '</span></div>' +
1553
+ '<div class="as">' + agentStatusLine(a) + '</div>' +
1554
+ '<div class="faint am"><span class="mono">' + esc(a.feedUrl || '') + '</span> · ' + who + '</div>' +
1555
+ '<div class="row">' + (a.pullsHere ? '<button type="button" class="btn" data-action="agent-refresh" data-name="' + esc(a.name) + '">Refresh</button>' : '') +
1556
+ (local ? '<button type="button" class="btn" data-action="agent-remove" data-name="' + esc(a.name) + '">Remove</button>' : '') + '</div></div>'
1557
+ }
1558
+ function agentsSectionHtml() {
1559
+ const d = agentUi.data
1560
+ if (!d) return '<div class="section dup" id="agents">Agents</div><div class="full nocp">' + esc(agentUi.err || 'Loading…') + '</div>'
1561
+ const local = d.viewer && d.viewer.local
1562
+ let h = '<div class="section dup" id="agents">Agents</div>' +
1563
+ '<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.') +
1564
+ ' <a class="agent-howto" href="' + AGENT_HOSTING_URL + '" target="_blank" rel="noopener noreferrer">How to host a feed ↗</a></div>'
1565
+ 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>'
1566
+ if (local) {
1567
+ h += '<div class="full agentadd"><div class="ah"><strong>Add agent feed</strong></div>' +
1568
+ '<div class="agrid"><label for="f-agent-name">Name</label><input id="f-agent-name" data-agent-field placeholder="tab" maxlength="31" autocapitalize="off" spellcheck="false" pattern="[a-z0-9][a-z0-9-]*">' +
1569
+ '<label for="f-agent-url">Feed URL</label><input id="f-agent-url" data-agent-field type="url" placeholder="https://…/feed.json" spellcheck="false">' +
1570
+ '<label for="f-agent-key">Key</label><input id="f-agent-key" data-agent-field type="password" autocomplete="new-password" spellcheck="false" placeholder="the feed’s key"></div>' +
1571
+ '<div class="row"><button type="button" class="btn primary" data-action="agent-add"' + (agentUi.busy ? ' disabled' : '') + '>' + (agentUi.busy ? 'Adding…' : 'Add and pull') + '</button><span class="faint">The key stays on this Mac (~/.config/checkpoints/agents/, mode 0600). Only the name and URL go in the synced folder.</span></div>' +
1572
+ (agentUi.addErr ? '<div class="need-note warn">' + esc(agentUi.addErr) + '</div>' : '') + '</div>'
1573
+ } else h += '<div class="full hint">Adding or removing a feed works on the Mac running Leftoff (or <code>leftoff agents add</code>).</div>'
1574
+ return h
1575
+ }
1576
+ async function agentsLoad() { try { agentUi.data = await api('GET', '/v1/agents'); agentUi.err = null } catch (e) { agentUi.err = e.message } }
1577
+ async function agentsRepaint() {
1578
+ await agentsLoad()
1579
+ const el = document.getElementById('agentsBox')
1580
+ if (el) el.innerHTML = agentsSectionHtml()
1581
+ paintSettingsDots()
1582
+ }
1583
+ async function agentAdd(btn) {
1584
+ const v = (id) => (document.getElementById(id) || {}).value || ''
1585
+ const body = { name: v('f-agent-name').trim().toLowerCase(), url: v('f-agent-url').trim(), key: v('f-agent-key') }
1586
+ if (!body.name || !body.url) { agentUi.addErr = 'Give it a name and the feed URL.'; return agentsRepaint() }
1587
+ agentUi.busy = true; agentUi.addErr = null; btn.disabled = true; btn.textContent = 'Adding…'
1588
+ try {
1589
+ const r = await api('POST', '/v1/agents', body, { timeoutMs: 30000 })
1590
+ const p = r.pull || {}
1591
+ toast(p.result === 'error' ? 'Added ' + body.name + ', but the first pull failed: ' + ((p.error && p.error.message) || 'error') : 'Added ' + body.name + (p.applied ? ': ' + p.applied.sessions + ' session' + (p.applied.sessions === 1 ? '' : 's') : ''), 4000)
1592
+ } catch (e) { agentUi.addErr = e.message }
1593
+ agentUi.busy = false
1594
+ await refreshHealth().catch(() => {})
1595
+ return agentsRepaint()
1596
+ }
1597
+
1598
+ // Settings → Spaces (v0.5.8): each Mac's default space, repo overrides,
1599
+ // agent feeds. Saved at once to spaces.json in the folder (no Save button).
1600
+ const spacesUi = { data: null, err: null }
1601
+ async function spacesLoad() { try { spacesUi.data = await api('GET', '/v1/spaces'); spacesUi.err = null } catch (e) { spacesUi.err = e.message } }
1602
+ async function spacesRepaint() { await spacesLoad(); const el = document.getElementById('spacesBox'); if (el) el.innerHTML = spacesSectionHtml(); paintSettingsDots() }
1603
+ function spaceSelect(kind, key, current, opts, { unsetLabel, isSet = true } = {}) {
1604
+ return selectHtml({ cls: 'spsel', label: 'Space for ' + key, value: isSet ? current : '', attrs: { 'data-space-kind': kind, 'data-space-key': key },
1605
+ options: [['', unsetLabel], ...opts.map((x) => [x, x]), ['__new', 'New space…']] })
1606
+ }
1607
+ function spacesSectionHtml() {
1608
+ const d = spacesUi.data
1609
+ if (!d) return '<div class="section dup" id="spaces">Spaces</div><div class="full nocp">' + esc(spacesUi.err || 'Loading…') + '</div>'
1610
+ const opts = sortSpacesUi([...(d.spaces || []), ...(d.quickPicks || [])])
1611
+ let h = '<div class="section dup" id="spaces">Spaces</div>' +
1612
+ '<div class="full hint sp-explain">A space is a name like <b>Work</b> or <b>Personal</b>. Every session belongs to one: its own pick, else its repo’s, else its Mac’s default. The digest has a section per space with <b>Copy for standup</b>, and the board can show one space at a time. Saved in <code>spaces.json</code> in the synced folder, so every Mac agrees. <span class="faint">Planned: spaces will also decide which Macs each part of the folder syncs to, so Personal never lands on a work Mac.</span></div>'
1613
+ h += '<div class="full spaces"><div class="sp-sub">Macs and their default space</div>' + d.machines.map((m) =>
1614
+ '<div class="sprow"><div class="sp-name"><span class="chip machine">⌂ ' + esc(m.machine) + '</span>' + (m.here ? '<span class="faint">this Mac</span>' : '') + (m.set ? '' : '<span class="faint">no space yet, so it’s “' + esc(m.machine) + '”</span>') + '</div>' +
1615
+ '<div class="sp-pick">' + (d.quickPicks || []).map((q) => '<button type="button" class="spq" data-action="space-quick" data-key="' + esc(m.machine) + '" data-space="' + esc(q) + '" aria-pressed="' + (m.set && m.space === q) + '">' + esc(q) + '</button>').join('') +
1616
+ spaceSelect('machine', m.machine, m.space, opts, { unsetLabel: 'Its name', isSet: m.set }) + '</div></div>').join('') + '</div>'
1617
+ h += '<div class="full spaces"><div class="sp-sub">Repo overrides <span class="faint">a repo counts as this space on every Mac (matched by its folder name)</span></div>' +
1618
+ (d.repos.length ? d.repos.map((r) => '<div class="sprow"><div class="sp-name"><span class="chip mono">' + esc(r.key) + '</span></div><div class="sp-pick"><span class="chip space">' + esc(r.space) + '</span><button type="button" class="btn" data-action="space-unset" data-kind="repo" data-key="' + esc(r.key) + '">Remove</button></div></div>').join('') : '<div class="nocp">None yet. A work repo you also open on your personal Mac goes here.</div>') +
1619
+ '<div class="sprow spadd">' + comboHtml({ id: 'f-space-repo', label: 'Repo', placeholder: 'repo folder name',
1620
+ options: (d.repoSuggestions || (d.knownRepos || []).map((r) => ({ name: r }))).map((r) => ({ value: r.name, label: r.name, sub: r.parent || '' })) }) +
1621
+ selectHtml({ id: 'f-space-repo-space', label: 'Space', options: opts.map((x) => [x, x]) }) +
1622
+ '<button type="button" class="btn" data-action="space-repo-add">' + icon('plus') + 'Add</button></div></div>'
1623
+ if (d.agents && d.agents.length) {
1624
+ h += '<div class="full spaces"><div class="sp-sub">Agent feeds <span class="faint">default: the space of the Mac that pulls the feed</span></div>' + d.agents.map((a) => {
1625
+ const own = (d.agentOverrides || []).find((x) => x.key === a.name)
1626
+ return '<div class="sprow"><div class="sp-name"><span class="chip machine agent">' + esc(a.display || a.name) + '</span>' + (a.puller ? '<span class="faint">pulled by ' + esc(a.puller) + '</span>' : '') + '</div><div class="sp-pick">' + spaceSelect('agent', a.name, own ? own.space : a.space, opts, { unsetLabel: 'Its Mac’s (' + (own ? 'default' : a.space) + ')', isSet: !!own }) + '</div></div>'
1627
+ }).join('') + '</div>'
1628
+ }
1629
+ if (d.sessions && d.sessions.length) h += '<div class="full hint">' + d.sessions.length + ' session' + (d.sessions.length === 1 ? ' has its' : 's have their') + ' own space (set on the session’s page).</div>'
1630
+ h += '<div class="full hint">From a terminal: <code>leftoff spaces</code>, <code>leftoff spaces set-machine Work</code>, <code>leftoff spaces set-repo &lt;repo&gt; Work</code>, <code>leftoff standup --copy</code>.</div>'
1631
+ return h
1632
+ }
1633
+ async function spacesAction(action, btn) {
1634
+ let body
1635
+ if (action === 'space-quick') body = { kind: 'machine', key: btn.dataset.key, space: btn.getAttribute('aria-pressed') === 'true' ? null : btn.dataset.space }
1636
+ else if (action === 'space-unset') body = { kind: btn.dataset.kind, key: btn.dataset.key, space: null }
1637
+ else {
1638
+ const repo = (document.getElementById('f-space-repo') || {}).value || ''
1639
+ const space = (document.getElementById('f-space-repo-space') || {}).value || ''
1640
+ if (!repo.trim()) return toast('Type the repo’s folder name')
1641
+ body = { kind: 'repo', key: repo.trim(), space }
1642
+ }
1643
+ btn.disabled = true
1644
+ try { await api('POST', '/v1/spaces', body); toast(body.space ? (body.kind === 'repo' ? body.key + ' counts as ' : body.key + ' is in ') + body.space : 'Removed') } catch (e) { toast(e.message, 5000) }
1645
+ return spacesRepaint()
1646
+ }
1647
+
1648
+ function machinesSection(p) {
1649
+ if (!p || p.off) return ''
1650
+ const local = p.viewer && p.viewer.local
1651
+ let h = '<div class="section" id="machines">Other machines</div>' +
1652
+ '<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow other machines to answer and Go here</span></label>' +
1653
+ '<div class="hint">Off by default. On, a Mac that shares this folder can answer this Mac’s permission prompts and questions and press Go, from its own board. Each command is signed with that Mac’s key from the folder, encrypted, and expires in a minute. ' +
1654
+ (p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>'
1655
+ h += '<div class="full">' + protectHtml('machines') + '</div>'
1656
+ if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
1657
+ h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
1658
+ const u = x.usable || {}
1659
+ const st = u.ok ? '<span class="chip ok">' + icon('check') + 'can answer and Go there · ' + esc(u.via === 'relay' ? 'through the relay' : 'direct') + '</span>' : '<span class="chip' + (x.trust === 'key_changed' ? ' warn' : '') + '">' + esc(u.reason || '') + '</span>'
1660
+ return '<div class="peer" data-machine="' + esc(x.machine) + '"><span class="chip machine remote">⌂ ' + esc(x.machine) + '</span>' + st +
1661
+ '<span class="fp" title="its key">' + esc(x.fingerprint || '') + '</span>' +
1662
+ (x.trust === 'key_changed' && local ? '<button type="button" class="btn sm" data-action="peer-trust" data-machine="' + esc(x.machine) + '">Trust the new key</button>' : '') + '</div>'
1663
+ }).join('') + '</div>'
1664
+ return h + '<div class="hint">A machine’s key is trusted the first time this Mac sees it. If it changes, actions stop until you confirm it here.</div>'
1665
+ }
1666
+
1667
+ function paintSyncWizard() {
1668
+ const el = document.getElementById('syncWizard')
1669
+ if (!el) return
1670
+ el.hidden = !syncUi.step
1671
+ const d = syncUi.data || {}
1672
+ let h = '<div class="wizard">'
1673
+ const steps = ['Pick a folder', 'Check', 'Move', 'Other Macs']
1674
+ const at = { pick: 0, preview: 1, moving: 2, done: 3, failed: 2 }[syncUi.step] || 0
1675
+ h += '<ol class="wsteps">' + steps.map((s, i) => '<li class="' + (i < at ? 'done' : i === at ? 'now' : '') + '">' + esc(s) + '</li>').join('') + '</ol>'
1676
+ if (syncUi.step === 'pick') {
1677
+ const cands = d.candidates || []
1678
+ h += cands.length ? '' : '<p class="hint">No Syncthing, Dropbox or iCloud Drive folder found on this Mac. Type any folder that something syncs.</p>'
1679
+ h += '<div class="wpicks" role="radiogroup" aria-label="Synced folder">' + cands.map((c, i) => '<button type="button" class="wpick' + (syncUi.pick === c.suggested ? ' on' : '') + '" role="radio" aria-checked="' + (syncUi.pick === c.suggested) + '" data-action="sync-pick" data-path="' + esc(c.suggested) + '">' +
1680
+ '<b>' + esc(c.label) + (c.recommended ? ' <span class="chip ok">recommended</span>' : '') + '</b><span class="mono">' + esc(c.display) + '</span>' +
1681
+ (c.hasLeftoff ? '<span class="join">Already has Leftoff from another Mac: this joins it</span>' : '') + '<span class="tradeoff">' + esc(c.tradeoff) + '</span></button>').join('') +
1682
+ '<div class="wpick other' + (syncUi.pick === '__custom' ? ' on' : '') + '"><b>Another folder</b><input id="syncCustom" placeholder="~/Google Drive/Leftoff" value="' + esc(syncUi.custom) + '" spellcheck="false" autocomplete="off"><span class="tradeoff">Any folder works if something syncs it. Leftoff only needs plain files.</span></div></div>'
1683
+ const picked = cands.find((c) => c.suggested === syncUi.pick)
1684
+ h += protectHtml(picked ? picked.provider : 'other')
1685
+ h += '<div class="wactions"><button type="button" class="btn" data-action="sync-cancel">Cancel</button><button type="button" class="btn primary" data-action="sync-preview"' + (syncUi.pick ? '' : ' disabled') + '>Next: check it</button></div>'
1686
+ } else if (syncUi.step === 'preview' && syncUi.plan) {
1687
+ const p = syncUi.plan, n = p.counts || {}
1688
+ const problems = [...(p.errors || []), ...(p.collisions || [])]
1689
+ h += '<p><b>' + (p.merge ? 'Join ' : 'Move to ') + '</b><span class="mono">' + esc(p.toDisplay || p.to) + '</span></p>'
1690
+ if (problems.length) h += problems.map((e) => '<div class="warnbox">' + esc(e.message) + '</div>').join('')
1691
+ else {
1692
+ h += '<ul class="wplan"><li>' + esc(n.total) + ' files: ' + esc(n.copy) + ' to copy' + (n.same ? ', ' + esc(n.same) + ' already there' : '') + (n.keepTarget ? ', ' + esc(n.keepTarget) + ' kept from the joined folder' : '') + (n.conflictCopy ? ', ' + esc(n.conflictCopy) + ' list' + (n.conflictCopy === 1 ? '' : 's') + ' merged item by item' : '') + '</li>' +
1693
+ (p.warnings || []).map((w) => '<li>' + esc(w.message) + '</li>').join('') +
1694
+ '<li>Leftoff stops for a few seconds, copies everything, checks every file, then restarts on the new folder.</li>' +
1695
+ '<li>The old folder stays, renamed to <span class="mono">' + esc(p.backupDisplay || p.backup) + '</span>, until you delete it.</li></ul>'
1696
+ }
1697
+ h += '<div class="wactions"><button type="button" class="btn" data-action="sync-back">Back</button>' + (problems.length ? '' : '<button type="button" class="btn primary" data-action="sync-move">' + (p.merge ? 'Join it' : 'Move it') + '</button>') + '</div>'
1698
+ } else if (syncUi.step === 'moving') {
1699
+ h += '<p class="wmoving"><span class="spin" aria-hidden="true"></span>' + esc(syncUi.moving || 'Moving…') + '</p><p class="hint">The board reconnects on its own when Leftoff is back.</p>'
1700
+ } else if (syncUi.step === 'done') {
1701
+ const m = (d.lastMove || {})
1702
+ h += '<div class="okbox">' + icon('check') + (m.merge ? 'Joined' : 'Moved') + '. ' + esc(m.copied) + ' files copied and ' + esc(m.verified) + ' checked. Leftoff now uses <span class="mono">' + esc((d.current || {}).display || '') + '</span>.</div>' +
1703
+ '<p><b>On each other Mac</b>, once the folder has synced there, run:</p>' +
1704
+ '<div class="joinline"><code class="cmd">' + esc(d.join.installed) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.join.installed) + '">Copy</button></div>' +
1705
+ '<p class="hint">Not installed there yet: <code>' + esc(d.join.fresh) + '</code>. It merges with what’s already in the folder; nothing is overwritten.</p>' +
1706
+ '<p class="hint">Then, on the Mac you want to answer from another one: Settings → Privacy & security → “Allow other machines to answer and Go here”.</p>' +
1707
+ '<div class="wactions"><button type="button" class="btn primary" data-action="sync-cancel">Done</button></div>'
1708
+ } else if (syncUi.step === 'failed') {
1709
+ h += '<div class="warnbox">The move stopped: ' + esc(syncUi.error || 'unknown error') + '. Everything was put back; Leftoff still uses <span class="mono">' + esc((d.current || {}).display || '') + '</span>.</div><div class="wactions"><button type="button" class="btn" data-action="sync-back">Back</button></div>'
1710
+ }
1711
+ el.innerHTML = h + '</div>'
1712
+ const ci = document.getElementById('syncCustom')
1713
+ if (ci) ci.addEventListener('input', () => { syncUi.custom = ci.value; syncUi.pick = ci.value.trim() ? '__custom' : null; const b = document.querySelector('[data-action="sync-preview"]'); if (b) b.disabled = !syncUi.pick; document.querySelectorAll('.wpick').forEach((x) => x.classList.toggle('on', x.classList.contains('other') && !!syncUi.pick)) })
1714
+ }
1715
+
1716
+ async function syncAction(action, btn) {
1717
+ if (action === 'sync-open') { syncUi.step = 'pick'; syncUi.plan = null; const c = ((syncUi.data || {}).candidates || [])[0]; if (!syncUi.pick && c) syncUi.pick = c.suggested; paintSyncWizard(); document.getElementById('syncWizard').scrollIntoView({ block: 'nearest' }); return }
1718
+ if (action === 'sync-cancel') { syncUi.step = null; return viewSettings() }
1719
+ if (action === 'sync-pick') { syncUi.pick = btn.dataset.path; return paintSyncWizard() }
1720
+ if (action === 'sync-back') { syncUi.step = 'pick'; return paintSyncWizard() }
1721
+ if (action === 'sync-preview') {
1722
+ const to = syncUi.pick === '__custom' ? syncUi.custom.trim() : syncUi.pick
1723
+ btn.disabled = true
1724
+ try { syncUi.plan = await api('POST', '/v1/sync/plan', { to }); syncUi.step = 'preview' } catch (e) { toast(e.message, 6000) }
1725
+ return paintSyncWizard()
1726
+ }
1727
+ if (action === 'sync-move') {
1728
+ btn.disabled = true
1729
+ try { await api('POST', '/v1/sync/move', { to: syncUi.plan.to }) } catch (e) { toast(e.message, 8000); btn.disabled = false; return }
1730
+ syncUi.step = 'moving'; syncUi.moving = 'Stopping Leftoff and copying…'; paintSyncWizard()
1731
+ const t0 = Date.now()
1732
+ for (;;) {
1733
+ await new Promise((r) => setTimeout(r, 700))
1734
+ if (Date.now() - t0 > 180000) { syncUi.step = 'failed'; syncUi.error = 'no word back in 3 minutes (check `leftoff status`)'; break }
1735
+ let d
1736
+ try { d = await api('GET', '/v1/sync', undefined, { timeoutMs: 1500 }) } catch { syncUi.moving = 'Leftoff is restarting…'; paintSyncWizard(); continue }
1737
+ const m = d.lastMove || {}
1738
+ if (m.to !== syncUi.plan.to || new Date(m.at) < new Date(t0 - 5000)) continue
1739
+ syncUi.data = d
1740
+ if (m.state === 'done') { syncUi.step = 'done'; break }
1741
+ if (m.state === 'failed' || m.state === 'refused') { syncUi.step = 'failed'; syncUi.error = m.error; break }
1742
+ syncUi.moving = m.message || 'Moving…'; paintSyncWizard()
1743
+ }
1744
+ const keep = syncUi.step
1745
+ connect() // the daemon restarted: a fresh live-update stream
1746
+ await viewSettings()
1747
+ syncUi.step = keep
1748
+ return paintSyncWizard()
1749
+ }
1750
+ if (action === 'sync-delete-backup') {
1751
+ const p = btn.dataset.path
1752
+ if (btn.dataset.armed !== '1') { btn.dataset.armed = '1'; btn.textContent = 'Delete for good'; btn.classList.add('danger'); toast('Press again to delete ' + p + '. This can’t be undone.', 6000); return }
1753
+ try { await api('POST', '/v1/sync/backup/delete', { path: p, confirm: true }); toast('Deleted the old folder') } catch (e) { toast(e.message, 6000) }
1754
+ return viewSettings()
1755
+ }
1756
+ if (action === 'peer-trust') {
1757
+ try { await api('POST', '/v1/peers/' + encodeURIComponent(btn.dataset.machine) + '/trust'); toast('Trusting ' + btn.dataset.machine + '’s new key') } catch (e) { toast(e.message, 6000) }
1758
+ return viewSettings()
1759
+ }
1760
+ }
1761
+
1762
+ // ------------------------------------------------------------ Easy Syncthing (v0.5.3)
1763
+ // Install Syncthing (Homebrew, after a confirm click), put the folder in it,
1764
+ // pair another Mac with a one-time code, join with a code, status, stop. All
1765
+ // changes are loopback only; a phone sees the status and nothing else.
1766
+
1767
+ const SECURITY_URL = REPO_URL + '/blob/main/docs/SECURITY.md'
1768
+ const PROTECT = {
1769
+ 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.'],
1770
+ 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.'],
1771
+ dropbox: ['A copy lives on Dropbox’s servers, under Dropbox’s terms. Dropbox can read it.', 'API keys and machine keys stay on each Mac. The board token and phone sign-in records (hashes only) sync with the folder.', 'Make the folder “available offline” so files stay on disk.'],
1772
+ other: ['Whoever runs that sync or storage can read the files.', 'API keys and machine keys stay on each Mac.', 'Leftoff only writes plain Markdown and JSON there.'],
1773
+ machines: ['Off by default, per Mac. While it’s off, nothing listens.', 'Each command is signed by the sending Mac, encrypted for this one, and expires in 60 seconds. Replays are refused.', 'A relay only passes along messages it can’t read.', 'A Mac’s private key never leaves it; a changed key is refused until you confirm it.'],
1774
+ }
1775
+ function protectHtml(kind, { open = false } = {}) {
1776
+ const items = PROTECT[kind] || PROTECT.other
1777
+ 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>' +
1778
+ '<a href="' + SECURITY_URL + '" target="_blank" rel="noopener">The full security notes</a></details>'
1779
+ }
1780
+
1781
+ const stUi = { data: null, panel: null, pair: null, joinCode: '', path: '', timer: null, busy: false, error: null, showLog: false }
1782
+
1783
+ function stCountdown(iso) {
1784
+ const s = Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 1000))
1785
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
1786
+ }
1787
+
1788
+ function stSection(d) {
1789
+ if (!d) return ''
1790
+ return '<label class="toplabel">Syncthing</label><div class="stwrap" id="stBox">' + stBoxHtml(d) + '</div>'
1791
+ }
1792
+
1793
+ function stStatusLine(d) {
1794
+ if (!d.installed) return '<span class="chip">not installed</span>'
1795
+ if (!d.running) return '<span class="chip warn">installed · not running</span>'
1796
+ const f = d.folder
1797
+ let h = '<span class="chip ok">' + icon('check') + 'running' + (d.version ? ' · ' + esc(d.version) : '') + '</span>'
1798
+ if (f) {
1799
+ const files = f.globalFiles != null ? (f.needFiles ? (f.inSyncFiles || 0) + '/' + f.globalFiles + ' files' : f.globalFiles + ' files') : ''
1800
+ h += '<span class="chip' + (f.needFiles ? '' : ' ok') + '">' + esc(f.needFiles ? 'syncing' : 'in sync') + (files ? ' · ' + esc(files) : '') + '</span>' +
1801
+ '<span class="chip">last sync ' + esc(f.lastSync ? ago(f.lastSync) : 'never') + '</span>'
1802
+ }
1803
+ return h
1804
+ }
1805
+
1806
+ function stPeersHtml(d) {
1807
+ if (!d.folder) return ''
1808
+ if (!d.peers.length) return '<div class="hint">No other Mac yet.</div>'
1809
+ return '<div class="stpeers">' + d.peers.map((p) => '<div class="stpeer"><span class="chip machine remote">⌂ ' + esc(p.name) + '</span>' +
1810
+ (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>'
1811
+ }
1812
+
1813
+ // A Mac that knocked during a pairing without the proof (never auto-accepted).
1814
+ // Accepting is a click on this Mac, then a confirm.
1815
+ function stKnockedHtml(d, local) {
1816
+ return (d.knocked || []).map((k) => {
1817
+ const confirming = local && stUi.panel === 'accept' && stUi.acceptId === k.deviceId
1818
+ let h = '<div class="warnbox stknock"><p>A Mac named <b>“' + esc(k.name) + '”</b> asked to connect without the pairing proof, so Leftoff didn’t accept it.' + (k.at ? ' <span class="muted">' + esc(ago(k.at)) + ' · ' + esc(k.short) + '</span>' : '') + '</p>'
1819
+ if (!local) return h + '<p class="hint">Accepting happens on this Mac itself.</p></div>'
1820
+ if (confirming) return h + '<p>Add “' + esc(k.name) + '” to Syncthing and share only the Leftoff folder with it? Only if it’s your Mac.</p><div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn primary" data-action="st-accept-go" data-id="' + esc(k.deviceId) + '">Add and share Leftoff</button></div></div>'
1821
+ return h + '<div class="wactions"><button type="button" class="btn" data-action="st-accept" data-id="' + esc(k.deviceId) + '">Accept ' + esc(k.name) + '</button></div></div>'
1822
+ }).join('')
1823
+ }
1824
+
1825
+ function stInstallHtml(d) {
1826
+ const inst = d.install || {}
1827
+ const job = inst.job || {}
1828
+ if (job.state === 'running') {
1829
+ return '<div class="wizard"><p class="wmoving"><span class="spin" aria-hidden="true"></span>' + esc(job.stage === 'Starting Syncthing…' ? 'Starting Syncthing…' : 'Installing Syncthing… this takes about a minute.') + '</p>' +
1830
+ '<pre class="stlog" aria-live="polite">' + esc((job.lines || []).slice(-10).join('\n')) + '</pre></div>'
1831
+ }
1832
+ if (job.state === 'failed') {
1833
+ return '<div class="wizard"><div class="warnbox">' + esc(job.error || 'The install stopped.') + '</div>' +
1834
+ (stUi.showLog ? '<pre class="stlog">' + esc((job.lines || []).slice(-30).join('\n')) + '</pre>' : '') +
1835
+ '<div class="wactions"><button type="button" class="btn" data-action="st-log">' + (stUi.showLog ? 'Hide details' : 'Details') + '</button><button type="button" class="btn primary" data-action="st-install-go">Try again</button></div></div>'
1836
+ }
1837
+ if (!inst.brew) {
1838
+ return '<div class="wizard"><p><b>Install Syncthing</b></p><ol class="stdirs">' +
1839
+ '<li>Install Homebrew from <a href="https://brew.sh" target="_blank" rel="noopener">brew.sh</a> (one line in Terminal). Then press Check again and Leftoff installs Syncthing for you.</li>' +
1840
+ '<li>Or download Syncthing for macOS from <a href="https://syncthing.net/downloads/" target="_blank" rel="noopener">syncthing.net</a> and open it once.</li></ol>' +
1841
+ '<div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn primary" data-action="st-refresh">Check again</button></div></div>'
1842
+ }
1843
+ return '<div class="wizard"><p><b>Install Syncthing</b></p><p>Leftoff will run:</p>' +
1844
+ '<div class="joinline"><code class="cmd">' + esc(inst.command) + '</code></div>' +
1845
+ '<p class="hint">Syncthing comes from Homebrew and runs quietly in the background, starting at login. You won’t need to open it.</p>' +
1846
+ '<div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn primary" data-action="st-install-go">' + icon('check') + 'Install</button></div></div>'
1847
+ }
1848
+
1849
+ function stRunHtml(run, { kind }) {
1850
+ if (!run || run.kind !== kind) return ''
1851
+ if (run.state === 'failed') return '<div class="warnbox">' + esc(run.error || 'It stopped.') + '</div>'
1852
+ if (run.state === 'done') return '<div class="okbox">' + icon('check') + esc(kind === 'join' ? 'Done. This Mac and ' + (run.with || 'your other Mac') + ' share the folder.' : 'The Leftoff folder is in Syncthing.') + '</div>'
1853
+ const pct = run.total ? Math.round(100 * (run.have || 0) / run.total) : null
1854
+ const stuck = run.stuck ? '<div class="warnbox ststuck"><p><b>If it’s stuck</b>, add this Mac by hand on ' + esc(run.stuck.first) + ':</p><ol class="stdirs">' + (run.stuck.steps || []).map((x) => '<li>' + esc(x) + '</li>').join('') + '</ol>' + (run.stuck.board ? '<p class="hint">' + esc(run.stuck.board).replace('Settings → Sync', '<a href="#/settings/sync">Settings → Sync</a>') + '</p>' : '') + '</div>' : ''
1855
+ return '<p class="wmoving"><span class="spin" aria-hidden="true"></span>' + esc(run.message || 'Working…') + '</p>' + (pct != null ? '<progress class="bar" max="100" value="' + pct + '"></progress>' : '') + stuck
1856
+ }
1857
+
1858
+ function stPairHtml(d) {
1859
+ const p = stUi.pair
1860
+ const pr = d.pairing || {}
1861
+ if (!p) return ''
1862
+ const peer = pr.paired ? (d.peers || []).find((x) => x.name === pr.paired.name) : null
1863
+ let status
1864
+ if (pr.paired) status = peer && peer.connected && peer.completion === 100 ? '<div class="okbox">' + icon('check') + 'Done: ' + esc(pr.paired.name) + ' is paired and in sync.</div>'
1865
+ : '<p class="wmoving"><span class="spin" aria-hidden="true"></span>Paired with ' + esc(pr.paired.name) + ' · ' + esc(peer && peer.connected ? 'syncing' + (peer.completion != null ? ' (' + peer.completion + '%)' : '…') : 'connecting…') + '</p>'
1866
+ else if (pr.expired || (!pr.armed && !pr.used)) status = '<div class="warnbox">The code expired; nothing was accepted. <button type="button" class="linkish" data-action="st-pair">Make a new one</button></div>'
1867
+ else status = '<p class="wmoving"><span class="spin" aria-hidden="true"></span>Waiting for your other Mac…</p><p class="hint">The code works once, for the next ' + esc(stCountdown(p.expiresAt)) + '. Only the Mac that uses it is accepted.</p>'
1868
+ return '<div class="wizard stpair"><p><b>On your other Mac</b>, run this in Terminal:</p>' +
1869
+ '<div class="joinline"><code class="cmd">' + esc(p.command) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(p.command) + '">Copy</button></div>' +
1870
+ '<p class="hint">Leftoff not installed there yet? <button type="button" class="linkish" data-action="copy-cmd" data-cmd="' + esc(p.fresh) + '">Copy the npx version</button> (it installs Leftoff, then joins).</p>' +
1871
+ '<p class="hint">Or on its board: Settings → Sync → Join another Mac, and paste the code.</p>' +
1872
+ status +
1873
+ '<div class="wactions">' + (pr.paired ? '<button type="button" class="btn primary" data-action="st-close">Done</button>' : '<button type="button" class="btn" data-action="st-pair-cancel">Cancel code</button>') + '</div></div>'
1874
+ }
1875
+
1876
+ function stBoxHtml(d) {
1877
+ const local = d.viewer && d.viewer.local
1878
+ let h = '<div class="ststatus">' + stStatusLine(d) + (d.folder ? '<span class="chip mono">' + esc(d.folder.display) + '</span>' : '') + '</div>'
1879
+ if (d.folder) h += stPeersHtml(d)
1880
+ h += stKnockedHtml(d, local)
1881
+ for (const e of (d.folder && d.folder.errors) || []) h += '<div class="warnbox">' + esc((e.path ? e.path + ': ' : '') + e.error) + '</div>'
1882
+ if (!local) return h + '<div class="hint">Installing, pairing and joining happen on the Mac itself.</div>'
1883
+ const run = d.run && Date.now() - new Date(d.run.at).getTime() < 3600000 ? d.run : null
1884
+ const running = run && !['done', 'failed'].includes(run.state)
1885
+ if (stUi.panel === 'install' || (d.install && d.install.job && d.install.job.state === 'running')) h += stInstallHtml(d)
1886
+ else if (stUi.panel === 'join' || stUi.panel === 'join-run' || (running && run.kind === 'join')) {
1887
+ h += '<div class="wizard"><p><b>Join another Mac</b></p>'
1888
+ if (run && run.kind === 'join' && (running || stUi.panel === 'join-run')) {
1889
+ h += stRunHtml(run, { kind: 'join' })
1890
+ if (run.state === 'failed' && run.code === 'needs_install') h += '<div class="wactions"><button type="button" class="btn primary" data-action="st-install">Install Syncthing first</button></div>'
1891
+ else if (!running) h += '<div class="wactions"><button type="button" class="btn" data-action="st-close">' + (run.state === 'done' ? 'Done' : 'Close') + '</button>' + (run.state === 'failed' ? '<button type="button" class="btn primary" data-action="st-join">Try again</button>' : '') + '</div>'
1892
+ } else {
1893
+ h += '<p class="hint">Paste the code from your first Mac (Settings → Sync → Pair another Mac).</p>' +
1894
+ '<textarea id="stCode" class="stcode" rows="3" placeholder="LO1-…" spellcheck="false" autocomplete="off" autocapitalize="characters">' + esc(stUi.joinCode) + '</textarea>' +
1895
+ (d.installed ? '' : '<p class="hint">Syncthing isn’t installed here yet; Leftoff will offer to install it first.</p>') +
1896
+ '<div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn primary" data-action="st-join-go">Join</button></div>'
1897
+ }
1898
+ h += '</div>'
1899
+ } else if (stUi.panel === 'pair') h += stPairHtml(d)
1900
+ else if (stUi.panel === 'setup' || stUi.panel === 'setup-run' || (running && run.kind === 'setup')) {
1901
+ h += '<div class="wizard"><p><b>Sync this folder with Syncthing</b></p>'
1902
+ if (run && run.kind === 'setup' && (running || stUi.panel === 'setup-run')) {
1903
+ h += stRunHtml(run, { kind: 'setup' }) + '<p class="hint">The board reconnects on its own if Leftoff restarts.</p>'
1904
+ if (!running) h += '<div class="wactions"><button type="button" class="btn' + (run.state === 'done' ? '' : ' primary') + '" data-action="st-close">Close</button>' + (run.state === 'done' ? '<button type="button" class="btn primary" data-action="st-pair">Pair another Mac</button>' : '') + '</div>'
1905
+ } else {
1906
+ h += '<label class="stlabel" for="stPath">Folder</label><input id="stPath" class="mono" value="' + esc(stUi.path || tildeUi(d.suggestedPath || '')) + '" spellcheck="false" autocomplete="off">' +
1907
+ '<p class="hint">Leftoff moves your data there (copies, checks every file, keeps the old folder as a backup) and shares it in Syncthing. Your other Syncthing folders are left alone.</p>' +
1908
+ protectHtml('syncthing') +
1909
+ '<div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn primary" data-action="st-setup-go">Set it up</button></div>'
1910
+ }
1911
+ h += '</div>'
1912
+ } else if (stUi.panel === 'stop') {
1913
+ h += '<div class="wizard"><p><b>Stop syncing?</b></p><p class="hint">Syncthing stops sharing ' + esc(d.folder ? d.folder.display : 'the folder') + '. The files stay here and Leftoff keeps using them; your other Mac stops getting updates. Nothing else in Syncthing changes.</p>' +
1914
+ '<div class="wactions"><button type="button" class="btn" data-action="st-close">Cancel</button><button type="button" class="btn danger" data-action="st-stop-go">Stop syncing</button></div></div>'
1915
+ }
1916
+ // the buttons for the current state
1917
+ const b = []
1918
+ if (!stUi.panel && !running) {
1919
+ if (!d.installed) b.push('<button type="button" class="btn primary" data-action="st-install">' + icon('folder-sync') + 'Install Syncthing (recommended)</button>')
1920
+ else if (!d.running) b.push('<button type="button" class="btn primary" data-action="st-start">Start Syncthing</button>')
1921
+ else if (!d.folder) b.push('<button type="button" class="btn primary" data-action="st-setup">' + icon('folder-sync') + 'Sync this folder</button>')
1922
+ else b.push('<button type="button" class="btn primary" data-action="st-pair">Pair another Mac</button>')
1923
+ b.push('<button type="button" class="btn" data-action="st-join">Join another Mac</button>')
1924
+ if (d.folder) b.push('<button type="button" class="btn" data-action="st-stop">Stop syncing…</button>')
1925
+ }
1926
+ if (b.length) h += '<div class="stactions">' + b.join('') + '</div>'
1927
+ if (!stUi.panel && !d.installed) h += '<div class="hint">Syncthing keeps your Macs in sync directly, with no cloud copy. Leftoff installs it, sets it up and pairs your Macs; you won’t need its own app.</div>'
1928
+ if (!stUi.panel && d.nested && !d.folder) h += '<div class="hint">This folder is already inside your Syncthing folder “' + esc(d.nested.label) + '”. To pair by code, Leftoff needs its own folder.</div>'
1929
+ return h
1930
+ }
1931
+
1932
+ function tildeUi(p) { const h = (state.cfg && state.cfg.folder || '').match(/^(\/Users\/[^/]+|\/home\/[^/]+)/); return h && p.startsWith(h[1] + '/') ? '~' + p.slice(h[1].length) : p }
1933
+
1934
+ async function stRefresh() {
1935
+ const before = stUi.data && stUi.data.run
1936
+ try { stUi.data = await api('GET', '/v1/syncthing', undefined, { timeoutMs: 4000 }) } catch { return false }
1937
+ const after = stUi.data.run
1938
+ // a setup or join that just finished moved the folder: redraw all of Settings once
1939
+ if (after && after.state === 'done' && (!before || before.state !== 'done' || before.at !== after.at) && ['setup', 'join'].includes(after.kind)) { await viewSettings(); return true }
1940
+ stPaint()
1941
+ return true
1942
+ }
1943
+ function stPaint() {
1944
+ const el = document.getElementById('stBox')
1945
+ if (!el || !stUi.data) return
1946
+ const ta = document.getElementById('stCode'); if (ta) stUi.joinCode = ta.value
1947
+ const pi = document.getElementById('stPath'); if (pi) stUi.path = pi.value
1948
+ const focused = document.activeElement && document.activeElement.id
1949
+ el.innerHTML = stBoxHtml(stUi.data)
1950
+ paintSettingsDots()
1951
+ if (focused && ['stCode', 'stPath'].includes(focused)) { const f = document.getElementById(focused); if (f) { f.focus(); try { f.setSelectionRange(f.value.length, f.value.length) } catch {} } }
1952
+ }
1953
+ function stLive() {
1954
+ const d = stUi.data || {}
1955
+ const job = d.install && d.install.job
1956
+ const run = d.run && !['done', 'failed'].includes(d.run.state)
1957
+ return (job && job.state === 'running') || run || stUi.panel === 'pair' || stUi.panel === 'setup-run' || stUi.panel === 'join-run'
1958
+ }
1959
+ function stSchedule() {
1960
+ clearTimeout(stUi.timer)
1961
+ stUi.timer = setTimeout(async () => {
1962
+ if (!document.getElementById('stBox')) return
1963
+ if (stLive()) await stRefresh()
1964
+ stSchedule()
1965
+ }, 1000)
1966
+ }
1967
+
1968
+ async function stAction(action, btn) {
1969
+ const d = stUi.data || {}
1970
+ if (action === 'st-close') { stUi.panel = null; stUi.pair = null; stUi.error = null; await stRefresh(); return viewSettings() }
1971
+ if (action === 'st-refresh') { await stRefresh(); if (stUi.data && stUi.data.installed) stUi.panel = null; return stPaint() }
1972
+ if (action === 'st-log') { stUi.showLog = !stUi.showLog; return stPaint() }
1973
+ if (action === 'st-install') { stUi.panel = 'install'; return stPaint() }
1974
+ if (action === 'st-install-go') {
1975
+ btn.disabled = true
1976
+ try { await api('POST', '/v1/syncthing/install', { confirm: true }) } catch (e) { toast(e.message, 6000); btn.disabled = false; return }
1977
+ stUi.panel = 'install'
1978
+ await stRefresh()
1979
+ // after it finishes, close the panel on success
1980
+ const wait = async () => { const j = stUi.data && stUi.data.install && stUi.data.install.job; if (j && j.state === 'done') { stUi.panel = null; toast('Syncthing is installed and running'); return viewSettings() } if (j && j.state === 'running') setTimeout(async () => { await stRefresh(); wait() }, 900) }
1981
+ return wait()
1982
+ }
1983
+ if (action === 'st-start') { btn.disabled = true; try { await api('POST', '/v1/syncthing/start', {}); toast('Syncthing is running') } catch (e) { toast(e.message, 6000) } await stRefresh(); return }
1984
+ if (action === 'st-setup') { stUi.panel = 'setup'; return stPaint() }
1985
+ if (action === 'st-setup-go') {
1986
+ const p = (document.getElementById('stPath') || {}).value
1987
+ btn.disabled = true
1988
+ try { await api('POST', '/v1/syncthing/setup', { path: p }) } catch (e) { toast(e.message, 6000); btn.disabled = false; return }
1989
+ stUi.panel = 'setup-run'
1990
+ stUi.data = { ...d, run: { kind: 'setup', state: 'starting', message: 'Starting…', at: new Date().toISOString() } }
1991
+ stPaint()
1992
+ return
1993
+ }
1994
+ if (action === 'st-pair') {
1995
+ try { stUi.pair = await api('POST', '/v1/syncthing/pair', {}) } catch (e) { toast(e.message, 6000); return }
1996
+ stUi.panel = 'pair'
1997
+ await stRefresh()
1998
+ return
1999
+ }
2000
+ if (action === 'st-pair-cancel') { await api('POST', '/v1/syncthing/pair/cancel', {}).catch(() => {}); stUi.panel = null; stUi.pair = null; return stRefresh() }
2001
+ if (action === 'st-join') { stUi.panel = 'join'; stUi.data = { ...d, run: null }; return stPaint() }
2002
+ if (action === 'st-join-go') {
2003
+ const code = ((document.getElementById('stCode') || {}).value || '').trim()
2004
+ if (!code) { toast('Paste the code from your other Mac first'); return }
2005
+ btn.disabled = true
2006
+ try { await api('POST', '/v1/syncthing/join', { code }) } catch (e) { toast(e.message, 7000); btn.disabled = false; return }
2007
+ stUi.panel = 'join-run'
2008
+ stUi.data = { ...d, run: { kind: 'join', state: 'starting', message: 'Checking Syncthing…', at: new Date().toISOString() } }
2009
+ stPaint()
2010
+ return
2011
+ }
2012
+ if (action === 'st-accept') { stUi.panel = 'accept'; stUi.acceptId = btn.dataset.id; return stPaint() }
2013
+ if (action === 'st-accept-go') {
2014
+ btn.disabled = true
2015
+ try { const r = await api('POST', '/v1/syncthing/accept', { deviceId: btn.dataset.id, confirm: true }); toast('Added ' + ((r.paired && r.paired.name) || 'that Mac') + ' and shared the Leftoff folder with it') } catch (e) { toast(e.message, 7000) }
2016
+ stUi.panel = null; stUi.acceptId = null; stUi.pair = null
2017
+ return stRefresh()
2018
+ }
2019
+ if (action === 'st-stop') { stUi.panel = 'stop'; return stPaint() }
2020
+ if (action === 'st-stop-go') {
2021
+ btn.disabled = true
2022
+ try { await api('POST', '/v1/syncthing/stop', { confirm: true }); toast('Stopped syncing. The files are still here.') } catch (e) { toast(e.message, 6000) }
2023
+ stUi.panel = null
2024
+ return viewSettings()
2025
+ }
2026
+ }
2027
+
2028
+ async function viewSearch(q) {
2029
+ const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
2030
+ let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + ' sessions, ' + r.checkpoints.length + ' checkpoints</div></div></div>'
2031
+ if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2032
+ if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2033
+ if (!r.sessions.length && !r.checkpoints.length) html += '<div class="empty mt">Nothing matched.</div>'
2034
+ $app.innerHTML = html
2035
+ }
2036
+
2037
+ // ------------------------------------------------------------ lists (v0.5.1)
2038
+
2039
+ const listUi = { found: {}, finding: {}, showAll: false, help: false }
2040
+ const listHref = (ref) => '#/lists/' + String(ref).split('/').map(encodeURIComponent).join('/')
2041
+
2042
+ function listChips(s) {
2043
+ let h = ''
2044
+ for (const l of s.lists || []) h += '<a class="chip listchip' + (l.done === l.total ? ' done' : '') + '" href="' + listHref(l.ref) + '" title="' + esc(l.name) + '">' + icon('list-checks') + esc(l.progress) + ' <span class="ln">' + esc(l.name) + '</span></a>'
2045
+ if (s.listHint && state.mayAnswer !== false) h += '<span class="chip listhint">Claude wrote a ' + esc(s.listHint.count) + '-step list · <button type="button" class="linkish" data-action="list-save-hint" title="Save “' + esc(s.listHint.name) + '” as a list you can tick off">Save</button>' +
2046
+ '<button type="button" class="icon x" data-action="list-dismiss" title="Not a list to keep" aria-label="Dismiss">×</button></span>'
2047
+ return h ? '<div class="listchips">' + h + '</div>' : ''
2048
+ }
2049
+
2050
+ const mayEditLists = () => state.listsMayEdit !== false
2051
+ function listItemsHtml(l, { max = 0 } = {}) {
2052
+ const items = max ? l.items.slice(0, max) : l.items
2053
+ const dis = mayEditLists() && !l.readOnly ? '' : ' disabled'
2054
+ const text = l.readOnly ? esc : mdInline
2055
+ return '<ul class="checklist">' + items.map((it) =>
2056
+ '<li class="' + (it.done ? 'done' : '') + '"><label><input type="checkbox" data-list="' + esc(l.ref) + '" data-n="' + esc(it.n) + '"' + (it.done ? ' checked' : '') + dis + '>' +
2057
+ '<span class="n">' + esc(it.n) + '</span><span class="t">' + text(it.text) + '</span></label></li>').join('') +
2058
+ (max && l.items.length > max ? '<li class="more"><a href="' + listHref(l.ref) + '">+' + (l.items.length - max) + ' more</a></li>' : '') + '</ul>'
2059
+ }
2060
+
2061
+ function listCard(l, { full = true, session = true } = {}) {
2062
+ const pct = l.total ? Math.round((l.done / l.total) * 100) : 0
2063
+ const src = l.sourceSession ? (state.sessions || []).find((x) => x.id === l.sourceSession) : null
2064
+ const edit = mayEditLists() && !l.readOnly
2065
+ return '<article class="listcard' + (l.readOnly ? ' readonly' : '') + (l.archived ? ' archived' : '') + (l.done === l.total ? ' complete' : '') + '" data-ref="' + esc(l.ref) + '">' +
2066
+ '<div class="lhead"><a class="lname" href="' + listHref(l.ref) + '">' + esc(l.name) + '</a>' +
2067
+ (edit ? '<button type="button" class="icon" data-action="list-rename" data-ref="' + esc(l.ref) + '" title="Rename">✎</button>' : '') +
2068
+ (l.agent ? agentChip(l.agent, { title: 'from ' + (l.agent.display || l.agent.name) + '’s feed · read-only' }) : '') +
2069
+ '<span class="lprog" title="' + esc(l.done + ' of ' + l.total + ' done') + '">' + esc(l.progress) + '</span></div>' +
2070
+ '<progress class="bar" max="' + Math.max(1, l.total) + '" value="' + l.done + '" aria-label="' + esc(pct + '% done') + '"></progress>' +
2071
+ listItemsHtml(l) +
2072
+ (edit && !l.archived ? '<form class="addform" data-list-add="' + esc(l.ref) + '"><input type="text" name="text" placeholder="Add an item" aria-label="Add an item to ' + esc(l.name) + '" maxlength="500"><button type="submit" class="btn">' + icon('plus') + 'Add</button></form>' : '') +
2073
+ '<div class="lfoot">' + (session && l.sourceSession ? '<a href="#/s/' + encodeURIComponent(l.sourceSession) + '">from ' + esc((src && src.title) || l.sourceTitle || 'its session') + '</a>' : '<span>' + esc(l.repo || 'no repo') + '</span>') +
2074
+ (l.sourceMachine && multi() ? '<span>· ' + esc(l.sourceMachine) + '</span>' : '') + '<span>· updated ' + esc(ago(l.updated)) + '</span>' +
2075
+ (edit ? '<button type="button" class="linkish" data-action="list-archive" data-ref="' + esc(l.ref) + '" data-archived="' + (l.archived ? '0' : '1') + '">' + (l.archived ? 'Unarchive' : 'Archive') + '</button>' : '') +
2076
+ (l.readOnly ? '<span class="faint">read-only · tell ' + esc((l.agent && l.agent.display) || 'the agent') + ' to tick items</span>' : '<span class="faint mono">lists/' + esc(l.ref) + '.md</span>') + '</div>' +
2077
+ '</article>'
2078
+ }
2079
+
2080
+ function listsHelp() {
2081
+ return '<section class="lhelp" id="listsHelp"><h3>How lists work</h3><ol>' +
2082
+ '<li><strong>Ask Claude for steps.</strong> “What are the next steps to submit to the App Store?” Work as usual.</li>' +
2083
+ '<li><strong>Save it.</strong> When Claude writes a checklist, the session’s card shows <em>Claude wrote a 7-step list · Save</em>. One click. Or type <code>/cp-todo</code> in the session. Older sessions: open the session and use <em>Find lists in this session</em>.</li>' +
2084
+ '<li><strong>Tick items off</strong> here or on your phone, with <code>/cp-done 3</code> in the session, or just tell Claude “step 3 is done”: it knows the list and runs <code>leftoff todo done</code> itself.</li></ol>' +
2085
+ '<p class="faint">Nothing is saved without your click. Lists are plain Markdown in <code>lists/&lt;repo&gt;/</code> in your folder, so they sync and you can edit them by hand. Item numbers never change. Claude’s own internal to-do list is separate and never shows up here.' +
2086
+ (mayEditLists() ? '' : ' Ticking from this device is off: it follows the same switch as answering (Settings → Privacy & security on the machine running Leftoff).') + '</p></section>'
2087
+ }
2088
+
2089
+ async function viewLists(ref) {
2090
+ const [o, sv] = await Promise.all([api('GET', '/v1/lists?all=1'), api('GET', '/v1/sessions?limit=300').catch(() => ({ sessions: [] }))])
2091
+ state.sessions = sv.sessions || state.sessions
2092
+ state.listsMayEdit = o.mayEdit !== false
2093
+ paintListsCount(o.lists)
2094
+ const helpOn = listUi.help || (state.route && state.route.params && state.route.params.get('help') === '1')
2095
+ if (ref) {
2096
+ const l = o.lists.find((x) => x.ref === ref)
2097
+ let html = '<a class="back" href="#/lists">← All lists</a>'
2098
+ if (!l) { $app.innerHTML = html + '<div class="empty">That list is gone (renamed, or deleted from the folder).</div>'; return }
2099
+ html += '<div class="lists one">' + listCard(l) + '</div>'
2100
+ $app.innerHTML = html
2101
+ return
2102
+ }
2103
+ const open = o.lists.filter((l) => !l.archived && (listUi.showAll || l.done < l.total))
2104
+ const hidden = o.lists.length - open.length
2105
+ let html = '<div class="pagehead"><div><h1>Lists <button type="button" class="helpq" data-action="list-help" aria-expanded="' + (helpOn ? 'true' : 'false') + '" title="How lists work">?</button></h1>' +
2106
+ '<div class="sub">Checklists Claude wrote for you, saved from your sessions. Tick them off here, on your phone, or in the session.' + (o.lists.some((l) => l.readOnly) ? ' Lists from an agent’s feed are read-only.' : '') + '</div></div>' +
2107
+ '<div class="actions">' + (hidden || listUi.showAll ? '<button type="button" class="btn" data-action="list-showall">' + (listUi.showAll ? 'Hide finished & archived' : 'Show finished & archived (' + hidden + ')') + '</button>' : '') + '</div></div>'
2108
+ if (helpOn) html += listsHelp()
2109
+ if (!state.listsMayEdit) html += '<div class="banner info">Read-only from this device. Ticking lists from another device follows the same switch as answering: Settings → Privacy & security, on the machine running Leftoff.</div>'
2110
+ const shown = listUi.showAll ? o.lists : open
2111
+ if (!shown.length) {
2112
+ html += '<div class="empty">' + (o.lists.length ? 'Every list is done. 🎉' : 'No lists yet.') + '<br><span class="faint">When Claude writes you a checklist, its session card shows <strong>Save</strong>. Or type <code>/cp-todo</code> in the session.</span>' +
2113
+ (helpOn ? '' : '<br><button type="button" class="linkish" data-action="list-help">How lists work</button>') + '</div>'
2114
+ }
2115
+ const byRepo = new Map()
2116
+ for (const l of shown) { const k = l.repo || 'no repo'; if (!byRepo.has(k)) byRepo.set(k, []); byRepo.get(k).push(l) }
2117
+ for (const [repo, ls] of [...byRepo.entries()].sort((a, b) => a[0].localeCompare(b[0]))) html += '<h2>' + esc(repo) + ' <span class="count">' + ls.length + '</span></h2><div class="lists">' + ls.map((l) => listCard(l)).join('') + '</div>'
2118
+ $app.innerHTML = html
2119
+ }
2120
+
2121
+ function paintListsCount(lists) {
2122
+ const el = document.getElementById('listsCount')
2123
+ if (!el || !lists) return
2124
+ const n = lists.filter((l) => !l.archived && l.done < l.total).length
2125
+ el.hidden = !n
2126
+ el.textContent = n ? String(n) : ''
2127
+ }
2128
+
2129
+ // The session page's Lists section: saved lists, the hint, and "Find lists in this session".
2130
+ function sessionListsSection(s) {
2131
+ const f = listUi.found[s.id]
2132
+ let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-go" title="How lists work">?</button></h2>'
2133
+ if (s.lists && s.lists.length) h += '<div class="lists">' + s.lists.map((x) => (state.listFull && state.listFull[x.ref] ? listCard(state.listFull[x.ref], { session: false }) : '')).join('') + '</div>'
2134
+ if (s.listHint) h += '<div class="lfound hint"><div><strong>Claude wrote a ' + esc(s.listHint.count) + '-step list</strong>: ' + esc(s.listHint.name) + '</div><div class="row"><button type="button" class="btn primary" data-action="list-save-hint" data-id="' + esc(s.id) + '">Save</button><button type="button" class="btn" data-action="list-dismiss" data-id="' + esc(s.id) + '">Dismiss</button></div></div>'
2135
+ h += '<div class="lfind"><button type="button" class="btn" data-action="list-find" data-id="' + esc(s.id) + '"' + (listUi.finding[s.id] ? ' disabled' : '') + '>' + icon('list-checks') + (listUi.finding[s.id] ? 'Reading the transcript…' : 'Find lists in this session') + '</button>' +
2136
+ '<span class="faint">Reads Claude Code’s transcript, so lists from before this version count too.</span></div>'
2137
+ if (f && f.error) h += '<div class="lfound err">' + esc(f.error) + '</div>'
2138
+ else if (f) {
2139
+ if (!f.candidates.length) h += '<div class="lfound none">No checklists in this session’s transcript (' + esc(f.messages) + ' messages). Leftoff looks for “- [ ]” checklists, and numbered or bulleted lists under a heading like “Next steps”.</div>'
2140
+ for (const c of f.candidates) {
2141
+ h += '<div class="lfound"><div class="fhead"><strong>' + esc(c.name) + '</strong> <span class="faint">' + esc(c.count) + ' items · ' + esc(c.kind) + '</span></div>' +
2142
+ '<ol class="preview">' + c.items.slice(0, 6).map((it) => '<li' + (it.done ? ' class="done"' : '') + '>' + mdInline(it.text) + '</li>').join('') + (c.items.length > 6 ? '<li class="faint">…' + (c.items.length - 6) + ' more</li>' : '') + '</ol>' +
2143
+ '<div class="row">' + (c.savedAs ? '<a class="btn" href="' + listHref(c.savedAs.ref) + '">Saved as “' + esc(c.savedAs.name) + '” →</a>' : mayEditLists() ? '<button type="button" class="btn primary" data-action="list-save-cand" data-id="' + esc(s.id) + '" data-n="' + esc(c.n) + '">Save</button>' : '<span class="faint">read-only from this device</span>') + '</div></div>'
2144
+ }
2145
+ }
2146
+ return h
2147
+ }
2148
+
2149
+ // ------------------------------------------------------------ router
2150
+
2151
+ function parseRoute() {
2152
+ const h = location.hash.replace(/^#\/?/, '')
2153
+ const [path, qs] = h.split('?')
2154
+ const parts = path.split('/').filter(Boolean)
2155
+ const params = new URLSearchParams(qs || '')
2156
+ if (parts[0] === 'lists' && parts.length > 2) return { name: 'lists', arg: parts.slice(1).map(decodeURIComponent).join('/'), q: null, params }
2157
+ return { name: parts[0] || 'board', arg: parts[1] ? decodeURIComponent(parts[1]) : null, q: params.get('q'), params }
2158
+ }
2159
+
2160
+ async function render() {
2161
+ const r = (state.route = parseRoute())
2162
+ document.querySelectorAll('.tabs a, .topright a[data-route]').forEach((a) => { const on = a.dataset.route === (r.name === 's' ? 'board' : r.name === 'catchup' ? 'digest' : r.name); a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
2163
+ try {
2164
+ if (r.name === 's' && r.arg) await viewSession(r.arg)
2165
+ else if (r.name === 'waiting') await viewWaiting()
2166
+ else if (r.name === 'timeline') await viewTimeline()
2167
+ else if (r.name === 'digest') await viewDigest(r.arg, r.params.get('machine'))
2168
+ else if (r.name === 'catchup') await viewCatchup(r.arg)
2169
+ else if (r.name === 'lists') await viewLists(r.arg)
2170
+ else if (r.name === 'settings') await viewSettings()
2171
+ else if (r.name === 'whats-new') await viewWhatsNew()
2172
+ else if (r.name === 'search') await viewSearch(r.q || '')
2173
+ else await viewBoard()
2174
+ } catch (e) {
2175
+ if (e.code === 'login_required') return showLogin()
2176
+ $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
2177
+ }
2178
+ }
2179
+
2180
+ // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
2181
+ // home-screen app always lands here first (it has its own cookie jar), so the
2182
+ // typed code is the main way in; the QR link is the other.
2183
+ function showLogin() {
2184
+ const expired = new URLSearchParams(location.search).get('login') === 'expired'
2185
+ const app = standalone()
2186
+ $app.innerHTML = '<div class="empty login"><div class="login-title">Sign in' + (app ? ' the Leftoff app' : ' this browser') + '</div>' +
2187
+ (expired ? '<p class="warn">That QR code or link was already used or has expired. Each one works once, for 15 minutes.</p>' : '') +
2188
+ '<form class="codeform" id="codeForm" autocomplete="off"><label for="signinCode">Sign-in code</label>' +
2189
+ '<input id="signinCode" name="code" inputmode="text" autocapitalize="characters" autocorrect="off" spellcheck="false" autocomplete="one-time-code" maxlength="8" placeholder="ABC-123" aria-describedby="codeHelp">' +
2190
+ '<button class="btn primary" type="submit">Sign in</button><div class="codeerr warn" id="codeErr" role="alert" hidden></div></form>' +
2191
+ '<p id="codeHelp">Get a code on the machine running Leftoff: <code>leftoff link</code>, or Settings → Phone & remote → Show QR code.' +
2192
+ (app ? '' : ' Or scan the QR code with the camera instead.') + '</p>' +
2193
+ '<p class="faint">Signed in, this ' + (app ? 'app' : 'browser') + ' stays signed in for 30 days after its last visit.</p></div>'
2194
+ const el = document.getElementById('health'); if (el) { el.className = 'health down'; el.querySelector('.label').textContent = 'not signed in' }
2195
+ const input = document.getElementById('signinCode')
2196
+ input.addEventListener('input', () => {
2197
+ const v = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6)
2198
+ input.value = v.length > 3 ? v.slice(0, 3) + '-' + v.slice(3) : v
2199
+ })
2200
+ document.getElementById('codeForm').addEventListener('submit', async (ev) => {
2201
+ ev.preventDefault()
2202
+ const err = document.getElementById('codeErr')
2203
+ err.hidden = true
2204
+ try {
2205
+ const res = await fetch('/v1/remote/signin', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ code: input.value }) })
2206
+ const j = await res.json().catch(() => ({}))
2207
+ if (!res.ok) throw new Error((j.error && j.error.message) || 'HTTP ' + res.status)
2208
+ location.replace('/')
2209
+ } catch (e) { err.textContent = e.message; err.hidden = false; input.select() }
2210
+ })
2211
+ input.focus()
2212
+ }
2213
+
2214
+ async function refreshHealth() {
2215
+ const el = document.getElementById('health')
2216
+ try {
2217
+ const h = await api('GET', '/v1/health')
2218
+ state.health = h
2219
+ el.className = 'health up'
2220
+ // the header shows this Mac's name; the rest is in the status popover (v0.5.9)
2221
+ const summary = h.sessionsOpen + ' open · ' + h.machine + ' · ' + h.summarizer
2222
+ el.querySelector('.label').textContent = h.machine
2223
+ el.title = summary
2224
+ el.setAttribute('aria-label', 'Status: ' + summary)
2225
+ const brand = document.getElementById('brand')
2226
+ if (brand) brand.title = 'Leftoff v' + h.version + (h.update && h.update.available ? ' · update available: v' + (h.update.remoteVersion || '?') : '')
2227
+ paintTopDot()
2228
+ if (statusOpen()) paintStatus()
2229
+ paintNeedsCount(Number(h.needsYou) || 0)
2230
+ { const lc = document.getElementById('listsCount'); if (lc) { lc.hidden = !h.listsOpen; lc.textContent = h.listsOpen ? String(h.listsOpen) : '' } }
2231
+ if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
2232
+ const dot = document.querySelector('#wnTab .newdot')
2233
+ if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
2234
+ paintSettingsDots()
2235
+ } catch (e) {
2236
+ el.className = 'health down'
2237
+ el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
2238
+ el.title = el.querySelector('.label').textContent
2239
+ el.setAttribute('aria-label', 'Status: ' + el.title)
2240
+ }
2241
+ }
2242
+
2243
+ // ------------------------------------------------------------ the top bar (v0.5.9)
2244
+ // Calm and roomy: the five main tabs; on the right a search button (⌘K), this
2245
+ // Mac's status (a popover with the details and the theme), What's new and
2246
+ // Settings as icons. Nothing wraps or is cut off from 900px up.
2247
+
2248
+ const isMac = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent)
2249
+ { const k = document.getElementById('searchKbd'); if (k) k.textContent = isMac ? '⌘K' : 'Ctrl K'; const b = document.getElementById('searchBtn'); if (b) b.title = 'Search (' + (isMac ? '⌘K' : 'Ctrl+K') + ' or /)' }
2250
+
2251
+ // The gear's dot: anything a Settings section would flag (an update, a failed digest, a feed that can't be pulled, sync trouble).
2252
+ function paintTopDot() {
2253
+ const el = document.querySelector('#setTab .topdot')
2254
+ if (!el) return
2255
+ const d = settingsDots()
2256
+ const h = state.health || {}
2257
+ if ((h.agents || []).some((a) => a.ok === false) && !d.agents) d.agents = { level: 'warn', why: ['A feed couldn’t be pulled'] }
2258
+ const all = Object.values(d)
2259
+ const warn = all.some((x) => x.level === 'warn')
2260
+ el.hidden = !all.length || (!warn && !(h.update && h.update.available))
2261
+ el.className = 'topdot' + (warn ? ' warn' : '')
2262
+ const why = all.flatMap((x) => x.why)
2263
+ document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
2264
+ }
2265
+
2266
+ // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
2267
+ const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
2268
+ function syncLine() {
2269
+ const st = stUi.data
2270
+ if (!st) return 'checking…'
2271
+ if (!st.installed) return syncUi.data && syncUi.data.current && syncUi.data.current.syncedBy ? 'in ' + syncUi.data.current.syncedBy.label : 'not set up'
2272
+ const d = settingsDots().sync
2273
+ if (d) return d.why[0]
2274
+ if (!st.folder) return 'Syncthing running, folder not shared yet'
2275
+ const peers = (st.peers || []).filter((p) => p.connected).length
2276
+ return (st.folder.needFiles ? 'syncing' : 'in sync') + ' · ' + peers + ' other Mac' + (peers === 1 ? '' : 's') + ' connected'
2277
+ }
2278
+ function paintStatus() {
2279
+ const pop = document.getElementById('statusPop')
2280
+ if (!pop) return
2281
+ const h = state.health
2282
+ const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
2283
+ const row = (k, v, cls) => '<div class="sp-row"><span class="sp-k">' + esc(k) + '</span><span class="sp-v' + (cls ? ' ' + cls : '') + '">' + v + '</span></div>'
2284
+ const syncWarn = !!settingsDots().sync
2285
+ pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint">' + (h ? (window.leftoffRelay ? 'through the relay' : 'this Mac') : 'offline') + '</span></div>' +
2286
+ (h ? row('Sessions', esc(h.sessionsOpen + ' open') + ' <span class="faint">of ' + esc(h.sessionsTotal) + '</span>') +
2287
+ (h.needsYou ? row('Needs you', '<a href="#/waiting">' + esc(h.needsYou) + ' waiting</a>') : '') +
2288
+ row('Summaries', esc(h.summarizer + (h.summarizerDetail && h.summarizerDetail.model ? ' · ' + h.summarizerDetail.model : '')) + ' <a href="#/settings/models">Change</a>') +
2289
+ relayRows(row) +
2290
+ ((h.machines || []).length > 1 && !window.leftoffRelay ? row('Macs', esc(h.machines.join(', '))) : '') +
2291
+ row('Sync', '<a href="#/settings/sync">' + esc(syncLine()) + '</a>', syncWarn ? 'warnish' : '') +
2292
+ row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : ' · <a href="#/whats-new">What’s new</a>'))
2293
+ : '<p class="faint">The daemon isn’t answering.</p>') +
2294
+ '<div class="sp-row sp-theme"><span class="sp-k">Theme</span><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></div>' +
2295
+ '<div class="sp-links"><a href="#/settings">' + icon('settings') + 'Settings</a><a href="#/whats-new">' + icon('sparkles') + 'What’s new</a></div>'
2296
+ placeStatus()
2297
+ }
2298
+ // Through the relay (v0.6.0): which Mac this board comes from, and a switcher.
2299
+ function relayRows(row) {
2300
+ const lr = window.leftoffRelay
2301
+ if (!lr) return ''
2302
+ const st = state.relayStatus
2303
+ const macs = st ? st.macs : [{ machine: lr.mac, online: true }]
2304
+ return '<div class="sp-row sp-macs"><span class="sp-k">Mac</span><div class="theme macswitch" role="group" aria-label="Which Mac">' + macs.map((m) =>
2305
+ '<button type="button" data-action="relay-mac" data-mac="' + esc(m.machine) + '" aria-pressed="' + (m.machine === lr.mac) + '"' + (m.online ? '' : ' disabled title="asleep · last seen ' + esc(fmtAgo(m.lastSeen)) + '"') + '><span class="mdot ' + (m.online ? 'on' : 'off') + '"></span>' + esc(m.machine) + '</button>').join('') + '</div></div>' +
2306
+ row('Relay', esc(location.hostname) + (lr.latencyMs != null ? ' <span class="faint">· ' + esc(lr.latencyMs) + ' ms</span>' : '') + ' <span class="faint">· end-to-end encrypted</span>')
2307
+ }
2308
+ function placeStatus() {
2309
+ const pop = document.getElementById('statusPop'), b = document.getElementById('health')
2310
+ if (!pop || pop.hidden || !b) return
2311
+ const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
2312
+ if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
2313
+ pop.style.right = 'auto'
2314
+ pop.style.top = (r.bottom + 8) + 'px'
2315
+ pop.style.left = Math.max(8, Math.min(r.right - pop.offsetWidth, vw - 8 - pop.offsetWidth)) + 'px'
2316
+ }
2317
+ async function toggleStatus(open = !statusOpen()) {
2318
+ const pop = document.getElementById('statusPop'), b = document.getElementById('health')
2319
+ if (!pop) return
2320
+ pop.hidden = !open
2321
+ b.setAttribute('aria-expanded', String(open))
2322
+ if (!open) return
2323
+ closeMenu(); closeSearch()
2324
+ paintStatus()
2325
+ if (window.leftoffRelay) window.leftoffRelay.status().then((st) => { state.relayStatus = st; if (statusOpen()) paintStatus() }).catch(() => {})
2326
+ // sync health: read it now if Settings hasn't
2327
+ if (!stUi.data) {
2328
+ const [st, sync] = await Promise.all([api('GET', '/v1/syncthing', undefined, { timeoutMs: 4000 }).catch(() => null), syncUi.data ? null : api('GET', '/v1/sync').catch(() => null)])
2329
+ if (st) stUi.data = st
2330
+ if (sync) syncUi.data = sync
2331
+ if (statusOpen()) paintStatus()
2332
+ paintTopDot()
2333
+ }
2334
+ }
2335
+ document.getElementById('health').addEventListener('click', () => toggleStatus())
2336
+ document.addEventListener('pointerdown', (e) => { if (statusOpen() && !e.target.closest('#statusPop, #health')) toggleStatus(false) }, true)
2337
+ document.getElementById('statusPop').addEventListener('click', (e) => { if (e.target.closest('a')) toggleStatus(false) })
2338
+ document.addEventListener('click', (e) => { if (statusOpen() && e.target.closest('#statusPop [data-theme-set]')) setTimeout(paintStatus, 0) })
2339
+ window.addEventListener('resize', () => { placeStatus() })
2340
+
2341
+ // ⌘K search: a centered panel with the results grouped as you type. Enter on
2342
+ // nothing picked opens the full results page (#/search?q=…), as before.
2343
+ const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
2344
+ const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
2345
+ const searchIsOpen = () => !document.getElementById('cmdk').hidden
2346
+ function openSearch(prefill) {
2347
+ const box = document.getElementById('cmdk'), q = document.getElementById('q')
2348
+ closeMenu(); if (statusOpen()) toggleStatus(false)
2349
+ box.hidden = false
2350
+ document.documentElement.classList.add('cmdk-open')
2351
+ if (prefill != null) q.value = prefill
2352
+ else if (state.route && state.route.name === 'search') q.value = state.route.q || ''
2353
+ q.focus(); q.select()
2354
+ searchUi.back = document.activeElement
2355
+ paintSearch()
2356
+ }
2357
+ function closeSearch() {
2358
+ const box = document.getElementById('cmdk')
2359
+ if (!box || box.hidden) return
2360
+ box.hidden = true
2361
+ document.documentElement.classList.remove('cmdk-open')
2362
+ clearTimeout(searchUi.timer)
2363
+ }
2364
+ function searchGroups(q, r) {
2365
+ const low = q.toLowerCase()
2366
+ const groups = []
2367
+ const pages = PAGES.filter(([n]) => !low || n.toLowerCase().includes(low)).map(([n, href]) => ({ href, title: n, sub: '', icon: 'layout-dashboard' }))
2368
+ const sets = low ? SETTINGS.filter((x) => (x.label + ' ' + x.words).toLowerCase().includes(low)).map((x) => ({ href: '#/settings/' + x.key, title: 'Settings → ' + x.label, sub: x.sub, icon: x.icon })) : []
2369
+ if (r) {
2370
+ if (r.sessions.length) groups.push(['Sessions', r.sessions.slice(0, 6).map((x) => ({ href: '#/s/' + encodeURIComponent(x.id), title: x.title || 'Untitled', sub: [x.repoLabel || x.repoName, x.machine, x.lastActivityAt ? ago(x.lastActivityAt) : ''].filter(Boolean).join(' · '), icon: 'history' }))])
2371
+ if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 6).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: cp.doing || cp.next || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
2372
+ }
2373
+ if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
2374
+ if (pages.length) groups.push(['Go to', pages.slice(0, low ? 4 : 8)])
2375
+ return groups
2376
+ }
2377
+ function paintSearch() {
2378
+ const q = document.getElementById('q').value.trim()
2379
+ const list = document.getElementById('cmdkList')
2380
+ const r = searchUi.last && searchUi.last.q === q ? searchUi.last.r : null
2381
+ const groups = searchGroups(q, r)
2382
+ searchUi.items = []
2383
+ let h = ''
2384
+ if (q) { searchUi.items.push({ href: '#/search?q=' + encodeURIComponent(q), title: 'All results for “' + q + '”', icon: 'search' }) }
2385
+ for (const [name, items] of groups) { h += '<div class="cmdk-group" role="presentation">' + esc(name) + '</div>'; for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) } }
2386
+ if (q) h = optHtml(searchUi.items[0], 0) + h
2387
+ if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
2388
+ else if (q && r && !r.sessions.length && !r.checkpoints.length) h += '<div class="cmdk-note">No sessions or checkpoints match “' + esc(q) + '”.</div>'
2389
+ list.innerHTML = h
2390
+ setSearchActive(searchUi.items.length ? 0 : -1)
2391
+ function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href) + '" aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
2392
+ }
2393
+ function setSearchActive(i) {
2394
+ searchUi.active = i
2395
+ const q = document.getElementById('q')
2396
+ document.querySelectorAll('.cmdk-opt').forEach((el) => { const on = Number(el.dataset.i) === i; el.classList.toggle('active', on); el.setAttribute('aria-selected', String(on)); if (on) el.scrollIntoView({ block: 'nearest' }) })
2397
+ if (i >= 0) q.setAttribute('aria-activedescendant', 'cmdk-o' + i); else q.removeAttribute('aria-activedescendant')
2398
+ }
2399
+ async function runSearch() {
2400
+ const q = document.getElementById('q').value.trim()
2401
+ const seq = ++searchUi.seq
2402
+ if (q.length < 2) { paintSearch(); return }
2403
+ paintSearch()
2404
+ try {
2405
+ const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
2406
+ if (seq !== searchUi.seq) return
2407
+ searchUi.last = { q, r }
2408
+ } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [] } } }
2409
+ if (searchIsOpen()) paintSearch()
2410
+ }
2411
+ document.getElementById('searchBtn').addEventListener('click', () => openSearch())
2412
+ document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
2413
+ document.getElementById('q').addEventListener('keydown', (e) => {
2414
+ const n = searchUi.items.length
2415
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
2416
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); document.getElementById('searchBtn').focus() }
2417
+ })
2418
+ document.getElementById('cmdk').addEventListener('click', (e) => {
2419
+ if (e.target.closest('[data-cmdk-close]') || e.target === e.currentTarget) { closeSearch(); return }
2420
+ if (e.target.closest('.cmdk-opt')) closeSearch()
2421
+ })
2422
+ document.getElementById('cmdk').addEventListener('mousemove', (e) => { const o = e.target.closest('.cmdk-opt'); if (o && Number(o.dataset.i) !== searchUi.active) setSearchActive(Number(o.dataset.i)) })
2423
+ document.addEventListener('keydown', (e) => {
2424
+ const typing = e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))
2425
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); if (searchIsOpen()) closeSearch(); else openSearch(); return }
2426
+ if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey && !searchIsOpen()) { e.preventDefault(); openSearch(); return }
2427
+ if (e.key === 'Escape' && statusOpen()) { toggleStatus(false); document.getElementById('health').focus() }
2428
+ })
2429
+
2430
+
2431
+ // ------------------------------------------------------------ actions
2432
+
2433
+ // A card opens its session's page from anywhere on it, except its buttons,
2434
+ // links, fields and selected text (0.4.3: the timeline was too hidden).
2435
+ document.addEventListener('click', (ev) => {
2436
+ const c = ev.target.closest('.card[data-href]')
2437
+ if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
2438
+ if (String(window.getSelection && window.getSelection()).trim()) return
2439
+ if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
2440
+ location.hash = c.dataset.href
2441
+ })
2442
+
2443
+ document.addEventListener('click', async (ev) => {
2444
+ const btn = ev.target.closest('[data-action]')
2445
+ if (!btn) return
2446
+ // An action never navigates: a stray <a href="#"> would change the hash and
2447
+ // the router would drop you on the board (0.4.1: Settings → Check now did).
2448
+ if (btn.tagName === 'A' && (btn.getAttribute('href') || '#') === '#') ev.preventDefault()
2449
+ const action = btn.dataset.action
2450
+ const card = btn.closest('[data-id]')
2451
+ const id = btn.dataset.id || (card && card.dataset.id)
2452
+ const s = state.sessions.find((x) => x.id === id)
2453
+ try {
2454
+ if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
2455
+ // Use from anywhere (v0.6.0)
2456
+ if (action === 'relay-toggle') {
2457
+ const on = btn.checked
2458
+ const user = ((document.getElementById('f-anywhere-user') || {}).value || '').trim().toLowerCase()
2459
+ if (on && !user) { btn.checked = false; toast('Type your name first (your subdomain, e.g. yourname)'); document.getElementById('f-anywhere-user').focus(); return }
2460
+ try { await api('POST', on ? '/v1/relay/enable' : '/v1/relay/disable', on ? { user } : undefined); toast(on ? 'Use from anywhere is on: connecting to ' + user + '.leftoff.dev' : 'Use from anywhere is off for this Mac') }
2461
+ catch (e) { btn.checked = !on; toast(e.message, 5000); return }
2462
+ setTimeout(() => { if (state.route && state.route.name === 'settings') render() }, 1200)
2463
+ return render()
2464
+ }
2465
+ if (action === 'relay-user') {
2466
+ const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
2467
+ await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
2468
+ }
2469
+ if (action === 'relay-signup' || action === 'relay-signup-resend') {
2470
+ const user = action === 'relay-signup' ? ((document.getElementById('f-signup-user') || {}).value || '').trim().toLowerCase() : btn.dataset.user
2471
+ const email = action === 'relay-signup' ? ((document.getElementById('f-signup-email') || {}).value || '').trim() : btn.dataset.email
2472
+ const errEl = document.getElementById('signupErr')
2473
+ const say = (m) => { if (errEl) { errEl.textContent = m; errEl.hidden = !m } else if (m) toast(m) }
2474
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(user) || user.includes('--')) { say('A name is 2–32 lowercase letters, digits or hyphens.'); const el = document.getElementById('f-signup-user'); if (el) el.focus(); return }
2475
+ if (!/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(email)) { say('That doesn’t look like an email address.'); const el = document.getElementById('f-signup-email'); if (el) el.focus(); return }
2476
+ btn.disabled = true
2477
+ try { await api('POST', '/v1/relay/signup', { user, email }); toast('Sent a link to ' + email) } catch (e) { btn.disabled = false; say(e.message); return }
2478
+ await render(); relaySignupPoll(); return
2479
+ }
2480
+ if (action === 'relay-signup-cancel') { await api('POST', '/v1/relay/signup/cancel'); stopSignupPoll(); return render() }
2481
+ if (action === 'relay-pair') { const l = await api('POST', '/v1/relay/pair'); paintRelayPair(l); return }
2482
+ if (action === 'relay-revoke') {
2483
+ if (!confirm('Revoke ' + btn.dataset.name + '? It stops working at once and needs pairing again.')) return
2484
+ const r = await api('POST', '/v1/relay/devices/' + encodeURIComponent(btn.dataset.device) + '/revoke')
2485
+ toast('Revoked ' + btn.dataset.name + (r.relay === 'done' ? '' : ' (the relay hears it once this Mac reconnects)')); return render()
2486
+ }
2487
+ if (action === 'relay-mac') { if (window.leftoffRelay && btn.dataset.mac !== window.leftoffRelay.mac) window.leftoffRelay.pick(btn.dataset.mac); return }
2488
+ // Lists (v0.5.1)
2489
+ if (action === 'list-save-hint') {
2490
+ btn.disabled = true
2491
+ const r = await api('POST', '/v1/lists', { sessionId: id, fromHint: true })
2492
+ toast('Saved “' + r.list.name + '” · ' + r.list.total + ' items')
2493
+ return render()
2494
+ }
2495
+ if (action === 'list-dismiss') { await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/list-hint/dismiss'); return render() }
2496
+ if (action === 'list-find') {
2497
+ listUi.finding[id] = true; render()
2498
+ try { listUi.found[id] = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists/find') } catch (e) { listUi.found[id] = { error: e.message } }
2499
+ listUi.finding[id] = false
2500
+ await render()
2501
+ const el = document.getElementById('lists'); if (el) el.scrollIntoView({ block: 'start' })
2502
+ return
2503
+ }
2504
+ if (action === 'list-save-cand') {
2505
+ btn.disabled = true
2506
+ const r = await api('POST', '/v1/lists', { sessionId: id, fromTranscript: Number(btn.dataset.n) })
2507
+ toast('Saved “' + r.list.name + '” · ' + r.list.total + ' items')
2508
+ try { listUi.found[id] = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists/find') } catch {}
2509
+ return render()
2510
+ }
2511
+ if (action === 'list-rename') {
2512
+ const cur = (btn.closest('.listcard') && btn.closest('.listcard').querySelector('.lname') || {}).textContent || ''
2513
+ const name = window.prompt('Rename this list', cur)
2514
+ if (!name || !name.trim() || name.trim() === cur) return
2515
+ await api('POST', '/v1/lists/' + btn.dataset.ref + '/change', { name: name.trim() })
2516
+ return render()
2517
+ }
2518
+ if (action === 'list-archive') {
2519
+ const archived = btn.dataset.archived === '1'
2520
+ await api('POST', '/v1/lists/' + btn.dataset.ref + '/change', { archived })
2521
+ toast(archived ? 'Archived. It’s still in the folder; “Show finished & archived” brings it back.' : 'Back on your lists')
2522
+ return render()
2523
+ }
2524
+ if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
2525
+ if (action === 'list-help') { listUi.help = !listUi.help; return render() }
2526
+ if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
2527
+ if (action === 'remote-link' || action === 'remote-code') {
2528
+ const l = await api('POST', '/v1/remote/link')
2529
+ if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
2530
+ paintSignin(l); return
2531
+ }
2532
+ if (action === 'length-try') { await tryLength(); return }
2533
+ if (action === 'cu-open') { cuUi.open = true; cuUi.opts = null; cuUi.err = null; cuUi.repos = null; cuUi.machines = null; return render() }
2534
+ if (action === 'cu-close') { cuUi.open = false; if (state.route && state.route.arg === 'new') { location.hash = '#/catchup'; return } return render() }
2535
+ if (action === 'cu-win') {
2536
+ cuUi.since = btn.dataset.win; cuUi.repos = null
2537
+ if (cuUi.since === 'custom' && !cuUi.from) { const d = new Date(Date.now() - 86400000); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); cuUi.from = d.toISOString().slice(0, 16) }
2538
+ await cuLoadOptions(); return render()
2539
+ }
2540
+ if (action === 'cu-repos-all') { const all = (cuUi.opts && cuUi.opts.repos) || []; const sel = cuUi.repos || new Set(all.map((r) => r.key)); cuUi.repos = sel.size === all.length ? new Set() : null; return render() }
2541
+ if (action === 'cu-make') {
2542
+ const o = cuUi.opts || {}
2543
+ const all = (o.repos || []).map((r) => r.key)
2544
+ const repos = cuUi.repos && cuUi.repos.size !== all.length ? [...cuUi.repos] : []
2545
+ const machines = cuUi.machines && o.allMachines && cuUi.machines.size !== o.allMachines.length ? [...cuUi.machines] : []
2546
+ cuUi.making = true; render()
2547
+ try {
2548
+ const r = await api('POST', '/v1/catchups', { since: cuSince() || undefined, to: cuUi.since === 'custom' && cuUi.to ? cuUi.to : undefined, repos, machines }, { timeoutMs: 120000 })
2549
+ cuUi.open = false; cuUi.making = false; cuUi.opts = null
2550
+ location.hash = '#/catchup/' + r.name
2551
+ if (state.route && state.route.arg === r.name) render()
2552
+ } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
2553
+ return
2554
+ }
2555
+ if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
2556
+ if (action === 'restore-close') { restoreUi.open = false; return render() }
2557
+ if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
2558
+ if (action === 'restore-all') { const all = restorePicked().length === (state.restore ? state.restore.sessions.length : 0); restoreUi.off = new Set(all && state.restore ? state.restore.sessions.map((x) => x.id) : []); return render() }
2559
+ if (action === 'restore-copy') { const p = restorePicked(); if (!p.length) { toast('Pick at least one'); return } await copy(p.map((x) => x.command).join('\n')); return }
2560
+ if (action === 'restore-go') {
2561
+ const ids = restorePicked().map((x) => x.id)
2562
+ if (!ids.length) return
2563
+ btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
2564
+ const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
2565
+ restoreUi.result = r.how === 'print' || r.failed ? r : null
2566
+ if (r.how === 'ghostty' && r.opened) { toast('Restored ' + r.opened + ' in Ghostty' + (r.failed ? ' · ' + r.failed + ' didn’t open' : '')); if (!r.failed) restoreUi.open = false }
2567
+ return render()
2568
+ }
2569
+ if (action === 'remote-revoke') {
2570
+ if (!confirm('Sign out every remote browser, the home-screen app included? Each needs a new QR code or code.')) return
2571
+ const r = await api('POST', '/v1/remote/revoke'); toast('Signed out ' + r.revoked + ' browser' + (r.revoked === 1 ? '' : 's')); return render()
2572
+ }
2573
+ if (action === 'dismiss-upgrade') { await api('POST', '/v1/changelog/dismiss'); await refreshHealth(); return render() }
2574
+ if (action === 'agent-add') return agentAdd(btn)
2575
+ if (action === 'agent-refresh') {
2576
+ const name = btn.dataset.name
2577
+ btn.disabled = true; btn.textContent = 'Pulling…'
2578
+ try { const r = await api('POST', '/v1/agents/' + encodeURIComponent(name) + '/refresh'); toast(r.result === 'error' ? 'Couldn’t pull ' + name + ': ' + ((r.error && r.error.message) || 'error') : r.result === 'unchanged' ? name + ': nothing new' : 'Pulled ' + name) } catch (e) { toast(e.message, 5000) }
2579
+ await refreshHealth().catch(() => {}); return agentsRepaint()
2580
+ }
2581
+ if (action === 'agent-remove') {
2582
+ const name = btn.dataset.name
2583
+ if (!confirm('Stop pulling “' + name + '” and take it off the board? Its history moves to agents/.removed/ in the folder (nothing is deleted), and this Mac forgets its key.')) return
2584
+ try { await api('DELETE', '/v1/agents/' + encodeURIComponent(name)); toast('Removed ' + name) } catch (e) { toast(e.message, 5000) }
2585
+ await refreshHealth().catch(() => {}); return agentsRepaint()
2586
+ }
2587
+ if (action === 'check-updates') {
2588
+ btn.disabled = true; btn.textContent = 'Checking…'
2589
+ const u = await api('POST', '/v1/updates/check')
2590
+ state.updates = u
2591
+ toast(u.error ? 'Couldn’t check: ' + u.error : u.available ? 'Update available: ' + (u.remote.version ? 'v' + u.remote.version : u.remote.sha.slice(0, 7)) : 'You’re up to date')
2592
+ await refreshHealth(); return render()
2593
+ }
2594
+ if (action === 'filter') {
2595
+ const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
2596
+ return setBoardFilter({ repos: f.repos, status: f.status === btn.dataset.filter ? null : btn.dataset.filter })
2597
+ }
2598
+ if (action === 'repo') {
2599
+ ev.preventDefault()
2600
+ const onBoard = state.route && (state.route.name === 'board' || state.route.name === 'waiting')
2601
+ const f = onBoard && state.boardFilter ? state.boardFilter : readFilter('', window.localStorage)
2602
+ const repos = toggleRepo(f.repos, { key: btn.dataset.key || btn.dataset.repo, label: btn.dataset.repo }, ev.shiftKey || ev.metaKey || ev.ctrlKey)
2603
+ if (btn.classList.contains('opt')) state.moreOpen = ev.shiftKey || ev.metaKey || ev.ctrlKey
2604
+ if (state.route && state.route.name === 'timeline') {
2605
+ const tf = state.tlFilter || {}
2606
+ const r2 = toggleRepo(tf.repos || [], { key: btn.dataset.key || btn.dataset.repo, label: btn.dataset.repo }, ev.shiftKey || ev.metaKey || ev.ctrlKey)
2607
+ saveRepos(window.localStorage, r2); location.hash = timelineHash({ repos: r2, session: null, trigger: tf.trigger }); return
2608
+ }
2609
+ if (!onBoard) { saveRepos(window.localStorage, repos); location.hash = filterHash({ repos, status: null }); return }
2610
+ return setBoardFilter({ repos, status: f.status })
2611
+ }
2612
+ if (action === 'pick-model') {
2613
+ if (btn.dataset.needsDl) { toast('Download it first: press Download & use'); return }
2614
+ document.getElementById('f-model').value = btn.dataset.tag
2615
+ const o = document.getElementById('f-model-other'); if (o) o.value = ''
2616
+ return paintModels()
2617
+ }
2618
+ if (action === 'ollama-start') {
2619
+ btn.disabled = true; btn.textContent = 'Starting…'
2620
+ try {
2621
+ const r = await api('POST', '/v1/models/ollama/start')
2622
+ if (r.models) state.models = r.models
2623
+ toast(r.ok ? (r.already ? 'Ollama is already running' : 'Ollama is running') : 'Started ' + (r.shell || 'Ollama') + ', but it didn’t answer within 20 s', r.ok ? 3000 : 8000)
2624
+ } finally { paintModels() }
2625
+ return
2626
+ }
2627
+ if (action === 'pull-model') {
2628
+ btn.disabled = true
2629
+ const v = await api('POST', '/v1/models/pull', { tag: btn.dataset.tag, use: btn.dataset.use === '1' })
2630
+ onPull(v); return
2631
+ }
2632
+ if (action === 'pull-cancel') { const v = await api('DELETE', '/v1/models/pull/' + encodeURIComponent(btn.dataset.tag)); onPull(v); return }
2633
+ if (action === 'day-toggle') {
2634
+ const on = btn.getAttribute('aria-pressed') === 'true'
2635
+ if (on && document.querySelectorAll('.days .day[aria-pressed="true"]').length === 1) { toast('Pick at least one day'); return }
2636
+ btn.setAttribute('aria-pressed', String(!on)); return
2637
+ }
2638
+ if (action === 'days-preset') {
2639
+ const want = btn.dataset.days === 'weekdays' ? ['mon', 'tue', 'wed', 'thu', 'fri'] : DAY_KEYS.map(([k]) => k)
2640
+ document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return
2641
+ }
2642
+ if (action === 'repo-all') {
2643
+ state.moreOpen = false
2644
+ if (state.route && state.route.name === 'timeline') { saveRepos(window.localStorage, []); location.hash = timelineHash({ repos: [], session: null, trigger: (state.tlFilter || {}).trigger }); return }
2645
+ return setBoardFilter({ repos: [], status: (state.boardFilter || {}).status })
2646
+ }
2647
+ if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
2648
+ if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
2649
+ if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
2650
+ if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null }) }
2651
+ // Spaces (v0.5.8)
2652
+ if (action === 'board-space') {
2653
+ state.boardSpace = btn.dataset.space || null
2654
+ saveSpace(state.boardSpace)
2655
+ const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
2656
+ return setBoardFilter({ repos: f.repos, status: f.status })
2657
+ }
2658
+ if (action === 'digest-space') {
2659
+ state.digestSpace = btn.dataset.space || null
2660
+ saveSpace(state.digestSpace)
2661
+ return paintDigestFilter()
2662
+ }
2663
+ if (action === 'standup-copy') return standupCopy(btn)
2664
+ if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
2665
+ if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
2666
+ if (action === 'digest-repo') {
2667
+ const cur = state.digestRepos || []
2668
+ state.digestRepos = btn.dataset.repo ? toggleRepo(cur, btn.dataset.repo, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
2669
+ saveRepos(window.localStorage, state.digestRepos)
2670
+ return paintDigestFilter()
2671
+ }
2672
+ // ---- Needs you (v0.4.4)
2673
+ if (action === 'ask-deny-open' || action === 'ask-deny-close') {
2674
+ const need = btn.closest('.need')
2675
+ const form = need && need.querySelector('.deny-form')
2676
+ if (form) { form.hidden = action === 'ask-deny-close'; if (!form.hidden) form.querySelector('input').focus() }
2677
+ return
2678
+ }
2679
+ if (action === 'ask-pick') {
2680
+ const q = btn.closest('.need-q')
2681
+ if (q && !q.dataset.multi) q.querySelectorAll('.opt').forEach((o) => { if (o !== btn) o.setAttribute('aria-pressed', 'false') })
2682
+ btn.setAttribute('aria-pressed', btn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true')
2683
+ return
2684
+ }
2685
+ if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
2686
+ if (action === 'reply-send') return replyClick(btn)
2687
+ if (action === 'go') return goClick(btn, id, s)
2688
+ if (action && action.startsWith('st-')) return stAction(action, btn)
2689
+ if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
2690
+ if (action === 'resume') {
2691
+ const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
2692
+ await copy(cmd)
2693
+ } else if (action === 'checkpoint') {
2694
+ btn.disabled = true; btn.textContent = 'Writing…'
2695
+ const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
2696
+ toast('Checkpoint saved · Next: ' + cp.next)
2697
+ render()
2698
+ } else if (action === 'pin') {
2699
+ await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { pinned: !(s && s.pinned) }); render()
2700
+ } else if (action === 'archive') {
2701
+ await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { archived: true }); toast('Archived'); render()
2702
+ } else if (action === 'gen-digest') {
2703
+ btn.disabled = true; btn.textContent = 'Generating…'
2704
+ const d = await api('POST', '/v1/digests', {})
2705
+ digestUi.standups = {}; digestUi.date = null
2706
+ location.hash = '#/digest/' + d.meta.date
2707
+ render()
2708
+ } else if (action === 'rename') {
2709
+ const box = btn.closest('.title')
2710
+ if (!box || box.querySelector('input')) return
2711
+ const current = s ? s.title || '' : ''
2712
+ box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title">'
2713
+ const input = box.querySelector('input')
2714
+ input.focus(); input.select()
2715
+ let finished = false
2716
+ const done = async (save) => {
2717
+ if (finished) return
2718
+ finished = true
2719
+ if (save && input.value.trim() && input.value.trim() !== current) {
2720
+ await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { title: input.value.trim() })
2721
+ toast('Renamed. With the mod, the session and its tab title follow within a minute')
2722
+ }
2723
+ render()
2724
+ }
2725
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(true); if (e.key === 'Escape') done(false) })
2726
+ input.addEventListener('blur', () => done(true))
2727
+ }
2728
+ } catch (e) { toast(e.message); btn.disabled = false }
2729
+ })
2730
+
2731
+
2732
+ // Answer a permission prompt or a question from the board. The daemon hands it
2733
+ // to the waiting hook or mod; the terminal's own prompt may already have won.
2734
+ async function answerClick(btn, action) {
2735
+ const need = btn.closest('.need')
2736
+ if (!need) return
2737
+ const askId = need.dataset.ask, key = need.dataset.key
2738
+ const s = state.sessions.find((x) => x.id === need.dataset.id)
2739
+ const n = s && s.needsYou
2740
+ let body
2741
+ if (action === 'ask-pick-send') {
2742
+ const q = n && n.ask && n.ask.questions && n.ask.questions[0]
2743
+ body = { action: 'answer', answers: { [q ? q.question : '']: btn.dataset.label } }
2744
+ } else if (btn.dataset.choice === 'answer') {
2745
+ const qs = (n && n.ask && n.ask.questions) || []
2746
+ const picks = {}, typed = {}
2747
+ need.querySelectorAll('.need-q').forEach((el) => {
2748
+ const i = Number(el.dataset.q)
2749
+ picks[i] = [...el.querySelectorAll('.opt[aria-pressed="true"]')].map((o) => o.dataset.label)
2750
+ const t = el.querySelector('.qother'); typed[i] = t ? t.value : ''
2751
+ })
2752
+ body = { action: 'answer', ...questionAnswer(qs, picks, typed) }
2753
+ if (!Object.keys(body.answers).length) { toast('Pick an option or type an answer first'); return }
2754
+ } else if (btn.dataset.choice === 'deny') {
2755
+ const note = need.querySelector('.deny-note')
2756
+ body = { action: 'deny', message: note ? note.value.trim() : '' }
2757
+ } else body = { action: btn.dataset.choice }
2758
+ need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
2759
+ btn.textContent = 'Sending…'
2760
+ state.pendingAsk[key] = 'Sending…'
2761
+ try {
2762
+ const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
2763
+ toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
2764
+ for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
2765
+ } catch (e) {
2766
+ toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
2767
+ } finally {
2768
+ delete state.pendingAsk[key]
2769
+ render()
2770
+ }
2771
+ }
2772
+
2773
+ async function replyClick(btn) {
2774
+ const need = btn.closest('.need')
2775
+ const ta = need && need.querySelector('.reply-text')
2776
+ const text = ta ? ta.value.trim() : ''
2777
+ if (!text) { toast('Type a reply first'); return }
2778
+ if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
2779
+ btn.disabled = true; btn.textContent = 'Sending…'
2780
+ try {
2781
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
2782
+ delete state.drafts['reply:' + need.dataset.id]
2783
+ toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
2784
+ } catch (e) { toast(e.message, 4200) } finally { render() }
2785
+ }
2786
+
2787
+ // Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
2788
+ function goClick(btn, id, s) {
2789
+ return goWithFeedback({
2790
+ id,
2791
+ title: s && s.title,
2792
+ label: s && s.terminal && s.terminal.label,
2793
+ toast,
2794
+ getBtn: () => document.querySelector('.btn.go[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]') || btn,
2795
+ post: () => api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/go', {}, { timeoutMs: GIVE_UP_MS }),
2796
+ })
2797
+ }
2798
+
2799
+ document.addEventListener('submit', async (ev) => {
2800
+ if (ev.target.dataset && ev.target.dataset.listAdd) {
2801
+ ev.preventDefault()
2802
+ const input = ev.target.querySelector('input[name=text]')
2803
+ const text = input.value.trim()
2804
+ if (!text) return
2805
+ try { await api('POST', '/v1/lists/' + ev.target.dataset.listAdd + '/change', { add: text }); input.value = ''; await render(); const again = document.querySelector('form[data-list-add="' + ev.target.dataset.listAdd + '"] input'); if (again) again.focus() } catch (e) { toast(e.message, 5000) }
2806
+ return
2807
+ }
2808
+ if (ev.target.id === 'searchForm') {
2809
+ ev.preventDefault()
2810
+ const q = document.getElementById('q').value.trim()
2811
+ const it = searchUi.items[searchUi.active]
2812
+ closeSearch()
2813
+ location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
2814
+ } else if (ev.target.id === 'settingsForm') {
2815
+ ev.preventDefault()
2816
+ try {
2817
+ const prov = document.getElementById('f-mode').value
2818
+ const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
2819
+ const prev = (state.cfg && state.cfg.summarizer) || {}
2820
+ const changed = String(prev.provider || 'auto').toLowerCase() !== prov
2821
+ const summarizer = {
2822
+ provider: prov,
2823
+ model: (f.model && (((document.getElementById('f-model-other') || {}).value || '').trim() || document.getElementById('f-model').value.trim())) || 'haiku',
2824
+ baseUrl: (f.base && document.getElementById('f-base').value.trim()) || null,
2825
+ }
2826
+ // A key setting from another provider must not follow the switch.
2827
+ if (changed) { summarizer.apiKeyEnv = null; summarizer.apiKeyFile = null }
2828
+ const key = f.key ? document.getElementById('f-key').value.trim() : ''
2829
+ if (key) await api('PUT', '/v1/config/keys/' + f.key, { key })
2830
+ await api('PATCH', '/v1/config', {
2831
+ dormantAfterMin: Number(document.getElementById('f-dormant').value),
2832
+ minGapMin: Number(document.getElementById('f-gap').value),
2833
+ digest: {
2834
+ time: document.getElementById('f-time').value || null,
2835
+ days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
2836
+ skipEmpty: document.getElementById('f-skip').checked,
2837
+ timeZone: document.getElementById('f-tz').value || null,
2838
+ },
2839
+ summarizer,
2840
+ summary: { length: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative' },
2841
+ updates: { check: document.getElementById('f-updates').checked },
2842
+ notify: { needsYou: document.getElementById('f-notify').checked },
2843
+ ...(() => {
2844
+ const el = document.getElementById('f-remote-answers')
2845
+ if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
2846
+ 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 {} }
2847
+ return { remote: { allowAnswers: el.checked } }
2848
+ })(),
2849
+ })
2850
+ toast(key ? 'Saved · key stored in .secret/' : 'Saved'); render(); refreshHealth()
2851
+ } catch (e) { toast(e.message) }
2852
+ }
2853
+ })
2854
+
2855
+ // Live updates over SSE; re-render the current view, at most every 800 ms.
2856
+ let pending = null
2857
+ const busy = () => !!document.querySelector('.lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
2858
+ // Settings isn't redrawn by live updates: it would wipe what you're editing,
2859
+ // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
2860
+ function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name === 'settings') && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
2861
+ let stream = null
2862
+ function connect() {
2863
+ if (stream) { try { stream.close() } catch {} }
2864
+ const es = stream = new EventSource('/v1/stream')
2865
+ for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated']) es.addEventListener(t, soon)
2866
+ // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
2867
+ for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
2868
+ es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
2869
+ // A daemon that restarted (an upgrade, a folder move) comes back on the same
2870
+ // port; if the browser gave up on the stream, open a new one.
2871
+ es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
2872
+ es.onopen = () => refreshHealth()
2873
+ }
2874
+
2875
+ document.addEventListener('change', async (ev) => {
2876
+ // Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
2877
+ if (ev.target.dataset && ev.target.dataset.spaceKind) {
2878
+ const sel = ev.target
2879
+ let space = sel.value
2880
+ if (space === '__new') { space = (prompt('Name the space (e.g. Work, Personal, Clients):') || '').trim(); if (!space) { sel.selectedIndex = 0; return } }
2881
+ try {
2882
+ await api('POST', '/v1/spaces', { kind: sel.dataset.spaceKind, key: sel.dataset.spaceKey, space: space || null })
2883
+ toast(space ? 'Now in ' + space : 'Back to the default space')
2884
+ } catch (e) { toast(e.message, 5000) }
2885
+ return sel.closest('#spacesBox') ? spacesRepaint() : render()
2886
+ }
2887
+ if (ev.target.id === 'f-peer-allow') {
2888
+ const cb = ev.target
2889
+ try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
2890
+ return viewSettings()
2891
+ }
2892
+ if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
2893
+ const cb = ev.target, done = cb.checked, li = cb.closest('li')
2894
+ if (li) li.classList.toggle('done', done)
2895
+ try {
2896
+ const r = await api('POST', '/v1/lists/' + cb.dataset.list + '/change', done ? { done: [Number(cb.dataset.n)] } : { undone: [Number(cb.dataset.n)] })
2897
+ const card = cb.closest('.listcard')
2898
+ if (card) { const p = card.querySelector('.lprog'); if (p) p.textContent = r.list.progress; const b = card.querySelector('progress.bar'); if (b) { b.max = Math.max(1, r.list.total); b.value = r.list.done } card.classList.toggle('complete', r.list.done === r.list.total) }
2899
+ if (r.list.done === r.list.total && done) toast('“' + r.list.name + '” is done 🎉')
2900
+ } catch (e) { cb.checked = !done; if (li) li.classList.toggle('done', !done); toast(e.message, 5000) }
2901
+ return
2902
+ }
2903
+ if (ev.target.name === 'rs') {
2904
+ if (ev.target.checked) restoreUi.off.delete(ev.target.value); else restoreUi.off.add(ev.target.value)
2905
+ const n = restorePicked().length
2906
+ const go = document.getElementById('restoreGo')
2907
+ if (go) { go.disabled = !n; go.innerHTML = icon('rotate-ccw') + 'Restore ' + n }
2908
+ const all = document.querySelector('[data-action="restore-all"]')
2909
+ if (all) all.textContent = n === (state.restore ? state.restore.sessions.length : 0) ? 'Select none' : 'Select all'
2910
+ return
2911
+ }
2912
+ if (ev.target.dataset && ev.target.dataset.cuRepo != null) {
2913
+ const all = ((cuUi.opts && cuUi.opts.repos) || []).map((r) => r.key)
2914
+ const sel = cuUi.repos || new Set(all)
2915
+ if (ev.target.checked) sel.add(ev.target.dataset.cuRepo); else sel.delete(ev.target.dataset.cuRepo)
2916
+ cuUi.repos = sel.size === all.length ? null : sel
2917
+ return render()
2918
+ }
2919
+ if (ev.target.dataset && ev.target.dataset.cuMachine != null) {
2920
+ const all = (cuUi.opts && cuUi.opts.allMachines) || []
2921
+ const sel = cuUi.machines || new Set(all)
2922
+ if (ev.target.checked) sel.add(ev.target.dataset.cuMachine); else sel.delete(ev.target.dataset.cuMachine)
2923
+ cuUi.machines = sel.size === all.length ? null : sel
2924
+ return render()
2925
+ }
2926
+ if (ev.target.id === 'cuFrom' || ev.target.id === 'cuTo') {
2927
+ if (ev.target.id === 'cuFrom') cuUi.from = ev.target.value; else cuUi.to = ev.target.value
2928
+ cuUi.repos = null
2929
+ return cuLoadOptions().then(render)
2930
+ }
2931
+ if (ev.target.id === 'tlSession') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: ev.target.value || null }); return }
2932
+ if (ev.target.id === 'machineFilter') { state.machine = ev.target.value; localStorage.setItem('cp.machine', state.machine); render() }
2933
+ })
2934
+
2935
+ // Moving between Settings sections only swaps the section shown (v0.5.9): a
2936
+ // redraw would drop what you typed in another section before saving.
2937
+ window.addEventListener('hashchange', () => {
2938
+ const r = parseRoute()
2939
+ if (r.name === 'settings' && state.route && state.route.name === 'settings' && document.getElementById('settingsWrap')) { state.route = r; return showSettingsSection(r) }
2940
+ closeMenu(); closeSearch(); if (statusOpen()) toggleStatus(false)
2941
+ render()
2942
+ })
2943
+ setInterval(() => { if (state.route && ['board', 'waiting'].includes(state.route.name) && !busy()) render() }, 30000)
2944
+ // More ▾: remember open/closed across re-renders, filter its list as you type, close on an outside click or Escape.
2945
+ document.addEventListener('toggle', (ev) => {
2946
+ if (ev.target.id === 'emptyFold') { state.emptyOpen = ev.target.open; return }
2947
+ if (!ev.target.classList || !ev.target.classList.contains('repomore')) return
2948
+ state.moreOpen = ev.target.open
2949
+ if (ev.target.open) { const i = ev.target.querySelector('.morefilter'); if (i) i.focus() }
2950
+ }, true)
2951
+ document.addEventListener('input', (ev) => {
2952
+ if (ev.target.dataset && ev.target.dataset.draft) { state.drafts[ev.target.dataset.draft] = ev.target.value; return }
2953
+ if (!ev.target.classList.contains('morefilter')) return
2954
+ state.moreQuery = ev.target.value
2955
+ const q = ev.target.value.trim().toLowerCase()
2956
+ for (const o of document.querySelectorAll('.repomore .opt')) o.hidden = !!q && !o.dataset.search.includes(q)
2957
+ })
2958
+ document.addEventListener('keydown', (ev) => {
2959
+ if ((ev.key === 'Enter' || ev.key === ' ') && ev.target.classList && ev.target.classList.contains('mcard')) { ev.preventDefault(); ev.target.click(); return }
2960
+ const m = document.querySelector('.repomore[open]')
2961
+ if (!m) return
2962
+ if (ev.key === 'Escape') { m.open = false; return }
2963
+ if (ev.key === 'Enter' && ev.target.classList.contains('morefilter')) {
2964
+ ev.preventDefault()
2965
+ const first = [...m.querySelectorAll('.opt')].find((o) => !o.hidden)
2966
+ if (first) first.click()
2967
+ }
2968
+ })
2969
+ document.addEventListener('pointerdown', (ev) => {
2970
+ const m = document.querySelector('.repomore[open]')
2971
+ if (m && !m.contains(ev.target)) m.open = false
2972
+ })
2973
+ api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
2974
+ refreshHealth().then(render)
2975
+ connect()
2976
+
2977
+ // The home-screen app's service worker (v0.4.6): it only serves an offline page
2978
+ // when the Mac can't be reached; it caches no API data and no board pages.
2979
+ if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})