@leftoff/cli 0.9.3 → 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>' : '')
@@ -420,17 +432,19 @@ function copyResumeIcon(s) {
420
432
  return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
421
433
  }
422
434
  // items: [{ action, label, icon, title, attrs }]
423
- // 0.9.2: an item may also be { note } (a plain line, no button) or { row: [items], label }
424
- // (small buttons side by side, e.g. the other checkpoint lengths), and carry a sub line.
435
+ // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
436
+ // 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
437
+ // at its right edge opens a second page of items (e.g. the checkpoint lengths,
438
+ // each with { sub, on }).
425
439
  function moreMenu(s, items, { project = true, title = '' } = {}) {
426
440
  items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
427
441
  if (!items.length) return ''
428
442
  const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
429
- Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + '>' +
443
+ Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
430
444
  (it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
431
445
  return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
432
446
  '<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
433
- : it.row ? '<div class="more-row" data-label="' + esc(it.label || '') + '">' + it.row.map(btn).join('') + '</div>' : btn(it)).join('') + '</template>'
447
+ : it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
434
448
  }
435
449
  function openCardMenu(btn) {
436
450
  const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
@@ -440,12 +454,12 @@ function openCardMenu(btn) {
440
454
  const toItem = (b) => {
441
455
  const svg = b.querySelector('svg')
442
456
  const attrs = {}
443
- for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub') || a.name === 'title') attrs[a.name] = a.value
444
- return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}) }
457
+ for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
458
+ return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
445
459
  }
446
460
  const items = [...tpl.content.children].map((el) => {
447
461
  if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
448
- if (el.classList.contains('more-row')) return { row: [...el.querySelectorAll('button')].map(toItem), label: el.dataset.label || '' }
462
+ if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
449
463
  return el.tagName === 'BUTTON' ? toItem(el) : null
450
464
  }).filter(Boolean)
451
465
  lastMenuAnchor = btn
@@ -463,12 +477,36 @@ function reachOf(s) {
463
477
  // The length "Checkpoint now" writes: yours from Settings → Summaries.
464
478
  function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
465
479
  const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
466
- // Checkpoint now at your length, then the other two lengths side by side.
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
+ }
503
+ // 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
504
+ // Informative / Long, each with its line, your default ticked.
467
505
  function checkpointItems() {
468
506
  const def = defaultLength()
469
507
  return [
470
- { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', sub: lengthLabel(def) + ' · your default', attrs: { 'data-length': def } },
471
- { row: LENGTH_CHOICES.filter((o) => o.key !== def).map((o) => ({ action: 'checkpoint', label: o.label, title: o.label + ' checkpoint: ' + o.blurb, attrs: { 'data-length': o.key } })), label: 'Or' },
508
+ { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
509
+ more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
472
510
  ]
473
511
  }
474
512
  // The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
@@ -502,6 +540,11 @@ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' ||
502
540
  // length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
503
541
  function checkpointSplit(s) {
504
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
+ }
505
548
  return '<span class="split" role="group" aria-label="Checkpoint now">' +
506
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>' +
507
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>'
@@ -532,7 +575,7 @@ function card(s) {
532
575
  '</div>' + pill(s.status) + '</div>' +
533
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>' +
534
577
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
535
- 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)) +
536
579
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
537
580
  '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
538
581
  copyResumeIcon(s) +
@@ -949,7 +992,7 @@ function parkedCard(s, viewer) {
949
992
  const p = s.parked || {}
950
993
  const open = s.status !== 'ended' && s.live !== 'closed'
951
994
  const cp = s.latestCheckpoint
952
- const note = p.note || (cp && cp.next) || ''
995
+ const note = p.note || (cp && plainCp(cp.next)) || ''
953
996
  const main = open ? goButton(s) : resumeButton(s, viewer)
954
997
  return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
955
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>' +
@@ -1203,8 +1246,12 @@ async function viewSession(id) {
1203
1246
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
1204
1247
  html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
1205
1248
  const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
1206
- if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
1207
- 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>'
1208
1255
  // 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
1209
1256
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
1210
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>'
@@ -1215,9 +1262,9 @@ async function viewSession(id) {
1215
1262
  }
1216
1263
  function cpTimeline(list, newestCp) {
1217
1264
  return list.map((cp) =>
1218
- '<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>' +
1219
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>' +
1220
- cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
1267
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
1221
1268
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
1222
1269
  (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
1223
1270
  '</div>').join('')
@@ -1762,8 +1809,12 @@ function onPull(v) {
1762
1809
  const was = state.pulls[v.tag]
1763
1810
  state.pulls[v.tag] = v
1764
1811
  if (state.route && state.route.name === 'settings') {
1765
- 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' }
1766
- 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()
1767
1818
  }
1768
1819
  if (v.done && (!was || !was.done)) toast(v.use ? 'Downloaded ' + v.tag + '. Checkpoints now summarize with it.' : 'Downloaded ' + v.tag, 6000)
1769
1820
  if (v.error && (!was || !was.error)) toast('Couldn’t download ' + v.tag + ': ' + v.error, 10000)
@@ -1801,7 +1852,7 @@ async function tryLength() {
1801
1852
  try {
1802
1853
  const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
1803
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>' +
1804
- 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>' : '')
1805
1856
  } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
1806
1857
  }
1807
1858
 
@@ -2161,6 +2212,63 @@ function phonePane(relay, remote, unavailable) {
2161
2212
  return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
2162
2213
  }
2163
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
+
2164
2272
  async function viewSettings() {
2165
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)])
2166
2274
  state.relay = relay
@@ -2221,7 +2329,7 @@ async function viewSettings() {
2221
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>' +
2222
2330
  '<form class="form" id="settingsForm" autocomplete="off">' +
2223
2331
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
2224
- '<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>'
2225
2333
  showSettingsSection(r)
2226
2334
  paintSettingsDots()
2227
2335
  syncProviderFields()
@@ -2251,6 +2359,10 @@ async function viewSettings() {
2251
2359
  if (ev.key === 'Escape' && f.value) { ev.preventDefault(); f.value = ''; settingsUi.q = ''; settingsFilter() }
2252
2360
  if (ev.key === 'Enter') { ev.preventDefault(); const k = settingsFilter(); if (k) location.hash = '#/settings/' + k }
2253
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)
2254
2366
  }
2255
2367
 
2256
2368
  // ------------------------------------------------------------ sync and other machines (v0.5.2)
@@ -3166,7 +3278,7 @@ function searchGroups(q, r) {
3166
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' }))])
3167
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' }))])
3168
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' }))])
3169
- 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' }))])
3170
3282
  }
3171
3283
  if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
3172
3284
  if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
@@ -3508,7 +3620,7 @@ document.addEventListener('click', async (ev) => {
3508
3620
  if (btn.dataset.needsDl) { toast('Download it first: press Download & use'); return }
3509
3621
  document.getElementById('f-model').value = btn.dataset.tag
3510
3622
  const o = document.getElementById('f-model-other'); if (o) o.value = ''
3511
- return paintModels()
3623
+ paintModels(); return paintSave()
3512
3624
  }
3513
3625
  if (action === 'ollama-start') {
3514
3626
  btn.disabled = true; btn.textContent = 'Starting…'
@@ -3528,11 +3640,11 @@ document.addEventListener('click', async (ev) => {
3528
3640
  if (action === 'day-toggle') {
3529
3641
  const on = btn.getAttribute('aria-pressed') === 'true'
3530
3642
  if (on && document.querySelectorAll('.days .day[aria-pressed="true"]').length === 1) { toast('Pick at least one day'); return }
3531
- btn.setAttribute('aria-pressed', String(!on)); return
3643
+ btn.setAttribute('aria-pressed', String(!on)); return paintSave()
3532
3644
  }
3533
3645
  if (action === 'days-preset') {
3534
3646
  const want = btn.dataset.days === 'weekdays' ? ['mon', 'tue', 'wed', 'thu', 'fri'] : DAY_KEYS.map(([k]) => k)
3535
- 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()
3536
3648
  }
3537
3649
  if (action === 'repo-all') {
3538
3650
  state.moreOpen = false
@@ -3628,17 +3740,29 @@ document.addEventListener('click', async (ev) => {
3628
3740
  const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
3629
3741
  if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3630
3742
  } else if (action === 'checkpoint') {
3631
- // 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.
3632
3745
  const length = btn.dataset.length || null
3633
3746
  const away = s && !s.isLocal ? s.machine : null
3634
- const was = btn.innerHTML
3635
- btn.disabled = true; btn.textContent = 'Writing…'
3636
- 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
3637
- let cp
3638
- try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
3639
- catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
3640
- 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))
3641
3763
  render()
3764
+ } else if (action === 'writing-dismiss') {
3765
+ dismissWriting(state.writing, btn.dataset.id || id); render()
3642
3766
  } else if (action === 'cp-lengths') {
3643
3767
  ev.preventDefault(); return openLengthMenu(btn, id)
3644
3768
  } else if (action === 'peer-recheck') {
@@ -4014,33 +4138,27 @@ document.addEventListener('submit', async (ev) => {
4014
4138
  location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
4015
4139
  } else if (ev.target.id === 'settingsForm') {
4016
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()
4017
4144
  try {
4018
- const prov = document.getElementById('f-mode').value
4019
- const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
4145
+ const v = settingsValues()
4146
+ const prov = v.provider
4020
4147
  const prev = (state.cfg && state.cfg.summarizer) || {}
4021
4148
  const changed = String(prev.provider || 'auto').toLowerCase() !== prov
4022
- const summarizer = {
4023
- provider: prov,
4024
- model: (f.model && (((document.getElementById('f-model-other') || {}).value || '').trim() || document.getElementById('f-model').value.trim())) || 'haiku',
4025
- baseUrl: (f.base && document.getElementById('f-base').value.trim()) || null,
4026
- }
4149
+ const summarizer = { provider: prov, model: v.model, baseUrl: v.baseUrl }
4027
4150
  // A key setting from another provider must not follow the switch.
4028
4151
  if (changed) { summarizer.apiKeyEnv = null; summarizer.apiKeyFile = null }
4029
- const key = f.key ? document.getElementById('f-key').value.trim() : ''
4030
- 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 })
4031
4154
  await api('PATCH', '/v1/config', {
4032
- dormantAfterMin: Number(document.getElementById('f-dormant').value),
4033
- minGapMin: Number(document.getElementById('f-gap').value),
4034
- digest: {
4035
- time: document.getElementById('f-time').value || null,
4036
- days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
4037
- skipEmpty: document.getElementById('f-skip').checked,
4038
- timeZone: document.getElementById('f-tz').value || null,
4039
- },
4155
+ dormantAfterMin: v.dormantAfterMin,
4156
+ minGapMin: v.minGapMin,
4157
+ digest: v.digest,
4040
4158
  summarizer,
4041
- summary: { length: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative' },
4042
- updates: { check: document.getElementById('f-updates').checked },
4043
- notify: { needsYou: document.getElementById('f-notify').checked },
4159
+ summary: { length: v.length },
4160
+ updates: { check: v.updates },
4161
+ notify: { needsYou: v.notify },
4044
4162
  ...(() => {
4045
4163
  const el = document.getElementById('f-remote-answers')
4046
4164
  if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
@@ -4048,11 +4166,15 @@ document.addEventListener('submit', async (ev) => {
4048
4166
  return { remote: { allowAnswers: el.checked } }
4049
4167
  })(),
4050
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()
4051
4172
  toast(key ? 'Saved · key stored in .secret/' : 'Saved'); render(); refreshHealth()
4052
- } catch (e) { toast(e.message) }
4173
+ } catch (e) { toast(e.message) } finally { saveUi.saving = false; paintSave() }
4053
4174
  }
4054
4175
  })
4055
4176
 
4177
+
4056
4178
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4057
4179
  let pending = null
4058
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).