@leftoff/cli 0.6.5 → 0.6.9
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 +37 -0
- package/bin/leftoff.js +272 -269
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +122 -5
- package/mod/hooks/register.js +31 -8
- package/package.json +1 -1
- package/ui/app.css +91 -3
- package/ui/app.js +299 -64
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +2 -0
- package/ui/settings-nav.js +2 -2
package/ui/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
|
|
|
8
8
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
9
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
10
10
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
11
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
11
12
|
const $app = document.getElementById('app')
|
|
12
13
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
13
14
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -142,7 +143,16 @@ function where(s) {
|
|
|
142
143
|
const branch = s.repo && s.repo.branch
|
|
143
144
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
144
145
|
return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' +
|
|
145
|
-
(multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '')
|
|
146
|
+
(multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '') + modelChip(s)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
150
|
+
function modelChip(s) {
|
|
151
|
+
if (!s || !s.backend || !s.modelLabel) return ''
|
|
152
|
+
// the model name gives way first; "· Ollama" always shows
|
|
153
|
+
const i = s.modelLabel.lastIndexOf(' · ')
|
|
154
|
+
const inner = i > 0 ? '<span class="m">' + esc(s.modelLabel.slice(0, i)) + '</span><span class="via">· ' + esc(s.modelLabel.slice(i + 3)) + '</span>' : '<span class="m">' + esc(s.modelLabel) + '</span>'
|
|
155
|
+
return '<span class="chip model" title="' + esc((s.backend.kind === 'ollama' ? 'Runs through Ollama: ' : 'Runs on another server: ') + s.modelLabel) + '">' + inner + '</span>'
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
// Notices above the board: things that need the user's hand, from /v1/health.
|
|
@@ -288,7 +298,7 @@ function card(s) {
|
|
|
288
298
|
'</div>' + pill(s.status) + '</div>' +
|
|
289
299
|
'<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>' +
|
|
290
300
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
291
|
-
listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
301
|
+
cardPrsHtml(s.prs) + listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
292
302
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
293
303
|
'<div class="actions-row">' + goButton(s) +
|
|
294
304
|
'<button type="button" class="btn" data-action="resume">Copy resume</button>' +
|
|
@@ -306,6 +316,7 @@ const FILTERS = {
|
|
|
306
316
|
waiting: { label: 'Waiting', cls: 's-waiting', statuses: ['waiting'] },
|
|
307
317
|
dormant: { label: 'Dormant', cls: 's-dormant', statuses: ['dormant', 'stale'] },
|
|
308
318
|
ended: { label: 'Ended today', cls: 's-ended', statuses: ['ended'] },
|
|
319
|
+
closed: { label: 'Closed', cls: 's-closed', statuses: ['ended'] },
|
|
309
320
|
}
|
|
310
321
|
|
|
311
322
|
// The board's filter: hash first, then the last repo choice from this browser.
|
|
@@ -360,9 +371,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
360
371
|
async function viewBoard() {
|
|
361
372
|
if (!state.health) await refreshHealth()
|
|
362
373
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
363
|
-
const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
374
|
+
const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
364
375
|
state.sessions = sessions
|
|
365
376
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
377
|
+
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
366
378
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
367
379
|
try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
|
|
368
380
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
|
@@ -391,8 +403,8 @@ async function viewBoard() {
|
|
|
391
403
|
const allShown = shown
|
|
392
404
|
const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
|
|
393
405
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
394
|
-
const count = (k) => inRepos.filter((s) => inStatus(s, k)).length
|
|
395
|
-
const list = applyFilter(inSpace, f)
|
|
406
|
+
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
407
|
+
const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
|
|
396
408
|
let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div></div>' +
|
|
397
409
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
398
410
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
@@ -403,8 +415,13 @@ async function viewBoard() {
|
|
|
403
415
|
const needs = needsItems(sessions)
|
|
404
416
|
html += needsStrip(needs, needOpts())
|
|
405
417
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
406
|
-
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '"><div class="n">' + count(k) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
418
|
+
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
407
419
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
420
|
+
if (filter === 'closed') {
|
|
421
|
+
html += await closedSection(f, sp)
|
|
422
|
+
$app.innerHTML = banners() + html
|
|
423
|
+
return closedWire()
|
|
424
|
+
}
|
|
408
425
|
html += repoRow(inSpace, f)
|
|
409
426
|
if (!shown.length && !empties.length) {
|
|
410
427
|
html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
|
|
@@ -416,7 +433,118 @@ async function viewBoard() {
|
|
|
416
433
|
html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
417
434
|
}
|
|
418
435
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
436
|
+
// 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
|
|
437
|
+
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
419
438
|
$app.innerHTML = banners() + html
|
|
439
|
+
revealStat()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Closed (0.6.7): every ended session on every machine, newest first, with
|
|
443
|
+
// a search box and Show more. Each card has its last checkpoint and Resume,
|
|
444
|
+
// which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
|
|
445
|
+
const CLOSED_PAGE = 30
|
|
446
|
+
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
|
|
447
|
+
function closedQuery(f, sp) {
|
|
448
|
+
const qs = new URLSearchParams({ limit: String(closedUi.limit) })
|
|
449
|
+
if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
|
|
450
|
+
if (state.machine) qs.set('machine', state.machine)
|
|
451
|
+
if (f.repos.length) qs.set('repo', f.repos.join(','))
|
|
452
|
+
if (sp) qs.set('space', sp)
|
|
453
|
+
return '/v1/sessions/closed?' + qs
|
|
454
|
+
}
|
|
455
|
+
async function closedFetch(f, sp) {
|
|
456
|
+
const seq = ++closedUi.seq
|
|
457
|
+
const r = await api('GET', closedQuery(f, sp))
|
|
458
|
+
if (seq === closedUi.seq) closedUi.data = r
|
|
459
|
+
return closedUi.data
|
|
460
|
+
}
|
|
461
|
+
async function closedSection(f, sp) {
|
|
462
|
+
const r = await closedFetch(f, sp)
|
|
463
|
+
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
464
|
+
'<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div></div>' +
|
|
465
|
+
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
466
|
+
}
|
|
467
|
+
function closedListHtml(r, f, sp) {
|
|
468
|
+
if (!r.sessions.length) {
|
|
469
|
+
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
470
|
+
return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
|
|
471
|
+
(f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
|
|
472
|
+
}
|
|
473
|
+
return '<div class="grid mt closed-grid">' + r.sessions.map((s) => closedCard(s, r.viewer || {})).join('') + '</div>' +
|
|
474
|
+
(r.more ? '<div class="closed-more"><button type="button" class="btn" data-action="closed-more">Show more <span class="faint">· ' + (r.total - r.sessions.length) + ' older</span></button></div>' : '<div class="closed-end faint">' + r.total + ' closed session' + (r.total === 1 ? '' : 's') + '</div>')
|
|
475
|
+
}
|
|
476
|
+
// Resume is a button only where it can run: a Mac's own browser, with Ghostty,
|
|
477
|
+
// for a session this Mac owns or one another Mac will open for it. Elsewhere
|
|
478
|
+
// (a phone, a remote browser) Copy resume is the way.
|
|
479
|
+
function resumeButton(s, viewer = {}) {
|
|
480
|
+
if (s.agent || !s.cwd) return ''
|
|
481
|
+
if (viewer.local === false || state.viewerLocal === false) return ''
|
|
482
|
+
const busy = closedUi.busy.has(s.id)
|
|
483
|
+
if (s.isLocal) {
|
|
484
|
+
if (!viewer.ghostty) return ''
|
|
485
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="Open it in a new Ghostty tab: claude --resume">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
|
|
486
|
+
}
|
|
487
|
+
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
488
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
|
|
489
|
+
}
|
|
490
|
+
function closedCard(s, viewer) {
|
|
491
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
492
|
+
const branch = s.repo && s.repo.branch
|
|
493
|
+
const label = s.repoLabel || s.repoName || 'no repo'
|
|
494
|
+
return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
495
|
+
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
|
|
496
|
+
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
497
|
+
'<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' +
|
|
498
|
+
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
499
|
+
'<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
|
|
500
|
+
cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
501
|
+
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
502
|
+
'<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
|
|
503
|
+
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
504
|
+
'</div></article>'
|
|
505
|
+
}
|
|
506
|
+
function closedRepaint() {
|
|
507
|
+
const el = document.getElementById('closedList')
|
|
508
|
+
if (!el || !closedUi.data) return
|
|
509
|
+
const f = state.boardFilter || { repos: [] }
|
|
510
|
+
el.innerHTML = closedListHtml(closedUi.data, f, state.boardSpace)
|
|
511
|
+
}
|
|
512
|
+
// On a phone the status counts are one row that scrolls: bring the picked one into view.
|
|
513
|
+
function revealStat() {
|
|
514
|
+
const on = document.querySelector('.stats .stat.on'), row = on && on.parentElement
|
|
515
|
+
if (row && row.scrollWidth > row.clientWidth) row.scrollLeft = Math.max(0, on.offsetLeft - row.offsetLeft - (row.clientWidth - on.offsetWidth) / 2)
|
|
516
|
+
}
|
|
517
|
+
function closedWire() {
|
|
518
|
+
revealStat()
|
|
519
|
+
const q = document.getElementById('closedQ')
|
|
520
|
+
if (!q) return
|
|
521
|
+
q.addEventListener('input', () => {
|
|
522
|
+
closedUi.q = q.value
|
|
523
|
+
closedUi.limit = CLOSED_PAGE
|
|
524
|
+
clearTimeout(closedUi.timer)
|
|
525
|
+
closedUi.timer = setTimeout(async () => {
|
|
526
|
+
try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) }
|
|
527
|
+
}, 180)
|
|
528
|
+
})
|
|
529
|
+
q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
|
|
530
|
+
}
|
|
531
|
+
// Resume, from a closed card or a session page. -> true when it opened.
|
|
532
|
+
async function resumeOpen(btn, id) {
|
|
533
|
+
if (closedUi.busy.has(id)) return false
|
|
534
|
+
closedUi.busy.add(id)
|
|
535
|
+
const idle = btn.innerHTML
|
|
536
|
+
btn.disabled = true; btn.textContent = 'Opening…'
|
|
537
|
+
try {
|
|
538
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
|
|
539
|
+
toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
|
|
540
|
+
return true
|
|
541
|
+
} catch (e) {
|
|
542
|
+
toast(e.message, 6000)
|
|
543
|
+
return false
|
|
544
|
+
} finally {
|
|
545
|
+
closedUi.busy.delete(id)
|
|
546
|
+
if (btn.isConnected) { btn.disabled = false; btn.innerHTML = idle }
|
|
547
|
+
}
|
|
420
548
|
}
|
|
421
549
|
|
|
422
550
|
// The folded row of empty sessions (v0.4.8), closed by default.
|
|
@@ -451,7 +579,7 @@ function restoreBanner() {
|
|
|
451
579
|
const local = state.viewerLocal !== false
|
|
452
580
|
const picked = restorePicked().length
|
|
453
581
|
const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
|
|
454
|
-
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span
|
|
582
|
+
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
|
|
455
583
|
return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
|
|
456
584
|
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
|
|
457
585
|
'<div class="rs-list">' + rows + '</div>' +
|
|
@@ -612,13 +740,14 @@ async function viewSession(id) {
|
|
|
612
740
|
const spv = await api('GET', '/v1/spaces').catch(() => null)
|
|
613
741
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
614
742
|
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
615
|
-
'<div class="actions">' + goButton(s) + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
|
|
743
|
+
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
|
|
616
744
|
html += '<div class="facts">' +
|
|
617
745
|
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
618
|
-
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.model || '—') + fact('Session id', s.id) +
|
|
746
|
+
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
619
747
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
620
748
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
621
749
|
html += spacePicker(s, spv, 'session', s.id)
|
|
750
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
622
751
|
if (s.needsYou) html += '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>'
|
|
623
752
|
if (s.statusChanges && s.statusChanges.length) {
|
|
624
753
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
@@ -636,7 +765,7 @@ async function viewSession(id) {
|
|
|
636
765
|
if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
|
|
637
766
|
else html += '<div class="timeline">' + s.checkpoints.map((cp) =>
|
|
638
767
|
'<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>' +
|
|
639
|
-
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + '</span>' : '') + '</div>' +
|
|
768
|
+
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
|
|
640
769
|
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
|
|
641
770
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
642
771
|
(cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
|
|
@@ -655,7 +784,43 @@ function spacePicker(s, spv, kind, key) {
|
|
|
655
784
|
options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
|
|
656
785
|
'<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
|
|
657
786
|
}
|
|
658
|
-
function fact(k, v) { return '<div class="fact"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
|
|
787
|
+
function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
|
|
788
|
+
|
|
789
|
+
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
790
|
+
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
791
|
+
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
792
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
793
|
+
async function viewPrs() {
|
|
794
|
+
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
795
|
+
let view = params.get('view')
|
|
796
|
+
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
797
|
+
view = view === 'team' ? 'team' : 'mine'
|
|
798
|
+
const opts = { view, mayEdit: true, sessionAction: prSessionAction }
|
|
799
|
+
if (!prsUi.data[view]) $app.innerHTML = banners() + prsPageHtml(null, opts)
|
|
800
|
+
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
801
|
+
let d
|
|
802
|
+
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
803
|
+
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
804
|
+
if (!(state.route && state.route.name === 'prs')) return
|
|
805
|
+
if (d && d.groups) {
|
|
806
|
+
// Go / Resume on a row look the session up like a card does.
|
|
807
|
+
for (const g of d.groups) for (const r of g.rows || []) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
808
|
+
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
809
|
+
}
|
|
810
|
+
prsUi.data[view] = d
|
|
811
|
+
$app.innerHTML = banners() + prsPageHtml(d, opts)
|
|
812
|
+
clearTimeout(prsUi.timer)
|
|
813
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
814
|
+
else if (!(d && d.loading)) prsUi.polls = 0
|
|
815
|
+
}
|
|
816
|
+
function prSessionAction(s) {
|
|
817
|
+
if (s.live === 'closed' || s.status === 'ended') {
|
|
818
|
+
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
819
|
+
return b ? b.replace('class="btn primary"', 'class="btn sm"') : ''
|
|
820
|
+
}
|
|
821
|
+
const g = goButton(s)
|
|
822
|
+
return g && !/disabled/.test(g) ? g.replace('class="btn go', 'class="btn sm go') : ''
|
|
823
|
+
}
|
|
659
824
|
|
|
660
825
|
// Timeline (0.4.3): the board's repo chips (same remembered pick), a session
|
|
661
826
|
// filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
|
|
@@ -1040,7 +1205,7 @@ function syncProviderFields() {
|
|
|
1040
1205
|
st.textContent = k.found ? '✓ key found' + (k.envSet ? ' ($' + k.env + ')' : ' (saved)') : 'no key yet'
|
|
1041
1206
|
document.getElementById('f-key').placeholder = k.found ? 'paste a new key to replace it' : 'paste your key, or set $' + (k.env || '') + ' for the daemon'
|
|
1042
1207
|
document.getElementById('f-key-hint').innerHTML = (f.key === 'ollama-cloud' ? 'Create one at <a href="https://ollama.com/settings/keys" target="_blank" rel="noopener">ollama.com/settings/keys</a>. ' : '') +
|
|
1043
|
-
'Saved
|
|
1208
|
+
'Saved in ' + esc(k.file || '.secret/') + ' on this Mac, never config.json.'
|
|
1044
1209
|
}
|
|
1045
1210
|
}
|
|
1046
1211
|
|
|
@@ -1123,9 +1288,12 @@ function summariesSection(c) {
|
|
|
1123
1288
|
'<label>Checkpoint length</label><div class="lengths" role="radiogroup" aria-label="Checkpoint length">' + LENGTH_CHOICES.map((o) =>
|
|
1124
1289
|
'<label class="lenopt"><input type="radio" name="f-length" value="' + o.key + '"' + (cur === o.key ? ' checked' : '') + '>' +
|
|
1125
1290
|
'<span class="lo-t">' + esc(o.label) + (o.recommended ? ' <span class="lo-rec">default</span>' : '') + '</span><span class="lo-a">' + esc(o.approx) + '</span><span class="lo-b">' + esc(o.blurb) + '</span></label>').join('') + '</div>' +
|
|
1126
|
-
'<div class="hint"
|
|
1127
|
-
|
|
1128
|
-
|
|
1291
|
+
'<div class="hint"><code>/cp --short</code> or <code>/cp --long</code> overrides it once.</div>'
|
|
1292
|
+
}
|
|
1293
|
+
function lengthExampleHtml(c) {
|
|
1294
|
+
const cur = (c.summary && c.summary.length) || 'informative'
|
|
1295
|
+
return '<label>Example</label><div class="len-example" id="f-length-example" aria-live="polite">' + lengthExample(cur) + '</div>' +
|
|
1296
|
+
'<div class="hint"><button type="button" class="linkish" data-action="length-try">Try it on your latest session</button> <span class="faint">(nothing is saved)</span></div>'
|
|
1129
1297
|
}
|
|
1130
1298
|
async function tryLength() {
|
|
1131
1299
|
const k = ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative'
|
|
@@ -1137,7 +1305,7 @@ async function tryLength() {
|
|
|
1137
1305
|
box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
|
|
1138
1306
|
try {
|
|
1139
1307
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
1140
|
-
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) : '') : '') + ' · not saved</span></div>' +
|
|
1308
|
+
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>' +
|
|
1141
1309
|
cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1142
1310
|
} catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
1143
1311
|
}
|
|
@@ -1174,9 +1342,11 @@ function remoteSection(r) {
|
|
|
1174
1342
|
: '<span class="chip">no sign-in needed</span>') + '</div>' +
|
|
1175
1343
|
'<div class="hint">' + (r.requireToken ? 'A signed-in browser stays signed in for 30 days after its last visit.' : 'Anyone on your tailnet who can reach the URL gets the board (remoteRequireToken is off).') + '</div>'
|
|
1176
1344
|
if (r.requireToken) {
|
|
1177
|
-
|
|
1345
|
+
// A trusted remote browser may sign in another phone too (0.6.6, Trust my paired devices).
|
|
1346
|
+
const trusted = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
|
|
1347
|
+
if (local || trusted) {
|
|
1178
1348
|
h += '<label>Sign in a phone</label><div class="remote-actions"><button type="button" class="btn primary" data-action="remote-link">' + icon('qr-code') + 'Show QR code</button>' +
|
|
1179
|
-
(n ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
|
|
1349
|
+
(n && local ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
|
|
1180
1350
|
'<div class="full signin" id="remoteLink" hidden></div>'
|
|
1181
1351
|
} else {
|
|
1182
1352
|
h += '<label>Home-screen app</label><div class="remote-actions"><button type="button" class="btn primary" data-action="remote-code">Get a code</button></div>' +
|
|
@@ -1212,7 +1382,7 @@ function signupBlock(r, su, local) {
|
|
|
1212
1382
|
|
|
1213
1383
|
// Use from anywhere (v0.6.0): the hosted relay at <user>.leftoff.dev. The
|
|
1214
1384
|
// switch, the name, the relay's state, pairing a phone, the paired devices.
|
|
1215
|
-
function anywhereSection(r) {
|
|
1385
|
+
function anywhereSection(r, adv = []) {
|
|
1216
1386
|
if (!r) return ''
|
|
1217
1387
|
const v = r.viewer || {}
|
|
1218
1388
|
const local = !!v.local
|
|
@@ -1220,27 +1390,33 @@ function anywhereSection(r) {
|
|
|
1220
1390
|
const dom = esc(r.domain || 'leftoff.dev')
|
|
1221
1391
|
const su = r.signup || { status: 'none' }
|
|
1222
1392
|
let h = '<div class="section" id="anywhere">Use from anywhere</div>' +
|
|
1223
|
-
'<div class="full hint">Your board at <b>' + esc(r.host || '
|
|
1393
|
+
'<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
|
|
1224
1394
|
// No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
|
|
1225
1395
|
if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
|
|
1226
|
-
h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
|
|
1227
|
-
|
|
1396
|
+
h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
|
|
1397
|
+
// The name: up top until there is one, then Advanced (0.6.6).
|
|
1398
|
+
const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
|
|
1228
1399
|
(local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
|
|
1229
|
-
'<div class="hint">' + (r.user ? 'Your
|
|
1400
|
+
'<div class="hint">' + (r.user ? 'Your other Macs join when you switch this on there.' : 'Claimed one on another Mac? Type it here and switch this on.') + '</div>'
|
|
1401
|
+
if (r.user) adv.push(nameRow); else h += nameRow
|
|
1230
1402
|
if (r.enabled) {
|
|
1231
1403
|
const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
|
|
1232
1404
|
h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
|
|
1233
1405
|
if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
|
|
1234
|
-
|
|
1406
|
+
// Pairing from a paired phone follows Trust my paired devices (0.6.6).
|
|
1407
|
+
const mayPair = v.mayPair != null ? !!v.mayPair : local
|
|
1408
|
+
if (mayPair) h += '<label>Pair a phone</label><div class="remote-actions"><button type="button" class="btn primary" data-action="relay-pair"' + (r.connected ? '' : ' disabled') + '>' + icon('qr-code') + 'Pair a phone</button></div>' +
|
|
1235
1409
|
'<div class="full signin" id="relayPair" hidden></div>'
|
|
1410
|
+
else h += '<label>Pair a phone</label><div class="hint nomt" id="pairUntrusted">Turn on <a href="#/settings/security">Trust my paired devices</a> on a Mac to pair from here.</div>'
|
|
1236
1411
|
}
|
|
1412
|
+
const mayRevoke = v.mayRevoke != null ? !!v.mayRevoke : local
|
|
1237
1413
|
const list = r.list || []
|
|
1238
1414
|
h += '<label>Paired devices</label><div class="devlist">' + (list.length ? list.map((d) =>
|
|
1239
1415
|
'<div class="dev" data-device="' + esc(d.id) + '"><div class="dev-main"><b>' + esc(d.name) + '</b>' + (v.device === d.id ? ' <span class="chip">this one</span>' : '') +
|
|
1240
1416
|
'<div class="faint">Paired ' + esc(fmtAgo(d.createdAt)) + ' on ' + esc(d.pairedOn || '?') + (d.lastSeenAt ? ' · used ' + esc(fmtAgo(d.lastSeenAt)) : '') + ' · reaches ' + esc((d.macs || []).join(', ')) + '</div></div>' +
|
|
1241
|
-
(
|
|
1417
|
+
(mayRevoke ? '<button type="button" class="btn sm danger" data-action="relay-revoke" data-device="' + esc(d.id) + '" data-name="' + esc(d.name) + '"' + (v.device === d.id ? ' data-self="1"' : '') + '>Revoke</button>' : '') + '</div>').join('')
|
|
1242
1418
|
: '<div class="faint">None yet.</div>') + '</div>' +
|
|
1243
|
-
'<div class="hint">' + (local ? 'Revoking works at once
|
|
1419
|
+
'<div class="hint">' + (local ? 'Revoking works at once, from any paired device.' : 'Revoking works at once, this browser included.') + '</div>'
|
|
1244
1420
|
return h
|
|
1245
1421
|
}
|
|
1246
1422
|
function fmtAgo(iso) {
|
|
@@ -1270,7 +1446,7 @@ function paintRelayPair(l) {
|
|
|
1270
1446
|
box.innerHTML = '<div class="qr"><img alt="QR code: scan with the iPhone camera" width="220" height="220" src="data:image/svg+xml;base64,' + btoa(l.qrSvg) + '"></div>' +
|
|
1271
1447
|
'<div class="signin-text"><div class="faint">Scan with your phone’s camera, or open this link on it:</div><code class="link">' + esc(l.link) + '</code>' +
|
|
1272
1448
|
'<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.link) + '">Copy link</button>' +
|
|
1273
|
-
'<div class="faint gap">Or type this code at ' + esc(l.link.split('#')[0]) + ':</div><div class="code-big long" aria-label="Pairing code">' + esc(l.code) + '</div>' +
|
|
1449
|
+
'<div class="faint gap">Or type this code at ' + esc(l.link.split('#')[0]) + ':</div><div class="joinline"><div class="code-big long" aria-label="Pairing code">' + esc(l.code) + '</div><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.code) + '">Copy code</button></div>' +
|
|
1274
1450
|
'<div class="expiry" id="relayExpiry" data-expires="' + esc(l.expiresAt) + '"></div>' +
|
|
1275
1451
|
'<button type="button" class="linkish" data-action="relay-pair">New code</button></div>'
|
|
1276
1452
|
const tick = () => {
|
|
@@ -1292,9 +1468,10 @@ function answeringSection(r, relay) {
|
|
|
1292
1468
|
if (!r || !(r.enabled || anywhere)) return ''
|
|
1293
1469
|
const local = r.via !== 'remote'
|
|
1294
1470
|
const allow = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
|
|
1295
|
-
return '<label for="f-remote-answers">
|
|
1296
|
-
'<div class="
|
|
1297
|
-
|
|
1471
|
+
return '<label for="f-remote-answers">Paired devices</label><label class="check"><input id="f-remote-answers" type="checkbox"' + (allow ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Trust my paired devices</span></label>' +
|
|
1472
|
+
'<div class="hint">On: your phone and other paired browsers can answer prompts and pair new devices. Off: they can look, not touch.</div>' +
|
|
1473
|
+
'<div class="warnbox"><b>Think first.</b> A trusted device can approve what Claude runs here, including deleting files or pushing code.' +
|
|
1474
|
+
(local ? '' : ' Change this on a Mac.') + '</div>'
|
|
1298
1475
|
}
|
|
1299
1476
|
|
|
1300
1477
|
// The sign-in box under "Show QR code" / "Get a code": the QR image, the link,
|
|
@@ -1338,11 +1515,11 @@ function digestSettings(c) {
|
|
|
1338
1515
|
const zoneNote = bz && zone && bz !== zone ? '<div class="hint warnish">This browser is in ' + esc(bz) + '. The digest runs on ' + esc(zone) + ' time' + (d.timeZone ? ' (set below)' : ', the zone of the machine running Leftoff') + '.</div>' : ''
|
|
1339
1516
|
return '<div class="section dup" id="digest">Digest</div>' +
|
|
1340
1517
|
'<label for="f-time">Time</label><div class="timerow"><input id="f-time" type="time" value="' + esc(d.time || '') + '"><span class="zone" id="f-zone">' + esc(zone) + (dg.zoneAbbr && dg.zoneAbbr !== zone ? ' (' + esc(dg.zoneAbbr) + ')' : '') + '</span></div>' +
|
|
1341
|
-
'<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear
|
|
1518
|
+
'<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear it to turn digests off.</div>' + zoneNote +
|
|
1342
1519
|
'<label>Days</label><div class="days" role="group" aria-label="Digest days">' + DAY_KEYS.map(([k, l, full]) => '<button type="button" class="day" data-action="day-toggle" data-day="' + k + '" aria-pressed="' + days.includes(k) + '" title="' + full + '" aria-label="' + full + '">' + l + '</button>').join('') +
|
|
1343
1520
|
'<span class="presets"><button type="button" class="linkish" data-action="days-preset" data-days="all">Every day</button> · <button type="button" class="linkish" data-action="days-preset" data-days="weekdays">Weekdays</button></span></div>' +
|
|
1344
|
-
'<label for="f-skip">Quiet days</label><label class="check"><input id="f-skip" type="checkbox"' + (d.skipEmpty !== false ? ' checked' : '') + '><span>Skip days with no activity (no empty digest is written).</span></label>' +
|
|
1345
|
-
'<label for="f-tz">Time zone</label>' + selectHtml({ id: 'f-tz', value: d.timeZone || '', options: [['', 'This machine’s (' + (dg.daemonZone || '?') + ')'], ...zones.map((z) => [z, z.replace(/_/g, ' ')])] })
|
|
1521
|
+
advanced('digest', '<label for="f-skip">Quiet days</label><label class="check"><input id="f-skip" type="checkbox"' + (d.skipEmpty !== false ? ' checked' : '') + '><span>Skip days with no activity (no empty digest is written).</span></label>' +
|
|
1522
|
+
'<label for="f-tz">Time zone</label>' + selectHtml({ id: 'f-tz', value: d.timeZone || '', options: [['', 'This machine’s (' + (dg.daemonZone || '?') + ')'], ...zones.map((z) => [z, z.replace(/_/g, ' ')])] }))
|
|
1346
1523
|
}
|
|
1347
1524
|
|
|
1348
1525
|
// Settings (v0.5.9): one section at a time. Desktop (≥900px) has a sidebar of
|
|
@@ -1417,6 +1594,12 @@ function settingsFilter() {
|
|
|
1417
1594
|
}
|
|
1418
1595
|
wrap.querySelectorAll('.set-hit').forEach((el) => el.classList.remove('set-hit'))
|
|
1419
1596
|
if (q) for (const el of wrap.querySelectorAll('.set-pane:not([hidden]) label, .set-pane:not([hidden]) .section, .set-pane:not([hidden]) .sp-sub')) if (el.textContent.toLowerCase().includes(q)) el.classList.add('set-hit')
|
|
1597
|
+
// A match under Advanced opens it; clearing the search folds it back (unless you opened it).
|
|
1598
|
+
for (const d of wrap.querySelectorAll('details.set-adv')) {
|
|
1599
|
+
const inside = q && [...d.querySelectorAll('.set-advbody label, .set-advbody .section, .set-advbody .sp-sub')].some((el) => el.textContent.toLowerCase().includes(q))
|
|
1600
|
+
if (inside && !d.open) { d.dataset.auto = '1'; d.open = true }
|
|
1601
|
+
else if (!inside && d.dataset.auto) { delete d.dataset.auto; d.open = !!settingsUi.adv[d.dataset.adv] }
|
|
1602
|
+
}
|
|
1420
1603
|
const none = wrap.querySelector('.set-nomatch')
|
|
1421
1604
|
none.hidden = !!first
|
|
1422
1605
|
none.textContent = first ? '' : 'Nothing in Settings matches “' + settingsUi.q.trim() + '”.'
|
|
@@ -1457,10 +1640,30 @@ function ghSection(g) {
|
|
|
1457
1640
|
'<button type="button" class="linkish" data-action="gh-check">Check again</button></div>'
|
|
1458
1641
|
if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
|
|
1459
1642
|
h += '</div>'
|
|
1460
|
-
if (!ok) h += '<div class="hint">' + (g.fix && g.fix.url ? 'Optional. Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this
|
|
1643
|
+
if (!ok) h += '<div class="hint">' + (g.fix && g.fix.url ? 'Optional. Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this.' : 'Optional. Run this in Terminal on this Mac.') + '</div>'
|
|
1461
1644
|
return h
|
|
1462
1645
|
}
|
|
1463
1646
|
|
|
1647
|
+
// Advanced (0.6.6): each section shows what people change up top; the rest
|
|
1648
|
+
// folds under one closed Advanced per section. Same form, same Save; the
|
|
1649
|
+
// search opens a fold when something inside matches; opening one sticks.
|
|
1650
|
+
settingsUi.adv = settingsUi.adv || {}
|
|
1651
|
+
function advanced(key, html) {
|
|
1652
|
+
if (!html) return ''
|
|
1653
|
+
return '<details class="set-adv" data-adv="' + key + '"' + (settingsUi.adv[key] ? ' open' : '') + '><summary>Advanced</summary><div class="set-advbody">' + html + '</div></details>'
|
|
1654
|
+
}
|
|
1655
|
+
// Phone & remote: Use from anywhere up top; Tailscale under Advanced unless it's
|
|
1656
|
+
// the only way in (remote access on, the relay off).
|
|
1657
|
+
function phonePane(relay, remote, unavailable) {
|
|
1658
|
+
const adv = []
|
|
1659
|
+
const any = anywhereSection(relay, adv)
|
|
1660
|
+
const ts = remoteSection(remote) || unavailable('Couldn’t read remote access from Leftoff.')
|
|
1661
|
+
const tsOnly = !!(remote && remote.enabled) && !(relay && relay.enabled)
|
|
1662
|
+
if (!any) return ts
|
|
1663
|
+
if (tsOnly) return any + advanced('phone', adv.join('')) + '<div class="section">Over Tailscale</div>' + ts
|
|
1664
|
+
return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1464
1667
|
async function viewSettings() {
|
|
1465
1668
|
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)])
|
|
1466
1669
|
state.relay = relay
|
|
@@ -1473,17 +1676,16 @@ async function viewSettings() {
|
|
|
1473
1676
|
const h = state.health || {}
|
|
1474
1677
|
const unavailable = (what) => '<div class="full hint set-empty">' + esc(what) + '</div>'
|
|
1475
1678
|
const panes = {
|
|
1476
|
-
general: '<div class="section">
|
|
1477
|
-
'<label for="f-
|
|
1478
|
-
'<div class="hint">
|
|
1479
|
-
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
1480
|
-
'<div class="hint">No automatic checkpoint closer than this to the last one.</div>' +
|
|
1481
|
-
'<div class="section">Needs you</div>' +
|
|
1482
|
-
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a macOS notification when a session has waited on a permission prompt or question for ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
1483
|
-
'<div class="hint">Answered sooner (in the terminal or here), no notification. With <code>terminal-notifier</code> installed (<code>brew install terminal-notifier</code>), clicking the notification jumps to the session.</div>' +
|
|
1484
|
-
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>' +
|
|
1679
|
+
general: '<div class="section">Needs you</div>' +
|
|
1680
|
+
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
1681
|
+
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
1485
1682
|
'<div class="section">Appearance</div>' +
|
|
1486
|
-
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>'
|
|
1683
|
+
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
1684
|
+
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
1685
|
+
'<div class="hint">No activity this long, and the session gets a checkpoint.</div>' +
|
|
1686
|
+
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
1687
|
+
'<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
|
|
1688
|
+
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
|
|
1487
1689
|
digest: digestSettings(c),
|
|
1488
1690
|
spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
|
|
1489
1691
|
sync: syncSection(sync, stUi.data) || unavailable('Couldn’t read the sync status from Leftoff.'),
|
|
@@ -1496,21 +1698,22 @@ async function viewSettings() {
|
|
|
1496
1698
|
'<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
|
|
1497
1699
|
'<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
|
|
1498
1700
|
'<div class="hint" data-row="key" id="f-key-hint"></div>' +
|
|
1499
|
-
summariesSection(c)
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
security: '<div class="section" id="answering">
|
|
1503
|
-
'<div class="full hint">
|
|
1504
|
-
|
|
1505
|
-
'
|
|
1506
|
-
'
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
'<div class="section">About</div>' +
|
|
1701
|
+
summariesSection(c) +
|
|
1702
|
+
advanced('models', lengthExampleHtml(c)),
|
|
1703
|
+
phone: phonePane(relay, remote, unavailable),
|
|
1704
|
+
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
1705
|
+
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
1706
|
+
// a changed machine key needs a click here, so Other machines stays up top then
|
|
1707
|
+
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
1708
|
+
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
1709
|
+
'<div class="section">Your data</div>' +
|
|
1710
|
+
'<div class="full hint">Plain Markdown and JSON in your Leftoff folder; keys stay on each Mac. <a href="' + SECURITY_URL + '" target="_blank" rel="noopener">Security notes ↗</a></div>'),
|
|
1711
|
+
updates: '<div class="section">About</div>' +
|
|
1511
1712
|
'<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + REPO_URL + '" target="_blank" rel="noopener">GitHub ↗</a></div>' +
|
|
1512
|
-
'<
|
|
1513
|
-
'<label>
|
|
1713
|
+
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
1714
|
+
advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
|
|
1715
|
+
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
1716
|
+
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
1514
1717
|
}
|
|
1515
1718
|
const r = state.route || parseRoute()
|
|
1516
1719
|
$app.innerHTML = '<div class="settings no-sec" id="settingsWrap">' +
|
|
@@ -1537,6 +1740,14 @@ async function viewSettings() {
|
|
|
1537
1740
|
document.getElementById('f-model').value = same ? (c.summarizer.model || '') : ''
|
|
1538
1741
|
syncProviderFields()
|
|
1539
1742
|
})
|
|
1743
|
+
// Opening or closing Advanced by hand sticks across redraws (the search's own opening doesn't).
|
|
1744
|
+
document.getElementById('settingsWrap').addEventListener('toggle', (ev) => {
|
|
1745
|
+
const d = ev.target
|
|
1746
|
+
if (!d.classList || !d.classList.contains('set-adv')) return
|
|
1747
|
+
if (d.dataset.auto && d.open) return
|
|
1748
|
+
delete d.dataset.auto
|
|
1749
|
+
settingsUi.adv[d.dataset.adv] = d.open
|
|
1750
|
+
}, true)
|
|
1540
1751
|
const f = document.getElementById('setFilter')
|
|
1541
1752
|
f.addEventListener('input', () => { settingsUi.q = f.value; settingsFilter() })
|
|
1542
1753
|
f.addEventListener('keydown', (ev) => {
|
|
@@ -2203,6 +2414,7 @@ async function render() {
|
|
|
2203
2414
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
2204
2415
|
else if (r.name === 'waiting') await viewWaiting()
|
|
2205
2416
|
else if (r.name === 'timeline') await viewTimeline()
|
|
2417
|
+
else if (r.name === 'prs') await viewPrs()
|
|
2206
2418
|
else if (r.name === 'digest') await viewDigest(r.arg, r.params.get('machine'))
|
|
2207
2419
|
else if (r.name === 'catchup') await viewCatchup(r.arg)
|
|
2208
2420
|
else if (r.name === 'lists') await viewLists(r.arg)
|
|
@@ -2380,7 +2592,7 @@ window.addEventListener('resize', () => { placeStatus() })
|
|
|
2380
2592
|
// ⌘K search: a centered panel with the results grouped as you type. Enter on
|
|
2381
2593
|
// nothing picked opens the full results page (#/search?q=…), as before.
|
|
2382
2594
|
const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
|
|
2383
|
-
const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
|
|
2595
|
+
const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['PRs', '#/prs'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
|
|
2384
2596
|
const searchIsOpen = () => !document.getElementById('cmdk').hidden
|
|
2385
2597
|
function openSearch(prefill) {
|
|
2386
2598
|
const box = document.getElementById('cmdk'), q = document.getElementById('q')
|
|
@@ -2488,9 +2700,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
2488
2700
|
const action = btn.dataset.action
|
|
2489
2701
|
const card = btn.closest('[data-id]')
|
|
2490
2702
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
2491
|
-
const s = state.sessions.find((x) => x.id === id)
|
|
2703
|
+
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
2492
2704
|
try {
|
|
2493
2705
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2706
|
+
// PRs (0.6.9)
|
|
2707
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v === 'team' ? '#/prs?view=team' : '#/prs'; return }
|
|
2708
|
+
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2709
|
+
if (action === 'pr-unlink') {
|
|
2710
|
+
btn.disabled = true
|
|
2711
|
+
try { await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/prs', { ref: btn.dataset.url, unlink: true }); toast('Unlinked') } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
2712
|
+
return render()
|
|
2713
|
+
}
|
|
2494
2714
|
if (action === 'gh-check') {
|
|
2495
2715
|
btn.disabled = true
|
|
2496
2716
|
try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
|
|
@@ -2527,8 +2747,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
2527
2747
|
if (action === 'relay-signup-cancel') { await api('POST', '/v1/relay/signup/cancel'); stopSignupPoll(); return render() }
|
|
2528
2748
|
if (action === 'relay-pair') { const l = await api('POST', '/v1/relay/pair'); paintRelayPair(l); return }
|
|
2529
2749
|
if (action === 'relay-revoke') {
|
|
2530
|
-
|
|
2750
|
+
const self = btn.dataset.self === '1'
|
|
2751
|
+
if (!confirm(self ? 'Revoke this browser? It stops working at once and needs pairing again.' : 'Revoke ' + btn.dataset.name + '? It stops working at once and needs pairing again.')) return
|
|
2531
2752
|
const r = await api('POST', '/v1/relay/devices/' + encodeURIComponent(btn.dataset.device) + '/revoke')
|
|
2753
|
+
// Revoked itself (0.6.6): the same screen a revoked browser gets.
|
|
2754
|
+
if ((self || r.self) && window.leftoffRelay && window.leftoffRelay.unpaired) return window.leftoffRelay.unpaired()
|
|
2532
2755
|
toast('Revoked ' + btn.dataset.name + (r.relay === 'done' ? '' : ' (the relay hears it once this Mac reconnects)')); return render()
|
|
2533
2756
|
}
|
|
2534
2757
|
if (action === 'relay-mac') { if (window.leftoffRelay && btn.dataset.mac !== window.leftoffRelay.mac) window.leftoffRelay.pick(btn.dataset.mac); return }
|
|
@@ -2572,7 +2795,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
2572
2795
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
2573
2796
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
2574
2797
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
2575
|
-
const l = await api('POST', '/v1/remote/link')
|
|
2798
|
+
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
2576
2799
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
|
2577
2800
|
paintSignin(l); return
|
|
2578
2801
|
}
|
|
@@ -2741,6 +2964,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
2741
2964
|
return render()
|
|
2742
2965
|
}
|
|
2743
2966
|
if (action === 'go') return goClick(btn, id, s)
|
|
2967
|
+
if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
|
|
2968
|
+
if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
|
|
2969
|
+
if (action === 'closed-clear') { closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
|
|
2744
2970
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2745
2971
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
2746
2972
|
if (action === 'resume') {
|
|
@@ -2912,6 +3138,15 @@ function goClick(btn, id, s) {
|
|
|
2912
3138
|
}
|
|
2913
3139
|
|
|
2914
3140
|
document.addEventListener('submit', async (ev) => {
|
|
3141
|
+
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3142
|
+
ev.preventDefault()
|
|
3143
|
+
const input = ev.target.querySelector('input[name=ref]')
|
|
3144
|
+
const ref = input.value.trim()
|
|
3145
|
+
if (!ref) { input.focus(); return }
|
|
3146
|
+
const b = ev.target.querySelector('button'); if (b) b.disabled = true
|
|
3147
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(ev.target.dataset.prLink) + '/prs', { ref }); input.value = ''; toast('Linked #' + String(r.url || '').split('/').pop()) ; await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
|
|
3148
|
+
return
|
|
3149
|
+
}
|
|
2915
3150
|
if (ev.target.dataset && ev.target.dataset.listAdd) {
|
|
2916
3151
|
ev.preventDefault()
|
|
2917
3152
|
const input = ev.target.querySelector('input[name=text]')
|
|
@@ -2958,7 +3193,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
2958
3193
|
...(() => {
|
|
2959
3194
|
const el = document.getElementById('f-remote-answers')
|
|
2960
3195
|
if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
|
|
2961
|
-
if (el.checked && !confirm('
|
|
3196
|
+
if (el.checked && !confirm('Trust your paired devices? They could approve commands on this machine and pair new devices.')) { el.checked = false; return {} }
|
|
2962
3197
|
return { remote: { allowAnswers: el.checked } }
|
|
2963
3198
|
})(),
|
|
2964
3199
|
})
|
|
@@ -2977,7 +3212,7 @@ let stream = null
|
|
|
2977
3212
|
function connect() {
|
|
2978
3213
|
if (stream) { try { stream.close() } catch {} }
|
|
2979
3214
|
const es = stream = new EventSource('/v1/stream')
|
|
2980
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated']) es.addEventListener(t, soon)
|
|
3215
|
+
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
2981
3216
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
2982
3217
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
2983
3218
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|