@leftoff/cli 0.9.4 → 0.9.6

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) +
@@ -790,9 +830,10 @@ async function viewBoard() {
790
830
  // 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
791
831
  // focus, and matches among closed sessions are one tap away.
792
832
  function boardSearch(q, closedHits, n) {
793
- return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" placeholder="Find a session: title, repo, branch, folder or id" aria-label="Find a session" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
833
+ return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" enterkeyhint="search" placeholder="Find a session, or anything said in one" aria-label="Find a session, or search what was said in sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
794
834
  (q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
795
- (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
835
+ (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
836
+ ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
796
837
  }
797
838
  // A repaint from a live update (or the 30 s refresh) while you type in the
798
839
  // search box keeps the box focused, with what you typed and the caret.
@@ -822,7 +863,11 @@ function boardSearchWire() {
822
863
  if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
823
864
  }, 160)
824
865
  })
825
- q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
866
+ q.addEventListener('keydown', (ev) => {
867
+ if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
868
+ // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
869
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
870
+ })
826
871
  }
827
872
 
828
873
  // Closed (0.6.7): every ended session on every machine, newest first, with
@@ -952,7 +997,7 @@ function parkedCard(s, viewer) {
952
997
  const p = s.parked || {}
953
998
  const open = s.status !== 'ended' && s.live !== 'closed'
954
999
  const cp = s.latestCheckpoint
955
- const note = p.note || (cp && cp.next) || ''
1000
+ const note = p.note || (cp && plainCp(cp.next)) || ''
956
1001
  const main = open ? goButton(s) : resumeButton(s, viewer)
957
1002
  return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
958
1003
  '<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 +1251,12 @@ async function viewSession(id) {
1206
1251
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
1207
1252
  html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
1208
1253
  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>'
1254
+ // 0.9.5: the one being written, at the top of the list until it lands
1255
+ const wr = writingFor(s)
1256
+ const wrRow = wr ? '<div class="tl-item tl-writing">' + writingLine(s, wr, 'tl') + '</div>' : ''
1257
+ const live = s.checkpoints.filter((cp) => !cp.doubt)
1258
+ if (!live.length && !wrRow) html += '<div class="empty">No checkpoints yet.</div>'
1259
+ else html += '<div class="timeline">' + wrRow + cpTimeline(live, newestCp) + '</div>'
1211
1260
  // 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
1212
1261
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
1213
1262
  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,12 +1264,98 @@ async function viewSession(id) {
1215
1264
  if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1216
1265
  html += answeredList(s)
1217
1266
  $app.innerHTML = html
1267
+ aroundWire(s.id)
1268
+ }
1269
+ // ------------------------------------------------------------ search inside transcripts (0.9.6)
1270
+
1271
+ // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
1272
+ // overlap, run backwards or past the end are skipped).
1273
+ function markedHtml(text, marks) {
1274
+ const t = String(text || '')
1275
+ let h = '', at = 0
1276
+ for (const m of Array.isArray(marks) ? marks : []) {
1277
+ const a = Number(m && m[0]), b = Number(m && m[1])
1278
+ if (!(Number.isInteger(a) && Number.isInteger(b) && a >= at && b > a && b <= t.length)) continue
1279
+ h += esc(t.slice(at, a)) + '<mark>' + esc(t.slice(a, b)) + '</mark>'
1280
+ at = b
1281
+ }
1282
+ return h + esc(t.slice(at))
1283
+ }
1284
+ const txHref = (h, q) => '#/s/' + encodeURIComponent(h.sessionId) + (h.anchor != null ? '?hit=' + encodeURIComponent(h.anchor) + '&q=' + encodeURIComponent(q) : '')
1285
+ const txWho = (h) => h.role === 'assistant' ? 'Claude' : 'You'
1286
+ function txSub(h) {
1287
+ return [h.project && h.project.name ? projectLabel(h.project) : h.repoLabel, multi() || (h.machine && state.health && h.machine !== state.health.machine) ? h.machine : '', h.at ? ago(h.at) : '', h.hits > 1 ? h.hits + ' matches' : ''].filter(Boolean).join(' · ')
1288
+ }
1289
+ const nfmt = (n) => Number(n || 0).toLocaleString()
1290
+ // "Still indexing (n of m)", other Macs that weren't searched, or null.
1291
+ function txNotes(r) {
1292
+ const out = []
1293
+ const me = (r.machines || []).find((m) => m.self) || {}
1294
+ if (me.status === 'indexing') out.push('Still indexing transcripts' + (me.total ? ' (' + nfmt(me.done) + ' of ' + nfmt(me.total) + ')' : '') + '. Results so far.')
1295
+ if (me.status === 'off') out.push('Transcript search is off on this Mac.')
1296
+ for (const m of r.machines || []) {
1297
+ if (m.self) continue
1298
+ if (m.status === 'indexing') out.push(m.machine + ' is still indexing' + (m.total ? ' (' + nfmt(m.done) + ' of ' + nfmt(m.total) + ')' : '') + '.')
1299
+ else if (m.status === 'unreachable') out.push(m.machine + ' wasn’t searched (not reachable right now).')
1300
+ else if (m.status === 'needs_update') out.push(m.machine + ' wasn’t searched (it needs Leftoff 0.9.6).')
1301
+ }
1302
+ return out
1303
+ }
1304
+ // One hit as a result row (search page, board).
1305
+ function txHitHtml(h, q) {
1306
+ return '<a class="tx-hit" href="' + esc(txHref(h, q)) + '">' +
1307
+ '<span class="tx-title">' + esc(h.title || 'Untitled') + (h.endedAt ? '<span class="tx-tag">closed</span>' : '') + '</span>' +
1308
+ '<span class="tx-sub">' + esc(txSub(h)) + '</span>' +
1309
+ '<span class="tx-snip"><span class="tx-who ' + (h.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(h)) + '</span> ' + markedHtml(h.snippet, h.marks) + '</span></a>'
1310
+ }
1311
+ function txSectionHtml(r, q, { more = null } = {}) {
1312
+ if (!r) return '<h2 class="tx-h">In transcripts</h2><div class="tx-note">Searching transcripts…</div>'
1313
+ const notes = txNotes(r)
1314
+ let h = '<h2 class="tx-h">In transcripts' + (r.results.length ? ' <span class="tx-count">' + esc(r.results.length + (r.results.length === 1 ? ' session' : ' sessions')) + '</span>' : '') + '</h2>'
1315
+ const idx = notes.find((n) => /^Still indexing/.test(n))
1316
+ if (idx) h += '<div class="tx-note tx-indexing" role="status"><span class="spin" aria-hidden="true"></span>' + esc(idx) + '</div>'
1317
+ if (r.results.length) h += '<div class="tx-list">' + r.results.map((x) => txHitHtml(x, q)).join('') + '</div>'
1318
+ else if (!notes.some((n) => /^Still indexing/.test(n))) h += '<div class="tx-note tx-empty">Nothing you or Claude said matches “' + esc(q) + '”.</div>'
1319
+ for (const n of notes) if (n !== idx) h += '<div class="tx-note">' + esc(n) + '</div>'
1320
+ if (more) h += '<div class="tx-more"><a href="' + esc(more) + '">All results for “' + esc(q) + '” →</a></div>'
1321
+ return h
1322
+ }
1323
+ const txQuery = (q, limit) => api('GET', '/v1/search?in=transcripts&limit=' + limit + '&q=' + encodeURIComponent(q))
1324
+
1325
+ // The session page opened from a transcript hit: the message and the turns
1326
+ // around it, at the top, scrolled to.
1327
+ const aroundCache = new Map()
1328
+ async function aroundWire(id) {
1329
+ const r = state.route
1330
+ if (!r || r.name !== 's' || !r.params || r.params.get('hit') == null) return
1331
+ const anchor = r.params.get('hit'), q = r.params.get('q') || ''
1332
+ const key = id + '|' + anchor + '|' + q
1333
+ const head = document.querySelector('#app .pagehead')
1334
+ if (!head) return
1335
+ let box = document.getElementById('txAround')
1336
+ if (!box) { box = document.createElement('section'); box.id = 'txAround'; box.className = 'tx-around'; box.setAttribute('aria-label', 'Found in the transcript'); head.after(box) }
1337
+ const first = !aroundCache.has(key)
1338
+ let d = aroundCache.get(key)
1339
+ if (!d) {
1340
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript</div><div class="tx-note">Loading…</div>'
1341
+ try { d = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/around?anchor=' + encodeURIComponent(anchor) + '&q=' + encodeURIComponent(q)) } catch (e) { d = { error: e.message, messages: [] } }
1342
+ aroundCache.set(key, d)
1343
+ if (!document.getElementById('txAround')) return
1344
+ }
1345
+ const close = '<a class="icon x tx-close" href="#/s/' + encodeURIComponent(id) + '" aria-label="Close" title="Close">×</a>'
1346
+ if (!d.messages || !d.messages.length) { box.innerHTML = '<div class="tx-around-head">Found in the transcript' + close + '</div><div class="tx-note">' + esc(d.error ? 'Couldn’t read that part of the transcript: ' + d.error : 'That message isn’t in the index any more (the transcript changed).') + '</div>'; return }
1347
+ box.innerHTML = '<div class="tx-around-head">Found in the transcript' + (q ? ' · “' + esc(q) + '”' : '') + close + '</div>' +
1348
+ d.messages.map((m) => '<div class="tx-msg ' + (m.role === 'assistant' ? 'a' : 'u') + (m.hit ? ' hit' : '') + '"' + (m.hit ? ' id="txHit" tabindex="-1"' : '') + '><div class="tx-msg-head"><span class="tx-who ' + (m.role === 'assistant' ? 'a' : 'u') + '">' + (m.role === 'assistant' ? 'Claude' : 'You') + '</span>' + (m.at ? '<span class="t" title="' + esc(clock(m.at)) + '">' + esc(ago(m.at)) + '</span>' : '') + '</div><div class="tx-msg-text">' + markedHtml(m.text, m.marks) + '</div></div>').join('')
1349
+ if (first) {
1350
+ const hit = document.getElementById('txHit')
1351
+ if (hit) { hit.scrollIntoView({ block: 'center' }); const mk = hit.querySelector('mark'); if (mk && hit.scrollHeight > hit.clientHeight) mk.scrollIntoView({ block: 'center' }) }
1352
+ }
1218
1353
  }
1219
1354
  function cpTimeline(list, newestCp) {
1220
1355
  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>' +
1356
+ '<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
1357
  (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 }) +
1358
+ cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp, md: true }) +
1224
1359
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
1225
1360
  (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
1226
1361
  '</div>').join('')
@@ -1765,8 +1900,12 @@ function onPull(v) {
1765
1900
  const was = state.pulls[v.tag]
1766
1901
  state.pulls[v.tag] = v
1767
1902
  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()
1903
+ if (v.done && (!was || !was.done) && v.use) {
1904
+ const m = document.getElementById('f-model'); if (m) m.value = v.tag; const sel = document.getElementById('f-mode'); if (sel) sel.value = 'ollama'
1905
+ // the daemon switched the summarizer itself: that's saved, not a change to save (0.9.5)
1906
+ if (saveUi.base) { saveUi.base.provider = 'ollama'; saveUi.base.model = v.tag; saveUi.base.baseUrl = settingsValues().baseUrl }
1907
+ }
1908
+ paintModels(); paintSave()
1770
1909
  }
1771
1910
  if (v.done && (!was || !was.done)) toast(v.use ? 'Downloaded ' + v.tag + '. Checkpoints now summarize with it.' : 'Downloaded ' + v.tag, 6000)
1772
1911
  if (v.error && (!was || !was.error)) toast('Couldn’t download ' + v.tag + ': ' + v.error, 10000)
@@ -1804,7 +1943,7 @@ async function tryLength() {
1804
1943
  try {
1805
1944
  const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
1806
1945
  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>' : '')
1946
+ cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
1808
1947
  } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
1809
1948
  }
1810
1949
 
@@ -2164,6 +2303,63 @@ function phonePane(relay, remote, unavailable) {
2164
2303
  return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
2165
2304
  }
2166
2305
 
2306
+ // Settings → Save (0.9.5): Save is on only while the form differs from what was
2307
+ // last saved (ui/settings-dirty.js), so a missed "Saved" toast can't leave you
2308
+ // guessing. The values are exactly what Save sends.
2309
+ const saveUi = { base: null, saving: false, timer: null }
2310
+ function settingsValues() {
2311
+ const $ = (id) => document.getElementById(id)
2312
+ const val = (id) => { const el = $(id); return el && el.value != null ? String(el.value) : '' }
2313
+ const on = (id) => { const el = $(id); return !!(el && el.checked) }
2314
+ const prov = val('f-mode') || 'auto'
2315
+ const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
2316
+ const ra = $('f-remote-answers')
2317
+ return {
2318
+ provider: prov,
2319
+ model: (f.model && (val('f-model-other').trim() || val('f-model').trim())) || 'haiku',
2320
+ baseUrl: (f.base && val('f-base').trim()) || null,
2321
+ key: f.key ? val('f-key').trim() : '',
2322
+ dormantAfterMin: Number(val('f-dormant')),
2323
+ minGapMin: Number(val('f-gap')),
2324
+ digest: {
2325
+ time: val('f-time') || null,
2326
+ days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
2327
+ skipEmpty: on('f-skip'),
2328
+ timeZone: val('f-tz') || null,
2329
+ },
2330
+ length: ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative',
2331
+ updates: on('f-updates'),
2332
+ notify: on('f-notify'),
2333
+ remoteAnswers: ra && !ra.disabled ? ra.checked : null,
2334
+ }
2335
+ }
2336
+ const settingsChanged = () => saveUi.base && document.getElementById('settingsForm') ? changedKeys(saveUi.base, settingsValues()) : []
2337
+ // Where each value lives, to name the sections with unsaved changes.
2338
+ 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' }
2339
+ function paintSave() {
2340
+ const box = document.getElementById('setSave')
2341
+ const btn = box && box.querySelector('button[type=submit]')
2342
+ if (!btn) return
2343
+ const keys = settingsChanged()
2344
+ const dirty = keys.length > 0
2345
+ btn.disabled = !dirty || saveUi.saving
2346
+ btn.setAttribute('aria-disabled', String(btn.disabled))
2347
+ btn.title = dirty ? '' : 'Nothing to save'
2348
+ box.classList.toggle('dirty', dirty)
2349
+ const note = document.getElementById('setSaveNote')
2350
+ if (!note) return
2351
+ const secs = []
2352
+ for (const k of keys) {
2353
+ const el = document.getElementById(SAVE_FIELD[k] || '')
2354
+ const pane = el && el.closest('.set-pane')
2355
+ const sec = pane && SETTINGS.find((x) => x.key === pane.dataset.sec)
2356
+ if (sec && !secs.includes(sec.label)) secs.push(sec.label)
2357
+ }
2358
+ note.textContent = saveUi.saving ? 'Saving…' : dirty ? 'Unsaved changes' + (secs.length ? ' in ' + secs.join(', ') : '') : 'Everything’s saved'
2359
+ }
2360
+ // After the click/key that changed something has done its work (a model card, a day button).
2361
+ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(paintSave, 0) }
2362
+
2167
2363
  async function viewSettings() {
2168
2364
  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
2365
  state.relay = relay
@@ -2224,7 +2420,7 @@ async function viewSettings() {
2224
2420
  '<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
2421
  '<form class="form" id="settingsForm" autocomplete="off">' +
2226
2422
  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>'
2423
+ '<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
2424
  showSettingsSection(r)
2229
2425
  paintSettingsDots()
2230
2426
  syncProviderFields()
@@ -2254,6 +2450,10 @@ async function viewSettings() {
2254
2450
  if (ev.key === 'Escape' && f.value) { ev.preventDefault(); f.value = ''; settingsUi.q = ''; settingsFilter() }
2255
2451
  if (ev.key === 'Enter') { ev.preventDefault(); const k = settingsFilter(); if (k) location.hash = '#/settings/' + k }
2256
2452
  })
2453
+ // 0.9.5: what's on screen now is what's saved; Save comes on with the first change.
2454
+ saveUi.base = settingsValues(); saveUi.saving = false; paintSave()
2455
+ const form = document.getElementById('settingsForm')
2456
+ for (const t of ['input', 'change', 'click', 'keyup']) form.addEventListener(t, queueSave)
2257
2457
  }
2258
2458
 
2259
2459
  // ------------------------------------------------------------ sync and other machines (v0.5.2)
@@ -2778,12 +2978,36 @@ async function stAction(action, btn) {
2778
2978
  }
2779
2979
 
2780
2980
  async function viewSearch(q) {
2781
- const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
2782
- let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + ' sessions, ' + r.checkpoints.length + ' checkpoints</div></div></div>'
2783
- if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2784
- if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2785
- if (!r.sessions.length && !r.checkpoints.length) html += '<div class="empty mt">Nothing matched.</div>'
2786
- $app.innerHTML = html
2981
+ const form = '<form class="search-page-form" id="searchPageForm" role="search"><div class="set-search">' + icon('search') + '<input id="searchPageQ" type="search" enterkeyhint="search" placeholder="Search sessions and what was said in them" aria-label="Search" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div><button class="btn primary" type="submit">Search</button></form>'
2982
+ if (!q.trim()) { $app.innerHTML = '<div class="pagehead"><div><h1>Search</h1><div class="sub">Titles, checkpoints, and everything you and Claude said.</div></div></div>' + form; return searchPageWire() }
2983
+ const seq = (viewSearch.seq = (viewSearch.seq || 0) + 1)
2984
+ const [r, tx] = await Promise.all([api('GET', '/v1/search?q=' + encodeURIComponent(q)), txQuery(q, 30).catch(() => null)])
2985
+ if (seq !== viewSearch.seq) return
2986
+ const paint = (tx) => {
2987
+ let html = '<div class="pagehead"><div><h1>Search</h1><div class="sub">“' + esc(q) + '” · ' + r.sessions.length + (r.sessions.length === 1 ? ' session, ' : ' sessions, ') + r.checkpoints.length + (r.checkpoints.length === 1 ? ' checkpoint' : ' checkpoints') + (tx ? ', ' + tx.results.length + ' in transcripts' : '') + '</div></div></div>' + form
2988
+ if (r.sessions.length) html += '<h2>Sessions</h2><div class="grid">' + r.sessions.map(card).join('') + '</div>'
2989
+ html += '<section class="tx-section">' + txSectionHtml(tx || { results: [], machines: [] }, q) + '</section>'
2990
+ if (r.checkpoints.length) html += '<h2>Checkpoints</h2><div class="timeline">' + r.checkpoints.map((cp) => '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><a class="tl-title" href="#/s/' + encodeURIComponent(cp.sessionId) + '">' + esc(cp.sessionTitle || 'Untitled') + '</a></div>' + cpBlock(cp, { showWhen: false }) + '</div>').join('') + '</div>'
2991
+ if (!r.sessions.length && !r.checkpoints.length && tx && !tx.results.length && !txNotes(tx).length) html += '<div class="empty mt">Nothing matched.</div>'
2992
+ $app.innerHTML = html
2993
+ searchPageWire()
2994
+ }
2995
+ paint(tx)
2996
+ // still indexing: look again in a moment (results fill in as the index grows)
2997
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(async () => {
2998
+ if (seq !== viewSearch.seq || !state.route || state.route.name !== 'search' || document.activeElement === document.getElementById('searchPageQ')) return
2999
+ const again = await txQuery(q, 30).catch(() => null)
3000
+ if (again && seq === viewSearch.seq && state.route.name === 'search') { paint(again); if ((again.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === viewSearch.seq && state.route.name === 'search') render() }, 3000) }
3001
+ }, 2500)
3002
+ }
3003
+ function searchPageWire() {
3004
+ const f = document.getElementById('searchPageForm')
3005
+ if (!f) return
3006
+ f.addEventListener('submit', (e) => {
3007
+ e.preventDefault()
3008
+ const v = document.getElementById('searchPageQ').value.trim()
3009
+ location.hash = '#/search' + (v ? '?q=' + encodeURIComponent(v) : '')
3010
+ })
2787
3011
  }
2788
3012
 
2789
3013
  // ------------------------------------------------------------ lists (v0.5.1)
@@ -3169,7 +3393,22 @@ function searchGroups(q, r) {
3169
3393
  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
3394
  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
3395
  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' }))])
3396
+ 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' }))])
3397
+ }
3398
+ // 0.9.6: what was said in sessions, below the rest of the results
3399
+ if (low && low.length >= 2) {
3400
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3401
+ if (!tx) groups.push(['In transcripts', [], '<span class="spin" aria-hidden="true"></span>Searching transcripts…'])
3402
+ else {
3403
+ const notes = txNotes(tx)
3404
+ const items = tx.results.slice(0, 6).map((h) => ({ href: txHref(h, q), title: h.title || 'Untitled', sub: txSub(h), snip: h, icon: 'message-square' }))
3405
+ const indexing = notes.find((n) => /^Still indexing/.test(n))
3406
+ // "still indexing" leads the section (what's below is partial); the rest follows the rows
3407
+ const lead = indexing ? '<span class="spin" aria-hidden="true"></span>' + esc(indexing) : ''
3408
+ const note = (!items.length && !indexing ? 'Nothing you or Claude said matches “' + esc(q) + '”.' : '') +
3409
+ notes.filter((n) => n !== indexing).map((n) => '<span class="tx-note-line">' + esc(n) + '</span>').join('')
3410
+ if (items.length || note || lead) groups.push(['In transcripts', items, note, lead])
3411
+ }
3173
3412
  }
3174
3413
  if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
3175
3414
  if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
@@ -3203,14 +3442,20 @@ function paintSearch() {
3203
3442
  searchUi.items = []
3204
3443
  let h = ''
3205
3444
  if (q) { searchUi.items.push({ href: '#/search?q=' + encodeURIComponent(q), title: 'All results for “' + q + '”', icon: 'search' }) }
3206
- for (const [name, items] of groups) { h += '<div class="cmdk-group" role="presentation">' + esc(name) + '</div>'; for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) } }
3207
- if (q) h = optHtml(searchUi.items[0], 0) + h
3445
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3208
3446
  if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
3209
- else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”.</div>'
3447
+ else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length && !(tx && tx.results.length)) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”' + (tx ? '' : ' by name') + '.</div>'
3448
+ for (const [name, items, note, lead] of groups) {
3449
+ h += '<div class="cmdk-group' + (name === 'In transcripts' ? ' tx-group' : '') + '" role="presentation">' + esc(name) + '</div>'
3450
+ if (lead) h += '<div class="cmdk-note tx-note tx-lead">' + lead + '</div>'
3451
+ for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) }
3452
+ if (note) h += '<div class="cmdk-note tx-note">' + note + '</div>'
3453
+ }
3454
+ if (q) h = optHtml(searchUi.items[0], 0) + h
3210
3455
  list.innerHTML = h
3211
3456
  // 0.9.0: Enter opens the best match (the first result); ↑ reaches "All results for …"
3212
3457
  setSearchActive(q && searchUi.items.length > 1 ? 1 : searchUi.items.length ? 0 : -1)
3213
- function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
3458
+ function optHtml(it, i) { return '<a class="cmdk-opt' + (it.snip ? ' tx-opt' : '') + '" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + (it.snip ? '<span class="co-snip"><span class="tx-who ' + (it.snip.role === 'assistant' ? 'a' : 'u') + '">' + esc(txWho(it.snip)) + '</span> ' + markedHtml(it.snip.snippet, it.snip.marks) + '</span>' : '') + '</span></a>' }
3214
3459
  }
3215
3460
  function setSearchActive(i) {
3216
3461
  searchUi.active = i
@@ -3224,17 +3469,39 @@ async function runSearch() {
3224
3469
  if (q.length < 2) { paintSearch(); return }
3225
3470
  paintSearch()
3226
3471
  try {
3472
+ // 0.9.6: transcripts in parallel; names and checkpoints paint first
3473
+ const txp = txQuery(q, 6).then((tr) => { if (seq === searchUi.seq) { searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } }, () => { if (seq === searchUi.seq) { searchUi.tx = { q, r: { results: [], machines: [] } }; if (searchIsOpen()) paintSearchKeeping() } })
3227
3474
  const r = await api('GET', '/v1/search?light=1&limit=20&q=' + encodeURIComponent(q))
3228
3475
  if (seq !== searchUi.seq) return
3229
3476
  searchUi.last = { q, r }
3477
+ if (searchIsOpen()) paintSearchKeeping()
3478
+ await txp
3479
+ // still indexing: ask again while the palette stays open on the same words
3480
+ const tx = searchUi.tx && searchUi.tx.q === q ? searchUi.tx.r : null
3481
+ if (tx && (tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3482
+ return
3230
3483
  } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [], prs: [], projects: [], lists: [], todos: [], files: [] } } }
3231
3484
  if (searchIsOpen()) paintSearch()
3232
3485
  }
3486
+ async function runSearchAgain(q, seq) {
3487
+ try { const tr = await txQuery(q, 6); if (seq !== searchUi.seq) return; searchUi.tx = { q, r: tr }; if (searchIsOpen()) paintSearchKeeping() } catch { return }
3488
+ const tx = searchUi.tx.r
3489
+ if ((tx.machines || []).some((m) => m.status === 'indexing')) setTimeout(() => { if (seq === searchUi.seq && searchIsOpen()) runSearchAgain(q, seq) }, 2500)
3490
+ }
3491
+ // A repaint when results arrive keeps the row you had moved to (by its link).
3492
+ function paintSearchKeeping() {
3493
+ const was = searchUi.items[searchUi.active]
3494
+ const moved = searchUi.moved
3495
+ paintSearch()
3496
+ if (!moved || !was) return
3497
+ const i = searchUi.items.findIndex((it) => it.href === was.href && it.title === was.title)
3498
+ if (i >= 0) setSearchActive(i)
3499
+ }
3233
3500
  document.getElementById('searchBtn').addEventListener('click', () => openSearch())
3234
- document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3501
+ document.getElementById('q').addEventListener('input', () => { clearTimeout(searchUi.timer); searchUi.moved = false; paintSearch(); searchUi.timer = setTimeout(runSearch, 160) })
3235
3502
  document.getElementById('q').addEventListener('keydown', (e) => {
3236
3503
  const n = searchUi.items.length
3237
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3504
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); searchUi.moved = true; if (n) setSearchActive((searchUi.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n) }
3238
3505
  else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); document.getElementById('searchBtn').focus() }
3239
3506
  })
3240
3507
  document.getElementById('cmdk').addEventListener('click', (e) => {
@@ -3511,7 +3778,7 @@ document.addEventListener('click', async (ev) => {
3511
3778
  if (btn.dataset.needsDl) { toast('Download it first: press Download & use'); return }
3512
3779
  document.getElementById('f-model').value = btn.dataset.tag
3513
3780
  const o = document.getElementById('f-model-other'); if (o) o.value = ''
3514
- return paintModels()
3781
+ paintModels(); return paintSave()
3515
3782
  }
3516
3783
  if (action === 'ollama-start') {
3517
3784
  btn.disabled = true; btn.textContent = 'Starting…'
@@ -3531,11 +3798,11 @@ document.addEventListener('click', async (ev) => {
3531
3798
  if (action === 'day-toggle') {
3532
3799
  const on = btn.getAttribute('aria-pressed') === 'true'
3533
3800
  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
3801
+ btn.setAttribute('aria-pressed', String(!on)); return paintSave()
3535
3802
  }
3536
3803
  if (action === 'days-preset') {
3537
3804
  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
3805
+ document.querySelectorAll('.days .day').forEach((b) => b.setAttribute('aria-pressed', String(want.includes(b.dataset.day)))); return paintSave()
3539
3806
  }
3540
3807
  if (action === 'repo-all') {
3541
3808
  state.moreOpen = false
@@ -3631,17 +3898,29 @@ document.addEventListener('click', async (ev) => {
3631
3898
  const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
3632
3899
  if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3633
3900
  } else if (action === 'checkpoint') {
3634
- // 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
3901
+ // 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it.
3902
+ // 0.9.5: "Writing checkpoint…" stays on the card and the session page until it lands or fails.
3635
3903
  const length = btn.dataset.length || null
3636
3904
  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)
3905
+ if (isWriting(state.writing, id)) { toast('Already writing a checkpoint for this session'); return }
3906
+ beginWriting(state.writing, id, { length: length || defaultLength(), lastCpId: (s && s.lastCheckpointId) || null, machine: away })
3907
+ const req = api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }).then((cp) => ({ cp }), (e) => ({ e }))
3908
+ await render()
3909
+ // nothing on screen shows it (⌘K from another page): say it once
3910
+ if (![...document.querySelectorAll('[data-writing]')].some((el) => el.dataset.writing === id)) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000)
3911
+ const { cp, e } = await req
3912
+ if (e && e.code === 'peer_slow') {
3913
+ settleWriting(state.writing, id, { slow: true })
3914
+ setTimeout(() => { if (state.writing[id] && !busy() && state.route && /^(board|s)$/.test(state.route.name || 'board')) render() }, WRITING_SYNC_MS + 300)
3915
+ return render()
3916
+ }
3917
+ if (e) { settleWriting(state.writing, id, { error: e.message }); toast(e.message, 6000); return render() }
3918
+ settleWriting(state.writing, id, { ok: true })
3919
+ state.cpFresh[cp.id] = Date.now()
3920
+ toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + plainCp(cp.next))
3644
3921
  render()
3922
+ } else if (action === 'writing-dismiss') {
3923
+ dismissWriting(state.writing, btn.dataset.id || id); render()
3645
3924
  } else if (action === 'cp-lengths') {
3646
3925
  ev.preventDefault(); return openLengthMenu(btn, id)
3647
3926
  } else if (action === 'peer-recheck') {
@@ -4017,33 +4296,27 @@ document.addEventListener('submit', async (ev) => {
4017
4296
  location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
4018
4297
  } else if (ev.target.id === 'settingsForm') {
4019
4298
  ev.preventDefault()
4299
+ // 0.9.5: nothing to save while the form matches what's saved (Save is off then too)
4300
+ if (saveUi.saving || !settingsChanged().length) return
4301
+ saveUi.saving = true; paintSave()
4020
4302
  try {
4021
- const prov = document.getElementById('f-mode').value
4022
- const f = PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto
4303
+ const v = settingsValues()
4304
+ const prov = v.provider
4023
4305
  const prev = (state.cfg && state.cfg.summarizer) || {}
4024
4306
  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
- }
4307
+ const summarizer = { provider: prov, model: v.model, baseUrl: v.baseUrl }
4030
4308
  // A key setting from another provider must not follow the switch.
4031
4309
  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 })
4310
+ const key = v.key
4311
+ if (key) await api('PUT', '/v1/config/keys/' + (PROVIDER_FIELDS[prov] || PROVIDER_FIELDS.auto).key, { key })
4034
4312
  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
- },
4313
+ dormantAfterMin: v.dormantAfterMin,
4314
+ minGapMin: v.minGapMin,
4315
+ digest: v.digest,
4043
4316
  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 },
4317
+ summary: { length: v.length },
4318
+ updates: { check: v.updates },
4319
+ notify: { needsYou: v.notify },
4047
4320
  ...(() => {
4048
4321
  const el = document.getElementById('f-remote-answers')
4049
4322
  if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
@@ -4051,11 +4324,15 @@ document.addEventListener('submit', async (ev) => {
4051
4324
  return { remote: { allowAnswers: el.checked } }
4052
4325
  })(),
4053
4326
  })
4327
+ // Saved: what's on screen is now what's saved, so Save goes off until the next edit.
4328
+ const kf = document.getElementById('f-key'); if (kf) kf.value = ''
4329
+ saveUi.base = settingsValues()
4054
4330
  toast(key ? 'Saved · key stored in .secret/' : 'Saved'); render(); refreshHealth()
4055
- } catch (e) { toast(e.message) }
4331
+ } catch (e) { toast(e.message) } finally { saveUi.saving = false; paintSave() }
4056
4332
  }
4057
4333
  })
4058
4334
 
4335
+
4059
4336
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4060
4337
  let pending = null
4061
4338
  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))