@leftoff/cli 0.9.4 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/leftoff.js +310 -289
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +50 -5
- package/ui/app.js +168 -49
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
package/ui/app.js
CHANGED
|
@@ -4,6 +4,9 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
+
import { changedKeys } from './settings-dirty.js'
|
|
8
|
+
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
9
|
+
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
7
10
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
|
|
8
11
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
12
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -11,7 +14,7 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
11
14
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
15
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
16
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
14
|
-
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
|
|
17
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
15
18
|
const $app = document.getElementById('app')
|
|
16
19
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
17
20
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -128,14 +131,23 @@ function cpExtras(cp) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
// more: show the informative / long sections, folded (open: unfolded).
|
|
131
|
-
|
|
134
|
+
// md (0.9.5): the session page's full view renders a markdown checkpoint (a
|
|
135
|
+
// model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
|
|
136
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false } = {}) {
|
|
132
137
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
133
138
|
const you = /^YOU:/i.test(cp.next || '')
|
|
134
139
|
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
135
|
-
|
|
136
|
-
'<
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
const dd = (t, cls = '') => {
|
|
141
|
+
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
142
|
+
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
143
|
+
const long = (cp.format === 'markdown' || looksMd(t)) ? plainT.length > 160 : plainT.length > 600
|
|
144
|
+
const c = (cls + (!md && long ? ' clamp' : '')).trim()
|
|
145
|
+
return '<dd' + (c ? ' class="' + c + '"' : '') + '>' + fmtCode(plainT) + '</dd>'
|
|
146
|
+
}
|
|
147
|
+
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
148
|
+
'<dt>Doing</dt>' + dd(cp.doing) +
|
|
149
|
+
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
150
|
+
'<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) +
|
|
139
151
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) : '') + '</div>' : '') +
|
|
140
152
|
'</dl>' +
|
|
141
153
|
(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>' : '')
|
|
@@ -465,6 +477,29 @@ function reachOf(s) {
|
|
|
465
477
|
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
466
478
|
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
467
479
|
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
480
|
+
// 0.9.5: "Checkpoint now" in flight stays on screen (ui/writing.js) until it lands
|
|
481
|
+
// or fails; the checkpoint that lands gets a short highlight.
|
|
482
|
+
state.writing = {}
|
|
483
|
+
state.cpFresh = {}
|
|
484
|
+
function writingFor(s) {
|
|
485
|
+
const w = reconcileWriting(state.writing, s)
|
|
486
|
+
if (w && w.landed) { state.cpFresh[w.landed] = Date.now(); return null }
|
|
487
|
+
return w
|
|
488
|
+
}
|
|
489
|
+
const isFresh = (cp) => !!(cp && state.cpFresh[cp.id] && Date.now() - state.cpFresh[cp.id] < FRESH_MS)
|
|
490
|
+
function writingLine(s, w, cls = '') {
|
|
491
|
+
const busy = w.phase === 'writing' || w.phase === 'syncing'
|
|
492
|
+
return '<div class="cp-writing ' + w.phase + (cls ? ' ' + cls : '') + '" role="' + (w.phase === 'failed' ? 'alert' : 'status') + '" data-writing="' + esc(s.id) + '">' +
|
|
493
|
+
(busy ? '<span class="spin" aria-hidden="true"></span>' : w.phase === 'failed' ? icon('info') : icon('history')) +
|
|
494
|
+
'<span class="wtext">' + esc(writingText(w, lengthLabel)) + '</span>' +
|
|
495
|
+
(busy ? '' : '<button type="button" class="icon wx" data-action="writing-dismiss" data-id="' + esc(s.id) + '" aria-label="Dismiss" title="Dismiss">' + icon('x') + '</button>') + '</div>'
|
|
496
|
+
}
|
|
497
|
+
// The card's checkpoint area: the writing line above the old checkpoint (dimmed while it's written).
|
|
498
|
+
function cpArea(s, block) {
|
|
499
|
+
const w = writingFor(s)
|
|
500
|
+
if (!w) return isFresh(s.latestCheckpoint) ? '<div class="cp-fresh">' + block + '</div>' : block
|
|
501
|
+
return writingLine(s, w) + (isWriting(state.writing, s.id) ? '<div class="cp-stale" aria-hidden="true">' + block + '</div>' : block)
|
|
502
|
+
}
|
|
468
503
|
// 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
|
|
469
504
|
// Informative / Long, each with its line, your default ticked.
|
|
470
505
|
function checkpointItems() {
|
|
@@ -505,6 +540,11 @@ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' ||
|
|
|
505
540
|
// length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
|
|
506
541
|
function checkpointSplit(s) {
|
|
507
542
|
const def = defaultLength()
|
|
543
|
+
if (isWriting(state.writing, s.id)) { // 0.9.5: busy until the checkpoint lands
|
|
544
|
+
return '<span class="split is-writing" role="group" aria-label="Checkpoint now" aria-busy="true">' +
|
|
545
|
+
'<button type="button" class="btn primary split-main" disabled aria-disabled="true" data-id="' + esc(s.id) + '"><span class="spin" aria-hidden="true"></span>Writing…</button>' +
|
|
546
|
+
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
547
|
+
}
|
|
508
548
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
509
549
|
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
|
|
510
550
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
@@ -535,7 +575,7 @@ function card(s) {
|
|
|
535
575
|
'</div>' + pill(s.status) + '</div>' +
|
|
536
576
|
'<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>' +
|
|
537
577
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
538
|
-
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
578
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
539
579
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
540
580
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
541
581
|
copyResumeIcon(s) +
|
|
@@ -952,7 +992,7 @@ function parkedCard(s, viewer) {
|
|
|
952
992
|
const p = s.parked || {}
|
|
953
993
|
const open = s.status !== 'ended' && s.live !== 'closed'
|
|
954
994
|
const cp = s.latestCheckpoint
|
|
955
|
-
const note = p.note || (cp && cp.next) || ''
|
|
995
|
+
const note = p.note || (cp && plainCp(cp.next)) || ''
|
|
956
996
|
const main = open ? goButton(s) : resumeButton(s, viewer)
|
|
957
997
|
return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
958
998
|
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
|
|
@@ -1206,8 +1246,12 @@ async function viewSession(id) {
|
|
|
1206
1246
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
1207
1247
|
html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
|
|
1208
1248
|
const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
|
|
1209
|
-
|
|
1210
|
-
|
|
1249
|
+
// 0.9.5: the one being written, at the top of the list until it lands
|
|
1250
|
+
const wr = writingFor(s)
|
|
1251
|
+
const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
|
|
1252
|
+
const live = s.checkpoints.filter((cp) => !cp.doubt)
|
|
1253
|
+
if (!live.length && !wrRow) html += '<div class="empty">No checkpoints yet.</div>'
|
|
1254
|
+
else html += '<div class="timeline">' + wrRow + cpTimeline(live, newestCp) + '</div>'
|
|
1211
1255
|
// 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
|
|
1212
1256
|
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
|
|
1213
1257
|
if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
@@ -1218,9 +1262,9 @@ async function viewSession(id) {
|
|
|
1218
1262
|
}
|
|
1219
1263
|
function cpTimeline(list, newestCp) {
|
|
1220
1264
|
return list.map((cp) =>
|
|
1221
|
-
'<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>' +
|
|
1265
|
+
'<div class="tl-item ' + esc(cp.trigger) + (isFresh(cp) ? ' cp-fresh' : '') + '"><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>' +
|
|
1222
1266
|
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
|
|
1223
|
-
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
|
|
1267
|
+
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
|
|
1224
1268
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
1225
1269
|
(cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
|
|
1226
1270
|
'</div>').join('')
|
|
@@ -1765,8 +1809,12 @@ function onPull(v) {
|
|
|
1765
1809
|
const was = state.pulls[v.tag]
|
|
1766
1810
|
state.pulls[v.tag] = v
|
|
1767
1811
|
if (state.route && state.route.name === 'settings') {
|
|
1768
|
-
if (v.done && (!was || !was.done) && v.use) {
|
|
1769
|
-
|
|
1812
|
+
if (v.done && (!was || !was.done) && v.use) {
|
|
1813
|
+
const m = document.getElementById('f-model'); if (m) m.value = v.tag; const sel = document.getElementById('f-mode'); if (sel) sel.value = 'ollama'
|
|
1814
|
+
// the daemon switched the summarizer itself: that's saved, not a change to save (0.9.5)
|
|
1815
|
+
if (saveUi.base) { saveUi.base.provider = 'ollama'; saveUi.base.model = v.tag; saveUi.base.baseUrl = settingsValues().baseUrl }
|
|
1816
|
+
}
|
|
1817
|
+
paintModels(); paintSave()
|
|
1770
1818
|
}
|
|
1771
1819
|
if (v.done && (!was || !was.done)) toast(v.use ? 'Downloaded ' + v.tag + '. Checkpoints now summarize with it.' : 'Downloaded ' + v.tag, 6000)
|
|
1772
1820
|
if (v.error && (!was || !was.error)) toast('Couldn’t download ' + v.tag + ': ' + v.error, 10000)
|
|
@@ -1804,7 +1852,7 @@ async function tryLength() {
|
|
|
1804
1852
|
try {
|
|
1805
1853
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
1806
1854
|
box.innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
|
|
1807
|
-
cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1855
|
+
cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1808
1856
|
} catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
1809
1857
|
}
|
|
1810
1858
|
|
|
@@ -2164,6 +2212,63 @@ function phonePane(relay, remote, unavailable) {
|
|
|
2164
2212
|
return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
|
|
2165
2213
|
}
|
|
2166
2214
|
|
|
2215
|
+
// Settings → Save (0.9.5): Save is on only while the form differs from what was
|
|
2216
|
+
// last saved (ui/settings-dirty.js), so a missed "Saved" toast can't leave you
|
|
2217
|
+
// guessing. The values are exactly what Save sends.
|
|
2218
|
+
const saveUi = { base: null, saving: false, timer: null }
|
|
2219
|
+
function settingsValues() {
|
|
2220
|
+
const $ = (id) => document.getElementById(id)
|
|
2221
|
+
const val = (id) => { const el = $(id); return el && el.value != null ? String(el.value) : '' }
|
|
2222
|
+
const on = (id) => { const el = $(id); return !!(el && el.checked) }
|
|
2223
|
+
const prov = val('f-mode') || 'auto'
|
|
2224
|
+
const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
|
|
2225
|
+
const ra = $('f-remote-answers')
|
|
2226
|
+
return {
|
|
2227
|
+
provider: prov,
|
|
2228
|
+
model: (f.model && (val('f-model-other').trim() || val('f-model').trim())) || 'haiku',
|
|
2229
|
+
baseUrl: (f.base && val('f-base').trim()) || null,
|
|
2230
|
+
key: f.key ? val('f-key').trim() : '',
|
|
2231
|
+
dormantAfterMin: Number(val('f-dormant')),
|
|
2232
|
+
minGapMin: Number(val('f-gap')),
|
|
2233
|
+
digest: {
|
|
2234
|
+
time: val('f-time') || null,
|
|
2235
|
+
days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
|
|
2236
|
+
skipEmpty: on('f-skip'),
|
|
2237
|
+
timeZone: val('f-tz') || null,
|
|
2238
|
+
},
|
|
2239
|
+
length: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative',
|
|
2240
|
+
updates: on('f-updates'),
|
|
2241
|
+
notify: on('f-notify'),
|
|
2242
|
+
remoteAnswers: ra && !ra.disabled ? ra.checked : null,
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const settingsChanged = () => saveUi.base && document.getElementById('settingsForm') ? changedKeys(saveUi.base, settingsValues()) : []
|
|
2246
|
+
// Where each value lives, to name the sections with unsaved changes.
|
|
2247
|
+
const SAVE_FIELD = { provider: 'f-mode', model: 'f-model', baseUrl: 'f-base', key: 'f-key', dormantAfterMin: 'f-dormant', minGapMin: 'f-gap', digest: 'f-time', length: 'f-length-example', updates: 'f-updates', notify: 'f-notify', remoteAnswers: 'f-remote-answers' }
|
|
2248
|
+
function paintSave() {
|
|
2249
|
+
const box = document.getElementById('setSave')
|
|
2250
|
+
const btn = box && box.querySelector('button[type=submit]')
|
|
2251
|
+
if (!btn) return
|
|
2252
|
+
const keys = settingsChanged()
|
|
2253
|
+
const dirty = keys.length > 0
|
|
2254
|
+
btn.disabled = !dirty || saveUi.saving
|
|
2255
|
+
btn.setAttribute('aria-disabled', String(btn.disabled))
|
|
2256
|
+
btn.title = dirty ? '' : 'Nothing to save'
|
|
2257
|
+
box.classList.toggle('dirty', dirty)
|
|
2258
|
+
const note = document.getElementById('setSaveNote')
|
|
2259
|
+
if (!note) return
|
|
2260
|
+
const secs = []
|
|
2261
|
+
for (const k of keys) {
|
|
2262
|
+
const el = document.getElementById(SAVE_FIELD[k] || '')
|
|
2263
|
+
const pane = el && el.closest('.set-pane')
|
|
2264
|
+
const sec = pane && SETTINGS.find((x) => x.key === pane.dataset.sec)
|
|
2265
|
+
if (sec && !secs.includes(sec.label)) secs.push(sec.label)
|
|
2266
|
+
}
|
|
2267
|
+
note.textContent = saveUi.saving ? 'Saving…' : dirty ? 'Unsaved changes' + (secs.length ? ' in ' + secs.join(', ') : '') : 'Everything’s saved'
|
|
2268
|
+
}
|
|
2269
|
+
// After the click/key that changed something has done its work (a model card, a day button).
|
|
2270
|
+
function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(paintSave, 0) }
|
|
2271
|
+
|
|
2167
2272
|
async function viewSettings() {
|
|
2168
2273
|
const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
|
|
2169
2274
|
state.relay = relay
|
|
@@ -2224,7 +2329,7 @@ async function viewSettings() {
|
|
|
2224
2329
|
'<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>' +
|
|
2225
2330
|
'<form class="form" id="settingsForm" autocomplete="off">' +
|
|
2226
2331
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
2227
|
-
'<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>'
|
|
2332
|
+
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
2228
2333
|
showSettingsSection(r)
|
|
2229
2334
|
paintSettingsDots()
|
|
2230
2335
|
syncProviderFields()
|
|
@@ -2254,6 +2359,10 @@ async function viewSettings() {
|
|
|
2254
2359
|
if (ev.key === 'Escape' && f.value) { ev.preventDefault(); f.value = ''; settingsUi.q = ''; settingsFilter() }
|
|
2255
2360
|
if (ev.key === 'Enter') { ev.preventDefault(); const k = settingsFilter(); if (k) location.hash = '#/settings/' + k }
|
|
2256
2361
|
})
|
|
2362
|
+
// 0.9.5: what's on screen now is what's saved; Save comes on with the first change.
|
|
2363
|
+
saveUi.base = settingsValues(); saveUi.saving = false; paintSave()
|
|
2364
|
+
const form = document.getElementById('settingsForm')
|
|
2365
|
+
for (const t of ['input', 'change', 'click', 'keyup']) form.addEventListener(t, queueSave)
|
|
2257
2366
|
}
|
|
2258
2367
|
|
|
2259
2368
|
// ------------------------------------------------------------ sync and other machines (v0.5.2)
|
|
@@ -3169,7 +3278,7 @@ function searchGroups(q, r) {
|
|
|
3169
3278
|
if ((r.lists || []).length) groups.push(['Lists', r.lists.slice(0, 4).map((l) => ({ href: listHref(l.ref), title: l.name, sub: [l.repo, l.progress].filter(Boolean).join(' · '), icon: 'list-checks' }))])
|
|
3170
3279
|
if ((r.todos || []).length) groups.push(['To-dos', r.todos.slice(0, 4).map((t) => ({ href: t.sessionId ? '#/s/' + encodeURIComponent(t.sessionId) : '#/waiting', title: t.text, sub: t.sessionTitle || '', icon: 'circle-check' }))])
|
|
3171
3280
|
if ((r.files || []).length) groups.push(['Files', r.files.slice(0, 5).map((f) => ({ href: '#/s/' + encodeURIComponent(f.sessionId), title: f.name, sub: f.sessionTitle || '', icon: 'file-text' }))])
|
|
3172
|
-
if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).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' }))])
|
|
3281
|
+
if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: plainCp(cp.doing || cp.next) || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
|
|
3173
3282
|
}
|
|
3174
3283
|
if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
|
|
3175
3284
|
if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
|
|
@@ -3511,7 +3620,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3511
3620
|
if (btn.dataset.needsDl) { toast('Download it first: press Download & use'); return }
|
|
3512
3621
|
document.getElementById('f-model').value = btn.dataset.tag
|
|
3513
3622
|
const o = document.getElementById('f-model-other'); if (o) o.value = ''
|
|
3514
|
-
return
|
|
3623
|
+
paintModels(); return paintSave()
|
|
3515
3624
|
}
|
|
3516
3625
|
if (action === 'ollama-start') {
|
|
3517
3626
|
btn.disabled = true; btn.textContent = 'Starting…'
|
|
@@ -3531,11 +3640,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
3531
3640
|
if (action === 'day-toggle') {
|
|
3532
3641
|
const on = btn.getAttribute('aria-pressed') === 'true'
|
|
3533
3642
|
if (on && document.querySelectorAll('.days .day[aria-pressed="true"]').length === 1) { toast('Pick at least one day'); return }
|
|
3534
|
-
btn.setAttribute('aria-pressed', String(!on)); return
|
|
3643
|
+
btn.setAttribute('aria-pressed', String(!on)); return paintSave()
|
|
3535
3644
|
}
|
|
3536
3645
|
if (action === 'days-preset') {
|
|
3537
3646
|
const want = btn.dataset.days === 'weekdays' ? ['mon', 'tue', 'wed', 'thu', 'fri'] : DAY_KEYS.map(([k]) => k)
|
|
3538
|
-
document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return
|
|
3647
|
+
document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return paintSave()
|
|
3539
3648
|
}
|
|
3540
3649
|
if (action === 'repo-all') {
|
|
3541
3650
|
state.moreOpen = false
|
|
@@ -3631,17 +3740,29 @@ document.addEventListener('click', async (ev) => {
|
|
|
3631
3740
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3632
3741
|
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3633
3742
|
} else if (action === 'checkpoint') {
|
|
3634
|
-
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
|
|
3743
|
+
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it.
|
|
3744
|
+
// 0.9.5: "Writing checkpoint…" stays on the card and the session page until it lands or fails.
|
|
3635
3745
|
const length = btn.dataset.length || null
|
|
3636
3746
|
const away = s && !s.isLocal ? s.machine : null
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3747
|
+
if (isWriting(state.writing, id)) { toast('Already writing a checkpoint for this session'); return }
|
|
3748
|
+
beginWriting(state.writing, id, { length: length || defaultLength(), lastCpId: (s && s.lastCheckpointId) || null, machine: away })
|
|
3749
|
+
const req = api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }).then((cp) => ({ cp }), (e) => ({ e }))
|
|
3750
|
+
await render()
|
|
3751
|
+
// nothing on screen shows it (⌘K from another page): say it once
|
|
3752
|
+
if (![...document.querySelectorAll('[data-writing]')].some((el) => el.dataset.writing === id)) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000)
|
|
3753
|
+
const { cp, e } = await req
|
|
3754
|
+
if (e && e.code === 'peer_slow') {
|
|
3755
|
+
settleWriting(state.writing, id, { slow: true })
|
|
3756
|
+
setTimeout(() => { if (state.writing[id] && !busy() && state.route && /^(board|s)$/.test(state.route.name || 'board')) render() }, WRITING_SYNC_MS + 300)
|
|
3757
|
+
return render()
|
|
3758
|
+
}
|
|
3759
|
+
if (e) { settleWriting(state.writing, id, { error: e.message }); toast(e.message, 6000); return render() }
|
|
3760
|
+
settleWriting(state.writing, id, { ok: true })
|
|
3761
|
+
state.cpFresh[cp.id] = Date.now()
|
|
3762
|
+
toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + plainCp(cp.next))
|
|
3644
3763
|
render()
|
|
3764
|
+
} else if (action === 'writing-dismiss') {
|
|
3765
|
+
dismissWriting(state.writing, btn.dataset.id || id); render()
|
|
3645
3766
|
} else if (action === 'cp-lengths') {
|
|
3646
3767
|
ev.preventDefault(); return openLengthMenu(btn, id)
|
|
3647
3768
|
} else if (action === 'peer-recheck') {
|
|
@@ -4017,33 +4138,27 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4017
4138
|
location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
|
|
4018
4139
|
} else if (ev.target.id === 'settingsForm') {
|
|
4019
4140
|
ev.preventDefault()
|
|
4141
|
+
// 0.9.5: nothing to save while the form matches what's saved (Save is off then too)
|
|
4142
|
+
if (saveUi.saving || !settingsChanged().length) return
|
|
4143
|
+
saveUi.saving = true; paintSave()
|
|
4020
4144
|
try {
|
|
4021
|
-
const
|
|
4022
|
-
const
|
|
4145
|
+
const v = settingsValues()
|
|
4146
|
+
const prov = v.provider
|
|
4023
4147
|
const prev = (state.cfg && state.cfg.summarizer) || {}
|
|
4024
4148
|
const changed = String(prev.provider || 'auto').toLowerCase() !== prov
|
|
4025
|
-
const summarizer = {
|
|
4026
|
-
provider: prov,
|
|
4027
|
-
model: (f.model && (((document.getElementById('f-model-other') || {}).value || '').trim() || document.getElementById('f-model').value.trim())) || 'haiku',
|
|
4028
|
-
baseUrl: (f.base && document.getElementById('f-base').value.trim()) || null,
|
|
4029
|
-
}
|
|
4149
|
+
const summarizer = { provider: prov, model: v.model, baseUrl: v.baseUrl }
|
|
4030
4150
|
// A key setting from another provider must not follow the switch.
|
|
4031
4151
|
if (changed) { summarizer.apiKeyEnv = null; summarizer.apiKeyFile = null }
|
|
4032
|
-
const key =
|
|
4033
|
-
if (key) await api('PUT', '/v1/config/keys/' +
|
|
4152
|
+
const key = v.key
|
|
4153
|
+
if (key) await api('PUT', '/v1/config/keys/' + (PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto).key, { key })
|
|
4034
4154
|
await api('PATCH', '/v1/config', {
|
|
4035
|
-
dormantAfterMin:
|
|
4036
|
-
minGapMin:
|
|
4037
|
-
digest:
|
|
4038
|
-
time: document.getElementById('f-time').value || null,
|
|
4039
|
-
days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
|
|
4040
|
-
skipEmpty: document.getElementById('f-skip').checked,
|
|
4041
|
-
timeZone: document.getElementById('f-tz').value || null,
|
|
4042
|
-
},
|
|
4155
|
+
dormantAfterMin: v.dormantAfterMin,
|
|
4156
|
+
minGapMin: v.minGapMin,
|
|
4157
|
+
digest: v.digest,
|
|
4043
4158
|
summarizer,
|
|
4044
|
-
summary: { length:
|
|
4045
|
-
updates: { check:
|
|
4046
|
-
notify: { needsYou:
|
|
4159
|
+
summary: { length: v.length },
|
|
4160
|
+
updates: { check: v.updates },
|
|
4161
|
+
notify: { needsYou: v.notify },
|
|
4047
4162
|
...(() => {
|
|
4048
4163
|
const el = document.getElementById('f-remote-answers')
|
|
4049
4164
|
if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
|
|
@@ -4051,11 +4166,15 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4051
4166
|
return { remote: { allowAnswers: el.checked } }
|
|
4052
4167
|
})(),
|
|
4053
4168
|
})
|
|
4169
|
+
// Saved: what's on screen is now what's saved, so Save goes off until the next edit.
|
|
4170
|
+
const kf = document.getElementById('f-key'); if (kf) kf.value = ''
|
|
4171
|
+
saveUi.base = settingsValues()
|
|
4054
4172
|
toast(key ? 'Saved · key stored in .secret/' : 'Saved'); render(); refreshHealth()
|
|
4055
|
-
} catch (e) { toast(e.message) }
|
|
4173
|
+
} catch (e) { toast(e.message) } finally { saveUi.saving = false; paintSave() }
|
|
4056
4174
|
}
|
|
4057
4175
|
})
|
|
4058
4176
|
|
|
4177
|
+
|
|
4059
4178
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
4060
4179
|
let pending = null
|
|
4061
4180
|
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .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))
|
package/ui/cp-text.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 0.9.5: a checkpoint's text can be markdown (a model's write-up kept as it
|
|
2
|
+
// came, see salvageCheckpointReply in mod/hooks/core.js). The session page
|
|
3
|
+
// renders it; cards, the digest and one-line previews show it as plain text,
|
|
4
|
+
// without the ### or ** marks. Shared by the board and the daemon.
|
|
5
|
+
|
|
6
|
+
// Does this read as markdown (headings, lists, bold, links, fences)?
|
|
7
|
+
export function looksMd(s) {
|
|
8
|
+
return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s+\S|\d{1,3}[.)]\s+\S|>\s|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\s]+\)/.test(String(s == null ? '' : s))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Markdown → one plain line: headings end with ":", list items start with "•",
|
|
12
|
+
// emphasis and link syntax go, `code` stays (the board shows it as code).
|
|
13
|
+
export function stripMd(s) {
|
|
14
|
+
const text = String(s == null ? '' : s).replace(/\r\n?/g, '\n')
|
|
15
|
+
const out = []
|
|
16
|
+
let fence = false
|
|
17
|
+
for (const raw of text.split('\n')) {
|
|
18
|
+
let line = raw.trim()
|
|
19
|
+
if (/^(```|~~~)/.test(line)) { fence = !fence; continue }
|
|
20
|
+
if (!line) continue
|
|
21
|
+
if (!fence) {
|
|
22
|
+
if (/^([-*_])(\s*\1){2,}$/.test(line)) continue // a rule
|
|
23
|
+
let m
|
|
24
|
+
if ((m = line.match(/^#{1,6}\s+(.*?)\s*#*$/))) { line = inline(m[1]); if (line && !/[.:!?…]$/.test(line)) line += ':'; out.push(line); continue }
|
|
25
|
+
line = line.replace(/^>\s?/, '')
|
|
26
|
+
if ((m = line.match(/^[-*+]\s+(.*)$/))) line = '• ' + m[1]
|
|
27
|
+
}
|
|
28
|
+
out.push(fence ? line : inline(line))
|
|
29
|
+
}
|
|
30
|
+
return out.join(' ').replace(/\s+/g, ' ').trim()
|
|
31
|
+
}
|
|
32
|
+
function inline(t) {
|
|
33
|
+
return t
|
|
34
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
35
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
36
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/__([^_]+)__/g, '$1')
|
|
37
|
+
.replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1$2').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1$2')
|
|
38
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
39
|
+
.trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// For one-line places: plain when it reads as markdown, as it was otherwise.
|
|
43
|
+
export const plainCp = (s) => (looksMd(s) ? stripMd(s) : String(s == null ? '' : s))
|
package/ui/handoffs.js
CHANGED
|
@@ -19,7 +19,7 @@ export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function todoAddForm(sessionId = '') {
|
|
22
|
-
return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
22
|
+
return '<form class="todo-add addform sess-add" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// Open to-dos grouped by session (your own, with no session, last).
|
package/ui/prs.js
CHANGED
|
@@ -60,7 +60,7 @@ export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
|
60
60
|
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
61
61
|
}).join('') + '</div>'
|
|
62
62
|
} else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
|
|
63
|
-
if (mayEdit) h += '<form class="prlink" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false"><button type="submit" class="btn">Link</button></form>'
|
|
63
|
+
if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
64
64
|
return h
|
|
65
65
|
}
|
|
66
66
|
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Settings → Save (0.9.5): Save is on only while the form differs from what
|
|
2
|
+
// was last saved. Pure, so the daemon-free unit tests can check the compare.
|
|
3
|
+
|
|
4
|
+
// Deep equality for the plain values the Settings form reads: numbers, strings,
|
|
5
|
+
// booleans, null, arrays and plain objects. A key that is missing equals one
|
|
6
|
+
// that is undefined; NaN equals NaN (an emptied number field reads the same twice).
|
|
7
|
+
export function sameValue(a, b) {
|
|
8
|
+
if (Object.is(a, b)) return true
|
|
9
|
+
if (a === undefined || b === undefined || a === null || b === null) return false
|
|
10
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
11
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
12
|
+
return a.every((x, i) => sameValue(x, b[i]))
|
|
13
|
+
}
|
|
14
|
+
if (typeof a !== 'object' || typeof b !== 'object') return false
|
|
15
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
16
|
+
for (const k of keys) if (!sameValue(a[k], b[k])) return false
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The top-level fields that differ, in the order of `now` (then `saved`).
|
|
21
|
+
export function changedKeys(saved, now) {
|
|
22
|
+
if (!saved || !now) return []
|
|
23
|
+
const keys = [...new Set([...Object.keys(now), ...Object.keys(saved)])]
|
|
24
|
+
return keys.filter((k) => !sameValue(saved[k], now[k]))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const settingsDirty = (saved, now) => changedKeys(saved, now).length > 0
|
package/ui/writing.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// "Checkpoint now" while it's being written (0.9.5): a state that stays on the
|
|
2
|
+
// card and the session page until the checkpoint lands or fails, instead of a
|
|
3
|
+
// toast you can miss. Pure, so the unit tests can drive it with a fake clock.
|
|
4
|
+
//
|
|
5
|
+
// One entry per session id:
|
|
6
|
+
// writing the request is out (this Mac's summarizer or the Mac it lives on)
|
|
7
|
+
// syncing the other Mac took longer than we wait; it shows up when it syncs
|
|
8
|
+
// late ...and still nothing after WRITING_SYNC_MS: a quiet note instead
|
|
9
|
+
// failed it didn't work; the error stays until dismissed or the next try
|
|
10
|
+
|
|
11
|
+
export const WRITING_SYNC_MS = 3 * 60 * 1000
|
|
12
|
+
export const FRESH_MS = 2500
|
|
13
|
+
|
|
14
|
+
export function beginWriting(map, id, { length = null, lastCpId = null, machine = null, now = Date.now() } = {}) {
|
|
15
|
+
map[id] = { phase: 'writing', length, lastCpId, machine, since: now }
|
|
16
|
+
return map[id]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const isWriting = (map, id) => !!(map[id] && (map[id].phase === 'writing' || map[id].phase === 'syncing'))
|
|
20
|
+
|
|
21
|
+
// The request came back: { ok: true }, { slow: true } (the other Mac is still at
|
|
22
|
+
// it) or { error: 'message' }.
|
|
23
|
+
export function settleWriting(map, id, outcome, now = Date.now()) {
|
|
24
|
+
const w = map[id]
|
|
25
|
+
if (!w) return null
|
|
26
|
+
if (outcome && outcome.ok) { delete map[id]; return null }
|
|
27
|
+
if (outcome && outcome.slow) { w.phase = 'syncing'; w.syncSince = now; return w }
|
|
28
|
+
w.phase = 'failed'; w.error = String((outcome && outcome.error) || 'Couldn’t write the checkpoint'); w.at = now
|
|
29
|
+
return w
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Read against the session as it is now. A synced checkpoint newer than the one
|
|
33
|
+
// we started from ends syncing/late (returns { landed: id }); syncing too long
|
|
34
|
+
// turns into late. Writing and failed don't change here.
|
|
35
|
+
export function reconcileWriting(map, s, now = Date.now()) {
|
|
36
|
+
const w = s && map[s.id]
|
|
37
|
+
if (!w) return null
|
|
38
|
+
const newer = s.lastCheckpointId && s.lastCheckpointId !== w.lastCpId
|
|
39
|
+
if ((w.phase === 'syncing' || w.phase === 'late' || w.phase === 'failed') && newer) { delete map[s.id]; return { landed: s.lastCheckpointId } }
|
|
40
|
+
if (w.phase === 'syncing' && now - (w.syncSince || w.since) >= WRITING_SYNC_MS) w.phase = 'late'
|
|
41
|
+
return w
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dismissWriting(map, id) { if (map[id] && (map[id].phase === 'failed' || map[id].phase === 'late')) delete map[id] }
|
|
45
|
+
|
|
46
|
+
// The words shown. lengthLabel maps 'short' → 'Short'.
|
|
47
|
+
export function writingText(w, lengthLabel = (k) => k) {
|
|
48
|
+
if (!w) return ''
|
|
49
|
+
const len = w.length ? ' · ' + lengthLabel(w.length) : ''
|
|
50
|
+
if (w.phase === 'writing') return 'Writing checkpoint' + len + (w.machine ? ' on ' + w.machine : '') + '…'
|
|
51
|
+
if (w.phase === 'syncing') return (w.machine || 'The other Mac') + ' is still writing it · it shows up here when it syncs…'
|
|
52
|
+
if (w.phase === 'late') return 'Not here yet · it shows up when ' + (w.machine || 'the other Mac') + ' syncs'
|
|
53
|
+
if (w.phase === 'failed') return 'Couldn’t write the checkpoint: ' + w.error
|
|
54
|
+
return ''
|
|
55
|
+
}
|