@leftoff/cli 0.6.3 → 0.6.8
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 +47 -0
- package/README.md +2 -1
- package/bin/leftoff.js +329 -313
- 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 +102 -3
- package/ui/app.js +355 -63
- package/ui/needs.js +237 -5
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +11 -2
- package/ui/settings-nav.js +3 -3
package/ui/app.js
CHANGED
|
@@ -4,12 +4,12 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
-
import { repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
|
|
8
|
-
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer } from './needs.js'
|
|
7
|
+
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
|
|
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
11
|
const $app = document.getElementById('app')
|
|
12
|
-
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true }
|
|
12
|
+
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
13
13
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
14
14
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
15
15
|
|
|
@@ -74,10 +74,25 @@ function clock(iso) { return iso ? new Date(iso).toLocaleString(undefined, { mon
|
|
|
74
74
|
function toast(msg, ms = 2600) {
|
|
75
75
|
const t = document.getElementById('toast')
|
|
76
76
|
t.textContent = msg
|
|
77
|
+
t.classList.remove('has-action')
|
|
77
78
|
t.classList.add('show')
|
|
78
79
|
clearTimeout(toast.t)
|
|
79
80
|
toast.t = setTimeout(() => t.classList.remove('show'), ms)
|
|
80
81
|
}
|
|
82
|
+
// A toast with an Undo button (0.6.5, dismissing a need): about 5 s.
|
|
83
|
+
function toastUndo(msg, onUndo, ms = 5000) {
|
|
84
|
+
const t = document.getElementById('toast')
|
|
85
|
+
t.innerHTML = ''
|
|
86
|
+
const span = document.createElement('span')
|
|
87
|
+
span.textContent = msg
|
|
88
|
+
const b = document.createElement('button')
|
|
89
|
+
b.type = 'button'; b.className = 'toast-undo'; b.textContent = 'Undo'
|
|
90
|
+
b.onclick = () => { t.classList.remove('show', 'has-action'); clearTimeout(toast.t); onUndo() }
|
|
91
|
+
t.append(span, b)
|
|
92
|
+
t.classList.add('show', 'has-action')
|
|
93
|
+
clearTimeout(toast.t)
|
|
94
|
+
toast.t = setTimeout(() => t.classList.remove('show', 'has-action'), ms)
|
|
95
|
+
}
|
|
81
96
|
async function copy(text) {
|
|
82
97
|
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
|
|
83
98
|
}
|
|
@@ -127,7 +142,16 @@ function where(s) {
|
|
|
127
142
|
const branch = s.repo && s.repo.branch
|
|
128
143
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
129
144
|
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>' +
|
|
130
|
-
(multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '')
|
|
145
|
+
(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)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
149
|
+
function modelChip(s) {
|
|
150
|
+
if (!s || !s.backend || !s.modelLabel) return ''
|
|
151
|
+
// the model name gives way first; "· Ollama" always shows
|
|
152
|
+
const i = s.modelLabel.lastIndexOf(' · ')
|
|
153
|
+
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>'
|
|
154
|
+
return '<span class="chip model" title="' + esc((s.backend.kind === 'ollama' ? 'Runs through Ollama: ' : 'Runs on another server: ') + s.modelLabel) + '">' + inner + '</span>'
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
// Notices above the board: things that need the user's hand, from /v1/health.
|
|
@@ -291,6 +315,7 @@ const FILTERS = {
|
|
|
291
315
|
waiting: { label: 'Waiting', cls: 's-waiting', statuses: ['waiting'] },
|
|
292
316
|
dormant: { label: 'Dormant', cls: 's-dormant', statuses: ['dormant', 'stale'] },
|
|
293
317
|
ended: { label: 'Ended today', cls: 's-ended', statuses: ['ended'] },
|
|
318
|
+
closed: { label: 'Closed', cls: 's-closed', statuses: ['ended'] },
|
|
294
319
|
}
|
|
295
320
|
|
|
296
321
|
// The board's filter: hash first, then the last repo choice from this browser.
|
|
@@ -345,9 +370,10 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
345
370
|
async function viewBoard() {
|
|
346
371
|
if (!state.health) await refreshHealth()
|
|
347
372
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
348
|
-
const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
373
|
+
const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
349
374
|
state.sessions = sessions
|
|
350
375
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
376
|
+
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
351
377
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
352
378
|
try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
|
|
353
379
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
|
@@ -376,8 +402,8 @@ async function viewBoard() {
|
|
|
376
402
|
const allShown = shown
|
|
377
403
|
const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
|
|
378
404
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
379
|
-
const count = (k) => inRepos.filter((s) =>
|
|
380
|
-
const list = applyFilter(inSpace, f)
|
|
405
|
+
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
406
|
+
const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
|
|
381
407
|
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>' +
|
|
382
408
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
383
409
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
@@ -390,6 +416,11 @@ async function viewBoard() {
|
|
|
390
416
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
391
417
|
'<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>'
|
|
392
418
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
419
|
+
if (filter === 'closed') {
|
|
420
|
+
html += await closedSection(f, sp)
|
|
421
|
+
$app.innerHTML = banners() + html
|
|
422
|
+
return closedWire()
|
|
423
|
+
}
|
|
393
424
|
html += repoRow(inSpace, f)
|
|
394
425
|
if (!shown.length && !empties.length) {
|
|
395
426
|
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>'
|
|
@@ -402,6 +433,115 @@ async function viewBoard() {
|
|
|
402
433
|
}
|
|
403
434
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
404
435
|
$app.innerHTML = banners() + html
|
|
436
|
+
revealStat()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Closed (0.6.7): every ended session on every machine, newest first, with
|
|
440
|
+
// a search box and Show more. Each card has its last checkpoint and Resume,
|
|
441
|
+
// which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
|
|
442
|
+
const CLOSED_PAGE = 30
|
|
443
|
+
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
|
|
444
|
+
function closedQuery(f, sp) {
|
|
445
|
+
const qs = new URLSearchParams({ limit: String(closedUi.limit) })
|
|
446
|
+
if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
|
|
447
|
+
if (state.machine) qs.set('machine', state.machine)
|
|
448
|
+
if (f.repos.length) qs.set('repo', f.repos.join(','))
|
|
449
|
+
if (sp) qs.set('space', sp)
|
|
450
|
+
return '/v1/sessions/closed?' + qs
|
|
451
|
+
}
|
|
452
|
+
async function closedFetch(f, sp) {
|
|
453
|
+
const seq = ++closedUi.seq
|
|
454
|
+
const r = await api('GET', closedQuery(f, sp))
|
|
455
|
+
if (seq === closedUi.seq) closedUi.data = r
|
|
456
|
+
return closedUi.data
|
|
457
|
+
}
|
|
458
|
+
async function closedSection(f, sp) {
|
|
459
|
+
const r = await closedFetch(f, sp)
|
|
460
|
+
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
461
|
+
'<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>' +
|
|
462
|
+
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
463
|
+
}
|
|
464
|
+
function closedListHtml(r, f, sp) {
|
|
465
|
+
if (!r.sessions.length) {
|
|
466
|
+
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
467
|
+
return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
|
|
468
|
+
(f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
|
|
469
|
+
}
|
|
470
|
+
return '<div class="grid mt closed-grid">' + r.sessions.map((s) => closedCard(s, r.viewer || {})).join('') + '</div>' +
|
|
471
|
+
(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>')
|
|
472
|
+
}
|
|
473
|
+
// Resume is a button only where it can run: a Mac's own browser, with Ghostty,
|
|
474
|
+
// for a session this Mac owns or one another Mac will open for it. Elsewhere
|
|
475
|
+
// (a phone, a remote browser) Copy resume is the way.
|
|
476
|
+
function resumeButton(s, viewer = {}) {
|
|
477
|
+
if (s.agent || !s.cwd) return ''
|
|
478
|
+
if (viewer.local === false || state.viewerLocal === false) return ''
|
|
479
|
+
const busy = closedUi.busy.has(s.id)
|
|
480
|
+
if (s.isLocal) {
|
|
481
|
+
if (!viewer.ghostty) return ''
|
|
482
|
+
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>'
|
|
483
|
+
}
|
|
484
|
+
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
485
|
+
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>'
|
|
486
|
+
}
|
|
487
|
+
function closedCard(s, viewer) {
|
|
488
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
489
|
+
const branch = s.repo && s.repo.branch
|
|
490
|
+
const label = s.repoLabel || s.repoName || 'no repo'
|
|
491
|
+
return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
492
|
+
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
|
|
493
|
+
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
494
|
+
'<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>' +
|
|
495
|
+
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
496
|
+
'<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>' +
|
|
497
|
+
cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
498
|
+
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
499
|
+
'<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
|
|
500
|
+
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
501
|
+
'</div></article>'
|
|
502
|
+
}
|
|
503
|
+
function closedRepaint() {
|
|
504
|
+
const el = document.getElementById('closedList')
|
|
505
|
+
if (!el || !closedUi.data) return
|
|
506
|
+
const f = state.boardFilter || { repos: [] }
|
|
507
|
+
el.innerHTML = closedListHtml(closedUi.data, f, state.boardSpace)
|
|
508
|
+
}
|
|
509
|
+
// On a phone the status counts are one row that scrolls: bring the picked one into view.
|
|
510
|
+
function revealStat() {
|
|
511
|
+
const on = document.querySelector('.stats .stat.on'), row = on && on.parentElement
|
|
512
|
+
if (row && row.scrollWidth > row.clientWidth) row.scrollLeft = Math.max(0, on.offsetLeft - row.offsetLeft - (row.clientWidth - on.offsetWidth) / 2)
|
|
513
|
+
}
|
|
514
|
+
function closedWire() {
|
|
515
|
+
revealStat()
|
|
516
|
+
const q = document.getElementById('closedQ')
|
|
517
|
+
if (!q) return
|
|
518
|
+
q.addEventListener('input', () => {
|
|
519
|
+
closedUi.q = q.value
|
|
520
|
+
closedUi.limit = CLOSED_PAGE
|
|
521
|
+
clearTimeout(closedUi.timer)
|
|
522
|
+
closedUi.timer = setTimeout(async () => {
|
|
523
|
+
try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) }
|
|
524
|
+
}, 180)
|
|
525
|
+
})
|
|
526
|
+
q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
|
|
527
|
+
}
|
|
528
|
+
// Resume, from a closed card or a session page. -> true when it opened.
|
|
529
|
+
async function resumeOpen(btn, id) {
|
|
530
|
+
if (closedUi.busy.has(id)) return false
|
|
531
|
+
closedUi.busy.add(id)
|
|
532
|
+
const idle = btn.innerHTML
|
|
533
|
+
btn.disabled = true; btn.textContent = 'Opening…'
|
|
534
|
+
try {
|
|
535
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
|
|
536
|
+
toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
|
|
537
|
+
return true
|
|
538
|
+
} catch (e) {
|
|
539
|
+
toast(e.message, 6000)
|
|
540
|
+
return false
|
|
541
|
+
} finally {
|
|
542
|
+
closedUi.busy.delete(id)
|
|
543
|
+
if (btn.isConnected) { btn.disabled = false; btn.innerHTML = idle }
|
|
544
|
+
}
|
|
405
545
|
}
|
|
406
546
|
|
|
407
547
|
// The folded row of empty sessions (v0.4.8), closed by default.
|
|
@@ -436,7 +576,7 @@ function restoreBanner() {
|
|
|
436
576
|
const local = state.viewerLocal !== false
|
|
437
577
|
const picked = restorePicked().length
|
|
438
578
|
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') + '>' +
|
|
439
|
-
'<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
|
|
579
|
+
'<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('')
|
|
440
580
|
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>' +
|
|
441
581
|
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
|
|
442
582
|
'<div class="rs-list">' + rows + '</div>' +
|
|
@@ -484,7 +624,13 @@ async function viewWaiting() {
|
|
|
484
624
|
(r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
|
|
485
625
|
: '<div class="nocp">Nothing yet since the daemon started.</div>') +
|
|
486
626
|
'<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
|
|
627
|
+
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
487
628
|
$app.innerHTML = banners() + html
|
|
629
|
+
// Open/closed survives live repaints (0.6.5).
|
|
630
|
+
const dz = $app.querySelector('details.dismissed')
|
|
631
|
+
if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
|
|
632
|
+
const rc = $app.querySelector('details.recent')
|
|
633
|
+
if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
|
|
488
634
|
}
|
|
489
635
|
|
|
490
636
|
// The nav's count, the page title and the favicon dot: visible from a background tab.
|
|
@@ -507,7 +653,7 @@ function paintNeedsCount(n) {
|
|
|
507
653
|
|
|
508
654
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
509
655
|
function needOpts(extra = {}) {
|
|
510
|
-
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
656
|
+
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
511
657
|
}
|
|
512
658
|
|
|
513
659
|
// ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
|
|
@@ -591,10 +737,10 @@ async function viewSession(id) {
|
|
|
591
737
|
const spv = await api('GET', '/v1/spaces').catch(() => null)
|
|
592
738
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
593
739
|
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>' +
|
|
594
|
-
'<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>'
|
|
740
|
+
'<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>'
|
|
595
741
|
html += '<div class="facts">' +
|
|
596
742
|
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)) +
|
|
597
|
-
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) +
|
|
743
|
+
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) +
|
|
598
744
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
599
745
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
600
746
|
html += spacePicker(s, spv, 'session', s.id)
|
|
@@ -615,7 +761,7 @@ async function viewSession(id) {
|
|
|
615
761
|
if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
|
|
616
762
|
else html += '<div class="timeline">' + s.checkpoints.map((cp) =>
|
|
617
763
|
'<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>' +
|
|
618
|
-
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + '</span>' : '') + '</div>' +
|
|
764
|
+
(cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
|
|
619
765
|
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
|
|
620
766
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
621
767
|
(cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
|
|
@@ -634,7 +780,7 @@ function spacePicker(s, spv, kind, key) {
|
|
|
634
780
|
options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
|
|
635
781
|
'<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>'
|
|
636
782
|
}
|
|
637
|
-
function fact(k, v) { return '<div class="fact"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
|
|
783
|
+
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>' }
|
|
638
784
|
|
|
639
785
|
// Timeline (0.4.3): the board's repo chips (same remembered pick), a session
|
|
640
786
|
// filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
|
|
@@ -1019,7 +1165,7 @@ function syncProviderFields() {
|
|
|
1019
1165
|
st.textContent = k.found ? '✓ key found' + (k.envSet ? ' ($' + k.env + ')' : ' (saved)') : 'no key yet'
|
|
1020
1166
|
document.getElementById('f-key').placeholder = k.found ? 'paste a new key to replace it' : 'paste your key, or set $' + (k.env || '') + ' for the daemon'
|
|
1021
1167
|
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>. ' : '') +
|
|
1022
|
-
'Saved
|
|
1168
|
+
'Saved in ' + esc(k.file || '.secret/') + ' on this Mac, never config.json.'
|
|
1023
1169
|
}
|
|
1024
1170
|
}
|
|
1025
1171
|
|
|
@@ -1102,9 +1248,12 @@ function summariesSection(c) {
|
|
|
1102
1248
|
'<label>Checkpoint length</label><div class="lengths" role="radiogroup" aria-label="Checkpoint length">' + LENGTH_CHOICES.map((o) =>
|
|
1103
1249
|
'<label class="lenopt"><input type="radio" name="f-length" value="' + o.key + '"' + (cur === o.key ? ' checked' : '') + '>' +
|
|
1104
1250
|
'<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>' +
|
|
1105
|
-
'<div class="hint"
|
|
1106
|
-
|
|
1107
|
-
|
|
1251
|
+
'<div class="hint"><code>/cp --short</code> or <code>/cp --long</code> overrides it once.</div>'
|
|
1252
|
+
}
|
|
1253
|
+
function lengthExampleHtml(c) {
|
|
1254
|
+
const cur = (c.summary && c.summary.length) || 'informative'
|
|
1255
|
+
return '<label>Example</label><div class="len-example" id="f-length-example" aria-live="polite">' + lengthExample(cur) + '</div>' +
|
|
1256
|
+
'<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>'
|
|
1108
1257
|
}
|
|
1109
1258
|
async function tryLength() {
|
|
1110
1259
|
const k = ((document.querySelector('input[name="f-length"]:checked') || {}).value) || 'informative'
|
|
@@ -1116,7 +1265,7 @@ async function tryLength() {
|
|
|
1116
1265
|
box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
|
|
1117
1266
|
try {
|
|
1118
1267
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
1119
|
-
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>' +
|
|
1268
|
+
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>' +
|
|
1120
1269
|
cpBlock(cp, { showWhen: false }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
1121
1270
|
} catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
1122
1271
|
}
|
|
@@ -1153,9 +1302,11 @@ function remoteSection(r) {
|
|
|
1153
1302
|
: '<span class="chip">no sign-in needed</span>') + '</div>' +
|
|
1154
1303
|
'<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>'
|
|
1155
1304
|
if (r.requireToken) {
|
|
1156
|
-
|
|
1305
|
+
// A trusted remote browser may sign in another phone too (0.6.6, Trust my paired devices).
|
|
1306
|
+
const trusted = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
|
|
1307
|
+
if (local || trusted) {
|
|
1157
1308
|
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>' +
|
|
1158
|
-
(n ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
|
|
1309
|
+
(n && local ? '<button type="button" class="btn" data-action="remote-revoke">Sign out remote browsers</button>' : '') + '</div>' +
|
|
1159
1310
|
'<div class="full signin" id="remoteLink" hidden></div>'
|
|
1160
1311
|
} else {
|
|
1161
1312
|
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>' +
|
|
@@ -1191,7 +1342,7 @@ function signupBlock(r, su, local) {
|
|
|
1191
1342
|
|
|
1192
1343
|
// Use from anywhere (v0.6.0): the hosted relay at <user>.leftoff.dev. The
|
|
1193
1344
|
// switch, the name, the relay's state, pairing a phone, the paired devices.
|
|
1194
|
-
function anywhereSection(r) {
|
|
1345
|
+
function anywhereSection(r, adv = []) {
|
|
1195
1346
|
if (!r) return ''
|
|
1196
1347
|
const v = r.viewer || {}
|
|
1197
1348
|
const local = !!v.local
|
|
@@ -1199,27 +1350,33 @@ function anywhereSection(r) {
|
|
|
1199
1350
|
const dom = esc(r.domain || 'leftoff.dev')
|
|
1200
1351
|
const su = r.signup || { status: 'none' }
|
|
1201
1352
|
let h = '<div class="section" id="anywhere">Use from anywhere</div>' +
|
|
1202
|
-
'<div class="full hint">Your board at <b>' + esc(r.host || '
|
|
1353
|
+
'<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>'
|
|
1203
1354
|
// No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
|
|
1204
1355
|
if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
|
|
1205
|
-
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>'
|
|
1206
|
-
|
|
1356
|
+
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>'
|
|
1357
|
+
// The name: up top until there is one, then Advanced (0.6.6).
|
|
1358
|
+
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>' +
|
|
1207
1359
|
(local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
|
|
1208
|
-
'<div class="hint">' + (r.user ? 'Your
|
|
1360
|
+
'<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>'
|
|
1361
|
+
if (r.user) adv.push(nameRow); else h += nameRow
|
|
1209
1362
|
if (r.enabled) {
|
|
1210
1363
|
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>'
|
|
1211
1364
|
h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
|
|
1212
1365
|
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>'
|
|
1213
|
-
|
|
1366
|
+
// Pairing from a paired phone follows Trust my paired devices (0.6.6).
|
|
1367
|
+
const mayPair = v.mayPair != null ? !!v.mayPair : local
|
|
1368
|
+
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>' +
|
|
1214
1369
|
'<div class="full signin" id="relayPair" hidden></div>'
|
|
1370
|
+
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>'
|
|
1215
1371
|
}
|
|
1372
|
+
const mayRevoke = v.mayRevoke != null ? !!v.mayRevoke : local
|
|
1216
1373
|
const list = r.list || []
|
|
1217
1374
|
h += '<label>Paired devices</label><div class="devlist">' + (list.length ? list.map((d) =>
|
|
1218
1375
|
'<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>' : '') +
|
|
1219
1376
|
'<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>' +
|
|
1220
|
-
(
|
|
1377
|
+
(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('')
|
|
1221
1378
|
: '<div class="faint">None yet.</div>') + '</div>' +
|
|
1222
|
-
'<div class="hint">' + (local ? 'Revoking works at once
|
|
1379
|
+
'<div class="hint">' + (local ? 'Revoking works at once, from any paired device.' : 'Revoking works at once, this browser included.') + '</div>'
|
|
1223
1380
|
return h
|
|
1224
1381
|
}
|
|
1225
1382
|
function fmtAgo(iso) {
|
|
@@ -1249,7 +1406,7 @@ function paintRelayPair(l) {
|
|
|
1249
1406
|
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>' +
|
|
1250
1407
|
'<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>' +
|
|
1251
1408
|
'<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(l.link) + '">Copy link</button>' +
|
|
1252
|
-
'<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>' +
|
|
1409
|
+
'<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>' +
|
|
1253
1410
|
'<div class="expiry" id="relayExpiry" data-expires="' + esc(l.expiresAt) + '"></div>' +
|
|
1254
1411
|
'<button type="button" class="linkish" data-action="relay-pair">New code</button></div>'
|
|
1255
1412
|
const tick = () => {
|
|
@@ -1271,9 +1428,10 @@ function answeringSection(r, relay) {
|
|
|
1271
1428
|
if (!r || !(r.enabled || anywhere)) return ''
|
|
1272
1429
|
const local = r.via !== 'remote'
|
|
1273
1430
|
const allow = !!(state.cfg && state.cfg.remote && state.cfg.remote.allowAnswers)
|
|
1274
|
-
return '<label for="f-remote-answers">
|
|
1275
|
-
'<div class="
|
|
1276
|
-
|
|
1431
|
+
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>' +
|
|
1432
|
+
'<div class="hint">On: your phone and other paired browsers can answer prompts and pair new devices. Off: they can look, not touch.</div>' +
|
|
1433
|
+
'<div class="warnbox"><b>Think first.</b> A trusted device can approve what Claude runs here, including deleting files or pushing code.' +
|
|
1434
|
+
(local ? '' : ' Change this on a Mac.') + '</div>'
|
|
1277
1435
|
}
|
|
1278
1436
|
|
|
1279
1437
|
// The sign-in box under "Show QR code" / "Get a code": the QR image, the link,
|
|
@@ -1317,11 +1475,11 @@ function digestSettings(c) {
|
|
|
1317
1475
|
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>' : ''
|
|
1318
1476
|
return '<div class="section dup" id="digest">Digest</div>' +
|
|
1319
1477
|
'<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>' +
|
|
1320
|
-
'<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear
|
|
1478
|
+
'<div class="hint">' + (dg.time ? 'Next one: ' + esc(dg.nextAt ? clock(dg.nextAt) : '—') + '. ' : '') + 'Clear it to turn digests off.</div>' + zoneNote +
|
|
1321
1479
|
'<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('') +
|
|
1322
1480
|
'<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>' +
|
|
1323
|
-
'<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>' +
|
|
1324
|
-
'<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, ' ')])] })
|
|
1481
|
+
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>' +
|
|
1482
|
+
'<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, ' ')])] }))
|
|
1325
1483
|
}
|
|
1326
1484
|
|
|
1327
1485
|
// Settings (v0.5.9): one section at a time. Desktop (≥900px) has a sidebar of
|
|
@@ -1396,6 +1554,12 @@ function settingsFilter() {
|
|
|
1396
1554
|
}
|
|
1397
1555
|
wrap.querySelectorAll('.set-hit').forEach((el) => el.classList.remove('set-hit'))
|
|
1398
1556
|
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')
|
|
1557
|
+
// A match under Advanced opens it; clearing the search folds it back (unless you opened it).
|
|
1558
|
+
for (const d of wrap.querySelectorAll('details.set-adv')) {
|
|
1559
|
+
const inside = q && [...d.querySelectorAll('.set-advbody label, .set-advbody .section, .set-advbody .sp-sub')].some((el) => el.textContent.toLowerCase().includes(q))
|
|
1560
|
+
if (inside && !d.open) { d.dataset.auto = '1'; d.open = true }
|
|
1561
|
+
else if (!inside && d.dataset.auto) { delete d.dataset.auto; d.open = !!settingsUi.adv[d.dataset.adv] }
|
|
1562
|
+
}
|
|
1399
1563
|
const none = wrap.querySelector('.set-nomatch')
|
|
1400
1564
|
none.hidden = !!first
|
|
1401
1565
|
none.textContent = first ? '' : 'Nothing in Settings matches “' + settingsUi.q.trim() + '”.'
|
|
@@ -1424,9 +1588,46 @@ function showSettingsSection(r) {
|
|
|
1424
1588
|
if (!wideSettings() || wrap.getBoundingClientRect().top < 0) window.scrollTo(0, 0)
|
|
1425
1589
|
}
|
|
1426
1590
|
|
|
1591
|
+
// Settings → General → PR cards (0.6.5): is GitHub's gh installed and signed
|
|
1592
|
+
// in on this Mac, and the one-line fix to copy. Nothing installs from here.
|
|
1593
|
+
const GH_LABEL = { missing: 'Missing', 'signed-out': 'Signed out', ready: 'Ready' }
|
|
1594
|
+
function ghSection(g) {
|
|
1595
|
+
let h = '<label>PR cards</label>'
|
|
1596
|
+
if (!g || !g.status) return h + '<div class="hint">Couldn’t check for gh on this Mac.</div>'
|
|
1597
|
+
const ok = g.status === 'ready'
|
|
1598
|
+
h += '<div class="ghcell"><div class="ghrow"><span class="chip ' + (ok ? 'ok' : 'warn') + '">' + esc(GH_LABEL[g.status] || g.status) + '</span>' +
|
|
1599
|
+
'<span class="ghwhat">' + (ok ? 'gh is signed in' + (g.account ? ' as <b>' + esc(g.account) + '</b>' : '') + '. Cards that need you show the branch’s pull request.' : g.status === 'signed-out' ? 'gh is installed but not signed in to GitHub.' : 'GitHub’s gh isn’t installed on this Mac.') + '</span>' +
|
|
1600
|
+
'<button type="button" class="linkish" data-action="gh-check">Check again</button></div>'
|
|
1601
|
+
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>'
|
|
1602
|
+
h += '</div>'
|
|
1603
|
+
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>'
|
|
1604
|
+
return h
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Advanced (0.6.6): each section shows what people change up top; the rest
|
|
1608
|
+
// folds under one closed Advanced per section. Same form, same Save; the
|
|
1609
|
+
// search opens a fold when something inside matches; opening one sticks.
|
|
1610
|
+
settingsUi.adv = settingsUi.adv || {}
|
|
1611
|
+
function advanced(key, html) {
|
|
1612
|
+
if (!html) return ''
|
|
1613
|
+
return '<details class="set-adv" data-adv="' + key + '"' + (settingsUi.adv[key] ? ' open' : '') + '><summary>Advanced</summary><div class="set-advbody">' + html + '</div></details>'
|
|
1614
|
+
}
|
|
1615
|
+
// Phone & remote: Use from anywhere up top; Tailscale under Advanced unless it's
|
|
1616
|
+
// the only way in (remote access on, the relay off).
|
|
1617
|
+
function phonePane(relay, remote, unavailable) {
|
|
1618
|
+
const adv = []
|
|
1619
|
+
const any = anywhereSection(relay, adv)
|
|
1620
|
+
const ts = remoteSection(remote) || unavailable('Couldn’t read remote access from Leftoff.')
|
|
1621
|
+
const tsOnly = !!(remote && remote.enabled) && !(relay && relay.enabled)
|
|
1622
|
+
if (!any) return ts
|
|
1623
|
+
if (tsOnly) return any + advanced('phone', adv.join('')) + '<div class="section">Over Tailscale</div>' + ts
|
|
1624
|
+
return any + advanced('phone', adv.join('') + '<div class="section">Over Tailscale</div>' + ts)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1427
1627
|
async function viewSettings() {
|
|
1428
|
-
const [c, , remote, sync, peers, st, , , relay] = 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)])
|
|
1628
|
+
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)])
|
|
1429
1629
|
state.relay = relay
|
|
1630
|
+
state.gh = gh
|
|
1430
1631
|
syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
|
|
1431
1632
|
state.cfg = c
|
|
1432
1633
|
const sm = c.summarizer || {}
|
|
@@ -1435,16 +1636,16 @@ async function viewSettings() {
|
|
|
1435
1636
|
const h = state.health || {}
|
|
1436
1637
|
const unavailable = (what) => '<div class="full hint set-empty">' + esc(what) + '</div>'
|
|
1437
1638
|
const panes = {
|
|
1438
|
-
general: '<div class="section">
|
|
1439
|
-
'<label for="f-
|
|
1440
|
-
'<div class="hint">
|
|
1441
|
-
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
1442
|
-
'<div class="hint">No automatic checkpoint closer than this to the last one.</div>' +
|
|
1443
|
-
'<div class="section">Needs you</div>' +
|
|
1444
|
-
'<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>' +
|
|
1445
|
-
'<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>' +
|
|
1639
|
+
general: '<div class="section">Needs you</div>' +
|
|
1640
|
+
'<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>' +
|
|
1641
|
+
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
1446
1642
|
'<div class="section">Appearance</div>' +
|
|
1447
|
-
'<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>'
|
|
1643
|
+
'<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>' +
|
|
1644
|
+
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
1645
|
+
'<div class="hint">No activity this long, and the session gets a checkpoint.</div>' +
|
|
1646
|
+
'<label for="f-gap">Min gap (min)</label><input id="f-gap" type="number" min="0" step="1" value="' + esc(c.minGapMin) + '">' +
|
|
1647
|
+
'<div class="hint">No automatic checkpoint closer than this to the last.</div>' +
|
|
1648
|
+
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>'),
|
|
1448
1649
|
digest: digestSettings(c),
|
|
1449
1650
|
spaces: '<div class="full-box" id="spacesBox">' + spacesSectionHtml() + '</div>',
|
|
1450
1651
|
sync: syncSection(sync, stUi.data) || unavailable('Couldn’t read the sync status from Leftoff.'),
|
|
@@ -1457,21 +1658,22 @@ async function viewSettings() {
|
|
|
1457
1658
|
'<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
|
|
1458
1659
|
'<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>' +
|
|
1459
1660
|
'<div class="hint" data-row="key" id="f-key-hint"></div>' +
|
|
1460
|
-
summariesSection(c)
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
security: '<div class="section" id="answering">
|
|
1464
|
-
'<div class="full hint">
|
|
1465
|
-
|
|
1466
|
-
'
|
|
1467
|
-
'
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
'<div class="section">About</div>' +
|
|
1661
|
+
summariesSection(c) +
|
|
1662
|
+
advanced('models', lengthExampleHtml(c)),
|
|
1663
|
+
phone: phonePane(relay, remote, unavailable),
|
|
1664
|
+
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
1665
|
+
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
1666
|
+
// a changed machine key needs a click here, so Other machines stays up top then
|
|
1667
|
+
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
1668
|
+
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
1669
|
+
'<div class="section">Your data</div>' +
|
|
1670
|
+
'<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>'),
|
|
1671
|
+
updates: '<div class="section">About</div>' +
|
|
1472
1672
|
'<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>' +
|
|
1473
|
-
'<
|
|
1474
|
-
'<label>
|
|
1673
|
+
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
1674
|
+
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>' +
|
|
1675
|
+
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
1676
|
+
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
1475
1677
|
}
|
|
1476
1678
|
const r = state.route || parseRoute()
|
|
1477
1679
|
$app.innerHTML = '<div class="settings no-sec" id="settingsWrap">' +
|
|
@@ -1498,6 +1700,14 @@ async function viewSettings() {
|
|
|
1498
1700
|
document.getElementById('f-model').value = same ? (c.summarizer.model || '') : ''
|
|
1499
1701
|
syncProviderFields()
|
|
1500
1702
|
})
|
|
1703
|
+
// Opening or closing Advanced by hand sticks across redraws (the search's own opening doesn't).
|
|
1704
|
+
document.getElementById('settingsWrap').addEventListener('toggle', (ev) => {
|
|
1705
|
+
const d = ev.target
|
|
1706
|
+
if (!d.classList || !d.classList.contains('set-adv')) return
|
|
1707
|
+
if (d.dataset.auto && d.open) return
|
|
1708
|
+
delete d.dataset.auto
|
|
1709
|
+
settingsUi.adv[d.dataset.adv] = d.open
|
|
1710
|
+
}, true)
|
|
1501
1711
|
const f = document.getElementById('setFilter')
|
|
1502
1712
|
f.addEventListener('input', () => { settingsUi.q = f.value; settingsFilter() })
|
|
1503
1713
|
f.addEventListener('keydown', (ev) => {
|
|
@@ -2449,9 +2659,16 @@ document.addEventListener('click', async (ev) => {
|
|
|
2449
2659
|
const action = btn.dataset.action
|
|
2450
2660
|
const card = btn.closest('[data-id]')
|
|
2451
2661
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
2452
|
-
const s = state.sessions.find((x) => x.id === id)
|
|
2662
|
+
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
2453
2663
|
try {
|
|
2454
2664
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2665
|
+
if (action === 'gh-check') {
|
|
2666
|
+
btn.disabled = true
|
|
2667
|
+
try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
|
|
2668
|
+
const box = document.getElementById('ghBox')
|
|
2669
|
+
if (box) box.innerHTML = ghSection(state.gh)
|
|
2670
|
+
return
|
|
2671
|
+
}
|
|
2455
2672
|
// Use from anywhere (v0.6.0)
|
|
2456
2673
|
if (action === 'relay-toggle') {
|
|
2457
2674
|
const on = btn.checked
|
|
@@ -2460,6 +2677,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
2460
2677
|
try { await api('POST', on ? '/v1/relay/enable' : '/v1/relay/disable', on ? { user } : undefined); toast(on ? 'Use from anywhere is on: connecting to ' + user + '.leftoff.dev' : 'Use from anywhere is off for this Mac') }
|
|
2461
2678
|
catch (e) { btn.checked = !on; toast(e.message, 5000); return }
|
|
2462
2679
|
setTimeout(() => { if (state.route && state.route.name === 'settings') render() }, 1200)
|
|
2680
|
+
refreshHealth().catch(() => {})
|
|
2463
2681
|
return render()
|
|
2464
2682
|
}
|
|
2465
2683
|
if (action === 'relay-user') {
|
|
@@ -2480,8 +2698,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
2480
2698
|
if (action === 'relay-signup-cancel') { await api('POST', '/v1/relay/signup/cancel'); stopSignupPoll(); return render() }
|
|
2481
2699
|
if (action === 'relay-pair') { const l = await api('POST', '/v1/relay/pair'); paintRelayPair(l); return }
|
|
2482
2700
|
if (action === 'relay-revoke') {
|
|
2483
|
-
|
|
2701
|
+
const self = btn.dataset.self === '1'
|
|
2702
|
+
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
|
|
2484
2703
|
const r = await api('POST', '/v1/relay/devices/' + encodeURIComponent(btn.dataset.device) + '/revoke')
|
|
2704
|
+
// Revoked itself (0.6.6): the same screen a revoked browser gets.
|
|
2705
|
+
if ((self || r.self) && window.leftoffRelay && window.leftoffRelay.unpaired) return window.leftoffRelay.unpaired()
|
|
2485
2706
|
toast('Revoked ' + btn.dataset.name + (r.relay === 'done' ? '' : ' (the relay hears it once this Mac reconnects)')); return render()
|
|
2486
2707
|
}
|
|
2487
2708
|
if (action === 'relay-mac') { if (window.leftoffRelay && btn.dataset.mac !== window.leftoffRelay.mac) window.leftoffRelay.pick(btn.dataset.mac); return }
|
|
@@ -2525,7 +2746,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
2525
2746
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
2526
2747
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
2527
2748
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
2528
|
-
const l = await api('POST', '/v1/remote/link')
|
|
2749
|
+
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
2529
2750
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
|
2530
2751
|
paintSignin(l); return
|
|
2531
2752
|
}
|
|
@@ -2684,7 +2905,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
2684
2905
|
}
|
|
2685
2906
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
2686
2907
|
if (action === 'reply-send') return replyClick(btn)
|
|
2908
|
+
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
2909
|
+
if (action === 'need-more') return needMoreClick(btn)
|
|
2910
|
+
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
2911
|
+
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
2912
|
+
btn.disabled = true
|
|
2913
|
+
state.dismissedOpen = true
|
|
2914
|
+
try { await api('POST', '/v1/needs/restore', action === 'need-restore-all' ? { all: true } : { key: btn.dataset.need }); toast(action === 'need-restore-all' ? 'Restored' : 'Restored to Needs you') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
2915
|
+
return render()
|
|
2916
|
+
}
|
|
2687
2917
|
if (action === 'go') return goClick(btn, id, s)
|
|
2918
|
+
if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
|
|
2919
|
+
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 }
|
|
2920
|
+
if (action === 'closed-clear') { closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
|
|
2688
2921
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2689
2922
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
2690
2923
|
if (action === 'resume') {
|
|
@@ -2784,6 +3017,65 @@ async function replyClick(btn) {
|
|
|
2784
3017
|
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
2785
3018
|
}
|
|
2786
3019
|
|
|
3020
|
+
// A quick reply (0.6.4): the chip's words go in the answer box and are sent
|
|
3021
|
+
// by its own button, exactly as if typed: same request, same checks.
|
|
3022
|
+
function quickReplyClick(btn) {
|
|
3023
|
+
const need = btn.closest('.need')
|
|
3024
|
+
if (!need) return
|
|
3025
|
+
const text = btn.dataset.text || ''
|
|
3026
|
+
const box = need.querySelector('.reply-text') || need.querySelector('.qother')
|
|
3027
|
+
const send = need.querySelector('[data-action="reply-send"]') || need.querySelector('[data-action="ask-answer"][data-choice="answer"]')
|
|
3028
|
+
if (!box || !send || send.disabled) return
|
|
3029
|
+
box.value = text
|
|
3030
|
+
if (box.dataset.draft) state.drafts[box.dataset.draft] = text
|
|
3031
|
+
need.querySelectorAll('.quick-replies .qr').forEach((b) => { b.disabled = true; b.classList.toggle('on', b === btn) })
|
|
3032
|
+
return send.dataset.action === 'reply-send' ? replyClick(send) : answerClick(send, 'ask-answer')
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// "Show more" on a Needs-you card (0.6.4): the session's last ~20 turns,
|
|
3036
|
+
// fetched when opened (through the relay when that's how the board is open).
|
|
3037
|
+
async function needMoreClick(btn) {
|
|
3038
|
+
const id = btn.dataset.id
|
|
3039
|
+
const panels = () => document.querySelectorAll('.need[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"] .need-ctx')
|
|
3040
|
+
const paint = () => panels().forEach((ctx) => {
|
|
3041
|
+
const open = !!state.needOpen[id]
|
|
3042
|
+
const b = ctx.querySelector('.need-more')
|
|
3043
|
+
if (b) { b.textContent = open ? 'Show less' : 'Show more'; b.setAttribute('aria-expanded', String(open)) }
|
|
3044
|
+
let box = ctx.querySelector('.need-turns')
|
|
3045
|
+
const t = state.needTurns[id]
|
|
3046
|
+
// Open, the turns replace the three lines (the last turn ends with them).
|
|
3047
|
+
ctx.classList.toggle('open', open && !!t && !t.error)
|
|
3048
|
+
if (!open) { if (box) box.remove(); return }
|
|
3049
|
+
if (!box) { box = document.createElement('div'); box.className = 'need-turns'; ctx.appendChild(box) }
|
|
3050
|
+
box.innerHTML = !t ? '<div class="need-note">Loading the last turns…</div>' : t.error ? '<div class="need-note warn">' + esc(t.error) + '</div>' : turnsHtml(t.turns, { need: needsItems(state.sessions).find((n) => n.session && n.session.id === id) })
|
|
3051
|
+
})
|
|
3052
|
+
if (state.needOpen[id]) { delete state.needOpen[id]; return paint() }
|
|
3053
|
+
state.needOpen[id] = true
|
|
3054
|
+
if (!state.needTurns[id] || state.needTurns[id].error) delete state.needTurns[id]
|
|
3055
|
+
paint()
|
|
3056
|
+
try {
|
|
3057
|
+
const r = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/turns?limit=20')
|
|
3058
|
+
state.needTurns[id] = { turns: r.turns || [], at: Date.now() }
|
|
3059
|
+
} catch (e) { state.needTurns[id] = { error: 'Couldn’t load this session’s turns: ' + e.message } }
|
|
3060
|
+
paint()
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3064
|
+
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
3065
|
+
async function needDismissClick(btn) {
|
|
3066
|
+
const key = btn.dataset.need
|
|
3067
|
+
if (!key) return
|
|
3068
|
+
const cards = [...document.querySelectorAll('.need[data-need="' + (window.CSS && CSS.escape ? CSS.escape(key) : key) + '"]')]
|
|
3069
|
+
cards.forEach((c) => { c.hidden = true })
|
|
3070
|
+
for (const s of state.sessions || []) if (s.needsYou && s.needsYou.key === key) s.needsYou = null
|
|
3071
|
+
try { await api('POST', '/v1/needs/dismiss', { key }) } catch (e) { cards.forEach((c) => { c.hidden = false }); toast('Couldn’t dismiss it: ' + e.message, 5000); return }
|
|
3072
|
+
toastUndo('Dismissed', async () => {
|
|
3073
|
+
try { await api('POST', '/v1/needs/restore', { key }) } catch (e) { toast(e.message, 5000) }
|
|
3074
|
+
render(); refreshHealth().catch(() => {})
|
|
3075
|
+
})
|
|
3076
|
+
render(); refreshHealth().catch(() => {})
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2787
3079
|
// Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
|
|
2788
3080
|
function goClick(btn, id, s) {
|
|
2789
3081
|
return goWithFeedback({
|
|
@@ -2843,7 +3135,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
2843
3135
|
...(() => {
|
|
2844
3136
|
const el = document.getElementById('f-remote-answers')
|
|
2845
3137
|
if (!el || el.disabled || el.checked === !!(state.cfg.remote && state.cfg.remote.allowAnswers)) return {}
|
|
2846
|
-
if (el.checked && !confirm('
|
|
3138
|
+
if (el.checked && !confirm('Trust your paired devices? They could approve commands on this machine and pair new devices.')) { el.checked = false; return {} }
|
|
2847
3139
|
return { remote: { allowAnswers: el.checked } }
|
|
2848
3140
|
})(),
|
|
2849
3141
|
})
|