@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/CHANGELOG.md +15 -0
- package/bin/leftoff.js +350 -324
- package/docs/SECURITY.md +1 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +103 -5
- package/ui/app.js +341 -64
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/icons.js +2 -1
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
package/ui/app.js
CHANGED
|
@@ -4,6 +4,9 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
+
import { changedKeys } from './settings-dirty.js'
|
|
8
|
+
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
9
|
+
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
7
10
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
|
|
8
11
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
12
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -11,7 +14,7 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
11
14
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
15
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
16
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
14
|
-
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
|
|
17
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
15
18
|
const $app = document.getElementById('app')
|
|
16
19
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
17
20
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -128,14 +131,23 @@ function cpExtras(cp) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
// more: show the informative / long sections, folded (open: unfolded).
|
|
131
|
-
|
|
134
|
+
// md (0.9.5): the session page's full view renders a markdown checkpoint (a
|
|
135
|
+
// model's write-up, ui/cp-text.js); everywhere else it's plain, a long one clamped.
|
|
136
|
+
function cpBlock(cp, { showWhen = true, more = false, open = false, md = false } = {}) {
|
|
132
137
|
if (!cp) return '<div class="nocp">No checkpoint yet. One appears when the session goes quiet, or press “Checkpoint now”.</div>'
|
|
133
138
|
const you = /^YOU:/i.test(cp.next || '')
|
|
134
139
|
const n = EXTRA_TITLES.filter(([k]) => (Array.isArray(cp[k]) ? cp[k].length : !!cp[k])).length
|
|
135
|
-
|
|
136
|
-
'<
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
const dd = (t, cls = '') => {
|
|
141
|
+
if (md && looksMd(t)) return '<dd class="' + (cls ? cls + ' ' : '') + 'cp-md">' + renderDoc(t) + '</dd>'
|
|
142
|
+
const plainT = looksMd(t) ? stripMd(t) : String(t == null ? '' : t)
|
|
143
|
+
const long = (cp.format === 'markdown' || looksMd(t)) ? plainT.length > 160 : plainT.length > 600
|
|
144
|
+
const c = (cls + (!md && long ? ' clamp' : '')).trim()
|
|
145
|
+
return '<dd' + (c ? ' class="' + c + '"' : '') + '>' + fmtCode(plainT) + '</dd>'
|
|
146
|
+
}
|
|
147
|
+
return '<dl class="cp' + (cp.format === 'markdown' ? ' cp-salvaged' : '') + '">' +
|
|
148
|
+
'<dt>Doing</dt>' + dd(cp.doing) +
|
|
149
|
+
'<dt>Stopped</dt>' + dd(cp.stoppedAt) +
|
|
150
|
+
'<dt>Next</dt>' + dd(cp.next, 'next' + (you ? ' you' : '')) +
|
|
139
151
|
(showWhen ? '<div class="when">' + esc(cp.trigger) + ' checkpoint · ' + esc(ago(cp.createdAt)) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) : '') + '</div>' : '') +
|
|
140
152
|
'</dl>' +
|
|
141
153
|
(more && n ? '<details class="cp-more"' + (open ? ' open' : '') + '><summary>' + (cp.length === 'long' ? 'Recap, decisions, files' : 'Decisions, files, open questions') + ' <span class="faint">· ' + n + ' section' + (n === 1 ? '' : 's') + '</span></summary><div class="cpx">' + cpExtras(cp) + '</div></details>' : '')
|
|
@@ -465,6 +477,29 @@ function reachOf(s) {
|
|
|
465
477
|
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
466
478
|
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
467
479
|
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
480
|
+
// 0.9.5: "Checkpoint now" in flight stays on screen (ui/writing.js) until it lands
|
|
481
|
+
// or fails; the checkpoint that lands gets a short highlight.
|
|
482
|
+
state.writing = {}
|
|
483
|
+
state.cpFresh = {}
|
|
484
|
+
function writingFor(s) {
|
|
485
|
+
const w = reconcileWriting(state.writing, s)
|
|
486
|
+
if (w && w.landed) { state.cpFresh[w.landed] = Date.now(); return null }
|
|
487
|
+
return w
|
|
488
|
+
}
|
|
489
|
+
const isFresh = (cp) => !!(cp && state.cpFresh[cp.id] && Date.now() - state.cpFresh[cp.id] < FRESH_MS)
|
|
490
|
+
function writingLine(s, w, cls = '') {
|
|
491
|
+
const busy = w.phase === 'writing' || w.phase === 'syncing'
|
|
492
|
+
return '<div class="cp-writing ' + w.phase + (cls ? ' ' + cls : '') + '" role="' + (w.phase === 'failed' ? 'alert' : 'status') + '" data-writing="' + esc(s.id) + '">' +
|
|
493
|
+
(busy ? '<span class="spin" aria-hidden="true"></span>' : w.phase === 'failed' ? icon('info') : icon('history')) +
|
|
494
|
+
'<span class="wtext">' + esc(writingText(w, lengthLabel)) + '</span>' +
|
|
495
|
+
(busy ? '' : '<button type="button" class="icon wx" data-action="writing-dismiss" data-id="' + esc(s.id) + '" aria-label="Dismiss" title="Dismiss">' + icon('x') + '</button>') + '</div>'
|
|
496
|
+
}
|
|
497
|
+
// The card's checkpoint area: the writing line above the old checkpoint (dimmed while it's written).
|
|
498
|
+
function cpArea(s, block) {
|
|
499
|
+
const w = writingFor(s)
|
|
500
|
+
if (!w) return isFresh(s.latestCheckpoint) ? '<div class="cp-fresh">' + block + '</div>' : block
|
|
501
|
+
return writingLine(s, w) + (isWriting(state.writing, s.id) ? '<div class="cp-stale" aria-hidden="true">' + block + '</div>' : block)
|
|
502
|
+
}
|
|
468
503
|
// 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
|
|
469
504
|
// Informative / Long, each with its line, your default ticked.
|
|
470
505
|
function checkpointItems() {
|
|
@@ -505,6 +540,11 @@ const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' ||
|
|
|
505
540
|
// length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
|
|
506
541
|
function checkpointSplit(s) {
|
|
507
542
|
const def = defaultLength()
|
|
543
|
+
if (isWriting(state.writing, s.id)) { // 0.9.5: busy until the checkpoint lands
|
|
544
|
+
return '<span class="split is-writing" role="group" aria-label="Checkpoint now" aria-busy="true">' +
|
|
545
|
+
'<button type="button" class="btn primary split-main" disabled aria-disabled="true" data-id="' + esc(s.id) + '"><span class="spin" aria-hidden="true"></span>Writing…</button>' +
|
|
546
|
+
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
547
|
+
}
|
|
508
548
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
509
549
|
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
|
|
510
550
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
@@ -535,7 +575,7 @@ function card(s) {
|
|
|
535
575
|
'</div>' + pill(s.status) + '</div>' +
|
|
536
576
|
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
537
577
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
538
|
-
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
578
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
539
579
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
540
580
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
541
581
|
copyResumeIcon(s) +
|
|
@@ -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
|
|
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>' : '') +
|
|
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) => {
|
|
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
|
-
|
|
1210
|
-
|
|
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) {
|
|
1769
|
-
|
|
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
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
if (
|
|
2786
|
-
|
|
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
|
-
|
|
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) + '
|
|
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
|
|
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
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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
|
|
4022
|
-
const
|
|
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 =
|
|
4033
|
-
if (key) await api('PUT', '/v1/config/keys/' +
|
|
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:
|
|
4036
|
-
minGapMin:
|
|
4037
|
-
digest:
|
|
4038
|
-
time: document.getElementById('f-time').value || null,
|
|
4039
|
-
days: [...document.querySelectorAll('.days .day[aria-pressed="true"]')].map((b) => b.dataset.day),
|
|
4040
|
-
skipEmpty: document.getElementById('f-skip').checked,
|
|
4041
|
-
timeZone: document.getElementById('f-tz').value || null,
|
|
4042
|
-
},
|
|
4313
|
+
dormantAfterMin: v.dormantAfterMin,
|
|
4314
|
+
minGapMin: v.minGapMin,
|
|
4315
|
+
digest: v.digest,
|
|
4043
4316
|
summarizer,
|
|
4044
|
-
summary: { length:
|
|
4045
|
-
updates: { check:
|
|
4046
|
-
notify: { needsYou:
|
|
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))
|