@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/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
- function cpBlock(cp, { showWhen = true, more = false, open = false } = {}) {
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
- return '<dl class="cp">' +
136
- '<dt>Doing</dt><dd>' + fmtCode(cp.doing) + '</dd>' +
137
- '<dt>Stopped</dt><dd>' + fmtCode(cp.stoppedAt) + '</dd>' +
138
- '<dt>Next</dt><dd class="next' + (you ? ' you' : '') + '">' + fmtCode(cp.next) + '</dd>' +
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
- if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
1210
- else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
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) { const m = document.getElementById('f-model'); if (m) m.value = v.tag; const sel = document.getElementById('f-mode'); if (sel) sel.value = 'ollama' }
1769
- paintModels()
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 paintModels()
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
- const was = btn.innerHTML
3638
- btn.disabled = true; btn.textContent = 'Writing…'
3639
- if (!btn.isConnected || btn.classList.contains('palette-proxy') || !btn.closest('.split')) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000) // from a menu (closed) or ⌘K
3640
- let cp
3641
- try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
3642
- catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
3643
- toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + cp.next)
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 prov = document.getElementById('f-mode').value
4022
- const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
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 = f.key ? document.getElementById('f-key').value.trim() : ''
4033
- if (key) await api('PUT', '/v1/config/keys/' + f.key, { key })
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: Number(document.getElementById('f-dormant').value),
4036
- minGapMin: Number(document.getElementById('f-gap').value),
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: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative' },
4045
- updates: { check: document.getElementById('f-updates').checked },
4046
- notify: { needsYou: document.getElementById('f-notify').checked },
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
+ }