@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.
- package/.claude-plugin/marketplace.json +14 -0
- package/CHANGELOG.md +564 -0
- package/LICENSE.md +9 -0
- package/README.md +28 -2
- package/assets/sample-transcript.jsonl +12 -0
- package/bin/checkpoints.js +7 -0
- package/bin/leftoff.js +585 -0
- package/hooks/README.md +22 -0
- package/hooks/settings-hooks.example.json +82 -0
- package/mod/.claude-plugin/plugin.json +15 -0
- package/mod/hooks/core.js +1397 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +1006 -0
- package/package.json +45 -4
- package/templates/agent-feed-worker/README.md +45 -0
- package/templates/agent-feed-worker/src/index.js +69 -0
- package/templates/agent-feed-worker/wrangler.toml +20 -0
- package/ui/app.css +1282 -0
- package/ui/app.js +2979 -0
- package/ui/apple-touch-icon.png +0 -0
- package/ui/changelog-md.js +64 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-badge-32.png +0 -0
- package/ui/favicon-badge.svg +1 -0
- package/ui/favicon.ico +0 -0
- package/ui/favicon.svg +1 -0
- package/ui/go.js +68 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/icon-maskable-512.png +0 -0
- package/ui/icons.js +41 -0
- package/ui/index.html +55 -0
- package/ui/manifest.webmanifest +37 -0
- package/ui/needs.js +197 -0
- package/ui/offline.css +11 -0
- package/ui/offline.html +24 -0
- package/ui/pair.html +31 -0
- package/ui/picker.js +294 -0
- package/ui/relay-client.js +187 -0
- package/ui/relay-crypto.js +155 -0
- package/ui/relay-pair.js +48 -0
- package/ui/repos.js +167 -0
- package/ui/settings-nav.js +23 -0
- package/ui/summary-examples.js +66 -0
- package/ui/sw.js +34 -0
- package/ui/theme.js +41 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 }) + ' ' + 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 & 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 & 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 & 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 & 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 <repo> 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/<repo>/</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(() => {})
|